UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

1,054 lines (1,053 loc) • 48.9 kB
import { homedir } from "node:os"; import { parsePermissionRule } from "./permission-rule-syntax.js"; import { compileReadDeny } from "../tools/fs/read-deny.js"; import { carriesShellRedirection, parseLeadingCommandName, splitShellCompoundSegments } from "../tools/fs/bash-readonly-classifier.js"; import { inlineUntrusted } from "./untrusted-text.js"; import { pathTargetOf, protectivePathTargetOf } from "./tool-registry.js"; import { heldValues } from "./tool-name-readings.js"; import { carriesDotSegment, isAbsoluteForFamily, isIdentityDir, isPathIdentity, isShellRootedSpellingUnmapped, isUncIdentity, pathFamilyOf, pathIdentityOf } from "../tools/fs/safety.js"; import { readShellCommand } from "./shell-lexer.js"; import { readOnlyLineObjection, readOnlyShellVerdict } from "./read-only-shell.js"; export const RULE_BEHAVIORS = Object.freeze(["deny", "ask", "allow"]); const RULE_BEHAVIOR_SET = new Set(RULE_BEHAVIORS); export function isRuleBehavior(v) { return RULE_BEHAVIOR_SET.has(v); } export const RULE_BEHAVIOR_PRECEDENCE = { deny: 0, ask: 1, allow: 2, }; export const RULE_BEHAVIORS_BY_PRECEDENCE = [...RULE_BEHAVIORS].sort((a, b) => RULE_BEHAVIOR_PRECEDENCE[a] - RULE_BEHAVIOR_PRECEDENCE[b]); export function readRowBehavior(v) { if (v === undefined) return "allow"; return isRuleBehavior(v) ? v : undefined; } export const COMMAND_RULE_TOOL = "Bash"; export const READ_RULE_TOOL = "Read"; export const PATH_RULE_FACES = Object.freeze({ [READ_RULE_TOOL]: Object.freeze({ deny: Object.freeze(["read", "write"]), ask: Object.freeze(["read"]) }), Edit: Object.freeze({ deny: Object.freeze(["write"]), ask: Object.freeze(["write"]) }), }); export function callPathFaceOf(call) { const target = protectivePathTargetOf(call); return target === undefined ? undefined : target.access === "read" ? "read" : "write"; } export function faceAskSpeaksFor(face) { return face !== undefined && Object.values(PATH_RULE_FACES).some((columns) => columns.ask?.includes(face) === true); } export function ruleToolGrammarOf(toolName) { if (toolName === COMMAND_RULE_TOOL) return "command"; return pathTargetOf(toolName) !== undefined ? "path" : undefined; } export function laneToolNamesOf(req) { return heldValues("policy.persistedGrammar", { get: (n) => (ruleToolGrammarOf(n) !== undefined ? n : undefined) }, req); } export const MAX_RULE_TEXT_CHARS = 512; export const BARE_INTERPRETER_NAMES = new Set([ "node", "deno", "bun", "python", "python2", "python3", "perl", "ruby", "php", "osascript", "java", "dotnet", "mono", "lua", "luajit", "julia", "Rscript", "tclsh", "groovy", "scala", "awk", "gawk", "mawk", "nawk", "sed", "bash", "sh", "zsh", "ksh", "csh", "tcsh", "dash", "fish", "cmd", "powershell", "pwsh", "busybox", "source", ".", "trap", "eval", "exec", "command", "builtin", "enable", "compgen", "complete", "bind", "mapfile", "readarray", "env", "noglob", "xargs", "sudo", "doas", "pkexec", "su", "runuser", "ssh", "chroot", "setpriv", "nohup", "nice", "ionice", "chrt", "taskset", "stdbuf", "timeout", "time", "watch", "setsid", "flock", "unshare", "nsenter", "script", "numactl", "prlimit", "systemd-run", "parallel", "strace", "ltrace", "valgrind", "gdb", "lldb", "perf", "firejail", "bwrap", ]); export const SHELL_RESERVED_WORDS = new Set([ "!", "[[", "]]", "{", "}", "((", "))", "case", "esac", "coproc", "do", "done", "elif", "else", "fi", "for", "function", "if", "in", "select", "then", "time", "until", "while", ]); export const SUGGESTION_LEXICON = [ "git stash list", "git stash show", "git stash push", "git stash pop", "git stash drop", "git remote show", "git worktree list", "git submodule update", "git submodule status", "yarn workspaces list", "go mod tidy", "go mod download", "go mod verify", "go mod graph", "docker compose up", "docker compose down", "docker compose ps", "docker compose logs", "docker compose build", "docker compose pull", "kubectl rollout status", "kubectl rollout restart", "kubectl rollout history", "gh pr view", "gh pr list", "gh pr diff", "gh pr checks", "gh pr status", "gh pr create", "gh pr merge", "gh issue view", "gh issue list", "gh issue create", "gh repo view", "gh repo clone", "gh release list", "gh release view", "gh run list", "gh run view", "gh run watch", "gh workflow list", "gh workflow view", "gh auth status", "gh search repos", "gh search issues", "gh search prs", "gh search code", ]; const LEXICON_BODIES = SUGGESTION_LEXICON.map((b) => b.split(" ")); const GENERIC_SUBCOMMAND_WORD = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/; const SCREENED_HEAD_NAMES = new Set([...BARE_INTERPRETER_NAMES].map((n) => n.toLowerCase())); const TRAILING_DOTS = /\.+$/; const EXECUTABLE_SUFFIX = /\.(?:exe|com|bat|cmd)$/; const PROPOSABLE_HEAD_NAME = /^[A-Za-z0-9._+-]+$/; function screenedHeadName(head) { let name = commandBasename(head).toLowerCase(); for (;;) { const peeled = name.replace(TRAILING_DOTS, "").replace(EXECUTABLE_SUFFIX, ""); if (peeled === name || peeled === "") return name; name = peeled; } } function genericPrefixBody(tokens) { const head = tokens[0]; const second = tokens[1]; if (head === undefined || second === undefined) return undefined; if (!PROPOSABLE_HEAD_NAME.test(commandBasename(head))) return undefined; if (SCREENED_HEAD_NAMES.has(screenedHeadName(head))) return undefined; return GENERIC_SUBCOMMAND_WORD.test(second) ? `${head} ${second}` : undefined; } function longestReviewedBody(tokens) { let best; for (const words of LEXICON_BODIES) { if (words.length > tokens.length) continue; if (best !== undefined && words.length <= best.length) continue; let hit = true; for (let i = 0; i < words.length; i++) { if (words[i] !== tokens[i]) { hit = false; break; } } if (hit) best = words; } return best?.join(" "); } function foldSpacing(s) { let out = ""; let quote; let pendingGap = false; for (const ch of s) { if (quote !== undefined) { if (ch === quote) quote = undefined; out += ch; continue; } if (ch === "'" || ch === '"') { if (pendingGap) { if (out !== "") out += " "; pendingGap = false; } quote = ch; out += ch; continue; } if (ch === " " || ch === "\t") { pendingGap = true; continue; } if (pendingGap) { if (out !== "") out += " "; pendingGap = false; } out += ch; } return quote === undefined ? out : undefined; } function reject(code, message) { return { reject: { code, message } }; } const RULE_LANE_FLOOR = { pathPrefixedNameIsText: true, quotedOperatorsAreText: true, }; const MATCH_READING = { terminator: "keep", redirection: "reject" }; const OFFER_READING = { terminator: "keep", redirection: "tolerate" }; function commandBasename(name) { return name.slice(name.lastIndexOf("/") + 1); } function ruleLaneShapeOf(command, reading) { const split = splitShellCompoundSegments(command, { trailingTerminator: reading.terminator, redirection: reading.redirection }); if ("reject" in split) return { reject: split.reject }; const names = []; for (const segment of split.segments) { if (reading.redirection === "tolerate" && carriesShellRedirection(segment)) { names.push(undefined); continue; } const floor = parseLeadingCommandName(segment, RULE_LANE_FLOOR); if ("reject" in floor) { return { reject: split.segments.length > 1 ? `the segment "${escapeForDisclosure(segment.trim())}" is not a single simple command (${floor.reject})` : floor.reject, }; } if (SHELL_RESERVED_WORDS.has(floor.name)) { return { reject: `"${escapeForDisclosure(floor.name)}" is a shell keyword, not a command name — a control structure's real program is not named by the text this lane compares`, }; } names.push(floor.name); } return { segments: split.segments, names }; } const CONTROL_CHARS_RE = /[\u0000-\u0008\u000A-\u001F\u007F-\u009F\p{Cf}\u2028\u2029]/u; const CONTROL_CHARS_GLOBAL_RE = new RegExp(CONTROL_CHARS_RE.source, "gu"); const DISCLOSED_RULE_TEXT_MAX_CHARS = 120; export function escapeForDisclosure(value) { let text; try { text = typeof value === "string" ? value : String(value); } catch { return "<unprintable>"; } const escaped = text.replace(CONTROL_CHARS_GLOBAL_RE, (ch) => { const cp = ch.codePointAt(0) ?? 0; const hex = cp.toString(16).toUpperCase(); return cp > 0xffff ? `\\u{${hex}}` : `\\u${hex.padStart(4, "0")}`; }); return escaped.length <= DISCLOSED_RULE_TEXT_MAX_CHARS ? escaped : `${escaped.slice(0, DISCLOSED_RULE_TEXT_MAX_CHARS)}…`; } const DISPLAY_STRIP_FORMAT_RE = /\p{Cf}/gu; export function stripFormatCharacters(text) { return text.replace(DISPLAY_STRIP_FORMAT_RE, ""); } export function renderUntrustedCommandText(text, maxLen = DISCLOSED_RULE_TEXT_MAX_CHARS) { let raw; try { raw = typeof text === "string" ? text : String(text); } catch { return "<unprintable>"; } return inlineUntrusted(stripFormatCharacters(raw), maxLen); } export function hasUnrenderableCharacters(text) { return CONTROL_CHARS_RE.test(text); } export function parseRuleText(text, behavior) { if (!isRuleBehavior(behavior)) { return reject("invalid.grammar", `rule "${text}" was parsed under behavior ${escapeForDisclosure(behavior)}, which is not one of ${RULE_BEHAVIORS.join("/")}`); } const tighten = behavior !== "allow"; if (text.length > MAX_RULE_TEXT_CHARS) { return reject("invalid.too_long", `rule text exceeds ${MAX_RULE_TEXT_CHARS} characters`); } if (CONTROL_CHARS_RE.test(text)) { return reject("invalid.control_chars", "rule text contains control characters (C0/C1, tab excepted), which cannot be part of a readable command"); } const parsed = parsePermissionRule(text); if (parsed.ruleContent === undefined) { return reject("invalid.grammar", `"${text}" is not a Tool(content) rule — a bare tool name claims the whole tool and is not a command rule`); } if (parsed.toolName !== COMMAND_RULE_TOOL) { if (ruleToolGrammarOf(parsed.toolName) !== "path") { return reject("unsupported.tool", `only Bash command rules and rules on path-targeting tools (Read, Edit, Write, …) have a rule form in this version (got "${parsed.toolName}")`); } return parsePathRuleContent(text, parsed.toolName, parsed.ruleContent, behavior); } const content = parsed.ruleContent; const prefixBody = /^(.+):\*$/.exec(content)?.[1]; let match; let body; if (prefixBody !== undefined) { if (prefixBody.includes("*")) { return reject("unsupported.wildcard", `a \`*\` inside a prefix body matches anything at that position, which has no rule form in this version ("${text}")`); } match = "prefix"; body = prefixBody; } else { const wildcardBody = /^(.+) \*$/.exec(content)?.[1]; if (wildcardBody !== undefined && !hasUnescapedStar(wildcardBody)) { match = "wildcard"; body = wildcardBody; } else if (hasUnescapedStar(content)) { return reject("unsupported.wildcard", `a \`*\` before the end of the pattern matches anything at that position, which has no rule form in this version — only the trailing \`<command> *\` form does ("${text}")`); } else { match = "exact"; body = content; } } const shape = ruleLaneShapeOf(body, MATCH_READING); if ("reject" in shape) { if (body.trim() === "" || shape.reject === "empty command") return reject("invalid.empty_command", `rule "${text}" names no command`); if (foldSpacing(body) === undefined) return reject("invalid.unbalanced_quotes", `rule "${text}" has unbalanced quoting, so the command it names is not determinable`); return reject("invalid.not_simple_command", `rule "${text}" is not a command this lane can name (${shape.reject})`); } if (match === "wildcard" && shape.segments.length > 1) { return reject("unsupported.wildcard", `a \`<chain> *\` pattern names more than one command, and the trailing-star form has no chain reading — the compound-prefix form spells one with the \`:*\` marker ("${text}")`); } const head = commandBasename(shape.names[shape.names.length - 1] ?? ""); if ((match === "prefix" || match === "wildcard") && BARE_INTERPRETER_NAMES.has(head) && !tighten) { return reject("invalid.bare_interpreter_prefix", shape.segments.length > 1 ? `prefix rule "${text}" leaves its final segment open-ended under the interpreter "${head}" — such a rule authorizes running arbitrary programs, which one approval click cannot be read as having granted (an exact rule naming the whole command line is accepted)` : `prefix rule "${text}" is headed by the interpreter "${head}" — such a rule authorizes running arbitrary programs, which one approval click cannot be read as having granted (an exact rule naming the whole command line is accepted)`); } const command = foldSpacing(body); if (command === undefined) { return reject("invalid.unbalanced_quotes", `rule "${text}" has unbalanced quoting, so the command it names is not determinable`); } return { rule: { behavior, rule: formatRuleText(command, match, COMMAND_RULE_TOOL), tool: COMMAND_RULE_TOOL, match, command } }; } export function formatRuleText(command, match, tool) { if (match === "subpath") return `${tool}(//${command.startsWith("/") ? command.slice(1) : command}/**)`; if (match === "path") return `${tool}(${command})`; return `${tool}(${command}${match === "prefix" ? ":*" : match === "wildcard" ? " *" : ""})`; } function parsePathRuleContent(text, tool, content, behavior) { const dirForm = /^\/\/(.+)\/\*\*$/.exec(content); if (dirForm !== null) { const bodyR = dirForm[1]; if (bodyR.includes("*")) { return reject("unsupported.wildcard", `a \`*\` inside the directory body matches anything at that position, which has no rule form ("${text}")`); } const segments = bodyR.split("/"); if (segments.some((sg) => sg === "" || sg.trim() === "")) { return reject("invalid.grammar", `the directory body of "${text}" is not in the canonical spelling (an empty or whitespace-only path segment) — the lexical normal form is the only accepted spelling`); } if (segments.some((sg) => sg === "." || sg === "..")) { return reject("invalid.path_traversal", `the directory body of "${text}" carries a \`.\` or \`..\` segment — the lexical normal form is the only accepted spelling, and a traversal segment is not normalized away`); } if (behavior === "allow" && tool !== READ_RULE_TOOL) { return reject("unsupported.tool", `an allow rule on a path tool speaks for the Read directory form only — a standing approval of a WRITE by directory is not a rule form ("${text}"); write it as a deny or an ask, or approve the call`); } const dir = "/" + bodyR; return { rule: { behavior, rule: formatRuleText(dir, "subpath", tool), tool, match: "subpath", command: dir } }; } if (behavior === "allow") { return tool === READ_RULE_TOOL ? reject("invalid.grammar", `only the Read(//abs-dir/**) directory form is a rule this version reads ("${text}") — relative, single-file, single-slash and no-tail spellings have no rule form`) : reject("unsupported.tool", `an allow rule on "${tool}" has no rule form — only the Read directory form is a standing approval by path ("${text}"); write it as a deny or an ask, or approve the call`); } if (content.trim() !== content || content === "") { return reject("invalid.grammar", `the path pattern of "${text}" is empty or carries surrounding whitespace — the pattern is stored as spelled, so the spelling must be the one meant`); } if (content.startsWith("!")) return reject("invalid.grammar", `a negated path pattern ("${text}") is not a rule — a rule says what it reaches, never what it excepts`); if (/[?[\]{}]/.test(content)) { return reject("unsupported.wildcard", `the path pattern of "${text}" carries a glob class (\`?\`, \`[]\`, \`{}\`) this lane does not read — only \`*\` inside a segment and \`**\` as a whole segment have a rule form`); } const body = content.startsWith("//") ? content.slice(2) : content.startsWith("~/") ? content.slice(2) : content.startsWith("/") ? content.slice(1) : content.startsWith("./") ? content.slice(2) : content; const segments = body.split("/"); if (segments.some((sg) => sg === "" || sg.trim() === "")) { return reject("invalid.grammar", `the path pattern of "${text}" carries an empty path segment — the lexical normal form is the only accepted spelling`); } if (segments.some((sg) => sg === "." || sg === "..")) { return reject("invalid.path_traversal", `the path pattern of "${text}" carries a \`.\` or \`..\` segment — the lexical normal form is the only accepted spelling, and a traversal segment is not normalized away`); } if (segments.some((sg) => sg.includes("**") && sg !== "**")) { return reject("unsupported.wildcard", `\`**\` in the path pattern of "${text}" must stand as a whole segment (\`a/**/b\`), never inside one`); } return { rule: { behavior, rule: formatRuleText(content, "path", tool), tool, match: "path", command: content } }; } export const PATH_RULE_BASES = Object.freeze(["cwd", "root", "home"]); export const PATH_RULE_BASE_LABEL = { cwd: "the working directory", root: "the project root", home: "the home directory of the execution environment", }; export function ruleBasesNeeded(rule) { if (rule.match !== "path") return []; const pattern = rule.command; if (pattern.startsWith("//")) return []; if (pattern.startsWith("~/")) return ["home"]; if (pattern.startsWith("/")) return ["root"]; return ["cwd"]; } function baseValueOf(base, bases) { switch (base) { case "home": return bases.home; case "root": return bases.root; case "cwd": return bases.cwd ?? bases.root; default: { const _exhaustive = base; return _exhaustive; } } } function resolvePathPattern(pattern, bases, behavior) { const family = pathFamilyOf(bases); const tree = bases.root ?? bases.cwd; if (pattern.startsWith("//")) { const abs = pathIdentityOf("/" + pattern.slice(2), family, tree); return abs.ok ? { resolved: abs.identity } : { missingBase: "root" }; } const needed = ruleBasesNeeded({ match: "path", command: pattern })[0]; const baseValue = baseValueOf(needed, bases); const base = baseValue !== undefined && isAbsoluteForFamily(family, baseValue) ? pathIdentityOf(baseValue, family, tree) : undefined; if (base === undefined || !base.ok) return { missingBase: needed }; const normal = base.identity; const rest = pattern.startsWith("~/") ? pattern.slice(2) : pattern.startsWith("/") ? pattern.slice(1) : pattern.startsWith("./") ? pattern.slice(2) : pattern; const peeled = rest.endsWith("/**") ? rest.slice(0, -3) : rest; const bare = needed === "cwd" && peeled !== "" && !peeled.includes("/"); const anyDepth = bare && (peeled === rest || behavior !== "allow"); const body = anyDepth ? "**/" + peeled : rest; return { resolved: (normal === "/" ? "" : normal) + "/" + body }; } export function isUsablePathBase(base, family) { return base !== undefined && isAbsoluteForFamily(family, base) && pathIdentityOf(base, family, base).ok; } function segmentGlobMatches(glob, segment) { if (!glob.includes("*")) return glob === segment; const parts = glob.split("*"); let at = 0; for (let i = 0; i < parts.length; i++) { const part = parts[i]; if (i === 0) { if (!segment.startsWith(part)) return false; at = part.length; continue; } if (i === parts.length - 1) return part === "" || (segment.endsWith(part) && segment.length - part.length >= at); const k = segment.indexOf(part, at); if (k === -1) return false; at = k + part.length; } return true; } function globSegmentsReach(globs, target) { const seen = new Set(); const width = target.length + 1; const walk = (g, t) => { if (g === globs.length) return true; const key = g * width + t; if (seen.has(key)) return false; seen.add(key); if (globs[g] === "**") return walk(g + 1, t) || (t < target.length && walk(g, t + 1)); if (t >= target.length) return false; return segmentGlobMatches(globs[g], target[t]) && walk(g + 1, t + 1); }; return walk(0, 0); } export function pathRuleReachOf(rule, target, bases) { if (rule.match === "subpath") return directoryRuleAdmits(rule, target, bases) ? REACHED : NOT_REACHED; if (rule.match !== "path") return NOT_REACHED; const family = pathFamilyOf(bases); if (!isPathIdentity(target, family)) return NOT_REACHED; const r = resolvePathPattern(rule.command, bases, rule.behavior); if ("missingBase" in r) { return { reach: "unreadable", reason: `the rule is relative to ${PATH_RULE_BASE_LABEL[r.missingBase]} and this call supplies no absolute \`${r.missingBase}\` base of this tree's family to resolve it against` }; } if (isUncIdentity(r.resolved) !== isUncIdentity(target)) return NOT_REACHED; const globs = r.resolved.split("/").filter((sg) => sg !== ""); const segments = target.split("/").filter((sg) => sg !== ""); return globSegmentsReach(globs, segments) ? REACHED : NOT_REACHED; } export function pathRuleHeadOf(rule, bases) { const family = pathFamilyOf(bases); const tree = bases.root ?? bases.cwd; let head; if (rule.match === "subpath") { if (carriesDotSegment(rule.command, family)) return undefined; const dir = pathIdentityOf(rule.command, family, tree); return dir.ok && isIdentityDir(dir.identity, family) ? dir.identity : undefined; } else if (rule.match === "path") { const r = resolvePathPattern(rule.command, bases, rule.behavior); if ("missingBase" in r) return undefined; const parts = r.resolved.split("/"); const wild = parts.findIndex((part) => part.includes("*")); head = (wild === -1 ? parts : parts.slice(0, wild)).join("/"); } else return undefined; const id = pathIdentityOf(head === "" ? "/" : head, family, tree); return id.ok ? id.identity : undefined; } function hasUnescapedStar(s) { for (let i = 0; i < s.length; i++) { if (s[i] !== "*") continue; let backslashes = 0; for (let j = i - 1; j >= 0 && s[j] === "\\"; j--) backslashes++; if (backslashes % 2 === 0) return true; } return false; } export function ruleAdmitsCommand(rule, command) { return admitsUnder(rule, command, MATCH_READING); } export const PROGRAM_RUN_REACHES = Object.freeze(["reached", "not_reached", "unreadable"]); const REACHED = { reach: "reached" }; const NOT_REACHED = { reach: "not_reached" }; export function programRunReachOf(rule, command) { if (rule.match === "subpath" || rule.match === "path") return NOT_REACHED; const body = readRuleBody(rule.command); if (body === undefined) return NOT_REACHED; const shape = readCommandMemo(command); const exact = rule.match === "exact"; if (body.segments.length > 1) { const broken = shape.segments.find((s) => s.unreadable !== undefined); if (broken !== undefined) return { reach: "unreadable", reason: broken.unreadable }; const hidden = shape.segments.find((s) => s.peelUnreadable !== undefined); const shaped = !shape.grouped && shape.segments.length === body.segments.length && shape.connectors.every((c, i) => c === body.connectors[i]); if (!shaped) return hidden === undefined ? NOT_REACHED : { reach: "unreadable", reason: hidden.peelUnreadable }; let outcome = REACHED; for (let i = 0; i < body.segments.length; i++) { const r = segmentReachOf(body.segments[i].argv, shape.segments[i], exact || i < body.segments.length - 1); if (r.reach === "not_reached") return hidden === undefined ? NOT_REACHED : { reach: "unreadable", reason: hidden.peelUnreadable }; if (r.reach === "unreadable") outcome = r; } return outcome; } const bodyArgv = body.segments[0].argv; let unreadable; for (const segment of shape.segments) { const r = segmentReachOf(bodyArgv, segment, exact); if (r.reach === "reached") return REACHED; if (r.reach === "unreadable") unreadable ??= r; } return unreadable ?? NOT_REACHED; } export function ruleReachesProgramRun(rule, command) { return programRunReachOf(rule, command).reach === "reached"; } function segmentReachOf(bodyArgv, segment, exact) { if (segment.unreadable !== undefined) return { reach: "unreadable", reason: segment.unreadable }; let unreadable; for (const argv of [segment.argv, ...segment.peeled]) { const r = argvReachOf(bodyArgv, argv, exact); if (r.reach === "reached") return REACHED; if (r.reach === "unreadable") unreadable ??= r; } if (segment.peelUnreadable !== undefined) unreadable ??= { reach: "unreadable", reason: segment.peelUnreadable }; return unreadable ?? NOT_REACHED; } function argvReachOf(bodyArgv, argv, exact) { for (let i = 0; i < bodyArgv.length; i++) { const word = argv[i]; const wanted = bodyArgv[i]; if (word === undefined) return NOT_REACHED; if (word.expands !== false) { if (word.raw === wanted.raw) continue; return { reach: "unreadable", reason: i === 0 ? "the program name is an expansion" : `an expansion (${word.raw}) stands where the rule reads a word` }; } if (word.text !== wanted.text) return NOT_REACHED; } if (!exact || argv.length === bodyArgv.length) return REACHED; const loose = argv.slice(bodyArgv.length).find((w) => w.expands === "many"); return loose === undefined ? NOT_REACHED : { reach: "unreadable", reason: `an unquoted expansion (${loose.raw}) may expand to nothing` }; } function readRuleBody(body) { const shape = readShellCommand(body); if (shape.grouped || shape.segments.length === 0) return undefined; return shape.segments.every((s) => s.unreadable === undefined && s.argv.length > 0) ? shape : undefined; } let lastRead; function readCommandMemo(command) { if (lastRead?.command !== command) lastRead = { command, shape: readShellCommand(command) }; return lastRead.shape; } function admitsUnder(rule, command, reading) { if (rule.match === "subpath" || rule.match === "path") return false; const shape = ruleLaneShapeOf(command, reading); if ("reject" in shape) return false; const folded = foldSpacing(command); if (folded === undefined) return false; if (rule.match === "exact") return folded === rule.command; const bodyShape = ruleLaneShapeOf(rule.command, reading); if ("reject" in bodyShape || foldSpacing(rule.command) === undefined) return false; if (shape.segments.length !== bodyShape.segments.length) return false; return folded === rule.command || folded.startsWith(rule.command + " "); } export function ruleBreadthWarningsOf(rule) { if (rule.match !== "prefix" && rule.match !== "wildcard") return []; const bodyShape = ruleLaneShapeOf(rule.command, MATCH_READING); if ("reject" in bodyShape) return []; if (bodyShape.segments.length > 1) { return [ { code: "compound_prefix", message: `this rule's body is a command chain — it admits every run of the whole chain "${rule.command}", with any text extending its final segment`, }, ]; } if (rule.command.split(/\s+/).filter((t) => t !== "").length === 1) { return [ { code: "broad_prefix", message: `this rule admits every argument form of "${rule.command}" — any \`${rule.command} …\` command, not just the one on this card`, }, ]; } return []; } export function pathWithinRoot(path, root) { if (path === root) return true; const base = root.endsWith("/") ? root : root + "/"; return path.startsWith(base); } export function lexicalNormalAbsolutePathOf(path, family, anchor) { const r = pathIdentityOf(path, family, anchor); return r.ok ? r.identity : undefined; } function isLexicalNormalAbsoluteDir(path) { if (typeof path !== "string" || !path.startsWith("/") || path.startsWith("//")) return false; const segments = path.split("/").slice(1); return segments.length > 0 && segments.every((s) => s !== "" && s !== "." && s !== ".."); } export function directoryRuleAdmits(rule, path, bases) { if (rule.match !== "subpath") return false; const family = pathFamilyOf(bases); if (carriesDotSegment(rule.command, family)) return false; const dir = pathIdentityOf(rule.command, family, bases.root ?? bases.cwd); if (!dir.ok || !isIdentityDir(dir.identity, family)) return false; if (!isPathIdentity(path, family) || !isIdentityDir(path, family)) return false; return pathWithinRoot(path, dir.identity); } export function directoryIdentityOf(spelling, bases) { const r = pathIdentityOf(spelling, pathFamilyOf(bases), bases.root ?? bases.cwd); return r.ok ? r.identity : undefined; } const CD_TARGET_REJECT_CHARS = /[*?[\]{}$`\\'"<>\n\r]/; function cdSegmentDirectoryOf(segment, opts) { if (/[^\S \t]/.test(segment)) return undefined; const tokens = segment.split(/[ \t]+/).filter((t) => t !== ""); if (tokens.length !== 2 || tokens[0] !== "cd") return undefined; const target = tokens[1]; if (CD_TARGET_REJECT_CHARS.test(target)) return undefined; if (target.startsWith("-") || target.startsWith("~")) return undefined; let resolved; if (target.startsWith("//")) return undefined; if (target.startsWith("/")) { resolved = target; } else if (target.startsWith("./")) { if (opts.priorCd || opts.cwd === undefined) return undefined; if (!opts.cwd.startsWith("/") || opts.cwd.startsWith("//")) return undefined; resolved = opts.cwd.replace(/\/+$/, "") + target.slice(1); } else { return undefined; } if (!isLexicalNormalAbsoluteDir(resolved)) return undefined; if (HOME_TOP_SHAPE.test(resolved)) return undefined; if (HOME_ANCESTOR_SHAPE.test(resolved)) return undefined; const processHome = userHomeTopLevel(); if (processHome !== undefined && (resolved === processHome || isStrictAncestorDir(resolved, processHome))) { return undefined; } return resolved; } function isStrictAncestorDir(ancestor, path) { return ancestor !== "/" && path.startsWith(`${ancestor}/`); } const HOME_TOP_SHAPE = /^(?:\/home\/[^/]+|\/Users\/[^/]+|\/root|\/var\/root)$/; const HOME_ANCESTOR_SHAPE = /^(?:\/home|\/Users|\/var)$/; let HOME_TOP_LEVEL = null; function userHomeTopLevel() { if (HOME_TOP_LEVEL === null) { try { const h = homedir(); HOME_TOP_LEVEL = typeof h === "string" && h.startsWith("/") && !h.startsWith("//") ? h.replace(/\/+$/, "") : undefined; } catch { HOME_TOP_LEVEL = undefined; } } return HOME_TOP_LEVEL === null ? undefined : HOME_TOP_LEVEL; } const CURSOR_MOVING_HEADS = new Set(["cd", "pushd", "popd"]); const OPAQUE_CURSOR_MOVERS = new Set(["eval", "source", ".", "trap", "enable", "builtin", "command", "time"]); function pushdPopdLeavesCursor(head, args) { for (const a of args) { if (a === "--") return false; if (/^-[a-zA-Z]*n[a-zA-Z]*$/.test(a)) return true; if (head === "popd" && /^\+0*[1-9]\d*$/.test(a)) return true; } return false; } function movesShellCursor(segment) { if (carriesShellRedirection(segment)) return true; const words = segment .split(/[ \t]+/) .map((w) => w.replace(/['"]/g, "")) .filter((t) => t !== ""); const head = words[0]; if (head === undefined) return false; if (OPAQUE_CURSOR_MOVERS.has(head)) return true; if (!CURSOR_MOVING_HEADS.has(head)) return false; return !((head === "pushd" || head === "popd") && pushdPopdLeavesCursor(head, words.slice(1))); } function resolveCdSegmentDirectories(segments, cwd) { let priorCd = false; return segments.map((segment) => { const dir = cdSegmentDirectoryOf(segment, { cwd, priorCd }); if (!priorCd && movesShellCursor(segment)) priorCd = true; const bases = basesOfCwd(cwd); return dir === undefined || isShellRootedSpellingUnmapped(pathFamilyOf(bases), dir) ? undefined : directoryIdentityOf(dir, bases); }); } export function scopeCoversCwd(scope, cwd, sessionId) { if (scope.kind === "global") return true; if (scope.kind === "session") return sessionId !== undefined && sessionId === scope.sessionId; return cwd !== undefined && pathWithinRoot(cwd, scope.root); } export function isRuleLive(rule) { return rule.adds.length > 0; } export function eligibleContext(rule, call) { return rule.tool === call.tool && scopeCoversCwd(rule.scope, call.cwd, call.sessionId); } export function eligiblePersisted(rule, call, behavior) { return rule.behavior === behavior && isRuleLive(rule) && eligibleContext(rule, call); } function firstEligibleAdmittingRule(rules, commandText, call) { for (const rule of rules) { if (!eligiblePersisted(rule, call, "allow")) continue; if (ruleAdmitsCommand(rule, commandText)) return rule; } return undefined; } export function findAdmittingRule(rules, call) { const shape = ruleLaneShapeOf(call.command, MATCH_READING); if ("reject" in shape) return undefined; const whole = firstEligibleAdmittingRule(rules, call.command, call); if (whole !== undefined) return [whole]; const cdDirs = resolveCdSegmentDirectories(shape.segments, call.execCwd ?? call.cwd); if (shape.segments.length < 2) { const dir = cdDirs[0]; const lone = dir !== undefined ? firstEligibleDirectoryRule(rules, dir, call) : undefined; return lone !== undefined ? [lone] : undefined; } const covering = []; const lineObjection = readOnlyLineObjection(call.command, call.execCwd ?? call.cwd); for (const [i, segment] of shape.segments.entries()) { const dir = cdDirs[i]; const first = firstEligibleAdmittingRule(rules, segment, call) ?? (dir !== undefined ? firstEligibleDirectoryRule(rules, dir, call) : undefined); if (first === undefined) { if (readsOnlyAsSegment(segment, lineObjection)) continue; return undefined; } if (!covering.includes(first)) covering.push(first); } return covering.length > 0 ? covering : undefined; } function readsOnlyAsSegment(segment, lineObjection) { return lineObjection === undefined && !movesShellCursor(segment) && readOnlyShellVerdict(segment).readOnly; } function firstEligibleDirectoryRule(rules, directory, call) { for (const rule of rules) { if (!eligiblePersisted(rule, { tool: "Read", cwd: call.cwd, sessionId: call.sessionId }, "allow")) continue; if (directoryRuleAdmits(rule, directory, basesOfCwd(call.execCwd ?? call.cwd))) return rule; } return undefined; } function basesOfCwd(cwd) { return cwd === undefined ? {} : { root: cwd }; } export function adjudicatePersistedRules(rules, call) { for (const behavior of RULE_BEHAVIORS_BY_PRECEDENCE) { if (behavior === "allow") { const covering = findAdmittingRule(rules, call); if (covering !== undefined) return { behavior, rules: covering }; continue; } let unreadable; for (const rule of rules) { if (!eligiblePersisted(rule, call, behavior)) continue; const outcome = programRunReachOf(rule, call.command); if (outcome.reach === "reached") return { behavior, rules: [rule] }; if (outcome.reach === "unreadable") unreadable ??= { behavior: "ask", rules: [rule], unreadable: outcome.reason }; } if (unreadable !== undefined) return unreadable; } return undefined; } export function adjudicatePersistedPathRules(rules, call, targets, bases) { const speaks = (rule, behavior) => eligiblePersisted(rule, call, behavior) || (behavior === "ask" && call.face !== undefined && PATH_RULE_FACES[rule.tool]?.ask?.includes(call.face) === true && eligiblePersisted(rule, { ...call, tool: rule.tool }, behavior)); for (const behavior of RULE_BEHAVIORS_BY_PRECEDENCE) { if (behavior === "allow") { if (targets.allow?.kind !== "path" || call.tool !== READ_RULE_TOOL) continue; for (const rule of rules) { if (!eligiblePersisted(rule, call, behavior)) continue; if (directoryRuleAdmits(rule, targets.allow.identity, bases)) return { behavior, rules: [rule] }; } continue; } const tighten = targets.tighten; if (tighten === undefined || tighten.kind === "refused") continue; let unreadable; for (const rule of rules) { if (!speaks(rule, behavior)) continue; if (tighten.kind === "unreadable") { if (rule.match === "path" || rule.match === "subpath") unreadable ??= { behavior: "ask", rules: [rule], unreadable: tighten.reason }; continue; } const outcome = pathRuleReachOf(rule, tighten.identity, bases); if (outcome.reach === "reached") return { behavior, rules: [rule] }; if (outcome.reach === "unreadable") unreadable ??= { behavior: "ask", rules: [rule], unreadable: outcome.reason }; } if (unreadable !== undefined) return unreadable; } return undefined; } export function segmentCoverageOf(command, rules, call) { const folded = foldSpacing(command); if (folded === undefined) return undefined; const shape = ruleLaneShapeOf(folded, OFFER_READING); if ("reject" in shape) return undefined; const proposed = rules.proposed ?? []; const cdDirs = resolveCdSegmentDirectories(shape.segments, call.execCwd ?? call.cwd); const lineObjection = readOnlyLineObjection(folded, call.execCwd ?? call.cwd); return shape.segments.map((segment, i) => { const dir = cdDirs[i]; return { segment: segment.trim(), covered: readsOnlyAsSegment(segment, lineObjection) || firstEligibleAdmittingRule(rules.persisted, segment, call) !== undefined || (dir !== undefined && firstEligibleDirectoryRule(rules.persisted, dir, call) !== undefined) || proposed.some((p) => p.rule.tool === "Read" ? dir !== undefined && eligibleContext({ tool: p.rule.tool, scope: p.scope }, { tool: "Read", cwd: call.cwd, sessionId: call.sessionId }) && directoryRuleAdmits(p.rule, dir, basesOfCwd(call.execCwd ?? call.cwd)) : eligibleContext({ tool: p.rule.tool, scope: p.scope }, call) && ruleAdmitsCommand(p.rule, segment)), }; }); } export const UNCOVERED_SEGMENT_REASON_BASELINE = { redirection: "This part carries a redirection, so it is approved per use — no rule can cover this spelling. If you trust its redirection-free form, a rule can be written for that form (the card's edit row, or settings).", no_rule_form: "No rule form exists for this part; it can be approved per use.", cap_overflow: "This batch does not cover this part; you will be asked again when it comes up.", }; export const RULE_OFFERS_ABSENCE_BASELINE = { mandated: "This approval is required by the deployment's policy each time; a rule cannot waive it.", lane_cannot_speak: "This card has no rule to offer for this exact command; it can be approved per use. A coverable form of what you trust can still get a rule — through a card drawn for that form, or settings.", shadowed: "A rule you already have matches this call and still asks — that standing rule keeps deciding, so a new rule minted here would change nothing; review your existing rules instead.", }; export function suggestRulesForCommand(command, ctx) { const shape = ruleLaneShapeOf(command, OFFER_READING); if ("reject" in shape) return []; const folded = foldSpacing(command); if (folded === undefined) return []; const offers = []; const exact = parseRuleText(formatRuleText(folded, "exact", COMMAND_RULE_TOOL), "allow"); if ("rule" in exact && exact.rule.match === "exact" && ruleAdmitsCommand(exact.rule, command)) { offers.push({ kind: "single", rule: exact.rule.rule, match: "exact", command: exact.rule.command }); } const tokensOf = (s) => s.split(/\s+/).filter((t) => t !== ""); const bodyOf = (tokens) => longestReviewedBody(tokens) ?? genericPrefixBody(tokens); const directoryGateOpen = (ctx?.scope?.kind === "project" || ctx?.scope?.kind === "session") && scopeCoversCwd(ctx.scope, ctx?.cwd, ctx?.sessionId); const deniesDirectory = ctx?.deniesDirectoryRead ?? defaultDeniesDirectoryRead; const mintDirectoryMember = (directory, segment) => { if (!directoryGateOpen || directory === undefined) return undefined; let denied; try { denied = deniesDirectory(directory) === true; } catch { denied = true; } if (denied) return undefined; const parsed = parseRuleText(formatRuleText(directory, "subpath", READ_RULE_TOOL), "allow"); if (!("rule" in parsed) || parsed.rule.match !== "subpath") return undefined; if (!directoryRuleAdmits(parsed.rule, directory, basesOfCwd(ctx?.execCwd ?? ctx?.cwd))) return undefined; return { kind: "directoryRead", rule: parsed.rule.rule, directory: parsed.rule.command, segment }; }; if (shape.segments.length === 1) { if (offers.length === 1) { const body = bodyOf(tokensOf(folded)); if (body !== undefined) { const parsed = parseRuleText(formatRuleText(body, "wildcard", COMMAND_RULE_TOOL), "allow"); if ("rule" in parsed && parsed.rule.match === "wildcard" && ruleAdmitsCommand(parsed.rule, command)) { offers.push({ kind: "single", rule: parsed.rule.rule, match: "wildcard", command: parsed.rule.command }); } } } const lone = folded.trim(); const loneCovered = ctx?.coverage !== undefined && ctx.coverage.length === 1 && ctx.coverage[0]?.segment === lone && ctx.coverage[0]?.covered === true; if (!loneCovered) { const member = mintDirectoryMember(resolveCdSegmentDirectories([lone], ctx?.execCwd ?? ctx?.cwd)[0], lone); if (member !== undefined) offers.push({ kind: "batch", rules: [member], uncoveredSegments: 0, uncoveredDetail: [] }); } return offers; } const foldedShape = ruleLaneShapeOf(folded, OFFER_READING); if ("reject" in foldedShape || foldedShape.segments.length !== shape.segments.length) return offers; const segments = foldedShape.segments.map((s) => s.trim()); const table = ctx?.coverage; const alignedTable = table !== undefined && table.length === segments.length && table.every((row, i) => row.segment === segments[i]) ? table : undefined; const coveredAt = (i) => alignedTable?.[i]?.covered === true; const mintSegmentRule = (segment) => { if (carriesShellRedirection(segment)) return undefined; const body = bodyOf(tokensOf(segment)); if (body !== undefined) { const parsed = parseRuleText(formatRuleText(body, "wildcard", COMMAND_RULE_TOOL), "allow"); if ("rule" in parsed && parsed.rule.match === "wildcard" && ruleAdmitsCommand(parsed.rule, segment)) { return { rule: parsed.rule.rule, match: "wildcard", command: parsed.rule.command }; } } const segExact = parseRuleText(formatRuleText(segment, "exact", COMMAND_RULE_TOOL), "allow"); if ("rule" in segExact && segExact.rule.match === "exact" && ruleAdmitsCommand(segExact.rule, segment)) { return { rule: segExact.rule.rule, match: "exact", command: segExact.rule.command }; } return undefined; }; const cdDirs = resolveCdSegmentDirectories(segments, ctx?.execCwd ?? ctx?.cwd); const minted = []; for (let i = 0; i < segments.length; i++) { if (coveredAt(i)) continue; const segment = segments[i]; const dirMember = mintDirectoryMember(cdDirs[i], segment); const member = dirMember ?? (() => { const rule = mintSegmentRule(segment); return rule !== undefined ? { kind: "command", ...rule, segment } : undefined; })(); if (member !== undefined && !minted.some((m) => m.rule === member.rule)) minted.push(member); } const batchRules = minted.slice(0, 5); if (batchRules.length > 0) { const parsedBatch = batchRules.flatMap((r) => { const p = parseRuleText(r.rule, "allow"); return "rule" in p ? [p.rule] : []; }); const uncoveredDetail = []; for (let i = 0; i < segments.length; i++) { const segment = segments[i]; if (coveredAt(i)) continue; if (parsedBatch.some((p) => p.tool !== "Read" && ruleAdmitsCommand(p, segment))) continue; const dir = cdDirs[i]; if (dir !== undefined && parsedBatch.some((p) => directoryRuleAdmits(p, dir, basesOfCwd(ctx?.execCwd ?? ctx?.cwd)))) continue; const reason = carriesShellRedirection(segment) ? "redirection" : mintSegmentRule(segment) === undefined ? "no_rule_form" : "cap_overflow"; uncoveredDetail.push({ segment, reason }); } offers.push({ kind: "batch", rules: batchRules, uncoveredSegments: uncoveredDetail.length, uncoveredDetail }); } return offers; } let DEFAULT_DIRECTORY_DENY; function defaultDeniesDirectoryRead(directory) { if (DEFAULT_DIRECTORY_DENY === undefined) { const matcher = compileReadDeny([], "directoryRead mint floor"); DEFAULT_DIRECTORY_DENY = (d) => matcher.matchPath(d) !== null; } return DEFAULT_DIRECTORY_DENY(directory); }