UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

341 lines (340 loc) • 17.8 kB
import { heldBy } from "./tool-name-readings.js"; import { isNamespacedCoveringRuleName, namespacedRuleNameCovers, parsePermissionRule } from "./permission-rules.js"; import { protocolOf } from "./protocol-table.js"; import { COARSE_SHELL_TOOLS } from "./tool-policy.js"; import { ALLOW_RULE_MEMBERS } from "./allow-rule-shadow.js"; import { COMMAND_RULE_TOOL, READ_RULE_TOOL, findAdmittingRule, laneToolNamesOf, parseRuleText, programRunReachOf, ruleToolGrammarOf } from "./permission-rule-model.js"; import { pathRuleLaneAnswer } from "./runner/permission-rule-lanes.js"; import { splitShellCompoundSegments } from "../tools/fs/bash-readonly-classifier.js"; import { mandateDisclosureOf } from "./ask-origin.js"; import { inlineUntrusted } from "./untrusted-text.js"; import { deliverEngineNotice } from "./engine-notice.js"; export const ALLOW_ENTRY_INERT_REASONS = Object.freeze(["wildcard_all", "namespace_wildcard", "path_pattern", "domain", "content_unsupported", "grammar"]); function ruleRowOf(parsed) { return { ...parsed, scope: { kind: "global" }, adds: [{ dot: { actor: "list", counter: 1 }, origin: "user", createdAt: "" }] }; } const TOOL_NAME_SPELLING = /^[^\s()]+$/; export function compileAllowEntry(raw, behavior) { if (raw !== raw.trim()) return "grammar"; const parsed = parsePermissionRule(raw); const name = parsed.toolName; if (!TOOL_NAME_SPELLING.test(name)) return "grammar"; if (name === "*") return "wildcard_all"; if (parsed.ruleContent === undefined) { const ns = protocolOf(name); if (ns !== undefined) { if (name.slice(ns.prefix.length).split("__")[0]?.includes("*") === true) return "namespace_wildcard"; if (isNamespacedCoveringRuleName(name)) return { kind: "namespace", pattern: name }; return { kind: "tool", name }; } return COARSE_SHELL_TOOLS.includes(name) ? { kind: "shell", name } : { kind: "tool", name }; } if (name === COMMAND_RULE_TOOL) { const allowRead = parseRuleText(raw, behavior); if ("rule" in allowRead) return { kind: "command", rule: ruleRowOf(allowRead.rule) }; if (behavior === "allow" && allowRead.reject.code === "invalid.bare_interpreter_prefix") { const wide = parseRuleText(raw, "deny"); if ("rule" in wide) return { kind: "command", rule: ruleRowOf({ ...wide.rule, behavior: "allow" }) }; } return "grammar"; } if (ruleToolGrammarOf(name) === "path") { const read = parseRuleText(raw, behavior); if ("rule" in read) { if (read.rule.match === "subpath" && read.rule.tool === READ_RULE_TOOL && behavior === "allow") return { kind: "read_dir", rule: ruleRowOf(read.rule) }; if (behavior === "deny") return { kind: "path", rule: ruleRowOf(read.rule) }; } return "path_pattern"; } if (/^domain:/.test(parsed.ruleContent)) return "domain"; return "content_unsupported"; } export function compileAllowList(entries, behavior) { const voided = behavior === "allow" && entries.some((e) => compileAllowEntry(e, behavior) === "wildcard_all"); const rows = []; const inert = []; for (const entry of entries) { const compiled = voided ? "wildcard_all" : compileAllowEntry(entry, behavior); if (typeof compiled === "string") inert.push({ entry, reason: compiled }); else rows.push({ entry, compiled }); } return { rows, inert }; } 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, (n) => COARSE_SHELL_TOOLS.includes(n)); case "command": return shellContentStripped(shellRuleContent(compiled.rule)); case "namespace": case "read_dir": case "path": 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); } 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; } export function judgeAllowEntries(rows, req, behavior, ctx) { 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 covering = findAdmittingRule([...byRule.keys()], { tool: call.tool, command: call.command, cwd: ctx.root, ...(ctx.sessionId !== undefined ? { sessionId: ctx.sessionId } : {}), ...(liveCwd !== undefined ? { execCwd: liveCwd } : {}) }); const hits = (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 pathKind = behavior === "allow" ? "read_dir" : "path"; const pathRows = live.filter((r) => r.compiled.kind === pathKind); 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, liveCwd, home: ctx.home }); 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]]; } } } return undefined; } export function allowingRows(rows, req, mode) { return mode === "auto" ? rows.filter((r) => !autoStripped(r.compiled, req)) : rows; } export class AllowEntryTable { held = []; seed(entries) { const { rows, inert } = compileAllowList(entries, "allow"); for (const r of rows) if (!this.held.some((h) => h.entry === r.entry)) this.held.push({ behavior: "allow", entry: r.entry, compiled: r.compiled }); return inert; } entries() { return this.held.map((r) => r.entry); } 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) { tables.request.seed(lists.inherited ?? []); const requestInert = tables.request.seed(lists.request ?? []); tables.deployment.seed(lists.deployment ?? []); return [...lists.skillInert.map((e) => ({ source: "skill", ...e })), ...requestInert.map((e) => ({ source: "request", ...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; } } const SHADOW_REASON = { 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", unverifiable_delete: "this removal could not be verified safe — 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", governance_unavailable: "this deployment is org-governed and cannot currently read its organization policy", rule_unreadable: "this call could not be read 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", 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"), write_protection_unresolved: mandateDisclosureOf("write_protection_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; 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)} "${inlineUntrusted(e.entry, 120)}" (${e.reason})`); const rest = inert.length - shown.length; 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, and a write-path or domain entry has no allow reading; name the tools (or `mcp__<server>`) instead.", detail: { ...(sessionId !== undefined ? { sessionId } : {}), entries: inert.map((e) => ({ ...e })) }, }; }