UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

374 lines (373 loc) • 18.2 kB
import { canonicalizeTarget, isWinFormPath, writeTargetPath, isUncPath, isAbsoluteForFamily, joinForFamily, pathFamilyOf } from "../tools/fs/safety.js"; import { protectivePathTargetOf } from "./tool-registry.js"; import { pathTargetBaseOf } from "./effective-path-target.js"; import { canonicalDataRootKeys, dataRootSpellings, relativeUnderRoot } from "./sensitive-path-policy.js"; import { symlinkChainHops } from "./path-identity.js"; import { readDenyPatternRun } from "../tools/fs/read-deny.js"; import { thrownText } from "./thrown-value.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) { 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(`writeProtectedPaths: entry ${describeEntryValue(entry)} is not a name string or { name, kind } row.`); } const name = shape.name; if (!KINDS.includes(shape.kind)) { throw new Error(`writeProtectedPaths: 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(`writeProtectedPaths: 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(`writeProtectedPaths: 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(`writeProtectedPaths: 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(`writeProtectedPaths: 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(`writeProtectedPaths: 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(`writeProtectedPaths: 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 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 = `${row.kind}:${foldWriteProtectName(row.name)}`; 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 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 dataRootPaths = dataRootSpellings(opts?.dataRoot); const rootViews = dataRootPaths.map((r) => { const win = isWinFormPath(r); return { win, segments: pathViewSegments(r, "literal", win ? BOTH_SEPARATOR_FAMILIES : POSIX_SEPARATOR_FAMILY, uncAnchorSegments(r)) }; }); const basenames = new Map(); const segments = new Map(); const runs = []; for (const row of rows) { const folded = foldWriteProtectName(row.name); if (row.kind === "basename") { if (!basenames.has(folded)) basenames.set(folded, row.name); } else if (row.kind === "segment") { if (!segments.has(folded)) segments.set(folded, row.name); } else { runs.push({ name: row.name, parts: folded.split("/").filter((s) => s.length > 0) }); } } const judgeSegments = (raw) => { if (raw.length === 0) return null; const segs = raw.map(foldWriteProtectSegment); const base = basenames.get(segs[segs.length - 1] ?? ""); if (base !== undefined) return { name: base, kind: "basename" }; for (const s of segs) { const hit = segments.get(s); if (hit !== undefined) return { name: hit, kind: "segment" }; } for (const run of runs) { const n = run.parts.length; for (let i = 0; i + n <= segs.length; i++) { if (run.parts.every((p, j) => segs[i + j] === p)) return { name: run.name, kind: "segment-run" }; } } return null; }; const judgeViews = (spelling) => { const posix = judgeSegments(pathViewSegments(spelling, "literal", POSIX_SEPARATOR_FAMILY, uncAnchorSegments(spelling))); if (posix !== null) return posix; const bothSeparators = judgeSegments(pathViewSegments(spelling, "literal")); if (bothSeparators !== null) return bothSeparators; for (const normalization of WIN32_NORMALIZATIONS) { const hit = judgeSegments(pathViewSegments(spelling, normalization)); if (hit !== null) return hit; } return null; }; const judgeSpelled = (spelled) => judgeViews(relativeUnderDataRoot(spelled, rootViews) ?? spelled); const judgeSpelling = (path, cwd) => { const base = typeof cwd === "string" && cwd.length > 0 ? cwd : undefined; const family = pathFamilyOf({ cwd: base }); const spelled = base !== undefined && family !== undefined && !isAbsoluteForFamily(family, path) ? joinForFamily(family, base, path) : path; return judgeSpelled(spelled); }; const env = opts?.env; const rootPath = opts?.rootPath; return { rows, targetView: env === undefined ? "spelling-only" : "target", matchPath(path, cwd) { return judgeSpelling(path, cwd); }, async judge(path, ctx = {}) { const givenKey = ctx.canonical === true && env !== undefined; const bySpelling = givenKey ? null : judgeSpelling(path, ctx.cwd); if (bySpelling !== null) return { kind: "hit", hit: bySpelling, via: "spelling" }; if (env === undefined) return { kind: "clear" }; const family = pathFamilyOf({ root: rootPath, cwd: ctx.cwd ?? env.cwd }); const base = ctx.base ?? ctx.cwd ?? rootPath; let noted = false; const noteAbsence = (cause, unresolvedSymlink) => { if (noted) return; noted = true; opts?.onViewAbsent?.({ cause, unresolvedSymlink }); }; let canonKey = givenKey ? path : undefined; if (!givenKey) { try { const canon = await canonicalizeTarget(env, path, ctx.signal, family, base); if (canon.ok) canonKey = canon.key; else noteAbsence(canon.message, canon.unresolvedSymlink === true); } catch (err) { noteAbsence(thrownText(err), false); } } try { if (canonKey !== undefined) { const byTarget = judgeViews(canonKey); if (byTarget !== null) { for (const rootKey of await canonicalDataRootKeys(env, dataRootPaths, ctx.signal)) { const relative = relativeUnderRoot(canonKey, rootKey); if (relative === null) continue; if (relative === "") return { kind: "clear" }; const remainder = judgeViews(relative); return remainder === null ? { kind: "clear" } : { kind: "hit", hit: remainder, via: "target" }; } return { kind: "hit", hit: byTarget, via: "target" }; } } for (const hop of givenKey ? [] : await symlinkChainHops(env, path, ctx.signal, family, base, (cause) => noteAbsence(cause, false))) { const byHop = judgeSpelled(hop); if (byHop === null) continue; if (canonKey !== undefined) { for (const rootKey of await canonicalDataRootKeys(env, dataRootPaths, ctx.signal)) { if (relativeUnderRoot(canonKey, rootKey) !== null) return { kind: "clear" }; } } return { kind: "hit", hit: byHop, via: "chain" }; } } catch (err) { noteAbsence(thrownText(err), false); } return { kind: "clear" }; }, }; } export function writeProtectionTightenDecision(toolName, verdict) { if (verdict.kind === "hit") { 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", }; } if (verdict.kind === "unresolvable") { return { action: "deny", message: verdict.unresolvedSymlink ? `tool "${toolName}" is blocked: its target is a symlink whose real target could not be resolved, so the write-protection judge cannot tell whether it lands on a protected path (${verdict.cause})` : `tool "${toolName}" is blocked: its target could not be resolved on this tree, so the write-protection judge cannot tell whether it lands on a protected path (${verdict.cause})`, decisionReason: "safety", }; } return undefined; } export function writeProtectionMandated(verdict) { return verdict.kind === "hit"; } export function writeProtectionUnresolved(verdict) { return verdict.kind === "unresolvable"; } 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 }; }