@sema-agent/core
Version:
Stateless, task-oriented AI agent core
482 lines (481 loc) • 24 kB
JavaScript
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,
};
}