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