UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

482 lines (481 loc) • 24 kB
import { sliceHeadSafe, sliceTailSafe } from "../../core/surrogate-safe-slice.js"; import { isAbsolutePathForm, normalizeAbsPathLexically, pathFamilyOf, pathIdentityOf, spellingOfIdentity } from "./safety.js"; import { PROBE_REASON_MAX } from "../../core/untrusted-text.js"; export const READ_DENY_HOME_WORD = "~"; export function readDenyPatternRun(pattern) { const segments = pattern.split("/").filter(Boolean); return segments[0] === READ_DENY_HOME_WORD ? { homeAnchored: true, run: segments.slice(1).join("/") } : { homeAnchored: false, run: pattern }; } export function readDenyRunHead(run) { const segments = run.split("/").filter(Boolean); const wild = segments.findIndex((seg) => seg.includes("*")); return wild === -1 ? segments.length : wild; } export const DENY_FACES = Object.freeze(["read", "write"]); export async function withRuleRowsWhereTheyLead(rows, leadsTo) { const leads = new Map(); const partsOf = (head) => { const family = pathFamilyOf({ root: head }); const id = pathIdentityOf(head, family, head); if (!id.ok) return [head]; const unc = id.identity.startsWith("//"); const segments = id.identity.split("/").filter((seg) => seg !== ""); const parts = []; for (let k = unc ? 2 : 1; k <= segments.length; k++) parts.push(spellingOfIdentity(`${unc ? "//" : "/"}${segments.slice(0, k).join("/")}`, family)); return parts.length > 0 ? parts : [head]; }; const partsByRow = rows.map((row) => (row.head === undefined ? [] : partsOf(row.head))); for (const parts of partsByRow) for (const part of parts) if (!leads.has(part)) leads.set(part, leadsTo(part).catch(() => undefined)); const out = []; for (const [i, row] of rows.entries()) { out.push(row); let parentLed; let parentPart; for (const part of partsByRow[i]) { const led = await leads.get(part); const extendsParent = led !== undefined && parentLed !== undefined && parentPart !== undefined && sameLocation(led, `${parentLed.replace(/[\\/]+$/, "")}/${part.slice(parentPart.replace(/[\\/]+$/, "").length).replace(/^[\\/]+/, "")}`); const twin = extendsParent ? undefined : rowLedTo(row, part, led); if (twin !== undefined) out.push(twin); parentLed = led; parentPart = part; } } return out; } function sameLocation(a, b) { const family = pathFamilyOf({ root: a }); const x = pathIdentityOf(a, family, a); const y = pathIdentityOf(b, family, b); return x.ok && y.ok && x.identity === y.identity; } function rowLedTo(row, head, at) { if (at === undefined) return undefined; const family = pathFamilyOf({ root: head }); const from = pathIdentityOf(at, family, head); const to = pathIdentityOf(head, family, head); if (!from.ok || !to.ok || from.identity === to.identity) return undefined; const base = from.identity; const under = base.endsWith("/") ? base : `${base}/`; const onto = to.identity.endsWith("/") ? to.identity.slice(0, -1) : to.identity; return { rule: row.rule, tool: row.tool, pattern: row.pattern, faces: row.faces, reaches: (absolutePath) => { const id = pathIdentityOf(absolutePath, family, head); if (!id.ok) return false; if (id.identity === base) return row.reaches(to.identity); return id.identity.startsWith(under) && row.reaches(`${onto}/${id.identity.slice(under.length)}`); }, }; } function ruleRowHit(rows, face, path) { for (const r of rows) if (r.faces.includes(face) && r.reaches(path)) return r; return undefined; } export function compileWriteDeny(rules = []) { const ruleRows = rules.filter((r) => r.faces.includes("write")); const matchPath = (path) => { const hit = ruleRowHit(ruleRows, "write", path); return hit === undefined ? null : { pattern: hit.rule }; }; return { face: "write", ruleRows, matchPath, matchTarget: (canonicalKey, lexicalView, opts) => matchTargetOver(matchPath, canonicalKey, lexicalView, opts) }; } function matchTargetOver(matchPath, canonicalKey, lexicalView, opts) { const canonical = matchPath(canonicalKey, opts); if (canonical !== null) return { ...canonical, matchedView: canonicalKey }; if (lexicalView !== undefined) { const lexical = matchPath(lexicalView); if (lexical !== null) return { ...lexical, matchedView: lexicalView }; } for (const hop of opts?.hops ?? []) { const viaHop = matchPath(hop); if (viaHop !== null) return { ...viaHop, matchedView: hop }; } return null; } export function readDenySeat(matcher) { const entries = [...(matcher?.entries ?? []).map((e) => e.pattern), ...(matcher?.ruleRows ?? []).map((r) => r.rule)]; if (matcher === undefined || entries.length === 0) return { denyEntries: entries }; return { denyMatch: (p, opts) => matcher.matchPath(p, opts)?.pattern ?? null, denyAtOrBelow: (p) => (matcher.rowAtOrBelow !== undefined ? (matcher.rowAtOrBelow(p)?.pattern ?? null) : entries[0]), denyEntries: entries, }; } export function sensitivePathDenyList(pattern, face = "read") { const rows = typeof pattern === "string" ? [pattern] : pattern; return `the ${face === "write" ? "write" : "sensitive-path read"} deny list (pattern${rows.length === 1 ? "" : "s"} ${rows.map((p) => `"${p}"`).join(", ")})`; } export const SENSITIVE_PATH_DENY_SCOPE = "This list guards credential-class paths and applies regardless of the containment roots."; export const WRITE_DENY_SCOPE = "A write deny row holds regardless of the containment roots and of the permission mode."; export function readDenySentence(finding) { const write = finding.face === "write"; const canonicalNote = finding.canonical !== undefined && finding.canonical !== finding.target ? `(canonical target: "${fitMiddle(finding.canonical, READ_DENY_SENTENCE_PART_MAX)}") ` : ""; const lexicalHit = finding.matchedView !== undefined && finding.matchedView !== (finding.canonical ?? finding.target); const judged = lexicalHit ? `the requested path's spelling ("${fitMiddle(finding.matchedView, READ_DENY_SENTENCE_PART_MAX)}") matches` : "the target matches"; return `${finding.action ?? (write ? "writing" : "reading")} "${fitMiddle(finding.target, READ_DENY_SENTENCE_PART_MAX)}" ${canonicalNote}is refused: ${judged} ${sensitivePathDenyList(fitMiddle(finding.pattern, READ_DENY_SENTENCE_PART_MAX), write ? "write" : "read")}. ${write ? WRITE_DENY_SCOPE : SENSITIVE_PATH_DENY_SCOPE}`; } export const READ_DENY_SENTENCE_PART_MAX = PROBE_REASON_MAX; export function fitMiddle(s, budget) { return s.length <= budget ? s : elideMiddle(s, budget); } export function elideMiddle(s, budget) { if (budget < 12) return sliceHeadSafe(s, Math.max(0, budget)); const keep = budget - 1; const head = Math.ceil(keep / 2); return `${sliceHeadSafe(s, head)}\u2026${sliceTailSafe(s, keep - head)}`; } export const READ_DENY_BUILTIN_TIERS = Object.freeze(["credentials", "shell-history", "browser", "wallet", "agent-config"]); export const READ_FACE_BUILTIN_DENY_TABLE = [ { pattern: ".ssh", tier: "credentials" }, { pattern: "id_rsa*", tier: "credentials" }, { pattern: "id_ed25519*", tier: "credentials" }, { pattern: "id_ecdsa*", tier: "credentials" }, { pattern: ".gnupg", tier: "credentials" }, { pattern: ".aws", tier: "credentials" }, { pattern: ".config/gcloud", tier: "credentials" }, { pattern: ".azure", tier: "credentials" }, { pattern: ".kube", tier: "credentials" }, { pattern: ".netrc", tier: "credentials" }, { pattern: "_netrc", tier: "credentials" }, { pattern: ".git-credentials", tier: "credentials" }, { pattern: ".docker/config.json", tier: "credentials" }, { pattern: ".config/gh", tier: "credentials" }, { pattern: ".npmrc", tier: "credentials" }, { pattern: ".pypirc", tier: "credentials" }, { pattern: ".local/share/keyrings", tier: "credentials" }, { pattern: "Library/Keychains", tier: "credentials" }, { pattern: ".credentials.json", tier: "credentials" }, { pattern: "credentials.env", tier: "credentials" }, { pattern: ".codex/auth.json", tier: "credentials" }, { pattern: ".config/github-copilot", tier: "credentials" }, { pattern: ".gemini/oauth_creds.json", tier: "credentials" }, { pattern: ".bash_history", tier: "shell-history" }, { pattern: ".zsh_history", tier: "shell-history" }, { pattern: "Library/Application Support/Google/Chrome", tier: "browser" }, { pattern: "Library/Application Support/Firefox", tier: "browser" }, { pattern: "Library/Safari", tier: "browser" }, { pattern: ".config/google-chrome", tier: "browser" }, { pattern: ".config/chromium", tier: "browser" }, { pattern: ".mozilla/firefox", tier: "browser" }, { pattern: "AppData/Local/Google/Chrome/User Data", tier: "browser" }, { pattern: "AppData/Local/Microsoft/Edge/User Data", tier: "browser" }, { pattern: "AppData/Roaming/Mozilla/Firefox", tier: "browser" }, { pattern: ".bitcoin", tier: "wallet" }, { pattern: ".ethereum", tier: "wallet" }, { pattern: ".electrum", tier: "wallet" }, { pattern: "Library/Application Support/Exodus", tier: "wallet" }, { pattern: "Library/Application Support/Ledger Live", tier: "wallet" }, { pattern: "wallet.dat", tier: "wallet" }, { pattern: "~/.sema/settings.json", tier: "agent-config" }, { pattern: "~/.sema/settings.local.json", tier: "agent-config" }, { pattern: "~/.sema.*", tier: "agent-config" }, { pattern: "~/.claude/settings.json", tier: "agent-config" }, { pattern: "~/.claude/settings.local.json", tier: "agent-config" }, { pattern: "~/.claude.*", tier: "agent-config" }, { pattern: "~/.mcp.json", tier: "agent-config" }, { pattern: "~/.ai-agent/.env", tier: "agent-config" }, { pattern: "~/.sema/engine-data/.env", tier: "agent-config" }, { pattern: "~/.codex/config.toml", tier: "agent-config" }, { pattern: "~/.cursor/mcp.json", tier: "agent-config" }, { pattern: "~/.continue/config.json", tier: "agent-config" }, { pattern: "~/.continue/config.yaml", tier: "agent-config" }, { pattern: "~/.aider.conf.yml", tier: "agent-config" }, { pattern: "~/.gemini/settings.json", tier: "agent-config" }, ]; export const READ_DENY_DEFAULT_TIERS = ["credentials", "agent-config"]; export function resolveReadDenyBuiltins(config) { let activeTiers; if (config?.tiers === undefined) { activeTiers = new Set(READ_DENY_DEFAULT_TIERS); } else { if (!Array.isArray(config.tiers)) { throw new Error(`readDenyBuiltinTiers: expected an array of tier names, got ${JSON.stringify(config.tiers)}.`); } for (const t of config.tiers) { if (typeof t !== "string" || !READ_DENY_BUILTIN_TIERS.includes(t)) { throw new Error(`readDenyBuiltinTiers: unknown tier ${JSON.stringify(t)} — known tiers: ${READ_DENY_BUILTIN_TIERS.join(", ")}.`); } } activeTiers = new Set(config.tiers); } let excluded; if (config?.exclude === undefined) { excluded = new Set(); } else { if (!Array.isArray(config.exclude)) { throw new Error(`readDenyBuiltinExclude: expected an array of built-in row names (canonical pattern texts), got ${JSON.stringify(config.exclude)}.`); } for (const name of config.exclude) { if (typeof name !== "string" || !READ_FACE_BUILTIN_DENY_TABLE.some((r) => r.pattern === name)) { const anchored = typeof name === "string" ? READ_FACE_BUILTIN_DENY_TABLE.find((r) => r.pattern === `${READ_DENY_HOME_WORD}/${name}`) : undefined; throw new Error(`readDenyBuiltinExclude: ${JSON.stringify(name)} names no built-in row — ${anchored !== undefined ? `the row is anchored at the home directory and is named ${JSON.stringify(anchored.pattern)}; ` : ""}row names are the canonical pattern texts of READ_FACE_BUILTIN_DENY_TABLE (e.g. ".ssh", ".config/gcloud", "~/.sema/settings.json").`); } } excluded = new Set(config.exclude); } return READ_FACE_BUILTIN_DENY_TABLE.filter((r) => activeTiers.has(r.tier) && !excluded.has(r.pattern)); } export function readDenyBuiltinConfigOf(seats) { resolveReadDenyBuiltins(seats); return { ...(seats.tiers !== undefined ? { tiers: [...seats.tiers] } : {}), ...(seats.exclude !== undefined ? { exclude: [...seats.exclude] } : {}), }; } export function activeReadDenyBuiltinTiers(config) { const rows = resolveReadDenyBuiltins(config); return READ_DENY_BUILTIN_TIERS.filter((t) => rows.some((r) => r.tier === t)); } export const READ_FACE_DEFAULT_DENY_ENTRIES = resolveReadDenyBuiltins().map((r) => r.pattern); function asciiLower(s) { let out = ""; for (let i = 0; i < s.length; i++) { const c = s.charCodeAt(i); out += c >= 0x41 && c <= 0x5a ? String.fromCharCode(c + 32) : s[i]; } return out; } export function compileSegmentPattern(raw, fold) { const source = fold === "ascii" ? asciiLower(raw) : raw; const parts = source.split("/").filter(Boolean); if (parts.length === 0) return null; const flags = fold === "unicode" ? "i" : ""; const segments = parts.map((segment) => { const escaped = segment.replace(/[.+^${}()|[\]\\?]/g, "\\$&").replace(/\*/g, "[^/]*"); return new RegExp(`^${escaped}$`, flags); }); return { raw, segments, foldAscii: fold === "ascii" }; } export function matchSegmentPatterns(path, compiled, opts) { const views = segmentViewsOf(path, opts); const rooted = isAbsolutePathForm(path); const lowered = new Map(); for (const pat of compiled) { for (const view of views) { const segs = pat.foldAscii ? (lowered.get(view) ?? lowered.set(view, view.map(asciiLower)).get(view)) : view; const n = pat.segments.length; const runAt = (i) => i + n <= segs.length && pat.segments.every((re, j) => re.test(segs[i + j] ?? "")); if (pat.anchors !== undefined && rooted) { for (const anchor of pat.anchors) if (anchor.every((a, j) => segs[j] === a) && runAt(anchor.length)) return pat.raw; continue; } if (pat.rootedOnly === true) continue; for (let i = 0; i + n <= segs.length; i++) if (runAt(i)) return pat.raw; } } return null; } function segmentViewsOf(path, opts) { const literalSegs = path.split(/[\\/]/).filter(Boolean); const win32Segs = []; for (const raw of literalSegs) { const dot = /^\.{1,2}$/.test(raw) ? raw : raw.replace(/ +$/, ""); if (dot === ".") continue; if (dot === "..") { const last = win32Segs[win32Segs.length - 1]; if (win32Segs.length > 0 && last !== "..") win32Segs.pop(); else win32Segs.push(".."); continue; } const stripped = raw.replace(/[. ]+$/, ""); win32Segs.push(stripped.length > 0 ? stripped : raw); } const sameView = opts?.aliasResolved === true || (win32Segs.length === literalSegs.length && win32Segs.every((s, i) => s === literalSegs[i])); return sameView ? [literalSegs] : [literalSegs, win32Segs]; } function rowMeetsPath(pat, segs, rooted) { const n = pat.segments.length; if (pat.anchors !== undefined && rooted) { return pat.anchors.some((a) => { const k = Math.min(segs.length, a.length + n); for (let i = 0; i < k; i++) if (i < a.length ? segs[i] !== a[i] : !pat.segments[i - a.length].test(segs[i])) return false; return true; }); } if (pat.rootedOnly === true) return false; for (let i = 0; i < segs.length; i++) { const m = Math.min(n, segs.length - i); if (pat.segments.slice(0, m).every((re, j) => re.test(segs[i + j]))) return true; } return false; } function rowAtOrBelowOf(rows, path) { const rooted = isAbsolutePathForm(path); const views = segmentViewsOf(path); for (const pat of rows) { for (const view of views) if (rowMeetsPath(pat, pat.foldAscii ? view.map(asciiLower) : view, rooted)) return pat.raw; } return null; } function homeAnchorSegments(home) { return normalizeAbsPathLexically(home).split(/[\\/]/).filter(Boolean); } function rgGlobEscape(pattern) { return pattern.replace(/[?[\]{}]/g, (c) => `[${c}]`); } function normalizeEntry(entry, layer) { const shape = typeof entry === "string" ? { pattern: entry, caseSensitive: false } : entry; if (typeof shape !== "object" || shape === null || typeof shape.pattern !== "string") { throw new Error(`readDenyPatterns (${layer}): entry ${JSON.stringify(entry)} is not a pattern string or { pattern, caseSensitive? } object.`); } if (shape.caseSensitive !== undefined && typeof shape.caseSensitive !== "boolean") { throw new Error(`readDenyPatterns (${layer}): entry ${JSON.stringify(entry)} has a non-boolean caseSensitive.`); } if (shape.pattern.includes("\\")) { throw new Error(`readDenyPatterns (${layer}): pattern ${JSON.stringify(shape.pattern)} contains a backslash — patterns are "/"-separated segment runs (both path families are matched); spell the segments with "/".`); } const segments = shape.pattern.split("/").filter(Boolean); const { homeAnchored, run } = readDenyPatternRun(segments.join("/")); if (run.length === 0) { throw new Error(`readDenyPatterns (${layer}): pattern ${JSON.stringify(shape.pattern)} contains no path segments${homeAnchored ? " below the home word" : ""} and would ${homeAnchored ? "name the whole home directory" : "deny nothing"}. ` + `Patterns are "/"-separated runs of path SEGMENTS (e.g. ".ssh", ".config/gcloud", "id_rsa*", "~/.sema/settings.json"); remove the entry or spell the segments.`); } return { pattern: segments.join("/"), caseSensitive: shape.caseSensitive === true }; } export function persistedReadDenyEntryProblem(e) { if (typeof e !== "object" || e === null) return "entry is not an object"; const pattern = e.pattern; const caseSensitive = e.caseSensitive; if (typeof pattern !== "string") return "pattern is not a string"; if (typeof caseSensitive !== "boolean") return "caseSensitive is not a boolean"; if (pattern.includes("\\")) return "pattern contains a backslash"; const segments = pattern.split("/").filter(Boolean); if (readDenyPatternRun(segments.join("/")).run.length === 0) return "pattern has no path segments"; if (segments.join("/") !== pattern) return "pattern is not in canonical a/b form"; return null; } export function checkedReadDenyHomes(homes, seat = "readDenyHomes") { if (!Array.isArray(homes) || !homes.every((h) => typeof h === "string" && isAbsolutePathForm(h))) { let shown; try { shown = JSON.stringify(homes) ?? String(homes); } catch { shown = String(homes); } throw new Error(`${seat}: expected an array of absolute ${seat === "readDenyHomes" ? "home paths" : "locations"}, got ${shown} — a home-anchored row ("~/…") is placed ${seat === "readDenyHomes" ? "under each" : "at each"}, so a relative or empty spelling would guard a place that is not the home's.`); } return homes; } export function compileReadDeny(additions = [], layer = "additions", builtin, rules = [], homes = [], linked = new Map()) { const ruleRows = rules.filter((r) => r.faces.includes("read")); const normalized = []; const seen = new Set(); const push = (e) => { const key = `${e.caseSensitive ? "s" : "i"}:${e.pattern}`; if (seen.has(key)) return; seen.add(key); normalized.push(e); }; for (const row of resolveReadDenyBuiltins(builtin)) push(normalizeEntry(row.pattern, "built-in")); for (const entry of additions) push(normalizeEntry(entry, layer)); const homeSegments = [...new Set(checkedReadDenyHomes(homes))].map(homeAnchorSegments); const compiled = normalized.map((e) => { const { homeAnchored, run } = readDenyPatternRun(e.pattern); const fold = e.caseSensitive ? "none" : "ascii"; const c = compileSegmentPattern(run, fold); if (c === null) throw new Error(`readDenyPatterns: pattern ${JSON.stringify(e.pattern)} compiled to zero segments.`); if (!homeAnchored || homeSegments.length === 0) return { ...c, raw: e.pattern }; return { ...c, raw: e.pattern, anchors: homeSegments.map((segs) => (fold === "ascii" ? segs.map(asciiLower) : segs)) }; }); if (!(linked instanceof Map)) throw new Error(`readDenyHomeLinks: expected a Map from a row's pattern to its locations, got ${typeof linked}.`); const linkedPlacements = []; normalized.forEach((e, i) => { const locations = linked.get(e.pattern); const { homeAnchored, run } = readDenyPatternRun(e.pattern); if (locations === undefined || !homeAnchored) return; const c = compiled[i]; const rest = c.segments.slice(readDenyRunHead(run)); for (const location of new Set(checkedReadDenyHomes(locations, "readDenyHomeLinks"))) { const segs = homeAnchorSegments(location); linkedPlacements.push({ raw: e.pattern, segments: rest, foldAscii: c.foldAscii, anchors: [c.foldAscii ? segs.map(asciiLower) : segs], rootedOnly: true }); } }); const judged = linkedPlacements.length === 0 ? compiled : [...compiled, ...linkedPlacements]; const rgExclusionGlobs = []; const rgProbeGlobs = []; normalized.forEach((e, i) => { if (compiled[i].anchors !== undefined) return; const flag = e.caseSensitive ? "--glob" : "--iglob"; const g = rgGlobEscape(readDenyPatternRun(e.pattern).run); rgExclusionGlobs.push({ flag, glob: `!**/${g}` }, { flag, glob: `!**/${g}/**` }); rgProbeGlobs.push({ flag, glob: `**/${g}` }, { flag, glob: `**/${g}/**` }); }); const placed = judged.filter((c) => c.anchors !== undefined); const unexcludableUnder = (scope) => placed.length > 0 && (!isAbsolutePathForm(scope) || rowAtOrBelowOf(placed, normalizeAbsPathLexically(scope)) !== null); const rowAtOrBelow = (path) => { const hit = rowAtOrBelowOf(judged, path); if (hit !== null) return { pattern: hit }; return ruleRows.length === 0 ? null : { pattern: ruleRows[0].rule }; }; const matchPath = (path, opts) => { const hit = matchSegmentPatterns(path, judged, opts); if (hit !== null) return { pattern: hit }; const rule = ruleRows.length === 0 || opts?.walked === true ? undefined : ruleRowHit(ruleRows, "read", path); return rule === undefined ? null : { pattern: rule.rule }; }; return { entries: normalized, ...(ruleRows.length > 0 ? { ruleRows } : {}), matchPath, matchTarget: (canonicalKey, lexicalView, opts) => matchTargetOver(matchPath, canonicalKey, lexicalView, opts), rgExclusionGlobs, rgProbeGlobs, unexcludableUnder, rowAtOrBelow, }; }