UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

468 lines (467 loc) • 24.9 kB
import { heldBy } from "./tool-name-readings.js"; import { isNamespacedCoveringRuleName, namespacedRuleNameCovers } from "./permission-rules.js"; import { parsePermissionRule, ruleSpellingOf } from "./permission-rule-syntax.js"; import { judgeRuleToolName, splitNamespacedName } from "./tool-name-aliases.js"; import { catalogToolNames, isShellFaceToolName } from "./tool-registry.js"; import { ALLOW_RULE_MEMBERS } from "./allow-rule-shadow.js"; import { COMMAND_RULE_TOOL, adjudicatePersistedDomainRules, adjudicatePersistedNameRules, commandCoverageOf, domainRuleSubjectOf, laneToolNamesOf, nameRuleSubjectOf, noticeRuleText, parseRuleText, programRunReachOf, ruleToolGrammarOf } from "./permission-rule-model.js"; import { pathRuleLaneAnswer } from "./runner/permission-rule-lanes.js"; import { callTargetReadingOf } from "./effective-path-target.js"; import { splitShellCompoundSegments } from "../tools/fs/bash-readonly-classifier.js"; import { mandateDisclosureOf } from "./ask-origin.js"; import { inlineUntrusted } from "./untrusted-text.js"; import { valueTypeLabel } from "./tool-name-list.js"; import { deliverEngineNotice } from "./engine-notice.js"; export const ALLOW_ENTRY_INERT_REASONS = Object.freeze(["wildcard_all", "namespace_wildcard", "content_unsupported", "grammar", "tool_name", "near_miss", "earlier_grammar"]); const NEAR_MISS_CARRIER = { run: "the mounted spelling is", pool: "a delegation pool carries", catalog: "the engine's tool catalog names" }; export function nonStringMemberLabel(value) { return `<not a string: ${valueTypeLabel(value)}>`; } export function nonListValueLabel(read) { return read.kind === "not_a_list" ? `<not a list: ${read.type}>` : "<a list whose entries cannot be read>"; } function ruleRowOf(parsed) { return { ...parsed, scope: { kind: "global" }, adds: [{ dot: { actor: "list", counter: 1 }, origin: "user", createdAt: "" }] }; } function mountedSpellingOf(name, roster) { const folded = name.toLowerCase(); for (const mounted of roster) if (mounted.toLowerCase() === folded) return mounted; return undefined; } export function nearMissSpellingOf(name, roster) { if (roster.has(name) || catalogToolNames().has(name) || splitNamespacedName(name) !== undefined) return undefined; return mountedSpellingOf(name, roster); } export function rootFacingToolNames(carried) { return new Set([...catalogToolNames(), ...carried]); } function toolNameReading(name, roster) { const verdict = judgeRuleToolName(name, roster); if (verdict.kind === "unprefixed_namespace") return { kind: "inert", reason: "tool_name", verdict: verdict.kind }; if (verdict.kind !== "current") { return { kind: "inert", reason: "tool_name", verdict: verdict.kind, guidance: verdict.guidance, ...(verdict.currentName !== undefined ? { currentName: verdict.currentName } : {}) }; } const mounted = nearMissSpellingOf(name, roster); return mounted === undefined ? undefined : { kind: "inert", reason: "near_miss", currentName: mounted }; } export function readRuleEntry(raw, behavior, roster) { const inert = (reason) => ({ kind: "inert", reason }); const row = (compiled) => ({ kind: "row", compiled }); if (typeof raw !== "string") return { kind: "inert", reason: "grammar", valueType: valueTypeLabel(raw) }; const parsed = ruleSpellingOf(raw); if (parsed === undefined) return inert("grammar"); const name = parsed.toolName; if (name === "*") return inert("wildcard_all"); if (roster !== undefined) { const stale = toolNameReading(name, roster); if (stale !== undefined) return stale; } if (parsed.ruleContent === undefined) { const split = splitNamespacedName(name); if (split !== undefined) { if (split.peer.includes("*")) return inert("namespace_wildcard"); if (isNamespacedCoveringRuleName(name)) return row({ kind: "namespace", pattern: name }); return row({ kind: "tool", name }); } return row(isShellFaceToolName(name) ? { kind: "shell", name } : { kind: "tool", name }); } const grammar = ruleToolGrammarOf(name); if (grammar === undefined) return inert("content_unsupported"); const read = parseRuleText(raw, behavior); if ("rule" in read) return row({ kind: grammar, rule: ruleRowOf(read.rule) }); if (grammar === "command" && behavior === "allow" && read.reject.code === "invalid.bare_interpreter_prefix") { const wide = parseRuleText(raw, "deny"); if ("rule" in wide) return row({ kind: "command", rule: ruleRowOf({ ...wide.rule, behavior: "allow" }) }); } return inert(inertReasonOf(read.reject.code)); } export function compiledEntryName(compiled) { switch (compiled.kind) { case "tool": case "shell": return compiled.name; case "namespace": return compiled.pattern; default: return compiled.rule.tool; } } function inertReasonOf(code) { return code === "unsupported.tool" ? "content_unsupported" : "grammar"; } export function compileAllowList(entries, behavior, roster) { const voided = behavior === "allow" && entries.some(namesWholeRoster); const rows = []; const inert = []; for (const entry of entries) { const read = typeof entry !== "string" || !voided ? readRuleEntry(entry, behavior, roster) : { kind: "inert", reason: "wildcard_all" }; if (read.kind === "row") rows.push({ entry, compiled: read.compiled }); else { const { kind: _kind, ...why } = read; void _kind; inert.push({ entry: typeof entry === "string" ? entry : nonStringMemberLabel(entry), ...why }); } } return { rows, inert }; } function namesWholeRoster(entry) { return typeof entry === "string" && entry === entry.trim() && parsePermissionRule(entry).toolName === "*"; } function answersTo(req, has) { return heldBy("allowEntry.names", { has }, req); } export const AUTO_STRIPPED_PROGRAMS = Object.freeze([ "python", "python3", "python2", "node", "deno", "tsx", "ruby", "perl", "php", "lua", "npx", "bunx", "npm run", "yarn run", "pnpm run", "bun run", "bash", "sh", "ssh", "zsh", "fish", "eval", "exec", "env", "xargs", "sudo", ]); function shellContentStripped(content) { if (content === undefined || content === "" || /^[\s*]+$/.test(content)) return true; const n = content.trim().toLowerCase(); if (n === "*") return true; for (const program of AUTO_STRIPPED_PROGRAMS) { const g = program.toLowerCase(); if (n === g || n === `${g}:*` || n === `${g} *` || n === `${g}*`) return true; if (n.startsWith(`${g} `) && n.endsWith("*")) { const tail = n.slice(g.length + 1); if (tail.startsWith("-") && !(/^python[\d.]*$/.test(g) && /^-m\s+\w+\.[\w.]+(\s*:|\s+)$/.test(tail.slice(0, -1)))) return true; } } return false; } function shellRuleContent(rule) { const split = splitShellCompoundSegments(rule.command, { trailingTerminator: "keep", redirection: "reject" }); const body = "reject" in split ? rule.command : (split.segments[split.segments.length - 1] ?? rule.command).trim(); return rule.match === "prefix" ? `${body}:*` : rule.match === "wildcard" ? `${body} *` : body; } export function autoStripped(compiled, call) { switch (compiled.kind) { case "shell": return true; case "tool": return call !== undefined && answersTo(call, isShellFaceToolName); case "command": return shellContentStripped(shellRuleContent(compiled.rule)); case "namespace": case "path": case "name": case "domain": return false; default: { const exhaustive = compiled; return exhaustive; } } } export function autoStrippedRule(rule, call) { return rule.behavior === "allow" && rule.tool === COMMAND_RULE_TOOL && autoStripped({ kind: "command", rule }, call); } const NO_LEADS = new Map(); function nameRowReaches(compiled, req) { const covers = compiled.kind === "namespace" ? (n) => namespacedRuleNameCovers(compiled.pattern, n) : compiled.kind === "tool" || compiled.kind === "shell" ? (n) => n === compiled.name : undefined; return covers !== undefined && answersTo(req, covers); } function commandOf(req) { const tool = laneToolNamesOf(req).find((n) => ruleToolGrammarOf(n) === "command"); const command = req.args?.command; return tool !== undefined && typeof command === "string" ? { tool, command } : undefined; } function admissionCallOf(call, ctx, liveCwd, posture) { return { tool: call.tool, command: call.command, cwd: ctx.root, ...(ctx.sessionId !== undefined ? { sessionId: ctx.sessionId } : {}), ...(liveCwd !== undefined ? { execCwd: liveCwd } : {}), posture }; } export function uncoveredCommandSegment(rows, req, ctx, posture) { const call = commandOf(req); if (call === undefined) return undefined; const rules = rows.flatMap((r) => (r.behavior === "allow" && r.compiled.kind === "command" ? [r.compiled.rule] : [])); const coverage = commandCoverageOf(rules, admissionCallOf(call, ctx, ctx.liveCwd(), posture)); return "uncovered" in coverage ? coverage.uncovered : undefined; } export function judgeAllowEntries(rows, req, behavior, ctx, posture = "grant") { const live = rows.filter((r) => r.behavior === behavior); if (live.length === 0) return undefined; for (const row of live) if (nameRowReaches(row.compiled, req)) return [row]; const call = commandOf(req); const liveCwd = ctx.liveCwd(); if (call !== undefined) { const byRule = new Map(); for (const r of live) if (r.compiled.kind === "command") byRule.set(r.compiled.rule, r); if (behavior === "allow") { const coverage = commandCoverageOf([...byRule.keys()], admissionCallOf(call, ctx, liveCwd, posture)); const hits = ("covering" in coverage ? coverage.covering : []).flatMap((rule) => byRule.get(rule) ?? []); if (hits.length > 0) return hits; } else { for (const [rule, row] of byRule) { if (programRunReachOf(rule, call.command).reach !== "not_reached") return [row]; } } } const pathRows = live.filter((r) => r.compiled.kind === "path"); if (pathRows.length > 0) { const table = pathRows.map((r) => r.compiled.rule); for (const name of laneToolNamesOf(req).filter((n) => ruleToolGrammarOf(n) === "path")) { const answer = pathRuleLaneAnswer(table, { ...req, toolName: name }, { root: ctx.root, sessionId: ctx.sessionId, anchorsOf: () => ctx.anchors, leads: callTargetReadingOf(req)?.leads ?? NO_LEADS }); const spoke = answer !== undefined && ("unreadable" in answer ? behavior === "deny" : "behavior" in answer && answer.behavior === behavior); if (spoke) { const rule = answer !== undefined && "rules" in answer ? answer.rules[0]?.rule : undefined; return [pathRows.find((r) => r.compiled.rule.rule === rule) ?? pathRows[0]]; } } } for (const grammar of ["name", "domain"]) { const familyRows = live.filter((r) => r.compiled.kind === grammar); if (familyRows.length === 0) continue; const table = familyRows.map((r) => r.compiled.rule); for (const name of laneToolNamesOf(req).filter((n) => ruleToolGrammarOf(n) === grammar)) { const at = { tool: name, cwd: ctx.root, ...(ctx.sessionId !== undefined ? { sessionId: ctx.sessionId } : {}) }; const verdict = grammar === "name" ? adjudicatePersistedNameRules(table, { ...at, name: nameRuleSubjectOf(name, req.args) }) : adjudicatePersistedDomainRules(table, { ...at, host: domainRuleSubjectOf(name, req.args) }); if (verdict !== undefined && verdict.behavior === behavior) return [familyRows.find((r) => r.compiled.rule === verdict.rules[0]) ?? familyRows[0]]; } } return undefined; } export function allowingRows(rows, req, mode) { return mode === "auto" ? rows.filter((r) => !autoStripped(r.compiled, req)) : rows; } export class AllowEntryTable { held = []; heldEntries = new Set(); seed(entries, roster) { const { rows, inert } = compileAllowList(entries, "allow", roster); for (const r of rows) { if (this.heldEntries.has(r.entry)) continue; this.heldEntries.add(r.entry); this.held.push({ behavior: "allow", entry: r.entry, compiled: r.compiled }); } return inert; } entries() { return this.held.map((r) => r.entry); } holdsPathRows() { return this.held.some((r) => r.compiled.kind === "path"); } lane(ctx) { return { allowing: (req, mode) => { const hit = judgeAllowEntries(allowingRows(this.held, req, mode), req, "allow", ctx); return hit === undefined ? undefined : { entries: hit.map((r) => r.entry) }; }, }; } } export function seedAllowEntryTables(tables, lists, roster) { tables.request.seed(lists.inherited ?? []); const requestInert = tables.request.seed(lists.request ?? [], roster); const deploymentInert = tables.deployment.seed(lists.deployment ?? [], roster).filter((e) => e.reason === "tool_name" || e.reason === "near_miss"); return [ ...lists.skillInert.map((e) => ({ source: "skill", ...e })), ...requestInert.map((e) => ({ source: "request", ...e })), ...deploymentInert.map((e) => ({ source: "deployment", ...e })), ]; } export function withDeploymentAllowedTools(deps) { if (deps.allowedTools === undefined) return deps; const allowedTools = screenAllowedToolsValue(deps.allowedTools, "RunnerDeps.allowedTools"); const inert = compileAllowList(allowedTools ?? [], "allow").inert; if (inert.length > 0) deliverEngineNotice(deps.onNotice, allowedToolsNotGrantedNotice(inert.map((e) => ({ source: "deployment", ...e })), undefined)); return { ...deps, allowedTools }; } export const INVALID_PERMISSION_RULES_CODE = "config.invalid_permission_rules"; export function screenAllowedToolsValue(value, seat) { if (value === undefined) return undefined; const issues = []; if (!Array.isArray(value)) { issues.push({ rule: "", code: "invalid.entry_type", message: `${seat} must be a list of permission-rule strings (got ${value === null ? "null" : typeof value})` }); } else { const list = value; list.forEach((entry, i) => { if (typeof entry !== "string") issues.push({ rule: "", code: "invalid.entry_type", message: `${seat}[${i}] must be a permission-rule string (got ${entry === null ? "null" : typeof entry})` }); }); } if (issues.length > 0) { const e = new Error(`${seat}: ${issues.length} invalid entr${issues.length === 1 ? "y" : "ies"} — refusing rather than running without them:\n${issues.map((i) => ` [${i.code}] ${i.message}`).join("\n")}`); e.code = INVALID_PERMISSION_RULES_CODE; e.issues = issues; throw e; } return Object.freeze([...value]); } const SHADOW_ENTRIES_MAX = 5; const SHADOW_ENTRY_MAX_CHARS = 200; export function allowRuleShadowOf(member, entries) { const shown = entries.slice(0, SHADOW_ENTRIES_MAX).map((e) => inlineUntrusted(e, SHADOW_ENTRY_MAX_CHARS)); return { member, entries: shown, ...(entries.length > shown.length ? { omitted: entries.length - shown.length } : {}) }; } const MEMBER_SET = new Set(ALLOW_RULE_MEMBERS); export function readAllowRuleShadows(value) { try { if (!Array.isArray(value) || value.length === 0) return undefined; const list = value; const out = []; for (const r of list) { if (typeof r !== "object" || r === null) return undefined; const { member, entries, omitted } = r; if (!MEMBER_SET.has(member) || !Array.isArray(entries) || !entries.every((e) => typeof e === "string")) return undefined; const shadow = allowRuleShadowOf(member, entries); const carried = typeof omitted === "number" && Number.isInteger(omitted) && omitted > 0 ? omitted : 0; out.push(carried > 0 ? { ...shadow, omitted: (shadow.omitted ?? 0) + carried } : shadow); } return out; } catch { return undefined; } } export const SHADOW_REASON = Object.freeze({ content_question: "the call is the engine's own question to a person", hook: "a PreToolUse hook asks about this call — the deployment's own screening is answered by a person", ancestor: "the task that delegated this one owns this question", dangerous_delete: "this removal targets a protected location (the filesystem root, a top-level directory, a home directory, or a workspace directory or one of its ancestors) — it is cleared by confirming this call, never by an allow rule", unverifiable_delete: "this removal could not be verified safe — it is cleared by confirming this call, never by an allow rule", unverifiable_shell_text: "the text this shell will run cannot be read by the engine — it is cleared by confirming this call, never by an allow rule", transcript_write: "this write targets the session transcript — it is cleared by confirming this call, never by an allow rule", org_rule: "an organization policy rule requires approval for this call", rule_unreadable: "a rule that may govern this call cannot currently be read (this deployment's organization policy, a partition of the person's standing rules, or this call against a deny or ask rule), so whether one forbids it is unknown", ask_rule: mandateDisclosureOf(undefined), denial_limit_fallback: "the auto-mode classifier refused too many calls in a row, so a person decides", classifier_no_ruling: "the auto-mode classifier could not judge this call, so a person decides", requires_user_interaction: "this tool's server declares that a call must be put to a person", operator_forced: "this call arrived already marked as requiring confirmation (an earlier layer of this run, or the task that delegated it, judged it so) — it is cleared by confirming this call, never by a standing allow rule", shell_floor: mandateDisclosureOf("operator_always"), target_unresolved: mandateDisclosureOf("target_unresolved"), probe: mandateDisclosureOf("probe_mandate"), write_protection: mandateDisclosureOf("write_protection"), tool_marks_mcp: mandateDisclosureOf("tool_marks"), tool_marks_a2a: mandateDisclosureOf("tool_marks"), policy_rra: "the deployment's policy requires a real approval for this call", artifact_shared_read: "this read targets another task's shared artifact — it is cleared by confirming this call", mode: "the permission mode puts this question to a person", unstated: "this call arrived already marked as requiring confirmation (an earlier layer of this run, or the task that delegated it, judged it so) — it is cleared by confirming this call, never by a standing allow rule", ordinary: "this call arrived already marked as requiring confirmation (an earlier layer of this run, or the task that delegated it, judged it so) — it is cleared by confirming this call, never by a standing allow rule", }); function memberPhrase(s) { const listed = `${s.entries.join(", ")}${s.omitted !== undefined ? ` and ${s.omitted} more` : ""}`; switch (s.member) { case "hook": return "a PreToolUse hook's allow"; case "deployment": return `the deployment's allowedTools (${listed})`; case "persisted": return s.entries.length + (s.omitted ?? 0) === 1 ? `a persisted allow rule (${listed})` : `persisted allow rules (${listed})`; case "skill": return `an active skill's allowed-tools (${listed})`; case "request": return `this run's allowedTools (${listed})`; default: { const exhaustive = s.member; return exhaustive; } } } export function allowRuleShadowSentence(shadows, ask) { const one = shadows.length === 1 && (shadows[0].member === "hook" || shadows[0].entries.length + (shadows[0].omitted ?? 0) === 1); const why = ask.mandate !== undefined ? mandateDisclosureOf(ask.mandate) : ask.authoredBy === "deployment" ? "this deployment requires a person's confirmation for this call" : SHADOW_REASON[ask.askClass ?? "ordinary"]; return `${shadows.map(memberPhrase).join("; ")} ${one ? "matches this call but does not clear the ask" : "match this call but do not clear the ask"} — ${why}`; } export function cardDisclosureOf(decision, fallbackMessage) { const base = decision.message ?? fallbackMessage; const shadows = readAllowRuleShadows(decision.allowRuleShadowed); if (shadows === undefined) return { message: base }; return { message: `${base} (${allowRuleShadowSentence(shadows, decision)})`, allowRuleShadowed: shadows }; } export const ALLOWED_TOOL_NOT_GRANTED_CODE = "permission.allowed_tool_not_granted"; const NOTICE_ENTRIES_SHOWN = 5; const UNSEEN_IN_ENTRY = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\p{Zs}]/gu; const ENTRY_RENDER_MAX = 122; function escapedCodePoint(ch) { const cp = ch.codePointAt(0) ?? 0; const hex = cp.toString(16).padStart(4, "0"); return cp > 0xffff ? `\\u{${hex}}` : `\\u${hex}`; } export function spelledListEntry(entry) { return JSON.stringify(entry) .replace(/ {2,}/g, (run) => "\\u0020".repeat(run.length)) .replace(UNSEEN_IN_ENTRY, (ch) => (ch === " " ? ch : escapedCodePoint(ch))); } export function renderListEntry(entry) { return inlineUntrusted(spelledListEntry(entry), ENTRY_RENDER_MAX); } export function inertEntryPhrase(e) { if (e.valueType !== undefined) return `a value that is ${e.valueType} (grammar: not a string)`; const spelling = e.currentName !== undefined ? inlineUntrusted(e.currentName, 120) : undefined; const why = (() => { if (e.reason === "near_miss") return `near_miss: ${NEAR_MISS_CARRIER[e.carriedBy ?? "run"]} "${spelling}"`; if (e.reason !== "tool_name" || e.verdict === undefined) return e.reason; switch (e.verdict) { case "retired": return `tool_name: a retired tool name${spelling !== undefined ? `, now "${spelling}"` : ""}`; case "unprefixed_namespace": return 'tool_name: a "__" name with no protocol prefix'; case "unmintable_segment": return `tool_name: a namespaced name with a segment the engine never mints${spelling !== undefined ? `, write "${spelling}"` : ""}`; default: { const exhaustive = e.verdict; return exhaustive; } } })(); return `${renderListEntry(e.entry)} (${why})`; } export function allowedToolsNotGrantedNotice(inert, sessionId) { const where = (e) => (e.source === "skill" ? `skill ${inlineUntrusted(e.skill, 80)} ${e.list}` : e.source === "request" ? "this run's allowedTools" : "the deployment's allowedTools"); const shown = inert.slice(0, NOTICE_ENTRIES_SHOWN).map((e) => `${where(e)} ${inertEntryPhrase(e)}`); const rest = inert.length - shown.length; const refusing = inert.some((e) => "list" in e && e.list === "disallowed-tools"); return { code: ALLOWED_TOOL_NOT_GRANTED_CODE, message: `${inert.length} allowed-tools entr${inert.length === 1 ? "y grants" : "ies grant"} or refuse${inert.length === 1 ? "s" : ""} nothing — ${shown.join("; ")}${rest > 0 ? `; and ${rest} more` : ""}. ` + "A list holding `*` voids the list, `mcp__*` names no server, a rule the grammar refuses (`grammar`) or one on a tool with no such rule form (`content_unsupported`: an allow by path is an `Edit(…)` or `Read(…)` rule) grants nothing, and a tool name must be spelled as the tool is mounted; name the tools (or `mcp__<server>`) instead." + (refusing ? " Such an entry in a disallowed-tools list keeps its Skill from activating: a refusal that reads as nothing is not dropped." : ""), detail: { ...(sessionId !== undefined ? { sessionId } : {}), entries: inert.map((e) => ({ ...e, entry: noticeRuleText(e.entry) })) }, }; }