UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

350 lines (349 loc) • 25.4 kB
import { type RuleToolNameVerdict } from "./tool-name-aliases.js"; import type { ToolCallRequest } from "./tool-policy.js"; import { type AllowRuleMember, type AllowRuleShadow } from "./allow-rule-shadow.js"; import { type AdmissionPosture, type PathRuleBases, type PersistedRule } from "./permission-rule-model.js"; import { type AskClass, type AssertAllKeysHandled, type PermissionMode, type PersistedRuleMandate } from "./ask-origin.js"; import { type ToolNameListReading } from "./tool-name-list.js"; import { type EngineNotice } from "./engine-notice.js"; /** Why an entry grants (or refuses) nothing — the closed set the start-of-run notice names, for every list. `tool_name`: the * entry's tool name is one the name judgment reads as stale against the run's roster (`verdict` says which); `near_miss`: the * run mounts no tool of that exact name but mounts one that differs only by case (`currentName` is its spelling); * `earlier_grammar`: a session's stored rules are written in an earlier rule grammar, in which the entry named no tool, or which * read an entry this grammar reads as no rule (the session store's list reading, `compileSessionRuleBucket` — this reader never * answers it). */ export declare const ALLOW_ENTRY_INERT_REASONS: readonly ["wildcard_all", "namespace_wildcard", "content_unsupported", "grammar", "tool_name", "near_miss", "earlier_grammar"]; export type AllowEntryInertReason = (typeof ALLOW_ENTRY_INERT_REASONS)[number]; /** The facts beside a reason: for `tool_name` / `near_miss` the judgment's word, the judgment's own one-line sentence when it has * one (`guidance`, byte for byte — `retired` and `unmintable_segment` carry one, `unprefixed_namespace` none) and the spelling to * write instead when there is one; for a list member that is not a string (`grammar`) the TYPE of the value (`a number`, `null`, * `an object`…) — `entry` then holds {@link nonStringMemberLabel}, never the value. */ export interface AllowEntryInertDetail { readonly verdict?: Exclude<RuleToolNameVerdict["kind"], "current">; readonly guidance?: string; readonly currentName?: string; readonly valueType?: string; /** Where the spelling of a `near_miss` comes from, when the station that read it reads more than the run's roster * ({@link NearMissCarrier}); absent ⇒ the roster the run mounts. A sentence only — never a field of a wire detail. */ readonly carriedBy?: NearMissCarrier; } /** Who carries the spelling a near miss reads as another spelling of: the roster the run mounts, a delegation tool's pool, or * only the engine's tool catalog (a tool the engine may mount, which this run's roster does not carry). */ export type NearMissCarrier = "run" | "pool" | "catalog"; /** * How a list member that is not a string is named wherever a list's entries are named (the inert record's `entry`, the Skill * card's binding): its TYPE, in a spelling no readable entry can have — a readable entry's name holds no whitespace and a * name-only entry holds no parenthesis, and this label holds a space and none. It can equal only another entry that reads as * nothing, so a binding that holds it never stands for an entry that grants or refuses. */ export declare function nonStringMemberLabel(value: unknown): string; /** How a tool-name list's VALUE that is not a list stands as one entry (the same kind of spelling as {@link nonStringMemberLabel}: it * reads as `grammar`): its TYPE, or that its entries could not be read — never the value. */ export declare function nonListValueLabel(read: Exclude<ToolNameListReading, { kind: "entries"; }>): string; /** One entry a list reads as nothing. */ export type AllowEntryInert = { readonly entry: string; readonly reason: AllowEntryInertReason; } & AllowEntryInertDetail; /** One entry the grammar reads (kind closed): a whole non-shell tool; a protocol namespace; the whole shell tool; and one * kind per rule grammar (`ruleToolGrammarOf`) — a shell command rule, a path rule, a name rule, a host rule — each the row * the persisted grammar parses, its `behavior` the list's direction. */ export type CompiledAllowEntry = { readonly kind: "tool"; readonly name: string; } | { readonly kind: "namespace"; readonly pattern: string; } | { readonly kind: "shell"; readonly name: string; } | { readonly kind: "command"; readonly rule: PersistedRule; } | { readonly kind: "path"; readonly rule: PersistedRule; } | { readonly kind: "name"; readonly rule: PersistedRule; } | { readonly kind: "domain"; readonly rule: PersistedRule; }; /** * THE near-miss reading of a tool name the judgment calls current: the spelling the run mounts when the run mounts no tool of this * exact name but one that differs from it only by case (`BASH` beside `Bash`), else `undefined`. Two kinds of name are never a near * miss, because the roster they would be read against is not the final one: an engine tool's (the engine mounts some of its tools * after a list is read) and a protocol tool's (a refresh mounts and unmounts them while the run goes on) — in both the exact * spelling can be the right one by the time it matters. Every station that asks "is this name a case twin of a mounted tool" reads * this answer: an entry list ({@link readRuleEntry}), a tool policy's name lists (the prepare-time policy-name audit). */ export declare function nearMissSpellingOf(name: string, roster: ReadonlySet<string>): string | undefined; /** * THE universe a ROOT-facing tool-name list is read against: every name the engine's catalog carries, and the names the reader knows * are carried (`carried`). A list is root-facing when it bounds the tools of the run that holds it — the run's own `allowTools` / * `excludeTools` (also the ceiling of its children), a tool policy's name lists, a session's stored rules — and its reader is that * run (`carried`: its roster, the caller's own tools, and the exact names of the pools its reachable delegation tools declare and can * list) or the store that writes the rules (`carried`: the tools its deployment declared). Another spelling of a carried name is * always refused there, whatever a delegation tool injects per spawn: the run's own tools are known, and an entry that is a case * twin of one of them names the other tool by mistake. A delegation tool's pool that cannot be listed carries nothing here. */ export declare function rootFacingToolNames(carried: Iterable<string>): Set<string>; /** What one rule entry means under one direction ({@link readRuleEntry}): the row the grammar compiles it to, or the reason it * reads as nothing with the facts a sentence about it names. */ export type RuleEntryReading = { readonly kind: "row"; readonly compiled: CompiledAllowEntry; } | ({ readonly kind: "inert"; readonly reason: AllowEntryInertReason; } & AllowEntryInertDetail); /** * THE ONE READER of a rule entry — a member of an entry list in either direction: a skill's `allowed-tools` / * `disallowed-tools`, a sub-agent's deny list, the request's and the deployment's `allowedTools`, a session's `toolDeny` / * `toolAllow`. Every station that compiles, validates, compares or names such an entry reads this answer and no other. * `roster` = the tool names the reader knows are mounted (aliases included): given, the entry's tool name is also read against * it (a stale name, or one that differs from a mounted tool's only by case, reads as `tool_name` / `near_miss`); absent, the * grammar alone is read — what a resume's pre-claim screen can read before the leg has a roster. */ export declare function readRuleEntry(raw: unknown, behavior: "allow" | "deny", roster?: ReadonlySet<string>): RuleEntryReading; /** The tool name a compiled entry speaks for — its tool, its namespace spelling, or its rule's tool (what a name audit reads). */ export declare function compiledEntryName(compiled: CompiledAllowEntry): string; /** * Compile one list WHOLE through {@link readRuleEntry}: the entries it reads and the ones it reads as nothing (each with its * reason and, for a name the roster refuses, the judgment's word and the spelling to write). An allow list holding `*` voids * the list (every other entry on it reads as nothing too: the list grants nothing — the safe direction) — `*` as the lenient * rule parser names it ({@link namesWholeRoster}), so an entry the stricter entry grammar refuses (`* (*)`) still voids. A * `*` on a deny list reads as nothing by itself and the rest of that list still refuses: a deny list never loses an entry to * a wildcard beside it. `roster` as in {@link readRuleEntry}. */ export declare function compileAllowList(entries: readonly string[], behavior: "allow" | "deny", roster?: ReadonlySet<string>): { rows: Array<{ entry: string; compiled: CompiledAllowEntry; }>; inert: AllowEntryInert[]; }; /** The programs whose open allow rule the reference implementation drops in auto mode — its interpreter / launcher table * plus the shells and the run-anything builtins (2.1.280). A program that only reaches the network is not here. */ export declare const AUTO_STRIPPED_PROGRAMS: readonly ["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"]; /** * Does auto mode strip this allow entry for this call (see the module doc)? Read for every rule-shaped member — a list * entry, and a person's stored row through {@link autoStrippedRule}; a hook's allow is not an entry and is never stripped. * A stripped entry takes no part in the match (the reference removes it before matching), so it neither clears nor is * disclosed. */ export declare function autoStripped(compiled: CompiledAllowEntry, call: ToolCallRequest | undefined): boolean; /** {@link autoStripped} over a person's stored ALLOW row (a deny / ask row is never stripped; a path row never is). */ export declare function autoStrippedRule(rule: PersistedRule, call: ToolCallRequest): boolean; /** One row a list matcher reads. */ export interface AllowEntryRow { readonly behavior: "allow" | "deny"; readonly entry: string; readonly compiled: CompiledAllowEntry; } /** The facts a list's content row is judged under: the leg's root and session (which rows apply), the live working * directory (where a command's `cd` lands), and the ANCHORS of the list's path rows — the leg the list was installed * on (its root and environment home): a path row of a list never moves with a `cd`. */ export interface AllowEntryJudgeContext { readonly root: string | undefined; readonly sessionId: string | undefined; readonly liveCwd: () => string | undefined; readonly anchors: PathRuleBases; } /** For a list whose allow rows admitted no call ({@link judgeAllowEntries}): the first segment of the call's command no command * row covers under `posture` — the word a refusal names; `undefined` when the call carries no command or no single segment is * to blame (a line the matcher cannot read at all). The same walk the matcher took. */ export declare function uncoveredCommandSegment(rows: readonly AllowEntryRow[], req: ToolCallRequest, ctx: AllowEntryJudgeContext, posture: AdmissionPosture): string | undefined; /** * The ONE list matcher, both directions — the rows that speak for the call, or undefined. A tool-name row first (the first * that reaches the call); then the content rows, each family by the predicate the stored rows of that grammar meet: * COMMAND — allow: the persisted allow matcher's coverage under the list's {@link AdmissionPosture} (the whole command, or each * segment covered by a row — or, for a `grant` list, read-only by itself); deny: a row whose program run the call performs (the * wide tightening reading; a call that cannot be read against the row is refused too). PATH — the persisted path adjudication * (allow: the narrow reading, every view of the target inside what the row names; deny: any view reaching it). NAME / DOMAIN — * the one predicate of each (the name the call names, the host it fetches); a keyed tool's call naming no value for its key is * refused by a deny list's row of the key and granted by no allow row (the persisted walk's reading, `keyedValueAbsentReason`). */ export declare function judgeAllowEntries<R extends AllowEntryRow>(rows: readonly R[], req: ToolCallRequest, behavior: "allow" | "deny", ctx: AllowEntryJudgeContext, posture?: AdmissionPosture): readonly R[] | undefined; /** The allow rows of a list, as the allow layer reads them in `mode`: the strip applied BEFORE the match (in `auto`). */ export declare function allowingRows<R extends AllowEntryRow>(rows: readonly R[], req: ToolCallRequest, mode: PermissionMode | undefined): readonly R[]; /** The two sources {@link AllowEntryTable} holds (a skill's list is the skill table's). */ export type AllowEntrySource = "request" | "deployment"; /** What spoke for a call: the entries it stands on, as the list spells them. */ export interface AllowEntryHit { readonly entries: readonly string[]; } /** The face the gate reads one entry list through. */ export interface AllowEntryLane { /** The allow layer's member: what grants this call in this mode, or undefined. */ allowing(req: ToolCallRequest, mode: PermissionMode | undefined): AllowEntryHit | undefined; } /** The gate inputs the two entry lists ride (a member group of `ToolGateInput`). Each is the allow layer's member for its * source — read under the one consumption predicate beside a person's stored rows and a skill's list, clearing only what an * allow rule may clear — with its attribution sink (`onAllowed` observes a call it cleared; never affects the outcome). * Absent ⇒ that source has no rows. */ export interface AllowEntryGateSeats { deploymentAllows?: AllowEntryLane & { onAllowed?: (info: { toolName: string; toolCallId: string; entries: readonly string[]; }) => void; }; requestAllows?: AllowEntryLane & { onAllowed?: (info: { toolName: string; toolCallId: string; entries: readonly string[]; }) => void; }; } /** * One source's allow rows, compiled once: the request's (prepare seeds it from the spec's snapshot and, in a delegated * child, from the parent's capture) or the deployment's (compiled from the Runner's snapshot). Allow rows only — neither * list has a deny side. Identity is the contract: prepare seeds it, the delegation capture and the gate's lane read it. */ export declare class AllowEntryTable { private held; private readonly heldEntries; /** Write a list's rows (a row already held is not held twice). Returns the entries that read as nothing. `roster` as in * {@link readRuleEntry}: a list the leg itself wrote is read against it, a carried one (a delegating parent's capture) is not. */ seed(entries: readonly string[], roster?: ReadonlySet<string>): readonly AllowEntryInert[]; /** The entries held, as written (a copy) — what a delegated child is handed. */ entries(): readonly string[]; /** Does the table hold a path row — must the leg read each call's target views for it? */ holdsPathRows(): boolean; /** The lane over this table, bound to the leg's call facts. */ lane(ctx: AllowEntryJudgeContext): AllowEntryLane; } /** * Seed one leg's request and deployment tables — a delegated child's captured request entries first (an entry that reads as * nothing was announced where it was written, and is read without the roster for the same reason), then the leg's own list * against `roster`; the deployment's grammar reading is announced once, at construction, so only its name readings * (`tool_name`, `near_miss` — facts about THIS leg's roster) are announced here — and return the dead entries of the leg's * OWN lists, the skills' beside the request's, for its one start-of-run notice. */ export declare function seedAllowEntryTables(tables: { readonly request: AllowEntryTable; readonly deployment: AllowEntryTable; }, lists: { skillInert: ReadonlyArray<{ skill: string; list: "allowed-tools" | "disallowed-tools"; } & AllowEntryInert>; inherited: readonly string[] | undefined; request: readonly string[] | undefined; deployment: readonly string[] | undefined; }, roster: ReadonlySet<string>): AllowEntryInertNote[]; /** The deployment's allow rules, read ONCE at construction: screened (a value that is not a list of strings refuses), frozen, * and its dead entries announced once (no session). Returns the deps with the snapshot in place of the caller's list. */ export declare function withDeploymentAllowedTools<D extends { readonly allowedTools?: readonly string[]; readonly onNotice?: ((n: EngineNotice) => void) | undefined; }>(deps: D): D; /** The code a list value that is not a list of strings refuses with — the policy compiler's family, with its issues. */ export declare const INVALID_PERMISSION_RULES_CODE = "config.invalid_permission_rules"; /** * Screen and snapshot one `allowedTools` value (`seat` names the key in the refusal): absent ⇒ undefined; a list of strings * ⇒ a frozen copy (the one read every later reader takes); anything else refuses loudly with * {@link INVALID_PERMISSION_RULES_CODE} and an issue per defect — never dropped, never read as "no entries". */ export declare function screenAllowedToolsValue(value: unknown, seat: string): readonly string[] | undefined; /** The disclosure record for one member, minted from the entries it matched: each sanitized (deployment or model-authored * text on a human trust boundary) and capped, the rest counted. */ export declare function allowRuleShadowOf(member: AllowRuleMember, entries: readonly string[]): AllowRuleShadow; /** Read a disclosure value off a decision WITHOUT trusting it (a policy's own value, an ancestor's): every record a known * member with a list of strings, or nothing — a display member must never throw where a card is minted. */ export declare function readAllowRuleShadows(value: unknown): readonly AllowRuleShadow[] | undefined; /** Why a matched entry did not clear the ask, by the ask's class — one row per class, so a class added later is a * compile error here rather than somebody else's reason on the card. Two facts the ask carries are read first, because * they name the reason more exactly than a class shared by several: a table word (`mandate` — the exact mark) and the * deployment's own authorship (`authoredBy`) — {@link allowRuleShadowSentence} is the reader that applies that order. * Exported (frozen) so a host that renders the card's "why it cannot be cleared" half reads the engine's row, never a copy. */ export declare const SHADOW_REASON: Readonly<{ readonly content_question: "the call is the engine's own question to a person"; readonly hook: "a PreToolUse hook asks about this call — the deployment's own screening is answered by a person"; readonly ancestor: "the task that delegated this one owns this question"; readonly 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"; readonly unverifiable_delete: "this removal could not be verified safe — it is cleared by confirming this call, never by an allow rule"; readonly 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"; readonly transcript_write: "this write targets the session transcript — it is cleared by confirming this call, never by an allow rule"; readonly org_rule: "an organization policy rule requires approval for this call"; readonly 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"; readonly ask_rule: string; readonly denial_limit_fallback: "the auto-mode classifier refused too many calls in a row, so a person decides"; readonly classifier_no_ruling: "the auto-mode classifier could not judge this call, so a person decides"; readonly requires_user_interaction: "this tool's server declares that a call must be put to a person"; readonly 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"; readonly shell_floor: string; readonly target_unresolved: string; readonly probe: string; readonly write_protection: string; readonly tool_marks_mcp: string; readonly tool_marks_a2a: string; readonly policy_rra: "the deployment's policy requires a real approval for this call"; readonly artifact_shared_read: "this read targets another task's shared artifact — it is cleared by confirming this call"; readonly mode: "the permission mode puts this question to a person"; readonly 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"; readonly 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"; }>; /** The fence over the reason table: `never` while every class has a row. */ export type ShadowReasonTableCoversEveryClass = AssertAllKeysHandled<Exclude<AskClass, keyof typeof SHADOW_REASON>>; /** * The disclosure in the sentence a PERSON reads — rendered where a card or a park row is minted, never onto the decision * the classifier reads: "<members> match(es) this call but do(es) not clear the ask — <why>". */ export declare function allowRuleShadowSentence(shadows: readonly AllowRuleShadow[], ask: { askClass?: AskClass; mandate?: PersistedRuleMandate; authoredBy?: "deployment"; }): string; /** The card's two disclosure members off a surviving ask, minted at every station that builds an approval request: the * sanitized records, and the message with the sentence rendered onto it. */ export declare function cardDisclosureOf(decision: { message?: string; allowRuleShadowed?: unknown; askClass?: AskClass; mandate?: PersistedRuleMandate; authoredBy?: "deployment"; }, fallbackMessage: string): { message: string; allowRuleShadowed?: readonly AllowRuleShadow[]; }; /** The notice for list entries that grant (or refuse) nothing — one code for every list. */ export declare const ALLOWED_TOOL_NOT_GRANTED_CODE = "permission.allowed_tool_not_granted"; /** One entry that reads as nothing, and the list it came from (a skill's names the skill and which of its two lists). */ export type AllowEntryInertNote = ({ readonly source: "skill"; readonly skill: string; readonly list: "allowed-tools" | "disallowed-tools"; } & AllowEntryInert) | ({ readonly source: AllowEntrySource; } & AllowEntryInert); /** * A list entry spelled so a person reads what the engine read: the entry as a JSON string — quotes, backslashes and the usual * escapes — with every character that prints as nothing, or as a space, written as `\uXXXX` (`\u{…}` beyond the BMP) and a * run of spaces kept space by space. Uncapped and one-to-one (two entries never share a spelling), so a value that BINDS an * entry may hold it ({@link renderListEntry} is this, capped for a sentence). */ export declare function spelledListEntry(entry: string): string; /** * THE ONE WRITER of a list entry into a sentence (the start-of-run notice, a skill's refusal and its decisions, a sub-agent * list's refusals and advisories): {@link spelledListEntry}, capped on what is printed. */ export declare function renderListEntry(entry: string): string; /** * One entry that reads as nothing, as a person reads it: the entry as written (quoted, so a surrounding space shows) and why * — the reason word, with the spelling to write when the reading has one. The one phrasing the start-of-run notice and a * skill's refused activation share. */ export declare function inertEntryPhrase(e: AllowEntryInert): string; /** * The notice for entries this grammar reads as nothing — delivered only when one exists: once per prepared task for the * skill and request lists (`sessionId` given), once per Runner for the deployment's list. The person whose list it is * reads it (audience `user`); the run goes on (an entry that grants nothing makes it ask more). A skill whose * `disallowed-tools` holds such an entry is named as one that does not activate. */ export declare function allowedToolsNotGrantedNotice(inert: readonly AllowEntryInertNote[], sessionId: string | undefined): EngineNotice;