@sema-agent/core
Version:
Stateless, task-oriented AI agent core
234 lines (233 loc) • 14.6 kB
JavaScript
import { planShellReads } from "./shell-read-plan.js";
import { literalTextOf } from "../../shell/lex.js";
import { createShellReadEstablisher, establishShellReadsLexically } from "./shell-read-establish.js";
import { isAbsoluteForFamily, isWinFormPath, normalizeAbsPathLexically } from "./safety.js";
import { launchedCommands, launchedReadings, reentryText, scriptFileAt, spellLaunched } from "./shell-reentry-text.js";
import { readProgramPosition } from "./bash-program-position.js";
import { bashClassifyDefaultAllow } from "./bash-readonly-classifier.js";
import { programArgs } from "./shell-read-write-targets.js";
import { implementationsOf, programRow, runsShellText, selectedOptions, writeReadings } from "./program-read-model.js";
import { inlineUntrusted } from "../../core/untrusted-text.js";
export const SHELL_REMOVAL_COMMANDS = Object.freeze(["rm", "rmdir"]);
const REMOVAL_COMMAND_SET = new Set(SHELL_REMOVAL_COMMANDS);
function removalKey(path) {
let k = normalizeAbsPathLexically(path.normalize("NFC")).replace(/\\/g, "/").toLowerCase();
k = k.replace(/(?:\/+\*+)+\/*$/, "");
const drive = driveOf(path, k);
let rest = k.slice(drive.length);
if (rest === "")
rest = "/";
if (rest.length > 1)
rest = rest.replace(/\/+$/, "");
return drive + rest.replace(/^\/private\/(etc|var|tmp|home)(?=\/|$)/, "/$1");
}
function driveOf(path, key) {
return isWinFormPath(path) && key.length >= 2 && key[1] === ":" ? key.slice(0, 2) : "";
}
export function dangerousRemovalOf(path, seat) {
if (!isAbsoluteForFamily("own", path))
return undefined;
const k = removalKey(path);
const rest = k.slice(driveOf(path, k).length);
if (rest === "/" || /^\/\/[^/]+\/[^/]+$/.test(k))
return "filesystem_root";
if (seat.homes.some((h) => isAbsoluteForFamily("own", h) && removalKey(h) === k))
return "home";
const prefix = k.endsWith("/") ? k : `${k}/`;
if (seat.workspace.some((w) => isAbsoluteForFamily("own", w) && (removalKey(w) === k || removalKey(w).startsWith(prefix))))
return "workspace";
if (/^\/[^/]+$/.test(rest))
return "top_level";
return undefined;
}
function tildeNamesAHome(spelled) {
const m = /^~([^/]*)(\/.*)?$/.exec(spelled);
if (m === null)
return false;
const key = removalKey(`/~home${m[2] ?? ""}`);
return key === "/~home" || !key.startsWith("/~home/");
}
function removalKindOf(read, seat) {
const floors = read.established === "unestablishable" && read.candidate.bases === "unmodelled" ? read.spellings : [];
for (const view of [read.target, read.origin, ...floors]) {
const kind = view === undefined ? undefined : dangerousRemovalOf(view, seat);
if (kind !== undefined)
return kind;
}
return read.established === "unestablishable" && tildeNamesAHome(read.candidate.spelled) ? "home" : undefined;
}
export const KNOWN_SAFE_ROOTS = Object.freeze(["TMPDIR", "HOME"]);
function belowKnownSafeRoot(word, safe, segment) {
const [head, ...rest] = word.pieces;
const e = head?.expansion;
const name = e?.kind === "parameter" && (e.body === undefined || e.body === e.name) ? e.name : undefined;
if (name === undefined || !safe.has(name) || segment.rebound === "all" || segment.rebound.has(name))
return false;
if (rest.some((p) => p.literal === undefined))
return false;
const below = rest.map((p) => p.literal).join("");
return /^\/[^*?[\]]+$/.test(below) && !below.split("/").includes("..");
}
function recursiveForce(row, args) {
return implementationsOf(row).some((view) => {
const selected = selectedOptions(view, args, "one-implementation");
return selected.some((o) => o.selects === "traversal") && selected.some((o) => o.mode === "forced");
});
}
export function removalFindingsOf(established, seat, safeRoots = KNOWN_SAFE_ROOTS, enclosingRelocated = false) {
if (unreadPlan(established.plan))
return [{ guard: "unverifiable_shell_text", program: "bash", text: established.plan.unreadable, source: "unread" }];
const out = [];
const safe = new Set(safeRoots);
for (const segment of established.plan.segments) {
const program = segment.verb?.basename;
const row = program !== undefined && REMOVAL_COMMAND_SET.has(program) ? programRow(program) : undefined;
if (program !== undefined && row !== undefined) {
const args = programArgs(segment);
const spelled = args.map((w) => w.spelled);
const removed = new Set(writeReadings(row, spelled).flatMap((r) => r.written.map((w) => w.at)));
const options = new Set(args.filter((_, at) => !removed.has(at)).map((w) => w.raw));
const forced = recursiveForce(row, spelled);
if (forced && segment.reentry === "argv")
out.push({ guard: "unverifiable_delete", program, operand: segment.text.trim(), source: "stream" });
const relocated = enclosingRelocated || readProgramPosition(segment.argv).launcherMovesBase;
for (const read of established.reads.get(segment) ?? []) {
if (read.candidate.face !== "write" || options.has(read.candidate.raw))
continue;
const kind = removalKindOf(read, seat);
if (kind !== undefined) {
const untracked = read.candidate.bases === "unmodelled" ? established.plan.segments.find((s) => s.bases === "unmodelled")?.text.trim() : undefined;
out.push({ guard: "dangerous_delete", kind, program, operand: read.candidate.raw, target: read.target ?? read.candidate.floorSpelled ?? read.candidate.spelled, ...(untracked !== undefined ? { untrackedFrom: untracked } : {}) });
continue;
}
if (!forced || read.established !== "unestablishable" || (read.candidate.expands !== "unresolvable" && !relocated))
continue;
const word = segment.argv.find((w) => w.raw === read.candidate.raw);
if (word !== undefined && belowKnownSafeRoot(word, safe, segment))
continue;
out.push({ guard: "unverifiable_delete", program, operand: read.candidate.raw, source: read.candidate.expands === "unresolvable" ? "rewritten" : "relocated" });
}
}
const script = runtimeScriptOf(segment);
if (script !== undefined && program !== undefined)
out.push({ guard: "unverifiable_shell_text", program, text: script, source: "produced" });
if (programUnread(segment))
out.push({ guard: "unverifiable_shell_text", program: segment.launcherChain[0] ?? segment.text.trim(), text: segment.text.trim(), source: "unread" });
const handedRelocated = enclosingRelocated || readProgramPosition(segment.argv).launcherMovesBase;
for (const nested of segment.nested) {
if (unreadPlan(nested)) {
out.push({ guard: "unverifiable_shell_text", program: program ?? segment.text.trim(), text: segment.text.trim(), source: "unread" });
continue;
}
const sub = established.nested.get(nested);
if (sub !== undefined)
out.push(...removalFindingsOf(sub, seat, safeRoots, handedRelocated));
}
}
return out;
}
function runtimeScriptOf(segment) {
const program = segment.verb;
if (segment.reentry !== "shell" || program === undefined || !runsShellText(program.basename))
return undefined;
const MARK = "\u0000";
const spell = (k, from = 0) => {
const w = segment.argv[k];
const literal = literalTextOf(w.pieces);
return literal !== undefined ? literal.slice(from) : `${MARK}${w.raw.slice(from)}`;
};
const pos = readProgramPosition(segment.argv);
if (pos.program !== undefined && pos.refusal === undefined) {
const argv = segment.argv.slice(pos.program.index + 1);
const file = scriptFileAt(program.basename, argv.map((w) => w.folded));
const source = file === undefined ? undefined : argv[file];
if (source !== undefined && source.pieces.length === 1 && source.pieces[0].expansion?.kind === "process")
return source.raw;
if (pos.argvFromStdin)
return segment.text.trim();
}
const named = pos.program !== undefined && pos.refusal === undefined ? [{ shell: program.basename, words: segment.argv.slice(pos.program.index + 1).map((_, k) => spell(pos.program.index + 1 + k)) }] : [];
const launched = launchedCommands(segment.argv, pos).flatMap((c) => {
const shell = launchedReadings(segment.argv, c).shell;
return shell === undefined ? [] : [{ shell, words: spellLaunched(segment.argv, c.args, { word: spell }) }];
});
const texts = [...named, ...launched].flatMap(({ shell, words }) => [reentryText(shell, words), ...(segment.stdin ?? []).map((s) => reentryText(shell, words, s.literal ?? `${MARK}${s.from}`))]);
const text = texts.find((t) => t?.includes(MARK) === true);
return text === undefined ? undefined : text.split(MARK).join("");
}
const unreadPlan = (plan) => plan.unreadable !== undefined && plan.segments.length === 0;
const programUnread = (s) => s.declines.includes("unreadable_program_position");
export function planRunsRemoval(plan) {
return unreadPlan(plan) || plan.segments.some((s) => (s.verb !== undefined && REMOVAL_COMMAND_SET.has(s.verb.basename)) || runtimeScriptOf(s) !== undefined || programUnread(s) || s.nested.some(planRunsRemoval));
}
const DANGEROUS_REMOVAL_SENTENCE = {
filesystem_root: (p, t) => `\`${p}\` would remove the filesystem root (${t})`,
home: (p, t) => `\`${p}\` would remove a home directory (${t})`,
workspace: (p, t) => `\`${p}\` would remove a workspace directory — the task root, the working directory or an additional working directory — or one of its ancestors (${t})`,
top_level: (p, t) => `\`${p}\` would remove a top-level system directory (${t})`,
};
const UNVERIFIABLE_REMOVAL_SENTENCE = {
rewritten: (p, o) => `\`${p}\` recursive+force target ${o} is rewritten by the shell from a value this command does not settle (a name with no value here or one it sets in a form the guard cannot read, a substitution, a parameter operator) — the deleted path cannot be verified from this command`,
stream: (p, o) => `\`${p}\` recursive+force takes its targets from the stream it is fed (${o}) — the deleted paths cannot be verified from this command`,
relocated: (p, o) => `\`${p}\` recursive+force target ${o} is relative to a directory a launcher option moved the program into, which this command does not settle — the deleted path cannot be verified from this command`,
};
const UNREADABLE_TEXT_SENTENCE = {
produced: (p, t) => `\`${p}\` runs shell text that is built only when the command runs (${t}) — the text this shell will run cannot be read by the engine, so whether it removes anything is unknown`,
unread: (p, t) => `\`${p}\` runs shell text this guard cannot read (${t}) — the text this shell will run cannot be read by the engine, so whether it removes anything is unknown`,
};
function verdictOf(findings) {
for (const f of findings) {
if (f.guard !== "dangerous_delete")
continue;
const untracked = f.untrackedFrom === undefined ? "" : ` — the working directory is not tracked from \`${inlineUntrusted(f.untrackedFrom, 120)}\` on (a directory change before it cannot be followed), so this target was judged where the command stood when tracking stopped`;
return { guard: f.guard, text: `${DANGEROUS_REMOVAL_SENTENCE[f.kind](f.program, inlineUntrusted(f.target, 200))}${untracked}` };
}
for (const f of findings)
if (f.guard === "unverifiable_delete")
return { guard: f.guard, text: UNVERIFIABLE_REMOVAL_SENTENCE[f.source](f.program, inlineUntrusted(f.operand, 200)) };
for (const f of findings)
if (f.guard === "unverifiable_shell_text")
return { guard: f.guard, text: UNREADABLE_TEXT_SENTENCE[f.source](f.program, inlineUntrusted(f.text, 200)) };
return undefined;
}
const REMOVAL_RESOLVE_TIMEOUT_MS = 10_000;
export function removalReadingOf(command, req, seat, safeRoots = KNOWN_SAFE_ROOTS) {
const homeDir = seat?.env?.homeDir;
const cwd = req.cwd ?? req.root ?? "";
const roots = req.root !== undefined ? [req.root] : cwd !== "" ? [cwd] : [];
const boundary = { roots, ...(cwd !== "" ? { cwd } : {}), ...(homeDir !== undefined ? { homeDir } : {}) };
const plan = planShellReads(command, { readers: new Set(bashClassifyDefaultAllow()), cwd, roots, cdPath: seat?.env?.cdPath ?? "unknown", ...(homeDir !== undefined ? { homeDir } : {}) });
if (!planRunsRemoval(plan))
return undefined;
return {
async judge(signal) {
const env = seat?.env;
const resolved = env !== undefined && homeDir !== undefined ? await resolvedOrUndefined(env.canonicalPath(homeDir, signal), signal) : undefined;
const removalSeat = {
homes: [...(homeDir !== undefined ? [homeDir] : []), ...(resolved !== undefined && resolved.ok ? [resolved.value] : [])],
workspace: [...(req.root !== undefined ? [req.root] : []), ...(req.cwd !== undefined ? [req.cwd] : []), ...(seat?.additionalRoots?.() ?? [])],
};
const lexical = removalFindingsOf(establishShellReadsLexically(plan, boundary), removalSeat, safeRoots);
if (lexical.some((f) => f.guard === "dangerous_delete") || env === undefined || roots.length === 0)
return verdictOf(lexical);
const established = await resolvedOrUndefined(createShellReadEstablisher(env, roots, { rootsCanonical: true }).establish(plan, boundary, signal), signal);
return verdictOf(established === undefined ? lexical : [...removalFindingsOf(established, removalSeat, safeRoots), ...lexical]);
},
};
}
async function resolvedOrUndefined(leg, signal) {
let timer;
try {
return await Promise.race([
leg.catch(() => undefined),
new Promise((resolve) => {
timer = setTimeout(() => resolve(undefined), REMOVAL_RESOLVE_TIMEOUT_MS);
signal?.addEventListener("abort", () => resolve(undefined), { once: true });
}),
]);
}
finally {
if (timer !== undefined)
clearTimeout(timer);
}
}