UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

217 lines (216 loc) • 11.5 kB
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" }; }, }; }