@sema-agent/core
Version:
Stateless, task-oriented AI agent core
217 lines (216 loc) • 11.5 kB
JavaScript
import { homedir } from "node:os";
import { isAbsolute, join, resolve } from "node:path";
import { anchorSpelling, canonicalizeTarget, expandHomeTilde, isAbsoluteForFamily, isWinFormPath, pathFamilyOf, writeTargetPath } from "../tools/fs/safety.js";
import { spelledExemptionOf } from "./write-protect.js";
import { compileSegmentPattern, READ_FACE_BUILTIN_DENY_TABLE, readDenyPatternRun, segmentGuardRow } from "../tools/fs/read-deny.js";
import { judgePathRows } from "./path-guard-rows.js";
import { agentConfigDirSegments, agentConfigDirTail, checkedAgentConfigDir } from "./agent-config-dir.js";
import { isProtectedWrite, protectivePathTargetOf } from "./tool-registry.js";
import { pathTargetBaseOf } from "./effective-path-target.js";
import { chainHopsOrNone, symlinkChainHops } from "./path-identity.js";
import { heldBy } from "./tool-name-readings.js";
import { assertNoRetiredKeys } from "./retired-keys.js";
import { fromSource } from "./gate-bounded-await.js";
import { thrownText } from "./thrown-value.js";
export function agentConfigDenyPatterns() {
return READ_FACE_BUILTIN_DENY_TABLE.filter((row) => row.tier === "agent-config").map((row) => readDenyPatternRun(row.pattern).run);
}
export const RECOMMENDED_SENSITIVE_PATTERNS = [
".env",
".env.*",
"*.pem",
"*.key",
"*.p12",
"*.pfx",
"id_rsa*",
"id_ed25519*",
"id_ecdsa*",
".ssh",
".git/hooks",
".git/config",
".git/modules",
".sema/settings.json",
".sema/settings.local.json",
".sema.*",
".claude/settings.json",
".claude/settings.local.json",
".claude.*",
".mcp.json",
".ai-agent/.env",
".sema/engine-data/.env",
".sema/config-lkg-*.json",
".codex/config.toml",
".cursor/mcp.json",
".continue/config.json",
".continue/config.yaml",
".aider.conf.yml",
".gemini/settings.json",
".claude/hooks",
".sema/hooks",
".claude/agents",
".sema/agents",
".sema-notifier-build",
".aws",
".config/gcloud",
".azure",
".kube/config",
".npmrc",
".pypirc",
".bash_history",
".zsh_history",
];
const CASE_INSENSITIVE_FS = process.platform === "darwin" || process.platform === "win32";
export function dataRootSpellings(explicit) {
const raw = explicit ?? process.env.AGENT_DATA_DIR ?? join(homedir(), ".ai-agent");
const home = homedir();
const tilde = expandHomeTilde(raw, home);
const engineForm = isAbsolute(raw) ? raw : resolve(raw);
const out = [];
for (const v of [raw, tilde, engineForm]) {
const anchored = isAbsoluteForFamily("own", v) ? v : resolve(v);
if (!out.includes(anchored))
out.push(anchored);
}
return out;
}
export async function canonicalDataRootKeys(env, spellings, signal, onLookupFailed) {
const keys = [];
for (const spelling of spellings) {
const answer = await fromSource(() => canonicalizeTarget(env, spelling, signal, pathFamilyOf({ root: spelling })), signal, "rootKey");
if (!answer.ok)
onLookupFailed?.(answer.cause);
else if (answer.value.ok && !keys.includes(answer.value.key))
keys.push(answer.value.key);
}
return keys;
}
export function relativeUnderRoot(targetKey, rootKey) {
const seps = isWinFormPath(rootKey) ? ["\\", "/"] : ["/"];
let root = rootKey;
while (root.length > 0 && seps.includes(root[root.length - 1]))
root = root.slice(0, -1);
if (root.length === 0)
return null;
if (targetKey === root)
return "";
for (const sep of seps)
if (targetKey.startsWith(root + sep))
return targetKey.slice(root.length + 1);
return null;
}
function compilePatterns(patterns) {
const out = [];
for (const raw of patterns) {
const { homeAnchored, run } = readDenyPatternRun(raw);
if (homeAnchored) {
throw new Error(`createSensitivePathPolicy: pattern ${JSON.stringify(raw)} opens with the home word "~", which this write list does not place — its patterns are judged anywhere in the real target. ` +
`Spell the segments without it (${JSON.stringify(run)}), which covers the home's file and every same-named one.`);
}
const compiled = compileSegmentPattern(raw, CASE_INSENSITIVE_FS ? "unicode" : "none");
if (compiled === null) {
throw new Error(`createSensitivePathPolicy: pattern ${JSON.stringify(raw)} contains no path segments and would guard nothing. ` +
`Patterns are "/"-separated runs of path SEGMENTS (e.g. ".ssh", ".git/hooks", "*.pem"); remove the entry or spell the segments.`);
}
out.push(compiled);
}
return out;
}
const RETIRED_SENSITIVE_PATH_POLICY_KEYS = Object.freeze({
dataRoot: "the data-root exemption reads the engine's one exempt root — a Runner resolves it once per leg from `RunnerDeps.memoryEngineDir` and stamps it on every request this policy judges (`ToolCallRequest.exemptRoots`); set the data root there and drop the option",
});
export function createSensitivePathPolicy(opts) {
assertNoRetiredKeys(opts, RETIRED_SENSITIVE_PATH_POLICY_KEYS, "createSensitivePathPolicy option");
const compiled = compilePatterns(opts.patterns);
const rowOf = (c) => segmentGuardRow(c, { faces: ["write"], polarity: "deny", exemptible: true, fold: CASE_INSENSITIVE_FS ? "host" : "exact", origin: "replacement" });
const guardRows = compiled.map(rowOf);
const configRoot = checkedAgentConfigDir(opts.agentConfigDir, "createSensitivePathPolicy agentConfigDir");
const configRows = configRoot === undefined ? [] : opts.patterns.flatMap((raw) => {
const tail = agentConfigDirTail(raw, !CASE_INSENSITIVE_FS);
return tail === undefined ? [] : [{ raw, tail }];
});
const configTwins = async (env, signal) => {
if (configRoot === undefined || configRows.length === 0)
return [];
const fold = CASE_INSENSITIVE_FS ? "ascii" : "none";
const lower = (segs) => (CASE_INSENSITIVE_FS ? segs.map((seg) => seg.replace(/[A-Z]/g, (c) => c.toLowerCase())) : segs);
const anchors = [configRoot, ...(env === undefined ? [] : await canonicalDataRootKeys(env, [configRoot], signal))].map((root) => lower(agentConfigDirSegments(root)));
return configRows.map(({ raw, tail }) => ({ ...(compileSegmentPattern(tail, fold) ?? { raw, segments: [], foldAscii: CASE_INSENSITIVE_FS }), raw, anchors, rootedOnly: true }));
};
const rowsOf = async (env, signal) => [...guardRows, ...(await configTwins(env, signal)).map(rowOf)];
const unstampedRoots = Object.freeze(dataRootSpellings(undefined));
const guardedByName = opts.tools !== undefined ? new Set(opts.tools) : undefined;
const family = (cwd) => pathFamilyOf({ root: opts.rootPath, cwd: cwd ?? opts.env.cwd });
const spelledByRoots = new WeakMap();
const spelledRemainderUnder = (roots) => {
let r = spelledByRoots.get(roots);
if (r === undefined)
spelledByRoots.set(roots, (r = spelledExemptionOf(roots)));
return r;
};
const judgeTarget = async (path, fam, base, roots, signal) => {
const spelledRemainder = spelledRemainderUnder(roots);
const spelled = base !== undefined ? anchorSpelling(fam, base, path) : path;
const spelledRest = spelledRemainder(spelled);
const answer = await fromSource(() => canonicalizeTarget(opts.env, path, signal, fam, base), signal, "canonicalTarget");
const canon = answer.ok ? answer.value : { ok: false, message: thrownText(answer.cause) };
let targetUnread = canon.ok ? undefined : canon.message;
const aliased = canon.ok && canon.aliasResolved === true ? { aliasResolved: true } : {};
const views = [{ path: spelled, kind: "lexical", ...aliased, ...(spelledRest !== null ? { exemptRemainder: spelledRest } : {}) }];
let identity = null;
if (canon.ok) {
for (const rootKey of await canonicalDataRootKeys(opts.env, roots, signal)) {
identity = relativeUnderRoot(canon.key, rootKey);
if (identity !== null)
break;
}
views.push({ path: canon.key, kind: "canonical", ...(canon.aliasResolved === true ? { aliasResolved: true } : {}), ...(identity !== null ? { exemptRemainder: identity } : {}) });
}
const rows = await rowsOf(opts.env, signal);
if (identity === null || !rows.every((row) => row.exemptible)) {
const chain = await fromSource(() => symlinkChainHops(opts.env, path, signal, fam, base), signal, "canonicalChain");
if (!chain.ok)
targetUnread ??= thrownText(chain.cause);
else if (!chain.value.ok && chain.value.asked)
targetUnread ??= chain.value.cause;
for (const hop of chain.ok ? chainHopsOrNone(chain.value) : []) {
const rest = spelledRemainder(hop);
views.push({ path: hop, kind: "hop", ...(rest !== null ? { exemptRemainder: rest } : {}) });
}
}
const hit = judgePathRows(rows, { views, ...(identity !== null ? { identityExempt: true } : {}), ...(targetUnread !== undefined ? { unreadable: targetUnread } : {}) }, "tighten");
if (hit === undefined)
return { kind: "clear" };
if (hit.unreadable !== undefined)
return { kind: "unresolved", cause: hit.unreadable };
return { kind: "hit", pattern: hit.row.name, via: hit.viewKind === "hop" ? "chain" : hit.viewKind === "canonical" ? "target" : "spelling" };
};
return {
...(compiled.length > 0 ? { writeGuardRows: (at) => rowsOf(at.env, at.signal) } : {}),
async check(req, signal) {
if (compiled.length === 0)
return { action: "allow" };
if (guardedByName !== undefined ? !heldBy("policy.sensitivePathGuardedByName", guardedByName, req) : !isProtectedWrite(req))
return { action: "allow" };
const protective = protectivePathTargetOf(req);
const path = writeTargetPath(req, protective);
if (typeof path !== "string" || path.length === 0)
return { action: "allow" };
const judged = await judgeTarget(path, family(req.cwd), pathTargetBaseOf(protective, { root: opts.rootPath, cwd: req.cwd }), req.exemptRoots ?? unstampedRoots, signal);
if (judged.kind === "unresolved") {
return { action: "deny", message: `write to "${path}" is refused (target_unresolved): where it leads on disk could not be determined, so the sensitive-path list cannot tell whether it is a guarded path — ${judged.cause}`, decisionReason: "safety" };
}
if (judged.kind === "hit") {
return {
action: "deny",
message: judged.via === "target"
? `write to "${path}" is blocked: its real target resolves onto the guarded sensitive path "${judged.pattern}"`
: judged.via === "chain"
? `write to "${path}" is blocked: its symlink chain passes through the guarded sensitive path "${judged.pattern}"`
: `write to "${path}" is blocked: its path names the guarded sensitive path "${judged.pattern}"`,
decisionReason: "safety",
};
}
return { action: "allow" };
},
};
}