UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

501 lines (500 loc) • 28.3 kB
import { attest } from "./tool-policy.js"; import { MCP_NAMESPACE, protocolOf } from "./protocol-table.js"; import { catalogRuleFaceOf, isShellFaceToolName, pathTargetOf } from "./tool-registry.js"; import { indexOfUnescaped, lastIndexOfUnescaped, parsePermissionRule, ruleSpellingOf } from "./permission-rule-syntax.js"; import { COMMAND_RULE_TOOL, NAME_RULE_FACES, PATH_RULE_BASE_LABEL, PATH_RULE_FACES, callPathFaceOf, isUsablePathBase, keyedValueAbsentReason, laneToolNamesOf, nameGrammarReads, nameRuleAdmits, nameRuleSubjectOf, parseRuleText, programRunReachOf, ruleBasesNeeded } from "./permission-rule-model.js"; import { pathFamilyOf } from "../tools/fs/safety.js"; import { argsAsTheToolReads, callTargetReadingOf, callTargetViewsOf } from "./effective-path-target.js"; import { canonicalize, UNCANONICALIZABLE } from "./canonical-json.js"; import { bindPathRules, judgeRuleRows, policyAnchors, rowsWhereTheyLead } from "./path-rule-binding.js"; import { ruleDenialSentence } from "./rule-denial-sentence.js"; import { heldValues } from "./tool-name-readings.js"; export { parsePermissionRule }; function describeBehavior(v) { switch (typeof v) { case "undefined": return "undefined"; case "string": { const q = JSON.stringify(v); return q.length > 40 ? q.slice(0, 37) + '..."' : q; } case "number": case "boolean": case "bigint": return String(v); case "symbol": return "a symbol"; case "function": return "a function"; default: return v === null ? "null" : "an object"; } } export const DEFAULT_CAPS = { maxRules: 256, maxRuleLength: 512, maxPatternLength: 256, maxStars: 16, maxScalarValueChars: 16_384, }; import { isNamespacedCoveringRuleName, namespacedRuleNameCovers, wildcardMatch } from "./namespaced-rule-name.js"; export { isNamespacedCoveringRuleName, namespacedRuleNameCovers, wildcardMatch }; export function catalogRuleFaceView(toolName) { const face = catalogRuleFaceOf(toolName); if (face === undefined) return undefined; return { ...face, pathTargeting: pathTargetOf(toolName) !== undefined }; } export const fileToolParamVocabulary = (canonicalTool) => { const face = catalogRuleFaceOf(canonicalTool); return face?.params === undefined || pathTargetOf(canonicalTool) === undefined ? undefined : new Set(face.params); }; function countStars(s) { let n = 0; for (const c of s) if (c === "*") n++; return n; } function unreadableRuleIssue(text) { const lenient = parsePermissionRule(text); if (!lenient.toolName) return { code: "invalid.empty_tool", message: "tool name cannot be empty" }; if (lenient.ruleContent === undefined && /[()\\]/.test(lenient.toolName)) { return { code: "invalid.paren", message: "tool name still contains paren/escape characters — a mis-escaped rule would compile to a dead bare rule" }; } const open = indexOfUnescaped(text, "("); if (lenient.ruleContent === undefined && open !== -1) { const close = lastIndexOfUnescaped(text, ")"); const legitimateBare = close === text.length - 1 && ["", "*"].includes(text.substring(open + 1, close)); if (!legitimateBare) return { code: "invalid.paren", message: "malformed parentheses / trailing characters (would silently downgrade to a bare-tool rule)" }; } return { code: "invalid.tool_name", message: `the tool name is a spelling no tool name can have — the rule syntax reads no rule in it (blanks around the rule, a character outside a tool name's spelling, or one before the "("), so the rule would name no tool; fix the entry`, }; } function compile(rules, caps, primaryFieldGeneric, ruleFaces, pathBases = {}) { const faceOf = (toolName) => { if (ruleFaces !== undefined) { const face = ruleFaces(toolName); return { face, judged: true }; } const face = catalogRuleFaceView(toolName); return { face, judged: face !== undefined }; }; const byTool = new Map(); const command = { deny: [], ask: [] }; const namespacedCovering = { deny: [], ask: [] }; const issues = []; const bad = (rule, code, message) => { issues.push({ rule, code, message }); }; if (rules.length > caps.maxRules) { return { byTool, command, namespacedCovering, issues: [{ rule: "", code: "invalid.cap_exceeded", message: `${rules.length} rules > maxRules ${caps.maxRules}` }] }; } for (const r of rules) { const text = r.rule; const behavior = r.behavior; if (behavior !== "deny" && behavior !== "ask") { if (behavior === "allow") bad(text, "unsupported.allow", "an allow rule does not belong to a policy — allow entries live on the allow layer: put the entry in `TaskSpec.allowedTools` (a request's) or `RunnerDeps.allowedTools` (the deployment's); deny / ask rules stay here"); else bad(text, "invalid.behavior", `a rule's behavior is one of "deny" | "ask" — got ${describeBehavior(behavior)}; a misspelled verb would compile to a rule that silently never fires`); continue; } const verb = behavior; if (text.length > caps.maxRuleLength) { bad(text, "invalid.cap_exceeded", `rule longer than ${caps.maxRuleLength} chars`); continue; } const parsed = ruleSpellingOf(text); if (parsed === undefined) { const why = unreadableRuleIssue(text); bad(text, why.code, why.message); continue; } if (parsed.toolName === "*") { bad(text, "invalid.tool_name", `\`*\` names no tool — a rule reaches the tool it names, so this one would compile and never match; name the tool, or state the answer for every call no rule reaches with \`defaultAction\``); continue; } const toolName = parsed.toolName; const parenthesised = parsed.ruleContent !== undefined || indexOfUnescaped(text, "(") !== -1; if (toolName.startsWith(MCP_NAMESPACE.prefix) && parenthesised) { bad(text, "unsupported.mcp_paren", "MCP rules do not support patterns in parentheses (CC parity); use the toolAxes system"); continue; } if (parenthesised && isNamespacedCoveringRuleName(toolName)) { bad(text, "unsupported.covering_paren", `"${toolName}" reaches a SET of tools, and the generic param DSL is keyed by exact tool name — a parenthesised rule on it can never match. ` + `Write the parenthesised rule against one exact tool name, or drop the parentheses to keep the covering rule.`); continue; } const entry = byTool.get(toolName) ?? { bare: {}, param: { deny: [], ask: [] }, content: { deny: [], ask: [] } }; if (parsed.ruleContent === undefined) { if (isNamespacedCoveringRuleName(toolName)) { namespacedCovering[verb].push({ ruleName: toolName, ruleText: text, ...(r.source !== undefined ? { source: r.source } : {}) }); continue; } entry.bare[verb] = { ruleText: text, source: r.source }; byTool.set(toolName, entry); continue; } const content = parsed.ruleContent; const colon = content.indexOf(":"); if (isShellFaceToolName(toolName)) { const paramPart = colon > 0 ? content.slice(0, colon).trim() : ""; const declaresParam = (tool) => colon > 0 && /^[A-Za-z_][A-Za-z0-9_]*$/.test(paramPart) && (catalogRuleFaceOf(tool)?.params ?? []).includes(paramPart); const looksLikeParamRule = declaresParam(toolName); if (toolName !== COMMAND_RULE_TOOL && !looksLikeParamRule) { const commandWordParam = declaresParam(COMMAND_RULE_TOOL) && !content.endsWith(":*"); bad(text, "invalid.command_rule", commandWordParam ? `"${text}" is a parameter rule on "${paramPart}", which ${toolName} does not declare (its rule parameters: ${(catalogRuleFaceOf(toolName)?.params ?? []).join(", ")}); a parameter rule reaches only the tool that declares the parameter, so ${COMMAND_RULE_TOOL}(${content}) is a rule on ${COMMAND_RULE_TOOL}'s own argument and does not reach ${toolName}. A shell command rule is written ${COMMAND_RULE_TOOL}(<command>) — it reaches ${toolName} and every other tool that runs a shell command by the command it runs.` : `a shell command rule is written ${COMMAND_RULE_TOOL}(…) — it reaches ${toolName} and every other tool that runs a shell command by the command it runs; "${text}" names no rule upstream reads. Write ${COMMAND_RULE_TOOL}(${content}).`); continue; } if (toolName === COMMAND_RULE_TOOL && (!looksLikeParamRule || content.endsWith(":*"))) { const parsedCommand = parseRuleText(text, verb); if ("reject" in parsedCommand) { bad(text, "invalid.command_rule", `${parsedCommand.reject.code}: ${parsedCommand.reject.message}`); continue; } command[verb].push({ parsed: parsedCommand.rule, ruleText: text, ...(r.source !== undefined ? { source: r.source } : {}) }); continue; } } else { const resolved = faceOf(toolName); if (resolved.judged && resolved.face === undefined) { bad(text, "unsupported.tool_unknown", `"${toolName}" is not a tool on this leg's roster — the rule would compile and never match`); continue; } const face = resolved.face; if (nameGrammarReads(toolName, content)) { const parsedName = parseRuleText(text, verb); if ("reject" in parsedName) { bad(text, "invalid.command_rule", `${parsedName.reject.code}: ${parsedName.reject.message}`); continue; } const param = NAME_RULE_FACES[toolName].param; if (face?.params !== undefined && !face.params.includes(param)) { bad(text, "unsupported.param_unknown", `"${param}" is not a parameter of ${toolName} on this leg (it carries ${[...face.params].sort().join(", ")}) — a ${toolName} rule is read against it, so the rule would compile and never match`); continue; } entry.content[verb].push({ parsed: parsedName.rule, ruleText: text, ...(r.source !== undefined ? { source: r.source } : {}) }); byTool.set(toolName, entry); continue; } if (face !== undefined && face.pathTargeting === true && colon <= 0) { const parsedPath = parseRuleText(text, verb); if ("reject" in parsedPath) { bad(text, "invalid.command_rule", `${parsedPath.reject.code}: ${parsedPath.reject.message}`); continue; } for (const base of ruleBasesNeeded(parsedPath.rule)) { const declared = base === "cwd" ? (pathBases.cwd ?? pathBases.root) : pathBases[base]; if (declared !== undefined && !isUsablePathBase(declared, pathFamilyOf(pathBases))) { bad(text, "unsupported.path_base", `"${text}" is relative to ${PATH_RULE_BASE_LABEL[base]}, and the \`pathBases.${base === "cwd" && pathBases.cwd === undefined ? "root" : base}\` this policy declares is not an absolute path of its tree's family — the rule could not be judged on any call`); } } if (issues.length > 0 && issues[issues.length - 1].rule === text && issues[issues.length - 1].code === "unsupported.path_base") continue; const faces = PATH_RULE_FACES[toolName]?.[verb]; entry.content[verb].push({ parsed: parsedPath.rule, ruleText: text, ...(r.source !== undefined ? { source: r.source } : {}), ...(faces !== undefined ? { faces } : {}) }); byTool.set(toolName, entry); continue; } if (face?.params !== undefined && colon > 0) { const paramPart = content.slice(0, colon).trim(); if (!face.params.includes(paramPart)) { if (face.nonScalar?.includes(paramPart)) { bad(text, "unsupported.param_non_scalar", `"${paramPart}" is a parameter of ${toolName} but its value is an object or an array — the generic matcher compares scalars only; the rule would compile and never match`); } else { bad(text, "unsupported.param_unknown", `"${paramPart}" is not a parameter of ${toolName} (it carries ${[...face.params].sort().join(", ")}) — the rule would compile and never match. ` + `For a path glob use the tool-specific lane (\`${toolName}(src/**)\` is not the generic param DSL); a Windows path such as \`${toolName}(C:/foo)\` parses as the param "C".`); } continue; } } } if (colon <= 0) { bad(text, "invalid.param_split", "param-level ruleContent must be `param:pattern`"); continue; } const param = content.slice(0, colon).trim(); const pattern = content.slice(colon + 1).trim(); if (param === "" || pattern === "") { bad(text, "invalid.param_split", "param and pattern must both be non-empty"); continue; } if (pattern.length > caps.maxPatternLength || countStars(pattern) > caps.maxStars) { bad(text, "invalid.cap_exceeded", `pattern exceeds maxPatternLength ${caps.maxPatternLength} or maxStars ${caps.maxStars}`); continue; } if (faceOf(toolName).face?.primaryParams.includes(param) === true && primaryFieldGeneric === "reject") { bad(text, "invalid.primary_field", `"${param}" is ${toolName}'s primary field — the generic matcher is a trim+FULL-STRING lexical match (no shell/path semantics); '${toolName}(${param}:git push*)' would NOT match 'cd /x && git push'. Opt in with primaryFieldGeneric:"allow-lexical" if you understand this.`); continue; } entry.param[verb].push({ param, pattern, behavior: verb, ruleText: text, source: r.source }); byTool.set(toolName, entry); } return { byTool, command, namespacedCovering, issues }; } export function validatePermissionRules(rules, opts) { const caps = { ...DEFAULT_CAPS, ...opts?.caps }; return compile(rules, caps, opts?.primaryFieldGeneric ?? "reject", opts?.ruleFaces, opts?.pathBases).issues; } function isPlainObjectArgs(args) { return typeof args === "object" && args !== null && !Array.isArray(args); } function matchParamRules(rules, req, maxScalarValueChars) { const args = req.args; if (!isPlainObjectArgs(args)) return undefined; for (const r of rules) { if (!Object.hasOwn(args, r.param)) { if (readingAbsorbs(req, r.param, r.pattern)) return r; continue; } const raw = args[r.param]; if (raw === undefined || raw === null) continue; const t = typeof raw; if (t === "object" || t === "function" || t === "symbol") continue; const value = String(raw).trim(); if (value.length > maxScalarValueChars) return r; if (wildcardMatch(r.pattern, value)) return r; } return undefined; } function readingAbsorbs(req, param, pattern) { if (pattern.includes("*")) return false; try { const read = canonicalize(argsAsTheToolReads(req, { ...req.args, [param]: pattern })); return read !== UNCANONICALIZABLE && read === canonicalize(req.args); } catch { return false; } } function ruleDenial(req, ruleText, source) { return ruleDenialSentence({ source: "permission", tool: req.toolName, rule: ruleText, ...(source ? { origin: source } : {}) }); } function ruleAskMessage(ruleText, source) { return `flagged by permission rule "${ruleText}"${source ? ` (${source})` : ""}`; } const NO_LEADS = new Map(); export function createPermissionRulePolicy(rules, opts) { const caps = { ...DEFAULT_CAPS, ...opts?.caps }; const pathBases = opts?.pathBases ?? {}; const { byTool, command, namespacedCovering, issues } = compile(rules, caps, opts?.primaryFieldGeneric ?? "reject", opts?.ruleFaces, pathBases); const setLevelCap = issues.some((i) => i.code === "invalid.cap_exceeded" && i.rule === ""); if (issues.length > 0 && ((opts?.onInvalidRule ?? "throw") === "throw" || setLevelCap)) { const e = new Error(`createPermissionRulePolicy: ${issues.length} invalid/unsupported rule(s):\n` + issues.map((i) => ` [${i.code}] ${i.rule || "(rule set)"}: ${i.message}`).join("\n")); e.code = "config.invalid_permission_rules"; e.issues = issues; throw e; } if (issues.length > 0) { if (opts?.onIssue) { for (const i of issues) { try { opts.onIssue(i); } catch { } } } else { console.warn(`createPermissionRulePolicy: skipped ${issues.length} invalid/unsupported rule(s): ` + issues.map((i) => `[${i.code}] ${i.rule}`).join("; ")); } } const defaultAction = opts?.defaultAction ?? "allow"; const nameSets = { deny: [], ask: [] }; for (const [toolName, entry] of byTool) { if (entry.bare.deny !== undefined || entry.param.deny.length > 0 || entry.content.deny.length > 0) nameSets.deny.push(toolName); if (entry.bare.ask !== undefined || entry.param.ask.length > 0 || entry.content.ask.length > 0) nameSets.ask.push(toolName); } for (const behavior of ["deny", "ask"]) if (command[behavior].length > 0 && !nameSets[behavior].includes(COMMAND_RULE_TOOL)) nameSets[behavior].push(COMMAND_RULE_TOOL); for (const behavior of ["deny", "ask"]) { for (const r of namespacedCovering[behavior]) nameSets[behavior].push(r.ruleName); } const coveringHit = (lane, toolName) => lane.length === 0 ? undefined : lane.find((r) => namespacedRuleNameCovers(r.ruleName, toolName)); const hasCovering = namespacedCovering.deny.length > 0 || namespacedCovering.ask.length > 0; const faceRules = { deny: [...byTool.values()].flatMap((e) => e.content.deny.filter((r) => r.faces !== undefined)), ask: [...byTool.values()].flatMap((e) => e.content.ask.filter((r) => r.faces !== undefined)), }; const contentLaneOf = (behavior, entry, shellCall, callFace) => [ ...(entry?.content[behavior].filter((r) => r.faces === undefined) ?? []), ...(shellCall ? command[behavior] : []), ...(callFace !== undefined ? faceRules[behavior].filter((r) => r.faces.includes(callFace)) : []), ]; const hasCommandRules = command.deny.length > 0 || command.ask.length > 0; const commandParam = catalogRuleFaceOf(COMMAND_RULE_TOOL)?.primaryParams[0] ?? "command"; const pathRuleOf = new Map(); for (const e of byTool.values()) { for (const behavior of ["deny", "ask"]) { for (const r of e.content[behavior]) { if (r.parsed.match !== "path" && r.parsed.match !== "subpath") continue; pathRuleOf.set(r, { parsed: r.parsed, ruleText: r.ruleText, ...(behavior === "deny" && r.faces !== undefined ? { faces: r.faces } : {}) }); } } } const pathSpecs = [...pathRuleOf.values()]; const bound = new Map(); const boundAt = (anchors) => { const key = JSON.stringify([anchors.root ?? null, anchors.cwd ?? null, anchors.home ?? null]); const prior = bound.get(key); if (prior !== undefined) return prior; const rows = bindPathRules(pathSpecs, anchors); const of = new Map(); [...pathRuleOf.keys()].forEach((r, i) => of.set(r, rows[i])); const out = { rows, of }; bound.set(key, out); return out; }; const contentHit = (lane, req) => { if (lane.length === 0) return undefined; const pathLane = lane.filter((r) => pathRuleOf.has(r)); if (pathLane.length > 0) { const views = callTargetViewsOf(req, "tighten", { ...(pathBases.root !== undefined ? { root: pathBases.root } : {}), ...(pathBases.cwd !== undefined ? { cwd: pathBases.cwd } : {}) }); if (views !== undefined) { const { of } = boundAt(policyAnchors(pathBases, req.pathRuleAnchors)); const rows = rowsWhereTheyLead(pathLane.map((r) => of.get(r)), callTargetReadingOf(req)?.leads ?? NO_LEADS); const hit = judgeRuleRows(rows, views, "tighten"); const rule = hit === undefined ? undefined : pathLane.find((r) => of.get(r).spec === hit.row.spec); if (hit !== undefined && hit.unreadable === undefined) return { rule: rule, ...(hit.view !== undefined ? { view: hit.view } : {}) }; if (hit !== undefined) return judgeOthers(lane.filter((r) => !pathRuleOf.has(r)), req) ?? { rule: rule, unreadable: hit.unreadable }; } } return judgeOthers(lane.filter((r) => !pathRuleOf.has(r)), req); }; const judgeOthers = (lane, req) => { const command = isPlainObjectArgs(req.args) ? req.args[commandParam] : undefined; let unreadable; for (const r of lane) { if (r.parsed.match === "name" || r.parsed.match === "name_prefix") { const named = nameRuleSubjectOf(r.parsed.tool, req.args); if (named !== undefined && nameRuleAdmits(r.parsed, named)) return { rule: r }; continue; } if (typeof command !== "string") continue; const outcome = programRunReachOf(r.parsed, command); if (outcome.reach === "reached") return { rule: r }; if (outcome.reach === "unreadable") unreadable ??= { rule: r, unreadable: outcome.reason }; } return unreadable; }; const keylessRefusal = (entry, req) => { for (const r of [...entry.content.deny, ...entry.content.ask]) { if (r.parsed.match !== "name" || nameRuleSubjectOf(r.parsed.tool, req.args) !== undefined) continue; const absent = keyedValueAbsentReason(r.parsed.tool); if (absent !== undefined) return ruleDenialSentence({ source: "permission", tool: req.toolName, rule: r.ruleText, ...(r.source ? { origin: r.source } : {}), detail: absent }); } return undefined; }; const unreadableAsk = (hit, lane, req) => ({ action: "ask", message: `tool "${req.toolName}" needs approval: the call could not be read against the permission rule ${hit.rule.ruleText} (${hit.unreadable}) — a person must decide`, ruleUnreadable: lane, requiresRealApproval: true, }); const entryFor = (req) => { const entries = heldValues("policy.permissionRules", byTool, req); if (entries.length <= 1) return entries[0]; const bare = {}; for (const b of ["deny", "ask"]) { const hit = entries.find((e) => e.bare[b] !== undefined)?.bare[b]; if (hit !== undefined) bare[b] = hit; } return { bare, param: { deny: entries.flatMap((e) => e.param.deny), ask: entries.flatMap((e) => e.param.ask) }, content: { deny: entries.flatMap((e) => e.content.deny), ask: entries.flatMap((e) => e.content.ask) }, }; }; return { nameSets: [nameSets], ...(pathSpecs.length > 0 ? { pathRules: (anchors) => boundAt(policyAnchors(pathBases, anchors)).rows } : {}), check(req) { const entry = entryFor(req); const covering = hasCovering && protocolOf(req.toolName) !== undefined; if (entry?.bare.deny) { return { action: "deny", message: ruleDenial(req, entry.bare.deny.ruleText, entry.bare.deny.source) }; } if (covering) { const coveringDeny = coveringHit(namespacedCovering.deny, req.toolName); if (coveringDeny) return { action: "deny", message: ruleDenial(req, coveringDeny.ruleText, coveringDeny.source) }; } const callFace = faceRules.deny.length > 0 || faceRules.ask.length > 0 ? callPathFaceOf(req) : undefined; const shellCall = hasCommandRules && laneToolNamesOf(req).includes(COMMAND_RULE_TOOL); const denyLane = contentLaneOf("deny", entry, shellCall, callFace); const contentDeny = denyLane.length > 0 ? contentHit(denyLane, req) : undefined; if (contentDeny !== undefined && contentDeny.unreadable === undefined) { const denied = { action: "deny", message: ruleDenial(req, contentDeny.rule.ruleText, contentDeny.rule.source) }; const face = contentDeny.view !== undefined ? callPathFaceOf(req) : undefined; return face !== undefined ? attest(denied, req, { pathDenial: { rule: contentDeny.rule.ruleText, view: contentDeny.view, face } }) : denied; } if (entry) { const paramDeny = matchParamRules(entry.param.deny, req, caps.maxScalarValueChars); if (paramDeny) { return { action: "deny", message: ruleDenial(req, paramDeny.ruleText, paramDeny.source) }; } } const keyless = entry !== undefined ? keylessRefusal(entry, req) : undefined; if (keyless !== undefined) return { action: "deny", message: keyless }; if (contentDeny?.unreadable !== undefined) return unreadableAsk({ rule: contentDeny.rule, unreadable: contentDeny.unreadable }, "content_deny", req); if (entry) { if (entry.bare.ask) { return { action: "ask", message: ruleAskMessage(entry.bare.ask.ruleText, entry.bare.ask.source), matchedAskRule: entry.bare.ask.ruleText }; } } if (covering) { const coveringAsk = coveringHit(namespacedCovering.ask, req.toolName); if (coveringAsk) return { action: "ask", message: ruleAskMessage(coveringAsk.ruleText, coveringAsk.source), matchedAskRule: coveringAsk.ruleText }; } const askLane = contentLaneOf("ask", entry, shellCall, callFace); const contentAsk = askLane.length > 0 ? contentHit(askLane, req) : undefined; if (contentAsk !== undefined) { if (contentAsk.unreadable !== undefined) return unreadableAsk({ rule: contentAsk.rule, unreadable: contentAsk.unreadable }, "content_ask", req); return { action: "ask", message: ruleAskMessage(contentAsk.rule.ruleText, contentAsk.rule.source), matchedAskRule: contentAsk.rule.ruleText }; } if (entry) { const paramAsk = matchParamRules(entry.param.ask, req, caps.maxScalarValueChars); if (paramAsk) { return { action: "ask", message: ruleAskMessage(paramAsk.ruleText, paramAsk.source), matchedAskRule: paramAsk.ruleText }; } } if (defaultAction === "allow") return { action: "allow" }; return { action: defaultAction, message: `no permission rule matched "${req.toolName}" (default-${defaultAction} rule set)` }; }, }; }