UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

569 lines (568 loc) • 28.7 kB
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; }