@sema-agent/core
Version:
Stateless, task-oriented AI agent core
374 lines (373 loc) • 18.2 kB
JavaScript
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 };
}