UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

193 lines (192 loc) • 10.4 kB
import { wireOf } from "./tool-name-readings.js"; import { parsePermissionRule } from "./permission-rules.js"; import { mountedToolPermissionSeat } from "./mode-policy.js"; import { inlineUntrusted } from "./untrusted-text.js"; import { boundInputHashOf } from "./canonical-json.js"; import { allowingRows, autoStripped, compileAllowEntry, compileAllowList, judgeAllowEntries } from "./allow-entry.js"; export const SKILL_GRANT_ORIGINS = Object.freeze(["model", "user", "inherited"]); const FROZEN_SKILL_GRANT_ORIGINS = new Set(["model", "inherited"]); export function skillNamedBy(args, skills) { const named = args?.skill; return typeof named === "string" ? skills.find((s) => s.name === named) : undefined; } export function skillGrantDigest(skill) { const canonical = (entries) => [...new Set((entries ?? []).map((e) => { const p = parsePermissionRule(e); return p.ruleContent === undefined ? p.toolName : `${p.toolName}(${p.ruleContent})`; }))].sort(); return boundInputHashOf({ skill: skill?.name ?? null, allowedTools: canonical(skill?.allowedTools), disallowedTools: canonical(skill?.disallowedTools) }); } export function skillCallGrantDigest(call, args, skills, skillToolName) { return skills.length > 0 && wireOf("skillGrant.binding", call) === skillToolName ? skillGrantDigest(skillNamedBy(args, skills)) : undefined; } export function parkedSkillApprovalRefusal(row, args, skills, skillToolName, site = "claim") { if (row.boundGrantDigest === undefined && skillCallGrantDigest(row, args, skills, skillToolName) === undefined) return undefined; if (skillGrantDigest(skillNamedBy(args, skills)) === row.boundGrantDigest) return undefined; const nameIn = (a) => a?.skill; const shown = (n) => inlineUntrusted(typeof n === "string" ? n : String(n), 120); const chosen = shown(nameIn(args)); const why = row.boundGrantDigest === undefined ? `this parked Skill activation of "${chosen}" carries no digest of the allowed-tools / disallowed-tools it was asked under (it was parked before Skill approvals bound them), so it cannot cover what the activation grants or refuses` : nameIn(args) !== nameIn(row.args) ? `this Skill approval was given on the card for "${shown(nameIn(row.args))}", and ${site === "claim" ? "this decision selects" : "the call would now activate"} "${chosen}" — not the Skill that card asked about, so the approval does not cover activating it` : `"${chosen}" no longer declares the allowed-tools / disallowed-tools this Skill approval was asked under — as this resume serves it (one it does not load counts as absent), it changed after the approval was given, so the approval does not cover what activating it would now grant or refuse`; return site === "claim" ? `${why} — refusing pre-CAS (nothing ran; the checkpoint stays pending). Decide this checkpoint as deny and re-activate it: the new card shows the current lists.` : `The approved Skill call was not executed: ${why}. This approval is spent — to use "${chosen}", have the model activate it again: the new card shows its lists.`; } export function detachSkillLists(skills) { return skills.map((s) => (typeof s === "object" && s !== null ? { ...s, ...(Array.isArray(s.allowedTools) ? { allowedTools: [...s.allowedTools] } : {}), ...(Array.isArray(s.disallowedTools) ? { disallowedTools: [...s.disallowedTools] } : {}) } : s)); } export function declaresSkillLists(skill) { return (skill.allowedTools?.length ?? 0) > 0 || (skill.disallowedTools?.length ?? 0) > 0; } export function compileSkillGrant(skill, origin) { const rows = []; const inert = []; for (const [list, behavior, entries] of [ ["allowed-tools", "allow", skill.allowedTools ?? []], ["disallowed-tools", "deny", skill.disallowedTools ?? []], ]) { const compiled = compileAllowList(entries, behavior); for (const r of compiled.rows) rows.push({ skill: skill.name, origin, behavior, entry: r.entry, compiled: r.compiled }); for (const i of compiled.inert) inert.push({ skill: skill.name, list, entry: i.entry, reason: i.reason }); } return { rows, inert }; } function judge(rows, req, behavior, ctx) { const hit = judgeAllowEntries(rows, req, behavior, ctx); return hit === undefined || hit[0] === undefined ? undefined : { skill: hit[0].skill, entries: hit.map((r) => r.entry) }; } export function skillDenyDecision(hit) { return { action: "deny", message: `the active skill "${inlineUntrusted(hit.skill, 120)}" lists this tool in disallowed-tools (${hit.entries.map((e) => inlineUntrusted(e, 200)).join(", ")}) — refused for the rest of this run`, decisionReason: "skill", }; } export function skillRowRefusal(lane, req) { const hit = lane?.denying(req); return hit === undefined ? undefined : skillDenyDecision(hit); } export function skillAllowDecision(hit, updatedInput) { return { action: "allow", message: `the active skill "${inlineUntrusted(hit.skill, 120)}" lists this tool in allowed-tools (${hit.entries.map((e) => inlineUntrusted(e, 200)).join(", ")})`, decisionReason: "skill", ...(updatedInput !== undefined ? { updatedInput } : {}), }; } const CARD_ENTRIES_MAX = 10; const CARD_ENTRY_MAX_CHARS = 120; export function skillActivationQuestion(skill, mode) { const head = `Execute skill: ${inlineUntrusted(skill.name, 200)}`; const granting = compileSkillGrant(skill, "model").rows.filter((r) => r.behavior === "allow" && !(mode === "auto" && autoStripped(r.compiled, undefined))); if (granting.length === 0) return head; const shown = granting.slice(0, CARD_ENTRIES_MAX).map((r) => inlineUntrusted(r.entry, CARD_ENTRY_MAX_CHARS)); const rest = granting.length - shown.length; return `${head}\nApproving also lets these tools be used without asking for the rest of this run: ${shown.join(", ")}${rest > 0 ? ` and ${rest} more` : ""}`; } export function skillSeatReader(skills) { if (skills.length === 0) return undefined; return (args, mode) => { const skill = skillNamedBy(args, skills); return skill !== undefined && declaresSkillLists(skill) ? { seat: "passthrough", question: skillActivationQuestion(skill, mode) } : undefined; }; } export function skillGateWiring(opts) { const skillGrants = { ...opts.lane, ...(opts.onAllowed !== undefined ? { onAllowed: opts.onAllowed } : {}) }; return (calledName, wireName) => ({ ...(mountedToolPermissionSeat(opts.tools, calledName) === "passthrough" ? { permissionSeat: "passthrough" } : {}), ...(opts.seatOf !== undefined && wireName === opts.skillToolName ? { permissionSeatOf: (args) => opts.seatOf?.(args, opts.mode()) } : {}), skillGrants, ...opts.allowEntries, }); } export function seedSkillGrants(table, opts) { table.seed(opts.inherited ?? [], "inherited"); const named = (n) => skillNamedBy({ skill: n }, opts.skills); const invoked = opts.userInvokedSkills; if (invoked !== undefined) { const unknown = Array.isArray(invoked) ? invoked.filter((n) => named(n) === undefined) : [invoked]; if (unknown.length > 0) { const e = new Error(`TaskSpec.userInvokedSkills names ${unknown.map((n) => JSON.stringify(n)).join(", ")}, which ${unknown.length === 1 ? "is" : "are"} not a skill this run carries (the loaded skills: ${opts.skills.map((s) => s.name).join(", ") || "none"}) — refusing rather than running without the invocation`); e.code = USER_INVOKED_SKILL_UNKNOWN_CODE; throw e; } for (const name of invoked) table.activate(named(name), "user"); } return opts.skills.flatMap((s) => compileSkillGrant(s, "model").inert); } export class SkillGrantTable { rowsHeld = []; hold(row) { if (!this.rowsHeld.some((r) => r.skill === row.skill && r.origin === row.origin && r.behavior === row.behavior && r.entry === row.entry)) this.rowsHeld.push(row); } activate(skill, origin) { const { rows, inert } = compileSkillGrant(skill, origin); for (const row of rows) this.hold(row); return inert; } seed(records, origin) { for (const r of records) { const compiled = compileAllowEntry(r.entry, r.behavior); if (typeof compiled === "string") continue; this.hold({ skill: r.skill, origin: r.origin === "model" ? "model" : origin, behavior: r.behavior, entry: r.entry, compiled }); } } records() { return this.rowsHeld.map((r) => ({ skill: r.skill, behavior: r.behavior, entry: r.entry, origin: r.origin })); } lane(ctx) { return { allowing: (req, mode) => judge(allowingRows(this.rowsHeld, req, mode), req, "allow", ctx), denying: (req) => judge(this.rowsHeld, req, "deny", ctx), }; } } function readCarriedRecords(raw, carrier) { if (raw === undefined) return []; if (!Array.isArray(raw)) return undefined; const out = []; for (const r of raw) { if (typeof r !== "object" || r === null) return undefined; const { skill, behavior, entry, origin } = r; if (typeof skill !== "string" || typeof entry !== "string" || (behavior !== "allow" && behavior !== "deny")) return undefined; if (typeof compileAllowEntry(entry, behavior) === "string") return undefined; if (carrier === "capture") { out.push({ skill, behavior, entry }); continue; } if (!FROZEN_SKILL_GRANT_ORIGINS.has(origin)) return undefined; out.push({ skill, behavior, entry, origin: origin }); } return out; } export function readSkillGrantCapture(snapshot) { return readCarriedRecords(snapshot, "capture"); } export function frozenSkillGrants(table) { const rows = table.records().filter((r) => FROZEN_SKILL_GRANT_ORIGINS.has(r.origin)); return rows.length > 0 ? rows : undefined; } export function readFrozenSkillGrants(section) { return readCarriedRecords(section, "frozen"); } export const USER_INVOKED_SKILL_UNKNOWN_CODE = "config.user_invoked_skill_unknown";