UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

234 lines (233 loc) • 14.6 kB
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); } }