UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

279 lines (278 loc) • 14.3 kB
import { hasWin32AliasedComponent, normalizeAbsPathLexically, withinAnyRoot } from "./safety.js"; import { globInWord } from "./bash-readonly-classifier.js"; import { readOfText } from "./shell-read-word.js"; import { passesUnasked } from "./shell-read-device.js"; import { skipsRootsContainment } from "./containment-face.js"; export const SHELL_READ_FINDINGS = Object.freeze(["deny_hit", "outside_roots", "unestablishable", "declined", "identity_changed", "unanswered", "none"]); export const SHELL_READ_SEATS = Object.freeze(["full_shell", "readonly", "rejudge"]); export const SHELL_READ_FINDING_RANK = { identity_changed: 6, deny_hit: 5, unanswered: 4, outside_roots: 3, unestablishable: 2, declined: 1, none: 0, }; export function dispositionOfCell(cell, context = {}) { if (typeof cell === "string") return cell; if (context.redirectsOnly === true && cell.redirect !== undefined) return cell.redirect; return context.narrowedReadFace !== false ? cell.narrowed : cell.open; } export const SHELL_READ_DECISION = { deny_hit: { full_shell: "refuse", readonly: "refuse", rejudge: "refuse" }, outside_roots: { full_shell: { narrowed: "mandated", open: "mandated", redirect: "ordinary" }, readonly: "refuse", rejudge: "refuse" }, unestablishable: { full_shell: { narrowed: "mandated", open: "ordinary" }, readonly: "refuse", rejudge: "refuse" }, declined: { full_shell: "ordinary", readonly: "refuse", rejudge: "refuse" }, identity_changed: { full_shell: "refuse", readonly: "refuse", rejudge: "refuse" }, unanswered: { full_shell: "mandated", readonly: "refuse", rejudge: "refuse" }, none: { full_shell: "vouch", readonly: "vouch", rejudge: "vouch" }, }; export function decideShellRead(finding, seat, context = {}) { const row = Object.hasOwn(SHELL_READ_DECISION, finding) ? SHELL_READ_DECISION[finding] : undefined; const cell = row !== undefined && Object.hasOwn(row, seat) ? row[seat] : undefined; if (cell === undefined) { throw new Error(`shell read decision: no cell for finding ${JSON.stringify(finding)} on seat ${JSON.stringify(seat)} — the seats are ${SHELL_READ_SEATS.map((w) => JSON.stringify(w)).join(", ")} (a seat names the face the command runs on)`); } return dispositionOfCell(cell, context); } export function dispositionsOfRow(finding) { const row = Object.hasOwn(SHELL_READ_DECISION, finding) ? SHELL_READ_DECISION[finding] : undefined; return new Set(Object.values(row ?? {}).flatMap((cell) => (typeof cell === "string" ? [cell] : [cell.narrowed, cell.open, ...(cell.redirect !== undefined ? [cell.redirect] : [])]))); } export function judgeRead(read, boundary) { const writeTarget = read.candidate.face === "write"; const deny = writeTarget ? boundary.writeDenyMatch : boundary.denyMatch; if (deny !== undefined) { const views = [...(read.target !== undefined ? [read.target] : []), ...read.spellings]; for (const view of views) { const folded = normalizeAbsPathLexically(view); const pattern = writeTarget ? boundary.writeDenyMatch(folded) : boundary.denyMatch(folded); if (pattern !== null) return { read, kind: "deny_hit", deny: { pattern, matchedView: folded, ...(writeTarget ? { face: "write" } : {}) }, containment: writeTarget ? writeContainmentOf(read, boundary) : containmentOf(read, boundary) }; } if (!writeTarget && read.candidate.provenance === "traversal_root" && boundary.denyUnder !== undefined) { let unplaced; for (const view of views) { const folded = normalizeAbsPathLexically(view); const under = boundary.denyUnder(folded); if (typeof under === "string") return { read, kind: "deny_hit", deny: { pattern: under, matchedView: folded }, containment: containmentOf(read, boundary) }; if (under !== null && typeof under === "object") unplaced ??= under.unplaced; } if (unplaced !== undefined) { const cause = `the home-anchored deny row "${unplaced}" cannot be placed (no home directory is known for this environment), so "${read.target ?? read.candidate.spelled}" may hold it`; return { read: { ...read, cause }, kind: "unanswered", containment: containmentOf(read, boundary) }; } } } if (writeTarget) return judgeWrite(read, boundary); const containment = containmentOf(read, boundary); if (read.candidate.denyOnly === true) return { read, kind: "none", containment }; if (passesUnasked(read.candidate)) return { read, kind: "none", containment: "open" }; const provenance = read.candidate.provenance; if (provenance === "env_assignment" || (provenance === "program_path" && read.candidate.expands !== "unresolvable")) return { read, kind: "none", containment }; if (read.established === "unestablishable") return { read, kind: "unestablishable", containment }; return { read, kind: containment === "outside" ? "outside_roots" : "none", containment }; } export function denyRowAtOrBelow(read, boundary, carriesContent) { if (boundary.writeDenyMatch !== undefined) return true; if (!carriesContent) return false; const atOrBelow = boundary.denyAtOrBelow; if (atOrBelow === undefined) return false; return [...(read.target !== undefined ? [read.target] : []), ...read.spellings].some((view) => atOrBelow(normalizeAbsPathLexically(view)) !== null); } export function denyFloorShadowsOf(candidates) { return candidates.flatMap((c) => (c.floorSpelled === undefined ? [] : [{ spelled: c.floorSpelled, raw: c.raw, provenance: c.provenance, expands: globInWord(readOfText(c.raw).word) ? "glob" : false, bases: [], denyOnly: true, ...(c.face !== undefined ? { face: c.face } : {}) }])); } function containmentOf(read, boundary) { if (boundary.face === "open") return "open"; if (read.target === undefined) return "unknown"; return withinAnyRoot(boundary.roots, read.target) ? "inside" : "outside"; } function writeContainmentOf(read, boundary) { if (read.written === undefined || read.candidate.denyOnly === true || passesUnasked(read.candidate)) return "open"; const roots = boundary.writeRoots; if (roots === undefined || read.target === undefined) return "unknown"; return withinAnyRoot(roots, read.target) ? "inside" : "outside"; } function judgeWrite(read, boundary) { const containment = writeContainmentOf(read, boundary); if (containment === "open") return { read, kind: "none", containment }; if (read.established === "unestablishable" || boundary.writeRoots === undefined) return { read, kind: "unestablishable", containment }; return { read, kind: containment === "outside" && !skipsRootsContainment(boundary.writeFace) ? "outside_roots" : "none", containment }; } export function stationGuards(boundary) { return boundary.face !== "open" || boundary.denyMatch !== undefined || boundary.unreadPartitions === true; } export function writeFaceGuards(boundary) { return !skipsRootsContainment(boundary.writeFace) || boundary.writeDenyMatch !== undefined; } export function anyFaceGuards(boundary) { return stationGuards(boundary) || writeFaceGuards(boundary); } export function foldShellReadFindings(judged) { let best = { finding: "none" }; let unestablished = false; let unplacedRead = false; const raise = (next) => { if (next.finding === "unestablishable" || next.finding === "declined" || next.finding === "unanswered") { const face = next.witness?.read.candidate.face; if (!(next.witness === undefined ? judged.guards || judged.writeGuards : face === "write" ? judged.writeGuards : judged.guards)) return; unestablished = true; if (next.finding === "unestablishable" && face !== "write") unplacedRead = true; } const rank = SHELL_READ_FINDING_RANK[next.finding] - SHELL_READ_FINDING_RANK[best.finding]; if (rank > 0 || (rank === 0 && next.witness?.read.candidate.face === "write" && best.witness?.read.candidate.face !== "write")) best = next; }; const walk = (j, denyOnly) => { if (j.plan.unreadable !== undefined && !denyOnly) raise({ finding: "unestablishable" }); for (const s of j.segments) { for (const f of s.findings) { if (denyOnly && f.kind !== "deny_hit" && f.kind !== "unanswered") continue; if (f.kind !== "none") raise({ finding: f.kind, witness: f }); } if (!denyOnly) { if (s.segment.declines.length > 0) raise({ finding: "declined", declined: s.segment }); if (s.segment.reentry === "shell") raise({ finding: "unestablishable", declined: s.segment }); if (s.segment.unreadProgramText !== undefined) raise({ finding: "unestablishable", declined: s.segment }); const move = s.segment.baseMove; if (move !== undefined && (typeof move.target === "string" || move.target.expands !== false)) raise({ finding: "unestablishable", declined: s.segment }); } const nestedDenyOnly = denyOnly || s.segment.reentry === "shell"; for (const n of s.nested) walk(n, nestedDenyOnly); } }; walk(judged, false); const unfinished = judged.incomplete?.[0]; if (unfinished !== undefined && (judged.guards || judged.writeGuards) && SHELL_READ_FINDING_RANK.unanswered > SHELL_READ_FINDING_RANK[best.finding]) { unestablished = true; best = { finding: "unanswered", cause: unfinished.detail, ...(best.finding === "unestablishable" ? { ...(best.witness !== undefined ? { witness: best.witness } : {}), ...(best.declined !== undefined ? { declined: best.declined } : {}) } : {}) }; } return { ...best, ...(unestablished ? { unestablished: true } : {}), ...(unplacedRead ? { unplacedRead: true } : {}) }; } export function judgeEstablishedPlan(plan, established, boundary, nestedOf) { return { plan, segments: plan.segments.map((segment) => ({ segment, findings: (established.get(segment) ?? []).map((r) => judgeRead(r, boundary)), nested: segment.nested.map(nestedOf), })), guards: stationGuards(boundary), writeGuards: writeFaceGuards(boundary), }; } export function allFindings(judged) { const out = []; const walk = (j) => { for (const s of j.segments) { out.push(...s.findings); for (const n of s.nested) walk(n); } }; walk(judged); return out; } export function parentDirLexically(p) { return normalizeAbsPathLexically(`${p}/..`); } export function deepestCommonDir(a, b) { if (a === b) return a; const shorter = Math.min(a.length, b.length); let end = 0; let i = 0; while (i < shorter && a[i] === b[i]) { if (a[i] === "/" || a[i] === "\\") end = i; i++; } if (i === shorter && (a[shorter] === "/" || a[shorter] === "\\" || b[shorter] === "/" || b[shorter] === "\\")) end = shorter; const cut = a.slice(0, end); const candidate = cut.includes("/") || cut.includes("\\") ? cut : a.slice(0, end + 1); if (candidate.length === 0 || !withinAnyRoot([candidate], a) || !withinAnyRoot([candidate], b)) return undefined; return candidate; } export function readRootGrantCandidateOf(judged, boundary) { const outside = allFindings(judged).filter((f) => f.kind === "outside_roots"); if (outside.length === 0) return undefined; const targets = [...new Set(outside.map((f) => f.read.target))]; const paths = targets.filter((p) => !targets.some((q) => q !== p && withinAnyRoot([p], q))); if (outside.some((f) => f.read.candidate.spelled.split(/[\\/]/).includes(".."))) return undefined; if (paths.some(hasWin32AliasedComponent)) return undefined; const readsDirectory = new Set(outside .filter((f) => (f.read.candidate.provenance === "traversal_root" && f.read.node !== "file") || f.read.candidate.provenance === "cd_target") .map((f) => f.read.target)); const dirOf = (path) => (readsDirectory.has(path) ? path : parentDirLexically(path)); let candidate = dirOf(paths[0]); for (const path of paths.slice(1)) { if (candidate === undefined) return undefined; const d = dirOf(path); if (d === undefined) return undefined; candidate = deepestCommonDir(candidate, d); } if (candidate === undefined) return undefined; const proposed = grantOfferFor(candidate, paths.length === 1 ? paths[0] : undefined, boundary); if (proposed === undefined) return undefined; const widened = { ...boundary, roots: [...boundary.roots, proposed.dir] }; const replay = rejudge(judged, widened); return foldShellReadFindings(replay).finding === "none" ? proposed : undefined; } export function grantOfferFor(candidate, single, bounds) { const tooWide = parentDirLexically(candidate) === candidate || (bounds.homeDir !== undefined && withinAnyRoot([candidate], bounds.homeDir)) || bounds.roots.some((root) => withinAnyRoot([candidate], root)); return tooWide ? (single !== undefined ? { dir: single, covers: "exact" } : undefined) : { dir: candidate }; } export function rejudge(judged, boundary) { return { plan: judged.plan, segments: judged.segments.map((s) => ({ segment: s.segment, findings: s.findings.map((f) => judgeRead(f.read, boundary)), nested: s.nested.map((n) => rejudge(n, boundary)), })), guards: stationGuards(boundary), writeGuards: writeFaceGuards(boundary), ...(judged.incomplete !== undefined ? { incomplete: judged.incomplete } : {}), }; }