UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

468 lines (467 loc) • 23.7 kB
import { MCP_NAMESPACE, protocolOf } from "./protocol-table.js"; import { catalogRuleFaceOf, pathTargetOf } from "./tool-registry.js"; import { indexOfUnescaped, lastIndexOfUnescaped, parsePermissionRule } from "./permission-rule-syntax.js"; import { PATH_RULE_BASE_LABEL, PATH_RULE_FACES, callPathFaceOf, isUsablePathBase, parseRuleText, pathRuleHeadOf, pathRuleReachOf, programRunReachOf, ruleBasesNeeded } from "./permission-rule-model.js"; import { pathFamilyOf, pathIdentityOf, spellingOfIdentity } from "../tools/fs/safety.js"; import { effectivePathTargetOf } from "./effective-path-target.js"; import { protectivePathTargetOf } from "./tool-registry.js"; import { ruleDenialSentence } from "./rule-denial-sentence.js"; import { heldValues } from "./tool-name-readings.js"; export { parsePermissionRule }; export const BASH_GENERIC_PARAMS = new Set(catalogRuleFaceOf("Bash")?.params ?? []); 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"; } } const DEFAULT_CAPS = { maxRules: 256, maxRuleLength: 512, maxPatternLength: 256, maxStars: 16, maxScalarValueChars: 16_384, }; export function wildcardMatch(pattern, value) { let p = 0; let v = 0; let star = -1; let mark = 0; while (v < value.length) { if (p < pattern.length && pattern[p] === value[v] && pattern[p] !== "*") { p++; v++; } else if (p < pattern.length && pattern[p] === "*") { star = p++; mark = v; } else if (star >= 0) { p = star + 1; v = ++mark; } else { return false; } } while (p < pattern.length && pattern[p] === "*") p++; return p === pattern.length; } function parseNamespacedRuleName(name) { const ns = protocolOf(name); if (ns === undefined) return undefined; const segments = name.slice(ns.prefix.length).split("__"); const peer = segments[0]; if (peer === undefined || peer === "") return undefined; const rest = segments.slice(1); return rest.length > 0 ? { protocol: ns.id, peer, tool: rest.join("__") } : { protocol: ns.id, peer }; } export function namespacedRuleNameCovers(ruleName, toolName) { const rule = parseNamespacedRuleName(ruleName); if (rule === undefined) return false; const target = parseNamespacedRuleName(toolName); if (target === undefined) return false; if (rule.protocol !== target.protocol) return false; if (rule.peer !== target.peer) return false; if (rule.tool === undefined || rule.tool === "*") return true; return target.tool !== undefined && rule.tool.includes("*") && wildcardMatch(rule.tool, target.tool); } export function isNamespacedCoveringRuleName(name) { const parsed = parseNamespacedRuleName(name); return parsed !== undefined && (parsed.tool === undefined || parsed.tool.includes("*")); } 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); }; export function pathRuleDenyRowOf(rule, bases) { const family = pathFamilyOf(bases); const tree = bases.root ?? bases.cwd; const head = pathRuleHeadOf(rule.parsed, bases); return { rule: rule.ruleText, tool: rule.parsed.tool, pattern: parsePermissionRule(rule.ruleText).ruleContent ?? "", faces: rule.faces, reaches: (absolutePath) => { const id = pathIdentityOf(absolutePath, family, tree); return id.ok && pathRuleReachOf(rule.parsed, id.identity, bases).reach !== "not_reached"; }, ...(head !== undefined ? { head: spellingOfIdentity(head, family) } : {}), }; } function countStars(s) { let n = 0; for (const c of s) if (c === "*") n++; return n; } 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 namespacedCovering = { deny: [], ask: [] }; const issues = []; const bad = (rule, code, message) => { issues.push({ rule, code, message }); }; if (rules.length > caps.maxRules) { return { byTool, 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 = parsePermissionRule(text); if (!parsed.toolName) { bad(text, "invalid.empty_tool", "tool name cannot be empty"); continue; } if (parsed.ruleContent === undefined && /[()\\]/.test(parsed.toolName)) { bad(text, "invalid.paren", "tool name still contains paren/escape characters — a mis-escaped rule would compile to a dead bare rule"); continue; } if (parsed.ruleContent === undefined && indexOfUnescaped(text, "(") !== -1) { const open = indexOfUnescaped(text, "("); const close = lastIndexOfUnescaped(text, ")"); const legitimateBare = close === text.length - 1 && ["", "*"].includes(text.substring(open + 1, close)); if (!legitimateBare) { bad(text, "invalid.paren", "malformed parentheses / trailing characters (would silently downgrade to a bare-tool rule)"); 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 (toolName === "Bash") { const paramPart = colon > 0 ? content.slice(0, colon).trim() : ""; const looksLikeParamRule = colon > 0 && /^[A-Za-z_][A-Za-z0-9_]*$/.test(paramPart) && BASH_GENERIC_PARAMS.has(paramPart); if (!looksLikeParamRule || content.endsWith(":*")) { const parsedCommand = parseRuleText(text, verb); if ("reject" in parsedCommand) { bad(text, "invalid.command_rule", `${parsedCommand.reject.code}: ${parsedCommand.reject.message}`); continue; } entry.content[verb].push({ parsed: parsedCommand.rule, ruleText: text, ...(r.source !== undefined ? { source: r.source } : {}) }); byTool.set(toolName, entry); 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 (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 value = base === "cwd" ? (pathBases.cwd ?? pathBases.root) : pathBases[base]; if (!isUsablePathBase(value, pathFamilyOf(pathBases))) { bad(text, "unsupported.path_base", `"${text}" is relative to ${PATH_RULE_BASE_LABEL[base]}, and this policy was given no absolute \`pathBases.${base}\`${base === "cwd" ? " (or `root`)" : ""} to resolve it against — 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, 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, args, maxScalarValueChars) { if (!isPlainObjectArgs(args)) return undefined; for (const r of rules) { if (!Object.hasOwn(args, r.param)) 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 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})` : ""}`; } export function createPermissionRulePolicy(rules, opts) { const caps = { ...DEFAULT_CAPS, ...opts?.caps }; const pathBases = opts?.pathBases ?? {}; const { byTool, 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"]) { 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 pathDenyRules = faceRules.deny.map((r) => pathRuleDenyRowOf({ parsed: r.parsed, ruleText: r.ruleText, faces: r.faces }, pathBases)); const contentLaneOf = (behavior, entry, callFace) => [ ...(entry?.content[behavior].filter((r) => r.faces === undefined) ?? []), ...(callFace !== undefined ? faceRules[behavior].filter((r) => r.faces.includes(callFace)) : []), ]; const commandParam = catalogRuleFaceOf("Bash")?.primaryParams[0] ?? "command"; const contentHit = (lane, req) => { if (lane.length === 0) return undefined; const target = effectivePathTargetOf(req, protectivePathTargetOf(req), pathBases); const command = isPlainObjectArgs(req.args) ? req.args[commandParam] : undefined; let unreadable; for (const r of lane) { if (r.parsed.match === "subpath" || r.parsed.match === "path") { if (target === undefined || target.kind === "refused") continue; if (target.kind === "unreadable") { unreadable ??= { rule: r, unreadable: target.reason }; continue; } const outcome = pathRuleReachOf(r.parsed, target.identity, pathBases); if (outcome.reach === "reached") return { rule: r }; if (outcome.reach === "unreadable") unreadable ??= { rule: r, unreadable: outcome.reason }; 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 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], ...(pathDenyRules.length > 0 ? { pathDenyRules } : {}), 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 denyLane = contentLaneOf("deny", entry, callFace); const contentDeny = denyLane.length > 0 ? contentHit(denyLane, req) : undefined; if (contentDeny !== undefined && contentDeny.unreadable === undefined) { return { action: "deny", message: ruleDenial(req, contentDeny.rule.ruleText, contentDeny.rule.source) }; } if (entry) { const paramDeny = matchParamRules(entry.param.deny, req.args, caps.maxScalarValueChars); if (paramDeny) { return { action: "deny", message: ruleDenial(req, paramDeny.ruleText, paramDeny.source) }; } } 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, 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.args, 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)` }; }, }; }