UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

177 lines (176 loc) • 7.79 kB
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" }; }, }; }