@sema-agent/core
Version:
Stateless, task-oriented AI agent core
569 lines (568 loc) • 28.7 kB
JavaScript
import { canonicalRootsOnce, observeEnvironment } from "./shell-read-environment.js";
import { anchorSpelling, normalizeAbsPathLexically, pathFamilyOf } from "./safety.js";
import { boundaryIsTheSandbox } from "../../core/remote-env.js";
import { resolveOperandSpellings } from "./bash-readonly-classifier.js";
import { judgedCandidatesOf, sedBackupNames } from "./shell-read-write-targets.js";
import { movedReads, withHomeViews } from "./shell-read-identity.js";
import { passesUnasked } from "./shell-read-device.js";
const GLOB_METACHAR_RE = /[*?[]/;
export const MAX_GLOB_DIRS = 256;
export const MAX_GLOB_MATCHES = 8192;
const WRITE_LINK_HOPS = 32;
function compileGlobSegment(pattern) {
if (pattern.includes("\\"))
return undefined;
let src = "";
for (let i = 0; i < pattern.length; i++) {
const ch = pattern[i];
if (ch === "*") {
src += "[^/]*";
}
else if (ch === "?") {
src += "[^/]";
}
else if (ch === "[") {
const cls = parseBracketExpression(pattern, i);
if (cls === undefined)
src += "\\[";
else {
src += cls.source;
i = cls.end;
}
}
else {
src += ch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
}
try {
return new RegExp(`^${src}$`, "s");
}
catch {
return undefined;
}
}
function parseBracketExpression(pattern, start) {
let i = start + 1;
let negate = false;
if (pattern[i] === "!" || pattern[i] === "^") {
negate = true;
i++;
}
const contentStart = i;
let body = "";
let anyChar = false;
for (; i < pattern.length; i++) {
const ch = pattern[i];
if (ch === "]" && i > contentStart) {
if (body.length === 0 && !anyChar)
return undefined;
return { source: anyChar ? "[^/]" : `[${negate ? "^" : ""}${body}]`, end: i };
}
if (ch === "[" && (pattern[i + 1] === ":" || pattern[i + 1] === "." || pattern[i + 1] === "=")) {
anyChar = true;
const close = pattern.indexOf(`${pattern[i + 1]}]`, i + 2);
if (close >= 0 && (pattern[i + 1] === ":" ? /^[A-Za-z]+$/.test(pattern.slice(i + 2, close)) : close === i + 3))
i = close + 1;
continue;
}
body += ch === "-" ? "-" : ch.replace(/[\]\\^]/g, "\\$&");
}
return undefined;
}
function globSegmentMatches(re, pattern, name) {
if (name.startsWith(".") && !pattern.startsWith("."))
return false;
return re.test(name);
}
function pathSegmentsFrom(p, from, posix = false) {
const segs = [];
const isSep = (c) => c === "/" || (!posix && c === "\\");
let scan = from;
while (scan < p.length) {
while (scan < p.length && isSep(p[scan]))
scan++;
const s = scan;
while (scan < p.length && !isSep(p[scan]))
scan++;
if (scan > s)
segs.push({ text: p.slice(s, scan), start: s });
}
return segs;
}
function basesFor(candidate, boundary) {
if (candidate.bases === "unmodelled")
return "unmodelled";
if (candidate.bases.length > 0)
return candidate.bases;
return [boundary.cwd ?? boundary.roots[0] ?? ""];
}
function lexicalReadsOf(candidate, boundary) {
if (candidate.expands === "unresolvable") {
return [{ candidate, established: "unestablishable", spellings: [candidate.spelled], cause: `"${candidate.raw}" is rewritten by the shell before the program sees it` }];
}
const bases = basesFor(candidate, boundary);
if (bases === "unmodelled") {
const floors = candidate.floorBases ?? [boundary.cwd ?? boundary.roots[0] ?? ""];
const spellings = [candidate.spelled];
for (const floorBase of floors) {
const viaFloor = floorBase === "" ? undefined : resolveOperandSpellings(floorBase, candidate.spelled, boundary.homeDir);
if (viaFloor === undefined)
continue;
spellings.push(viaFloor.folded);
if (viaFloor.spelled !== undefined)
spellings.push(viaFloor.spelled);
}
return [{ candidate, established: "unestablishable", spellings, cause: `"${candidate.raw}" is relative to a working directory this command's text does not settle` }];
}
const out = [];
const seen = new Set();
for (const base of bases) {
const sp = resolveOperandSpellings(base === "" ? undefined : base, candidate.spelled, boundary.homeDir);
if (sp === undefined) {
const key = `?${candidate.spelled}`;
if (!seen.has(key)) {
seen.add(key);
out.push({ candidate, established: "unestablishable", spellings: [candidate.spelled], cause: `"${candidate.raw}" cannot be placed against ${base === "" ? "no working directory" : `"${base}"`}` });
}
continue;
}
if (seen.has(sp.folded))
continue;
seen.add(sp.folded);
out.push({ candidate, established: "lexical", target: sp.folded, spellings: sp.spelled !== undefined ? [sp.folded, sp.spelled] : [sp.folded] });
}
return out;
}
function writtenByText(plan, boundary) {
const targets = new Set();
const speculativeSeen = new Set();
let unplaced = false;
const walk = (p) => {
for (const s of p.segments) {
for (const c of s.reads) {
if (c.speculative === undefined || c.expands !== false || c.bases === "unmodelled")
continue;
for (const r of lexicalReadsOf(c, boundary)) {
if (r.target === undefined)
continue;
const t = normalizeAbsPathLexically(r.target);
if (speculativeSeen.has(t))
targets.add(t);
speculativeSeen.add(t);
}
}
for (const j of judgedCandidatesOf(s)) {
if (j.candidate.face !== "write" || j.onlyIfExists !== undefined)
continue;
if (j.candidate.expands !== false || j.candidate.bases === "unmodelled") {
unplaced = true;
continue;
}
for (const r of lexicalReadsOf(j.candidate, boundary)) {
if (r.target === undefined)
unplaced = true;
else
targets.add(normalizeAbsPathLexically(r.target));
}
}
for (const n of s.nested)
walk(n);
}
};
walk(plan);
return { targets, unplaced };
}
function unfollowedBackupReads(j, boundary, cause) {
const spellings = sedBackupNames(j.followed.operand.spelled, j.followed.suffix).flatMap((name) => lexicalReadsOf({ ...j.candidate, spelled: name }, boundary).flatMap((r) => [...(r.target !== undefined ? [r.target] : []), ...r.spellings]));
const read = { candidate: j.candidate, established: "unestablishable", spellings: [...new Set(spellings)], cause };
return (j.seats ?? ["landing"]).map((seat) => writtenAt(read, j, seat));
}
function writtenAt(read, j, seat) {
if (read.candidate.face !== "write" || !(j.written ?? j.seats ?? ["landing"]).includes(seat))
return read;
return { ...read, written: { seat, ...(j.child === true ? { child: true } : {}) } };
}
export function establishShellReadsLexically(plan, boundary) {
const reads = new Map();
const nested = new Map();
for (const segment of plan.segments) {
reads.set(segment, judgedCandidatesOf(segment).flatMap((j) => {
if (j.followed !== undefined)
return unfollowedBackupReads({ ...j, followed: j.followed }, boundary, `"${j.followed.operand.raw}" names its backup through a link chain only the filesystem can follow`);
const seat = (j.written ?? j.seats ?? ["landing"]).includes("landing") ? "landing" : "entry";
return lexicalReadsOf(j.candidate, boundary).map((r) => writtenAt(r, j, seat));
}));
for (const n of segment.nested)
nested.set(n, establishShellReadsLexically(n, boundary));
}
return { plan, reads, nested, roots: boundary.roots };
}
export function createShellReadEstablisher(env, readRoots, opts) {
const lexicalOnly = boundaryIsTheSandbox(env);
const canonicalRoots = canonicalRootsOnce(env, readRoots, opts?.rootsCanonical === true);
const legOver = (seen) => establishLegOver(seen, readRoots);
return {
lexicalOnly,
establish: async (plan, boundary, signal, door = observeEnvironment(env)) => {
if (lexicalOnly)
return establishShellReadsLexically(plan, boundary);
const { roots, incomplete } = await canonicalRoots(signal);
door.adopt(incomplete);
return await withHomeViews(door, opts, await legOver(door)(plan, { ...boundary, roots }, roots, signal), signal);
},
reestablish: async (previous, boundary, signal) => {
if (lexicalOnly)
return { changed: [] };
const again = await legOver(observeEnvironment(env))(previous.plan, { ...boundary, roots: previous.roots }, previous.roots, signal);
return { changed: movedReads(previous, again) };
},
};
}
function establishLegOver(seen, readRoots) {
const env = seen.fs;
const resolverIsAuthoritative = seen.authoritative;
const bounded = (detail) => (seen.bound(detail), { unverifiable: detail });
const family = pathFamilyOf({ root: readRoots[0] ?? "/" });
const SEP = family === "posix" ? /\// : /[\\/]/;
const TRAILING_SEP = family === "posix" ? /\/+$/ : /[\\/]+$/;
const joinUnder = (dir, name) => `${dir.replace(TRAILING_SEP, "")}/${name}`;
const resolveWithoutParentSteps = async (p, signal, writeTarget = false, linkHops = WRITE_LINK_HOPS) => {
const parts = p.split(SEP);
const tail = [];
let allAbsent = true;
while (parts.length > 0) {
const at = parts.join("/") || "/";
const canon = await env.canonicalPath(at, signal);
if (!canon.ok && canon.error.code === "not_found" && writeTarget && allAbsent) {
const entry = await env.fileInfo(at, signal);
const link = entry.ok && entry.value.kind === "symlink" ? (linkHops > 0 ? await env.readLink?.(at, signal) : void bounded(`"${at}" is a link chain of more than ${WRITE_LINK_HOPS} links, too many to follow to verify where it lands`)) : undefined;
if (link?.ok === true) {
const landing = anchorSpelling(family, parts.slice(0, -1).join("/") || "/", link.value);
return await resolve([landing, ...[...tail].reverse()].join("/"), signal, true, linkHops - 1);
}
if (entry.ok || entry.error.code !== "not_found")
allAbsent = false;
}
if (canon.ok) {
if (tail.length === 0)
return { value: canon.value, exact: true };
return { value: [canon.value.replace(TRAILING_SEP, ""), ...[...tail].reverse()].join("/"), exact: false, missing: allAbsent };
}
if (canon.error.code !== "not_found" && canon.error.code !== "not_directory")
allAbsent = false;
tail.push(parts.pop());
}
return undefined;
};
const parentOf = (canonical) => normalizeAbsPathLexically(`${canonical}/..`);
const resolve = async (p, signal, writeTarget = false, linkHops = WRITE_LINK_HOPS) => {
const parts = p.split(SEP);
const first = parts.findIndex((seg) => seg === "..");
if (first < 0)
return await resolveWithoutParentSteps(p, signal, writeTarget, linkHops);
const headEnd = parts.slice(0, first).reduce((n, seg) => n + seg.length + 1, 0);
let head = await resolveWithoutParentSteps(p.slice(0, headEnd) || "/", signal, writeTarget, linkHops);
if (head === undefined)
return undefined;
let pending = [];
const flush = async () => {
if (pending.length === 0)
return true;
const next = await resolveWithoutParentSteps([head.value.replace(TRAILING_SEP, ""), ...pending].join("/"), signal, writeTarget, linkHops);
pending = [];
if (next === undefined)
return false;
head = next;
return true;
};
for (let i = first; i < parts.length; i++) {
const seg = parts[i];
if (seg === "" || seg === ".")
continue;
if (seg !== "..") {
pending.push(seg);
continue;
}
if (!(await flush()))
return undefined;
if (!head.exact)
return head;
head = { value: parentOf(head.value), exact: true };
}
return (await flush()) ? head : undefined;
};
const literalRootHead = (p, roots) => {
let best;
for (const r of [...readRoots, ...roots]) {
if (!r)
continue;
const withSep = r.endsWith("/") || r.endsWith("\\") ? r : `${r}/`;
if (p !== r && !p.startsWith(withSep))
continue;
if (best === undefined || r.length > best.length)
best = r;
}
return best;
};
const expandGlobCandidate = async (p, roots, signal) => {
const head = literalRootHead(p, roots);
let segs = pathSegmentsFrom(p, head?.length ?? 0, family === "posix");
let first = segs.findIndex((s) => GLOB_METACHAR_RE.test(s.text));
if (first < 0 && head !== undefined && GLOB_METACHAR_RE.test(head)) {
segs = pathSegmentsFrom(p, 0, family === "posix");
first = segs.findIndex((s) => GLOB_METACHAR_RE.test(s.text));
}
if (first < 0)
return undefined;
let frontier = [p.slice(0, segs[first].start).replace(TRAILING_SEP, "") || "/"];
let dirsUsed = 0;
let matches = 0;
const canonicalOfMatch = new Map();
for (let j = first; j < segs.length; j++) {
const seg = segs[j].text;
if (!GLOB_METACHAR_RE.test(seg)) {
frontier = frontier.map((d) => joinUnder(d, seg));
continue;
}
const re = compileGlobSegment(seg);
if (re === undefined)
return { unverifiable: `the pattern "${seg}" cannot be expanded to verify what it would read` };
const isLast = j === segs.length - 1;
const next = [];
for (const dir of frontier) {
if (++dirsUsed > MAX_GLOB_DIRS)
return bounded(`"${p}" expands over more than ${MAX_GLOB_DIRS} directories, too many to enumerate to verify`);
const canonDir = await resolve(dir, signal);
if (canonDir !== undefined && canonDir.missing === true)
continue;
if (canonDir !== undefined && !canonDir.exact)
return { unverifiable: `"${dir}" cannot be resolved to verify what "${seg}" would expand to` };
const listed = await env.listDir(canonDir?.value ?? dir, signal);
if (!listed.ok) {
if (listed.error.code === "not_found" || listed.error.code === "not_directory")
continue;
return { unverifiable: `"${dir}" cannot be enumerated to verify what "${seg}" would expand to (${listed.error.code})` };
}
if (seg.startsWith(".")) {
for (const dot of [".", ".."])
if (re.test(dot))
next.push(joinUnder(dir, dot));
}
const canonDirHere = isLast ? canonDir?.value : undefined;
for (const entry of listed.value) {
if (!globSegmentMatches(re, seg, entry.name))
continue;
if (++matches > MAX_GLOB_MATCHES)
return bounded(`"${p}" expands to more than ${MAX_GLOB_MATCHES} names, too many to enumerate to verify`);
const candidate = joinUnder(dir, entry.name);
if (canonDirHere !== undefined && entry.kind !== "symlink")
canonicalOfMatch.set(candidate, joinUnder(canonDirHere, entry.name));
next.push(candidate);
}
}
frontier = next;
if (frontier.length === 0)
return { paths: [], canonical: canonicalOfMatch };
}
return { paths: frontier, canonical: canonicalOfMatch };
};
const rootKindOf = async (rootCanonical, signal) => {
const info = await env.fileInfo(rootCanonical, signal);
if (!info.ok)
return undefined;
return info.value.kind === "directory" ? "directory" : info.value.kind === "file" ? "file" : undefined;
};
const entryOf = async (spelling, signal) => {
const parts = spelling.split(SEP);
const leaf = parts.pop() ?? "";
if (leaf === "" || leaf === "." || leaf === ".." || parts.length === 0)
return await resolve(spelling, signal, true);
const parent = await resolve(parts.join("/") || "/", signal, true);
if (parent === undefined)
return undefined;
return { value: joinUnder(parent.value, leaf), exact: parent.exact, ...(parent.missing !== undefined ? { missing: parent.missing } : {}) };
};
const canonicalReadOf = async (candidate, spelling, spellings, established, origin, canonicalKnown, signal, seat = "landing") => {
const real = canonicalKnown !== undefined ? { value: canonicalKnown, exact: true } : seat === "entry" ? await entryOf(spelling, signal) : await resolve(spelling, signal, candidate.face === "write");
if (real === undefined) {
if (resolverIsAuthoritative)
return { candidate, established: "unestablishable", spellings, ...(origin !== undefined ? { origin } : {}), cause: `"${spelling}" cannot be resolved to verify where it reads`, unresolved: true };
return { candidate, established: "lexical", target: normalizeAbsPathLexically(spelling), spellings, ...(origin !== undefined ? { origin } : {}) };
}
if (!real.exact && real.missing !== true) {
return { candidate, established: "unestablishable", spellings, ...(origin !== undefined ? { origin } : {}), cause: `"${spelling}" cannot be resolved to verify where it reads`, unresolved: true };
}
return { candidate, established, target: real.value, spellings, ...(origin !== undefined ? { origin } : {}) };
};
const followedName = async (spelled, base, homeDir, signal) => {
let name = spelled;
for (let hop = 0; hop <= WRITE_LINK_HOPS; hop++) {
const at = resolveOperandSpellings(base === "" ? undefined : base, name, homeDir);
if (at === undefined)
return undefined;
const walked = at.spelled ?? at.folded;
if (TRAILING_SEP.test(walked))
return name;
const parts = walked.split(SEP);
const leaf = parts.pop() ?? "";
if (leaf === "" || leaf === "." || leaf === "..")
return name;
const parent = await resolve(parts.join("/") || "/", signal);
if (parent === undefined)
return undefined;
if (!parent.exact)
return parent.missing === true ? name : undefined;
const entry = await env.fileInfo(joinUnder(parent.value, leaf), signal);
if (!entry.ok)
return entry.error.code === "not_found" || entry.error.code === "not_directory" ? name : undefined;
if (entry.value.kind !== "symlink")
return name;
const link = await env.readLink?.(joinUnder(parent.value, leaf), signal);
if (link?.ok !== true)
return undefined;
const dir = name.slice(0, name.search(/[^/]*$/));
name = dir === "" ? link.value : anchorSpelling(family, dir, link.value);
}
return void bounded(`"${spelled}" is a link chain of more than ${WRITE_LINK_HOPS} links, too many to follow to verify where its backup is written`);
};
const followedBackupReads = async (j, boundary, signal) => {
const { operand, suffix } = j.followed;
const bases = basesFor(operand, boundary);
const unfollowed = (cause) => unfollowedBackupReads(j, boundary, cause);
if (bases === "unmodelled")
return unfollowed(`"${operand.raw}" is relative to a working directory this command's text does not settle`);
const out = [];
for (const base of bases) {
const name = await followedName(operand.spelled, base, boundary.homeDir, signal);
if (name === undefined) {
out.push(...unfollowed(`"${operand.raw}" names its backup through a link chain that could not be read`));
continue;
}
for (const backup of sedBackupNames(name, suffix)) {
const at = resolveOperandSpellings(base === "" ? undefined : base, backup, boundary.homeDir);
if (at === undefined) {
out.push(...unfollowed(`"${backup}" cannot be placed against "${base}"`));
continue;
}
const spellings = at.spelled !== undefined ? [at.folded, at.spelled] : [at.folded];
for (const seat of j.seats ?? ["landing"])
out.push(writtenAt(await canonicalReadOf(j.candidate, at.spelled ?? at.folded, spellings, "canonical", undefined, undefined, signal, seat), j, seat));
}
}
return out;
};
const confirmedMissing = async (candidate, boundary, signal) => {
if (candidate.expands !== false)
return false;
const reads = lexicalReadsOf(candidate, boundary);
if (reads.length === 0)
return false;
for (const r of reads) {
if (r.established !== "lexical" || r.target === undefined)
return false;
const real = await resolve(r.target, signal);
if (real === undefined || real.exact || real.missing !== true)
return false;
}
return true;
};
const establishSegment = async (segment, boundary, roots, written, signal) => {
const out = [];
const missing = new Map();
for (const listed of judgedCandidatesOf(segment)) {
let judged = listed;
const speculative = judged.candidate.speculative;
if (speculative !== undefined && !written.unplaced) {
const c = judged.candidate;
if (!missing.has(c))
missing.set(c, (await confirmedMissing(c, boundary, signal)) && !lexicalReadsOf(c, boundary).some((r) => r.target !== undefined && written.targets.has(normalizeAbsPathLexically(r.target))));
if (missing.get(c) === true) {
if (speculative === "whole")
continue;
judged = { ...judged, candidate: { ...c, denyOnly: true } };
}
}
const decides = judged.onlyIfExists;
if (decides !== undefined) {
if (!missing.has(decides))
missing.set(decides, await confirmedMissing(decides, boundary, signal));
if (missing.get(decides) === true)
continue;
}
if (judged.followed !== undefined) {
out.push(...(await followedBackupReads({ ...judged, followed: judged.followed }, boundary, signal)));
continue;
}
const candidate = judged.candidate;
const traversal = candidate.provenance === "traversal_root";
const resolvesItself = TRAILING_SEP.test(candidate.spelled) || [".", ".."].includes(candidate.spelled.split(SEP).pop() ?? "");
const landsToo = (ss) => (resolvesItself && ss.length > 0 && !ss.includes("landing") ? [...ss, "landing"] : ss);
const seats = judged.seats === undefined ? ["landing"] : landsToo(judged.seats);
const writtenSeats = { ...judged, written: judged.written === undefined ? seats : landsToo(judged.written) };
const atSeats = async (spelling, spellings, established, origin, canonicalKnown) => {
const reads = [];
for (const seat of seats) {
const read = writtenAt(await canonicalReadOf(candidate, spelling, spellings, established, origin, canonicalKnown, signal, seat), writtenSeats, seat);
const same = reads.findIndex((r) => r.established === read.established && r.target === read.target);
if (same < 0)
reads.push(read);
else if (reads[same].written === undefined && read.written !== undefined)
reads[same] = read;
}
return reads;
};
const land = async (read) => {
if (!traversal || (read.established !== "canonical" && read.established !== "expansion") || read.target === undefined) {
out.push(read);
return;
}
const node = await rootKindOf(read.target, signal);
out.push({ ...read, ...(node !== undefined ? { node } : {}) });
};
for (const [i, lexical] of lexicalReadsOf(candidate, boundary).entries()) {
if (lexical.established !== "lexical" || passesUnasked(candidate)) {
out.push(writtenAt(lexical, writtenSeats, writtenSeats.written.includes("landing") ? "landing" : "entry"));
continue;
}
const folded = lexical.target;
const spellings = lexical.spellings;
const walked = spellings.find((s) => s !== folded) ?? folded;
if (candidate.expands === "glob") {
let matched = false;
for (const p of candidate.pattern === undefined ? spellings : lexicalReadsOf({ ...candidate, spelled: candidate.pattern }, boundary)[i]?.spellings ?? spellings) {
const expanded = await expandGlobCandidate(p, roots, signal);
if (expanded !== undefined && "unverifiable" in expanded) {
out.push({ candidate, established: "unestablishable", spellings, origin: folded, cause: expanded.unverifiable });
matched = true;
continue;
}
if (expanded !== undefined && expanded.paths.length > 0) {
matched = true;
for (const t of expanded.paths)
for (const read of await atSeats(t, [t, folded], "expansion", folded, expanded.canonical.get(t)))
await land(read);
}
}
if (!matched || candidate.pattern !== undefined)
for (const read of await atSeats(walked, spellings, "canonical", undefined, undefined))
await land(read);
continue;
}
for (const read of await atSeats(walked, spellings, "canonical", undefined, undefined))
await land(read);
}
}
return out;
};
const establishPlan = async (plan, boundary, roots, signal, written = writtenByText(plan, boundary)) => {
const reads = new Map();
const nested = new Map();
for (const segment of plan.segments) {
reads.set(segment, await establishSegment(segment, boundary, roots, written, signal));
for (const n of segment.nested)
nested.set(n, await establishPlan(n, boundary, roots, signal, written));
}
return { plan, reads, nested, roots };
};
return establishPlan;
}