@sema-agent/core
Version:
Stateless, task-oriented AI agent core
177 lines (176 loc) • 7.79 kB
JavaScript
import { homedir } from "node:os";
import { isAbsolute, join, resolve } from "node:path";
import { canonicalizeTarget, expandHomeTilde, isAbsoluteForFamily, isWinFormPath, pathFamilyOf, writeTargetPath } from "../tools/fs/safety.js";
import { compileSegmentPattern, matchSegmentPatterns, READ_FACE_BUILTIN_DENY_TABLE, readDenyPatternRun } from "../tools/fs/read-deny.js";
import { isProtectedWrite, protectivePathTargetOf } from "./tool-registry.js";
import { pathTargetBaseOf } from "./effective-path-target.js";
import { symlinkChainHops } from "./path-identity.js";
import { heldBy } from "./tool-name-readings.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",
".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(pathFamilyOf({ root: v }), v) ? v : resolve(v);
if (!out.includes(anchored))
out.push(anchored);
}
return out;
}
export async function canonicalDataRootKeys(env, spellings, signal) {
const keys = [];
for (const spelling of spellings) {
try {
const r = await canonicalizeTarget(env, spelling, signal, pathFamilyOf({ root: spelling }));
if (r.ok && !keys.includes(r.key))
keys.push(r.key);
}
catch {
}
}
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;
}
function matchSensitive(canonicalKey, compiled, aliasResolved) {
return matchSegmentPatterns(canonicalKey, compiled, { aliasResolved });
}
export function createSensitivePathPolicy(opts) {
const compiled = compilePatterns(opts.patterns);
const dataRootPaths = dataRootSpellings(opts.dataRoot);
const guardedByName = opts.tools !== undefined ? new Set(opts.tools) : undefined;
const family = (cwd) => pathFamilyOf({ root: opts.rootPath, cwd: cwd ?? opts.env.cwd });
const judgeTarget = async (path, fam, base, signal) => {
const canon = await canonicalizeTarget(opts.env, path, signal, fam, base);
if (!canon.ok)
return { kind: "unresolved", unresolvedSymlink: canon.unresolvedSymlink === true, cause: canon.refused ?? canon.message };
let relative = null;
for (const rootKey of await canonicalDataRootKeys(opts.env, dataRootPaths, signal)) {
relative = relativeUnderRoot(canon.key, rootKey);
if (relative !== null)
break;
}
if (relative === "")
return { kind: "clear" };
const hit = matchSensitive(relative ?? canon.key, compiled, canon.aliasResolved === true);
if (hit)
return { kind: "hit", pattern: hit, via: "target" };
if (relative === null) {
for (const hop of await symlinkChainHops(opts.env, path, signal, fam, base)) {
const viaHop = matchSensitive(hop, compiled, false);
if (viaHop)
return { kind: "hit", pattern: viaHop, via: "chain" };
}
}
return { kind: "clear" };
};
return {
...(compiled.length > 0 ? { writeTargetGuard: async (target, signal) => (await judgeTarget(target, family(undefined), undefined, signal)).kind !== "clear" } : {}),
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 }), signal);
if (judged.kind === "unresolved") {
return {
action: "deny",
message: judged.unresolvedSymlink
? `write to "${path}" is blocked: it is a symlink whose real target could not be resolved (it could point onto a guarded sensitive path)`
: `write to "${path}" is blocked: its target could not be resolved on this tree (${judged.cause}), so the sensitive-path guard cannot judge it`,
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}"` : `write to "${path}" is blocked: its symlink chain passes through the guarded sensitive path "${judged.pattern}"`,
decisionReason: "safety",
};
}
return { action: "allow" };
},
};
}