UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

427 lines (426 loc) • 23 kB
import { errorResult } from "../../core/tools.js"; import { BASH_READONLY_CONFINEMENT_NOTE } from "./fs-shared.js"; import { isAbsoluteForFamily, normalizeAbsPathLexically, pathFamilyOf, violationDetails, withinAnyRoot } from "./safety.js"; import { checkedReadDenyHomes, fitMiddle, READ_DENY_SENTENCE_PART_MAX, readDenySentence } from "./read-deny.js"; import { NOT_AUTO_ALLOWED, programPositionRefusalSentence, readProgramPosition } from "./bash-program-position.js"; import { bashClassifyDefaultAllow } from "./bash-readonly-classifier.js"; import { planShellReads } from "./shell-read-plan.js"; import { createShellReadEstablisher, establishShellReadsLexically } from "./shell-read-establish.js"; import { declaredReadDenyHomes, homeViewsAt } from "./read-deny-home.js"; import { programArgs, spellsOption } from "./shell-read-write-targets.js"; import { allFindings, decideShellRead, denyRowAtOrBelow, foldShellReadFindings, judgeEstablishedPlan, readRootGrantCandidateOf, } from "./shell-read-judge.js"; import { seatOf } from "../../core/tool-name-readings.js"; import { normalizeReadRootCandidate } from "../../core/read-root-candidate.js"; import { argvOf, lexShellText } from "../../shell/lex.js"; function containmentDetails(paths) { return { type: "readonly_out_of_root", code: "readonly_out_of_root", paths }; } function unestablishedDetails() { return { type: "read_unestablished", code: "read_unestablished" }; } function findingDetails(finding, paths) { return finding === "unestablishable" || finding === "declined" ? unestablishedDetails() : containmentDetails(paths); } const REMOVAL_COMMANDS = new Set(["rm", "rmdir"]); const WRITES_BENEATH = { cp: () => true, mv: () => true, rm: (s) => s.argv.some((w) => spellsOption(w.folded, "recursive", "[rR]")), }; const isPattern = (f) => f.read.candidate.expands !== false && f.read.candidate.expands !== "unresolvable"; const RESHAPES_TREE = new Set(["mv"]); const anyOptionWord = (words) => words.some((w) => w.startsWith("-")); const UNPLACED_OPTIONS = { rmdir: (ws) => ws.some((w) => spellsOption(w, "parents", "p")), sed: (ws) => ws.some((w) => spellsOption(w, "file", "f")), cp: anyOptionWord, mv: anyOptionWord, }; function runOrderOf(judged) { const runs = []; const plans = []; let concurrent = false; const walk = (j) => { plans.push(j); for (const s of j.segments) { s.nested.forEach(walk); runs.push(s); if (j.plan.shape.backgrounded || s.segment.connector === "|") concurrent = true; } }; walk(judged); return { runs, plans, concurrent }; } function lineOrderPlaced(runs, concurrent) { const reshapes = (s) => RESHAPES_TREE.has(s.segment.verb?.basename ?? ""); const writes = (s) => s.findings.some((f) => f.read.candidate.face === "write"); const stepsUp = (s) => s.findings.some((f) => f.read.candidate.spelled.split(/[\\/]/).includes("..")); return runs.every((later, j) => runs.every((earlier, i) => i === j || (i > j && !concurrent) || (!reshapes(earlier) && !(writes(earlier) && stepsUp(later))))); } function writesPlacedOf(judged, boundary) { const writeRoots = boundary.writeRoots; if (writeRoots === undefined || writeRoots.length === 0) return false; const { runs, plans, concurrent } = runOrderOf(judged); const placed = (s) => { const verb = s.segment.verb?.basename ?? ""; if (s.segment.reentry === "shell" || s.segment.unreadProgramText !== undefined) return false; if (UNPLACED_OPTIONS[verb]?.(programArgs(s.segment).map((w) => w.spelled)) === true) return false; const beneath = WRITES_BENEATH[verb]?.(s.segment) === true; for (const f of s.findings) { if (f.read.candidate.face !== "write") continue; const target = f.read.target; if (f.read.established === "unestablishable" || target === undefined || isPattern(f) || !withinAnyRoot(writeRoots, target)) return false; if (beneath && denyRowAtOrBelow(f.read, boundary, !REMOVAL_COMMANDS.has(verb))) return false; if (REMOVAL_COMMANDS.has(verb) && writeRoots.some((root) => withinAnyRoot([target], root))) return false; } return true; }; return plans.every((p) => p.plan.unreadable === undefined) && runs.every(placed) && lineOrderPlaced(runs, concurrent); } function unplacedOptionsOf(judged) { for (const s of judged.segments) { const nested = s.nested.map(unplacedOptionsOf).find((u) => u !== undefined); if (nested !== undefined) return nested; const verb = s.segment.verb?.basename; if ((verb === "cp" || verb === "mv") && anyOptionWord(programArgs(s.segment).map((w) => w.spelled))) return { program: verb }; } return undefined; } async function writeGuardsOf(judged, boundary, placed, env, signal) { const protection = boundary.writeProtectionJudge; const guard = placed ? boundary.writeTargetGuard : undefined; if (protection === undefined && guard === undefined) return { unplaced: false, writeProtected: false }; const { runs, concurrent } = runOrderOf(judged); const asKey = new Map(); const asSpelling = new Map(); const landingKeys = new Set(); const writtenBy = runs.map(() => []); const add = (into, view, child, at) => { if (!isAbsoluteForFamily(pathFamilyOf({ root: view }), view)) return; const seen = into.get(view); into.set(view, seen === undefined ? { child, at } : { child: seen.child && child, at: Math.max(seen.at, at) }); }; runs.forEach((s, at) => { for (const f of s.findings) { const written = f.read.written; if (written === undefined || f.read.established === "unestablishable" || f.read.target === undefined) continue; const child = written.child === true; const key = f.read.established === "canonical" || f.read.established === "expansion"; add(key ? asKey : asSpelling, f.read.target, child, at); if (key && written.seat === "landing") landingKeys.add(f.read.target); if (written.seat === "landing") for (const spelling of f.read.spellings) add(asSpelling, spelling, child, at); writtenBy[at].push(f.read.target, ...f.read.spellings); } }); const probes = new Map(); const probe = (p) => { let r = probes.get(p); if (r === undefined) { r = env.canonicalPath(p, signal).catch(() => undefined); probes.set(p, r); } return r; }; const parentOf = (view) => normalizeAbsPathLexically(`${view}/..`); const writable = async (view, v) => { const at = await probe(view); if (at === undefined || at.ok) return true; if (at.error.code === "not_directory") return false; if (!v.child || at.error.code !== "not_found") return true; const dest = await probe(parentOf(view)); return dest === undefined || dest.ok || (dest.error.code !== "not_found" && dest.error.code !== "not_directory"); }; const changedBefore = (view, at) => { const fold = (p) => p.normalize("NFC").toLowerCase(); const dir = fold(parentOf(view)); return writtenBy.some((paths, i) => i !== at && (i < at || concurrent) && paths.some((w) => withinAnyRoot([fold(w)], dir) || withinAnyRoot([dir], fold(w)))); }; const answer = async (ask) => { try { return (await ask()) === true; } catch { return undefined; } }; const asked = [...[...asKey].map(([view, v]) => [view, v, true]), ...[...asSpelling].filter(([view]) => !landingKeys.has(view)).map(([view, v]) => [view, v, false])]; let unplaced = false; let writeProtected = false; for (const [view, v, key] of asked) { if (!(await writable(view, v)) && !changedBefore(view, v.at)) continue; const covered = protection === undefined ? false : await answer(() => protection(view, { ...(signal !== undefined ? { signal } : {}), ...(key ? { canonical: true } : {}) })); const guarded = guard === undefined ? false : await answer(() => guard(view, signal)); if (covered === true) writeProtected = true; if (covered !== false || guarded !== false) unplaced = true; if (writeProtected) break; } return { unplaced, writeProtected }; } export function readStationMemberOf(call, members) { return seatOf("fold.readStation", members, call); } const ADMISSION_MAX = 1024; function shellCommandOf(args) { const command = args?.command; return typeof command === "string" ? command : undefined; } function declineSentence(segment) { const lexed = lexShellText(segment.text); if (!("reject" in lexed) && lexed.segments[0] !== undefined) { const pos = readProgramPosition(argvOf(lexed.segments[0])); if (pos.refusal !== undefined) return programPositionRefusalSentence(pos.refusal); if (pos.argvFromStdin) { const runs = pos.program?.basename ?? "the program it runs"; return `"${segment.text.trim()}" hands ${runs} its operands on its standard input, which this station does not read ${NOT_AUTO_ALLOWED}`; } } return `"${segment.text.trim()}" runs a program this station cannot name ${NOT_AUTO_ALLOWED}`; } const ACCOUNT_PART_MAX = READ_DENY_SENTENCE_PART_MAX; const CAUSE_SPELLING_MAX = 64; function fitCause(cause) { return cause.replace(/^"([^"]*)"/, (_m, spelling) => `"${fitMiddle(spelling, CAUSE_SPELLING_MAX)}"`); } function findingAccount(fold, roots) { const allowed = fitMiddle(roots.length > 0 ? roots.join(", ") : "(none)", ACCOUNT_PART_MAX); const w = fold.witness; switch (fold.finding) { case "outside_roots": return `reads outside the allowed directories for this session: "${fitMiddle(w?.read.target ?? "?", ACCOUNT_PART_MAX)}" is not under ${allowed} ${NOT_AUTO_ALLOWED}`; case "unestablishable": if (w?.read.cause !== undefined) return `where this command reads could not be established: ${fitCause(w.read.cause)}, so it cannot be confirmed to read only inside the allowed directories for this session (${allowed}) ${NOT_AUTO_ALLOWED}`; if (fold.declined?.baseMove !== undefined) return `"${fitMiddle(fold.declined.text.trim(), ACCOUNT_PART_MAX)}" moves the working directory somewhere this command's text does not settle ${NOT_AUTO_ALLOWED}`; if (fold.declined?.unreadProgramText !== undefined) return `"${fitMiddle(fold.declined.text.trim(), ACCOUNT_PART_MAX)}": ${fold.declined.unreadProgramText}, so where it reads cannot be established ${NOT_AUTO_ALLOWED}`; if (fold.declined !== undefined) return `"${fitMiddle(fold.declined.text.trim(), ACCOUNT_PART_MAX)}" runs its argument as shell text this station does not parse ${NOT_AUTO_ALLOWED}`; return `the command's text could not be read as shell, so where it reads cannot be established ${NOT_AUTO_ALLOWED}`; case "declined": return fold.declined !== undefined ? declineSentence(fold.declined) : `the station could not read which program runs ${NOT_AUTO_ALLOWED}`; case "identity_changed": return `"${fitMiddle(w?.read.candidate.raw ?? "?", ACCOUNT_PART_MAX)}" resolves to "${fitMiddle(w?.read.target ?? "?", ACCOUNT_PART_MAX)}" now, not where it resolved when this call was judged`; case "deny_hit": case "none": return ""; default: { const exhaustive = fold.finding; return exhaustive; } } } function outsidePathsOf(judged) { const out = []; for (const f of allFindings(judged)) { if (f.kind === "outside_roots" && f.read.target !== undefined && !out.includes(f.read.target)) out.push(f.read.target); } return out; } function refusalOf(tool, fold, roots, paths) { const w = fold.witness; if (fold.finding === "deny_hit" && w?.deny !== undefined) { const target = w.read.target ?? w.deny.matchedView; const write = w.deny.face === "write"; const core = readDenySentence({ target, pattern: w.deny.pattern, matchedView: w.deny.matchedView, ...(write ? { face: "write" } : {}) }); const sentence = `Error (${tool}): ${core} ${write ? WRITE_DENY_REFUSAL_NOTE : DENY_REFUSAL_NOTE}`; return { sentence, ...(write ? {} : { details: violationDetails({ code: "read_path_denied", message: sentence, target, pattern: w.deny.pattern }) }) }; } const account = findingAccount(fold, roots); const sentence = `Error (${tool}): ${account}. ${BASH_READONLY_CONFINEMENT_NOTE}`; return { sentence, details: findingDetails(fold.finding, paths) }; } const WRITE_DENY_REFUSAL_NOTE = "The boundary judges this command's write targets — its output redirections and the operands of mkdir, touch, rm, " + "rmdir, mv, cp, tee and an in-place sed — against the write deny list; a denied write is refused here rather than escalated."; const DENY_REFUSAL_NOTE = "The read boundary judges this command's written spelling and, where a filesystem can be consulted, the " + "paths it would actually read; a deny-listed read is refused here rather than escalated."; function verdictOf(tool, seat, fold, judged, boundary) { const disposition = decideShellRead(fold.finding, seat); if (disposition === "vouch") return { disposition, finding: fold.finding }; const paths = outsidePathsOf(judged); const unestablished = fold.unestablished === true ? { unestablished: true } : {}; if (disposition === "refuse") { const r = refusalOf(tool, fold, boundary.roots, paths); return { disposition, finding: fold.finding, sentence: r.sentence, ...(r.details !== undefined ? { details: r.details } : {}) }; } const grant = fold.finding === "outside_roots" ? readRootGrantCandidateOf(judged, boundary) : undefined; const containment = fold.finding === "outside_roots" || fold.finding === "unestablishable"; return { disposition, finding: fold.finding, ...unestablished, sentence: findingAccount(fold, boundary.roots), ...(disposition === "mandated" && containment ? { details: findingDetails(fold.finding, paths) } : {}), ...(grant !== undefined ? { readRootCandidate: { dir: grant.dir, ...(grant.covers !== undefined ? { covers: grant.covers } : {}), clearsThisAsk: true } } : {}), }; } function declarationsOf(boundary, readers) { return { readers, cwd: boundary.cwd ?? boundary.roots[0] ?? "", roots: boundary.roots, ...(boundary.homeDir !== undefined ? { homeDir: boundary.homeDir } : {}), ...(boundary.face !== undefined ? { face: boundary.face } : {}) }; } function judgePlan(plan, established, boundary) { return judgeEstablishedPlan(plan, established.reads, boundary, (n) => { const nested = established.nested.get(n); return nested === undefined ? { plan: n, segments: [] } : judgePlan(n, nested, boundary); }); } async function applyAlsoInside(judged, boundary) { const alsoInside = boundary.alsoInside; if (alsoInside === undefined) return judged; const rescued = new Set(); for (const f of allFindings(judged)) { if (f.kind !== "outside_roots" || f.read.target === undefined || rescued.has(f.read.target)) continue; if (await alsoInside(f.read.target)) rescued.add(f.read.target); } if (rescued.size === 0) return judged; const rewrite = (j) => ({ plan: j.plan, segments: j.segments.map((s) => ({ segment: s.segment, findings: s.findings.map((f) => (f.kind === "outside_roots" && f.read.target !== undefined && rescued.has(f.read.target) ? { ...f, kind: "none", containment: "inside" } : f)), nested: s.nested.map(rewrite), })), }); return rewrite(judged); } async function runShellReadStation(tool, command, declared, seat, readers, establisher, env, signal) { const plan = planShellReads(command, declarationsOf(declared, readers)); const established = await establisher.establish(plan, declared, signal); const boundary = { ...declared, roots: established.roots }; const judged = await applyAlsoInside(judgePlan(plan, established, boundary), boundary); const judgedVerdict = verdictOf(tool, seat, foldShellReadFindings(judged), judged, boundary); const unplaced = unplacedOptionsOf(judged); const placed = writesPlacedOf(judged, boundary); const guarded = judgedVerdict.disposition === "refuse" ? { unplaced: false, writeProtected: false } : await writeGuardsOf(judged, boundary, placed, env, signal); const facts = guarded.writeProtected ? { ...judgedVerdict, writeProtected: true } : judgedVerdict; const verdict = placed && !guarded.unplaced ? { ...facts, writesPlaced: true } : unplaced !== undefined ? { ...facts, unplacedOptions: unplaced } : facts; return { plan, established, judged, boundary, verdict }; } export async function judgeShellRead(input) { const readers = input.readers ?? new Set(bashClassifyDefaultAllow()); const run = await runShellReadStation(input.tool ?? "Bash", input.command, input.boundary, input.seat, readers, createShellReadEstablisher(input.env, input.boundary.roots, { homeViews: homeViewsAt(input.env, input.readDenyHomes === undefined ? declaredReadDenyHomes(input.env) : checkedReadDenyHomes(input.readDenyHomes)) }), input.env, input.signal); const offer = normalizeReadRootCandidate(run.verdict.readRootCandidate); return { plan: run.plan, establishment: allFindings(run.judged).map((f) => f.read), verdict: run.verdict, ...(offer !== undefined ? { offer } : {}), }; } export function judgeShellReadLexically(command, boundary, seat, opts) { const readers = opts?.readers ?? new Set(bashClassifyDefaultAllow()); const plan = planShellReads(command, declarationsOf(boundary, readers)); const established = establishShellReadsLexically(plan, boundary); const judged = judgePlan(plan, established, boundary); return verdictOf(opts?.tool ?? "Bash", seat, foldShellReadFindings(judged), judged, boundary); } export function createShellReadStation(opts) { const readers = opts.readers ?? new Set(bashClassifyDefaultAllow()); const establishers = new Map(); const establisherFor = (roots) => { const key = roots.join("\0"); let e = establishers.get(key); if (e === undefined) { e = createShellReadEstablisher(opts.env, roots, { rootsCanonical: opts.rootsCanonical === true, ...(opts.homeViews !== undefined ? { homeViews: opts.homeViews } : {}) }); establishers.set(key, e); } return e; }; const admissions = new Map(); const remember = (toolCallId, a) => { if (admissions.size >= ADMISSION_MAX) { const oldest = admissions.keys().next().value; if (oldest !== undefined) admissions.delete(oldest); } admissions.set(toolCallId, a); }; const memberOf = (call) => readStationMemberOf(call, opts.tools)?.name; const check = { judge(call, args, cwd, signal, callOpts) { const member = memberOf(call); if (member === undefined) return null; const command = shellCommandOf(args); if (command === undefined) return null; const boundary = opts.boundaryOf(member, cwd); if (boundary === undefined) return null; const seat = callOpts.seat ?? opts.seatOf(member); return (async () => { const run = await runShellReadStation(call.toolName, command, boundary, seat, readers, establisherFor(boundary.roots), opts.env, signal); if (run.verdict.disposition !== "refuse" && callOpts.toolCallId !== undefined) remember(callOpts.toolCallId, { toolName: member, boundary: run.boundary, established: run.established, command }); return run.verdict; })(); }, unanswered(call, cause) { const toolName = call.toolName; const seat = opts.seatOf(memberOf(call) ?? toolName); const fold = { finding: "unestablishable" }; const disposition = decideShellRead("unestablishable", seat); const account = `${cause}, so where this command reads could not be established ${NOT_AUTO_ALLOWED}`; const details = unestablishedDetails(); if (disposition === "refuse") return { disposition, finding: "unestablishable", sentence: `Error (${toolName}): ${account}. ${BASH_READONLY_CONFINEMENT_NOTE}`, details }; return { disposition, finding: fold.finding, sentence: account, ...(disposition === "mandated" ? { details } : {}) }; }, }; const admit = async (toolName, toolCallId, command, signal) => { if (toolCallId === undefined) return undefined; const a = admissions.get(toolCallId); if (a === undefined) return undefined; admissions.delete(toolCallId); if (a.toolName !== toolName || a.command !== command) { return errorResult(`Error (${toolName}): the command that would run is not the one the read station judged for this call — refused before the spawn; re-submit it as a fresh call.`, containmentDetails([])); } const establisher = establisherFor(a.boundary.roots); const { changed } = await establisher.reestablish(a.established, a.boundary, signal); if (changed.length === 0) return undefined; const again = await establisher.establish(a.established.plan, a.boundary, signal); const judged = await applyAlsoInside(judgePlan(a.established.plan, again, a.boundary), a.boundary); const fold = foldShellReadFindings(judged); if (fold.finding === "deny_hit") { const r = refusalOf(toolName, fold, a.boundary.roots, outsidePathsOf(judged)); return errorResult(r.sentence, r.details); } const moved = changed[0]; const identity = { finding: "identity_changed", witness: { read: moved, kind: "none", containment: "unknown" } }; const r = refusalOf(toolName, identity, a.boundary.roots, []); return errorResult(r.sentence, containmentDetails(changed.map((c) => c.target).filter((t) => t !== undefined))); }; return { check, admit }; }