@sema-agent/core
Version:
Stateless, task-oriented AI agent core
341 lines (340 loc) • 17.8 kB
JavaScript
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 })) },
};
}