UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

462 lines (461 loc) • 24.5 kB
import { anchorSpelling, canonicalizeTarget, isAbsoluteForFamily, isWinFormPath, writeTargetPath, isUncPath, pathFamilyOf } from "../tools/fs/safety.js"; import { judgePathRows } from "./path-guard-rows.js"; import { protectivePathTargetOf } from "./tool-registry.js"; import { pathTargetBaseOf } from "./effective-path-target.js"; import { canonicalDataRootKeys, dataRootSpellings, relativeUnderRoot } from "./sensitive-path-policy.js"; import { chainHopsOrNone, symlinkChainHops } from "./path-identity.js"; import { readDenyPatternRun } from "../tools/fs/read-deny.js"; import { agentConfigDirSegments, agentConfigDirTail } from "./agent-config-dir.js"; import { thrownText } from "./thrown-value.js"; import { fromSource, gateWaitFailureOf } from "./gate-bounded-await.js"; const freezeTable = (rows) => Object.freeze(rows.map((r) => Object.freeze(r))); export const WRITE_PROTECTED_DEFAULT_TABLE = freezeTable([ { name: ".gitconfig", kind: "basename" }, { name: ".gitmodules", kind: "basename" }, { name: ".bashrc", kind: "basename" }, { name: ".bash_profile", kind: "basename" }, { name: ".zshrc", kind: "basename" }, { name: ".zprofile", kind: "basename" }, { name: ".profile", kind: "basename" }, { name: ".zshenv", kind: "basename" }, { name: ".zlogin", kind: "basename" }, { name: ".zlogout", kind: "basename" }, { name: ".bash_login", kind: "basename" }, { name: ".bash_aliases", kind: "basename" }, { name: ".bash_logout", kind: "basename" }, { name: ".envrc", kind: "basename" }, { name: ".ripgreprc", kind: "basename" }, { name: ".mcp.json", kind: "basename" }, { name: ".claude.json", kind: "basename" }, { name: ".npmrc", kind: "basename" }, { name: ".yarnrc", kind: "basename" }, { name: ".yarnrc.yml", kind: "basename" }, { name: ".pnp.cjs", kind: "basename" }, { name: ".pnp.loader.mjs", kind: "basename" }, { name: ".pnpmfile.cjs", kind: "basename" }, { name: "bunfig.toml", kind: "basename" }, { name: ".bunfig.toml", kind: "basename" }, { name: ".bazelrc", kind: "basename" }, { name: ".bazelversion", kind: "basename" }, { name: ".bazeliskrc", kind: "basename" }, { name: ".pre-commit-config.yaml", kind: "basename" }, { name: "lefthook.yml", kind: "basename" }, { name: ".lefthook.yml", kind: "basename" }, { name: "lefthook.yaml", kind: "basename" }, { name: ".lefthook.yaml", kind: "basename" }, { name: "gradle-wrapper.properties", kind: "basename" }, { name: "maven-wrapper.properties", kind: "basename" }, { name: ".devcontainer.json", kind: "basename" }, { name: "pyrightconfig.json", kind: "basename" }, { name: ".git", kind: "segment" }, { name: ".vscode", kind: "segment" }, { name: ".idea", kind: "segment" }, { name: ".claude", kind: "segment" }, { name: ".husky", kind: "segment" }, { name: ".cargo", kind: "segment" }, { name: ".devcontainer", kind: "segment" }, { name: ".yarn", kind: "segment" }, { name: ".mvn", kind: "segment" }, { name: ".config/git", kind: "segment-run" }, { name: ".ssh", kind: "segment" }, { name: ".gnupg", kind: "segment" }, { name: ".sema.json", kind: "basename" }, { name: ".sema", kind: "segment" }, { name: "credentials.env", kind: "basename" }, ]); export function foldWriteProtectSegment(s) { const cased = s.toLowerCase().replace(/ı/g, "i").replace(/ſ/g, "s"); return stripInvisibleFormat(cased) || cased; } function stripInvisibleFormat(s) { return s.replace(/[\u200c-\u200f\u202a-\u202e\u206a-\u206f\ufeff]/g, ""); } function stripWin32Component(raw, normalization) { const base = normalization.invisibleFirst ? stripInvisibleFormat(raw) : raw; return (normalization.stream ? base.replace(/:.*$/, "") : base).replace(/[. ]+$/, ""); } function foldWriteProtectName(name) { return name.split("/").map(foldWriteProtectSegment).join("/"); } const KINDS = ["basename", "segment", "segment-run"]; function describeEntryValue(v) { try { return JSON.stringify(v) ?? String(v); } catch { return String(v); } } function normalizeWriteProtectedEntry(entry, owner = "writeProtectedPaths") { const shape = typeof entry === "string" ? { name: entry, kind: entry.includes("/") ? "segment-run" : "segment" } : entry; if (typeof shape !== "object" || shape === null || typeof shape.name !== "string") { throw new Error(`${owner}: entry ${describeEntryValue(entry)} is not a name string or { name, kind } row.`); } const name = shape.name; if (!KINDS.includes(shape.kind)) { throw new Error(`${owner}: entry ${describeEntryValue(entry)} has kind ${describeEntryValue(shape.kind)} — known kinds: ${KINDS.join(", ")}.`); } const kind = shape.kind; const { homeAnchored, run } = readDenyPatternRun(name); if (homeAnchored) { throw new Error(`${owner}: entry ${describeEntryValue(name)} opens with the home word "~", which this table does not place — its names are matched wherever their segments appear. ` + (run.length > 0 ? `Spell the name without it (${describeEntryValue(run)}), which covers the home's file and every same-named one.` : "The home directory itself is not a row; spell the name of what it holds.")); } if (name.includes("*") || name.includes("?")) { throw new Error(`${owner}: entry ${describeEntryValue(name)} contains a wildcard metacharacter — this table speaks LITERAL names only (a silently-literal "*" would guard less than it reads); pattern semantics live in createSensitivePathPolicy.`); } if (name.includes("\\")) { throw new Error(`${owner}: entry ${describeEntryValue(name)} contains a backslash — names are "/"-separated (both path families are matched); spell the segments with "/".`); } const segments = name.split("/").filter((s) => s.length > 0); if (segments.length === 0) { throw new Error(`${owner}: entry ${describeEntryValue(name)} contains no path segments and would protect nothing — remove the entry or spell the name.`); } if (segments.some((s) => s === "." || s === "..")) { throw new Error(`${owner}: entry ${describeEntryValue(name)} contains a "." or ".." segment — the judge folds dot segments lexically before matching, so such a row could never match anything; spell the real name.`); } if (segments.length > 1 && kind !== "segment-run") { throw new Error(`${owner}: entry ${describeEntryValue(name)} spans ${segments.length} segments but declares kind "${kind}" — multi-segment names match as kind "segment-run".`); } return { name: segments.join("/"), kind }; } export function writeProtectedEntryProblem(entry, owner) { try { normalizeWriteProtectedEntry(entry, owner); return undefined; } catch (e) { return e.message; } } export function writeProtectedEntryIdentity(entry) { const row = normalizeWriteProtectedEntry(entry); return `${row.kind}:${foldWriteProtectName(row.name)}`; } export function resolveWriteProtectedTable(entries) { if (entries === undefined) return WRITE_PROTECTED_DEFAULT_TABLE; if (!Array.isArray(entries)) { throw new Error(`writeProtectedPaths: expected an array of entries (whole-table replacement; [] = no write-protection table), got ${describeEntryValue(entries)}.`); } const out = []; const seen = new Set(); for (const entry of entries) { const row = normalizeWriteProtectedEntry(entry); const key = writeProtectedEntryIdentity(row); if (seen.has(key)) continue; seen.add(key); out.push(row); } return freezeTable(out); } function pathViewSegments(path, normalization, seps = BOTH_SEPARATOR_FAMILIES, anchorFloor = 0) { const segs = []; const win32 = normalization !== "literal"; for (const raw of path.split(seps)) { if (raw.length === 0) continue; const dot = !win32 || /^\.{1,2}$/.test(raw) ? raw : raw.replace(/ +$/, ""); if (dot === ".") continue; if (dot === "..") { const last = segs[segs.length - 1]; if (segs.length > anchorFloor && last !== "..") segs.pop(); else if (segs.length > anchorFloor || anchorFloor === 0) segs.push(".."); continue; } const stripped = normalization === "literal" ? raw : stripWin32Component(raw, normalization); segs.push(stripped.length > 0 ? stripped : raw); } return segs; } const WIN32_NORMALIZATIONS = [ { invisibleFirst: false, stream: false }, { invisibleFirst: false, stream: true }, { invisibleFirst: true, stream: false }, { invisibleFirst: true, stream: true }, ]; const BOTH_SEPARATOR_FAMILIES = /[\\/]/; const POSIX_SEPARATOR_FAMILY = /\//; function uncAnchorSegments(path) { const third = path[2]; return isUncPath(path) && third !== undefined && third !== "\\" && third !== "/" ? 2 : 0; } function relativeUnderDataRoot(path, roots) { for (const root of roots) { if (root.segments.length === 0) continue; if (root.win ? !isWinFormPath(path) : isWinFormPath(path) || !path.startsWith("/")) continue; const seps = root.win ? BOTH_SEPARATOR_FAMILIES : POSIX_SEPARATOR_FAMILY; const segs = pathViewSegments(path, "literal", seps, uncAnchorSegments(path)); if (segs.length < root.segments.length) continue; if (root.segments.every((s, i) => segs[i] === s)) return segs.slice(root.segments.length).join("/"); } return null; } export function spelledExemptionOf(dataRootPaths) { const roots = dataRootPaths.map((r) => { const win = isWinFormPath(r); return { win, segments: pathViewSegments(r, "literal", win ? BOTH_SEPARATOR_FAMILIES : POSIX_SEPARATOR_FAMILY, uncAnchorSegments(r)) }; }); return (spelling) => relativeUnderDataRoot(spelling, roots); } export const WRITE_PROTECTION_TARGET_VIEWS = Object.freeze(["spelling-only", "target"]); export function compileWriteProtection(entries, opts) { const rows = resolveWriteProtectedTable(entries); if (rows.length === 0) return undefined; const { dataRoot, ...seats } = opts ?? {}; return compileWriteJudgment(rows, entries === undefined ? "builtin" : "replacement", { ...seats, exemptRoots: dataRootSpellings(dataRoot) }).matcher; } export function compileLegWriteJudgment(entries, opts) { const rows = resolveWriteProtectedTable(entries); const compiled = compileWriteJudgment(rows, "replacement", opts); const guardRows = opts.guardRows; return { matcher: rows.length === 0 ? undefined : compiled.matcher, judgeWriteTarget: rows.length === 0 && guardRows === undefined ? undefined : async (target, judgeOpts) => { const hit = await compiled.judgment(target, { ...(judgeOpts.signal !== undefined ? { signal: judgeOpts.signal } : {}), ...(judgeOpts.canonical === true ? { canonical: true } : {}) }, judgeOpts.fs, guardRows); return hit === undefined ? undefined : hit.unreadable === undefined && TABLE_ROWS.has(hit.row) ? "protected" : "guarded"; }, }; } const TABLE_ROWS = new WeakSet(); function heldRows(cause) { return { name: "the deployment's guarded write rows", grammar: { kind: "segment-glob", run: "" }, polarity: "deny", faces: ["write"], placement: "anywhere", fold: "exact", exemptible: false, origin: "replacement", reach: () => ({ reach: "unreadable", reason: `the rows did not come back: ${cause}` }) }; } function compileWriteJudgment(rows, origin, opts) { const dataRootPaths = opts.exemptRoots; const spelledRemainder = spelledExemptionOf(dataRootPaths); const readingMemo = new Map(); const readingsOf = (spelling) => { const known = readingMemo.get(spelling); if (known !== undefined) return known; const raw = [pathViewSegments(spelling, "literal", POSIX_SEPARATOR_FAMILY, uncAnchorSegments(spelling)), pathViewSegments(spelling, "literal"), ...WIN32_NORMALIZATIONS.map((n) => pathViewSegments(spelling, n))]; const folded = raw.map((segs) => segs.map(foldWriteProtectSegment)); if (readingMemo.size >= 64) readingMemo.clear(); readingMemo.set(spelling, folded); return folded; }; const literalRow = (row, reach, placement) => { const minted = { name: row.name, kind: row.kind, grammar: { kind: "literal-name", match: row.kind, name: row.name }, polarity: "ask", faces: ["write"], placement, fold: "protective", exemptible: true, origin, reach }; TABLE_ROWS.add(minted); return minted; }; const tableRows = []; for (const kind of KINDS) { for (const row of rows) { if (row.kind !== kind) continue; const parts = foldWriteProtectName(row.name).split("/").filter((s) => s.length > 0); tableRows.push(literalRow(row, (view) => (readingsOf(view.path).some((segs) => literalNameMeets(kind, parts, segs)) ? REACHED : NOT_REACHED), "anywhere")); } } const configTails = []; for (const row of rows) { const tail = row.kind === "basename" ? undefined : agentConfigDirTail(row.name); if (tail !== undefined && opts?.agentConfigDir !== undefined) configTails.push({ row, tail: foldWriteProtectName(tail).split("/").filter((s) => s.length > 0) }); } const configRootSpelled = opts?.agentConfigDir === undefined ? [] : [agentConfigDirSegments(opts.agentConfigDir)]; const configRowsAt = (canonicalRoots) => configTails.map(({ row, tail }) => literalRow(row, (view) => { if (!isAbsoluteForFamily("own", view.path)) return NOT_REACHED; const roots = view.kind === "canonical" ? [...configRootSpelled, ...canonicalRoots] : configRootSpelled; return readingsOf(view.path).some((segs) => roots.some((root) => atConfigRoot(segs, root.map(foldWriteProtectSegment), tail))) ? REACHED : NOT_REACHED; }, { below: opts?.agentConfigDir ?? "" })); const tableAt = (canonicalRoots) => (configTails.length === 0 ? tableRows : [...tableRows, ...configRowsAt(canonicalRoots)]); const spelledView = (spelled, kind) => { const remainder = spelledRemainder(spelled); return { path: spelled, kind, ...(remainder !== null ? { exemptRemainder: remainder } : {}) }; }; const anchoredSpelling = (path, cwd) => { const base = typeof cwd === "string" && cwd.length > 0 ? cwd : undefined; return base !== undefined ? anchorSpelling(pathFamilyOf({ cwd: base }), base, path) : path; }; const rootPath = opts?.rootPath; const judgment = async (path, ctx, env, extraSource) => { let noted = false; const noteAbsence = (cause, unresolvedSymlink) => { if (noted) return; noted = true; opts?.onViewAbsent?.({ cause, unresolvedSymlink }); }; const sourceFailed = (err, what) => { if (gateWaitFailureOf(err) !== "aborted") noteAbsence(what === undefined ? thrownText(err) : `${what}: ${thrownText(err)}`, false); }; const extra = await (async () => { if (extraSource === undefined) return []; const answer = await fromSource(() => extraSource({ ...(env !== undefined ? { env } : {}), ...(ctx.signal !== undefined ? { signal: ctx.signal } : {}) }), ctx.signal, "deploymentRows"); if (answer.ok) return answer.value; sourceFailed(answer.cause, "the deployment's guarded write rows did not come back, so the writes they could guard are not placed"); return [heldRows(thrownText(answer.cause))]; })(); const givenKey = ctx.canonical === true && env !== undefined; const base = ctx.base ?? ctx.cwd ?? rootPath; const lexical = givenKey ? undefined : spelledView(anchoredSpelling(path, ctx.base ?? ctx.cwd), "lexical"); const bySpelling = lexical === undefined ? undefined : judgePathRows([...tableAt([]), ...extra], { views: [lexical] }, "tighten"); if (bySpelling !== undefined && bySpelling.unreadable === undefined && TABLE_ROWS.has(bySpelling.row)) return bySpelling; if (env === undefined) return bySpelling; const family = pathFamilyOf({ root: rootPath, cwd: ctx.cwd ?? env.cwd }); const rootLookupFailed = (err) => sourceFailed(err, "a root's key did not come back, so that root takes no part"); let canonKey = givenKey ? path : undefined; let canonAliased = false; let targetUnread; let targetUnreadSymlink = false; if (!givenKey) { const answer = await fromSource(() => canonicalizeTarget(env, path, ctx.signal, family, base), ctx.signal, "canonicalTarget"); if (!answer.ok) { sourceFailed(answer.cause); targetUnread = thrownText(answer.cause); } else if (answer.value.ok) { canonKey = answer.value.key; canonAliased = answer.value.aliasResolved === true; } else { noteAbsence(answer.value.message, answer.value.unresolvedSymlink === true); targetUnread = answer.value.message; targetUnreadSymlink = answer.value.unresolvedSymlink === true; } } const canonicalRoots = canonKey === undefined || configTails.length === 0 || opts?.agentConfigDir === undefined ? [] : (await canonicalDataRootKeys(env, [opts.agentConfigDir], ctx.signal, rootLookupFailed)).map(agentConfigDirSegments); const table = [...tableAt(canonicalRoots), ...extra]; const aliased = canonAliased ? { aliasResolved: true } : {}; const views = [...(lexical !== undefined ? [{ ...lexical, ...aliased }] : []), ...(canonKey !== undefined ? [{ path: canonKey, kind: "canonical", ...aliased }] : [])]; const identity = {}; const identityRemainder = () => (identity.asked ??= (async () => { if (canonKey === undefined) return undefined; for (const rootKey of await canonicalDataRootKeys(env, dataRootPaths, ctx.signal, rootLookupFailed)) { const relative = relativeUnderRoot(canonKey, rootKey); if (relative !== null) return relative; } return undefined; })()); const judged = async () => { const unread = targetUnread !== undefined ? { unreadable: targetUnread } : {}; const hit = judgePathRows(table, { views, ...unread }, "tighten"); if (hit === undefined) return undefined; const relative = await identityRemainder(); const final = relative === undefined ? hit : judgePathRows(table, { views: views.map((v) => (v.kind === "canonical" ? { ...v, exemptRemainder: relative } : v)), identityExempt: true, ...unread }, "tighten"); return final !== undefined && final.unreadable !== undefined && targetUnread !== undefined && targetUnreadSymlink ? { ...final, unresolvedSymlink: true } : final; }; const byTarget = canonKey === undefined ? undefined : await judged(); if (byTarget !== undefined && byTarget.unreadable === undefined && TABLE_ROWS.has(byTarget.row)) return byTarget; if (givenKey) return byTarget; if (identity.asked !== undefined && (await identity.asked) !== undefined && table.every((row) => row.exemptible)) return byTarget; const chain = await fromSource(() => symlinkChainHops(env, path, ctx.signal, family, base), ctx.signal, "canonicalChain"); if (!chain.ok) { sourceFailed(chain.cause); targetUnread ??= thrownText(chain.cause); } else { if (!chain.value.ok && chain.value.asked) { noteAbsence(chain.value.cause, false); targetUnread ??= chain.value.cause; } for (const hop of chainHopsOrNone(chain.value)) views.push(spelledView(hop, "hop")); } return judged(); }; const matcher = { rows, targetView: opts?.env === undefined ? "spelling-only" : "target", matchPath(path, cwd) { const hit = judgePathRows(tableAt([]), { views: [spelledView(anchoredSpelling(path, cwd), "lexical")] }, "tighten"); return hit === undefined ? null : { name: hit.row.name, kind: hit.row.kind }; }, async judge(path, ctx = {}) { const hit = await judgment(path, ctx, opts?.env); if (hit === undefined) return { kind: "clear" }; if (hit.unreadable !== undefined) return { kind: "unresolvable", cause: hit.unreadable, unresolvedSymlink: hit.unresolvedSymlink === true }; return verdictOf(hit); }, }; return { matcher, judgment }; } const REACHED = { reach: "reached" }; const NOT_REACHED = { reach: "not_reached" }; function literalNameMeets(kind, parts, segs) { if (segs.length === 0) return false; if (kind === "basename") return segs[segs.length - 1] === parts[0]; if (kind === "segment") return segs.includes(parts[0]); for (let i = 0; i + parts.length <= segs.length; i++) if (parts.every((p, j) => segs[i + j] === p)) return true; return false; } function atConfigRoot(segs, root, tail) { return segs.length >= root.length + tail.length && root.every((a, i) => segs[i] === a) && tail.every((t, i) => segs[root.length + i] === t); } function verdictOf(hit) { return { kind: "hit", hit: { name: hit.row.name, kind: hit.row.kind }, via: hit.viewKind === "canonical" ? "target" : hit.viewKind === "hop" ? "chain" : "spelling" }; } export function writeProtectionTightenDecision(toolName, verdict) { if (verdict.kind === "clear") return undefined; if (verdict.kind === "unresolvable") { return { action: "deny", message: `tool "${toolName}" is refused (target_unresolved): where its write target leads on disk could not be determined${verdict.unresolvedSymlink ? " (a link on the way could not be followed)" : ""}, so the write-protected table cannot tell whether it lands on a protected path — ${verdict.cause}`, decisionReason: "safety", }; } return { action: "ask", message: verdict.via === "target" ? `tool "${toolName}" writes to a write-protected path — its real target resolves onto table entry "${verdict.hit.name}" — explicit approval required` : verdict.via === "chain" ? `tool "${toolName}" writes to a write-protected path — its symlink chain passes through table entry "${verdict.hit.name}" — explicit approval required` : `tool "${toolName}" writes to a write-protected path (table entry "${verdict.hit.name}") — explicit approval required`, decisionReason: "safety", }; } export function writeProtectionMandated(verdict) { return verdict.kind === "hit"; } export function createWriteProtectionCheck(entries, opts) { const matcher = compileWriteProtection(entries, opts); return matcher === undefined ? undefined : writeProtectionMountOver(matcher, opts?.rootPath); } export function writeProtectionMountOver(matcher, rootPath) { const check = (toolName, args, face, cwd, signal) => { const call = { toolName, args, ...(face !== undefined ? { face } : {}) }; const protective = protectivePathTargetOf(call); if (protective === undefined || protective.access === "read") return null; const path = writeTargetPath(call, protective); if (typeof path !== "string" || path.length === 0) return null; return matcher.judge(path, { ...(cwd !== undefined ? { cwd } : {}), ...(signal !== undefined ? { signal } : {}), ...(() => { const b = pathTargetBaseOf(protective, { root: rootPath, cwd }); return b !== undefined ? { base: b } : {}; })() }); }; return { check, targetView: matcher.targetView }; }