@sema-agent/core
Version:
Stateless, task-oriented AI agent core
1,444 lines • 108 kB
TypeScript
/**
* design/179 §2 — the persisted permission-rule model and its matchers.
*
* A persisted rule is one person's standing instruction about a tool call, in one of THREE behaviors
* ({@link RULE_BEHAVIORS}): an `allow` is the standing form of ONE recorded human approval (the person
* confirmed a command once on an approval card and asked not to be asked again); a `deny` and an `ask`
* are the standing forms of "never run this" and "ask me every time" — the same content-form rules
* upstream reads out of its settings' deny/ask lists. ONE grammar, ONE canonical spelling and ONE
* matcher family serve all three; the behavior is a FIELD beside the text, never part of it, so
* `Bash(rm -r:*)` is one spelling whether it allows, denies or asks. This module owns the rule's SHAPE
* (text grammar, canonical spelling, scope, behavior) and its MATCH semantics; who may mint one, where
* it is stored, and where in the gate it is consumed live in `permission-rule-store.ts`,
* `permission-rule-consent.ts` and `gate-lanes.ts` respectively.
*
* The two DIRECTIONS read a command differently, on purpose: an allow must cover the WHOLE call
* ({@link ruleAdmitsCommand} — one recorded yes clears exactly what it spells), while a deny/ask
* speaks about any PROGRAM RUN the call may perform ({@link programRunReachOf} — whole command or
* any segment of a chain, read with a real shell lexer, `shell-lexer.ts`, and answering `unreadable`
* for what it cannot read rather than silence). Precedence across behaviors is the upstream one,
* deny > ask > allow ({@link RULE_BEHAVIOR_PRECEDENCE}, the one table every adjudication reads).
*
* ## The floor: what a rule may name, and what a rule may match (the ALLOW direction)
*
* Everything a shell can use to run a program the text does not NAME — substitution, subshells,
* backgrounding, escapes, line breaks, and **redirection** — puts the command outside this lane
* entirely: it falls back to the pre-existing chain and asks. Redirection specifically is a deliberate
* registered divergence from upstream (upstream strips redirections before matching; with this repo's
* write gate not covering the shell tool, stripping would turn a rule as innocuous as `Bash(ls)` into a
* licence for `ls > ~/.ssh/authorized_keys`).
*
* CONNECTORS are the one construct the lane does speak for, in two forms. The EXACT form spells a
* whole chain and admits exactly it: `./gradlew build && ./gradlew test` is one thing a person reads
* and approves in one glance, and nothing is admitted that the rule text does not spell out end to
* end. The COMPOUND-PREFIX form (`Bash(cd /tmp && adb pull:*)`) widens that by ONE seam and no other:
* every leading segment — connectors included — is matched byte-for-byte like an exact rule, and only
* the FINAL segment takes appended arguments, so the rule text still reads as the concrete combination
* the person saw, with the last command's arguments free. A SINGLE-COMMAND prefix body stays
* single-command on the match side (`Bash(npm:*)` does not admit `npm test && curl evil.example`) —
* the matcher requires the command to read as exactly as many segments as the rule body names, which
* is that historical line and the compound form's own containment in one predicate. The compound-prefix
* rule TEXT is a registered divergence from upstream, which has no compound rule form at all: upstream
* reaches compound coverage through a per-segment evaluation of its full decision chain, where a
* segment DENY is returned strictly before any whole-string allow; this lane's equivalent is that the
* deny/ask layer (`permission-rule-org.ts`) judges every segment and runs ahead of the allow lane at
* the gate — a compound-prefix allow can therefore never outrun a deny on any segment it admits.
*
* design/375 adds upstream's OTHER half of that per-segment evaluation on the allow side: a compound
* whose EVERY segment is admitted by some eligible rule is covered by the CONJUNCTION of those rules
* (`findAdmittingRule` answers with the coverage SET). No single rule widens: the matcher predicates
* above are untouched, and the set form is the decision layer's answer, fenced by the same
* ahead-running per-segment deny/ask layer.
*
* The floor is `parseLeadingCommandName` + `splitShellCompoundSegments` — the one simple-command parser
* and the one segmentation, both already shared with the read-only classifier, the reversibility probe,
* the coarse command-name policy and the skill tool specifier. A second tokenizer would drift, and drift
* on a LOOSENING face shows up as a circumvention rather than as a test failure. The tightening
* direction's lexer is not that second tokenizer: it reads MORE than the floor (never less), and a
* drift there costs an approval prompt, never a standing approval.
*
* argv[0] may carry a PATH prefix here (`./gradlew`, `/usr/bin/git`) — opt-in at the shared parser and
* used by this lane alone. The bare-name requirement belongs to the argv[0]-NAME filters, which compare
* one token against a name set; this lane compares the whole command line, so a path prefix is not a way
* past anything — it IS the text that was approved. No basename folding follows from it: `./gradlew`
* and `gradlew` stay two different commands to the matcher. The single place a basename is taken is the
* interpreter refusal below, which must read `/usr/bin/node` as `node`.
*
* ## Normalization order is load-bearing
*
* Whitespace folding runs AFTER the shell-construct rejection, never before: folding first would
* swallow a newline and glue two commands into one string that then reads as a single simple command.
* Only space/tab runs fold; there is no case folding, no alias expansion (no shell alias view exists
* here) and no leading-env-assignment stripping (`FOO=1 git status` simply does not match
* `Bash(git status:*)`) — the last two are strict-side divergences from upstream, registered as such.
*/
import { type DenyFace } from "../tools/fs/read-deny.js";
import type { AssertAllKeysHandled, RuleOffersAbsence } from "./ask-origin.js";
import { type ToolCallLike } from "./tool-registry.js";
import { type PathDenialEvidence, type PathFamily, type PathIdentity } from "../tools/fs/safety.js";
import { type TargetViews } from "./path-rule-binding.js";
/**
* The closed set of rule BEHAVIORS — what a matching rule says about the call. The upstream vocabulary
* verbatim (`allow` / `deny` / `ask`, the three settings lists), in PRECEDENCE order: when rules of more
* than one behavior speak for one call, the earliest member here decides ({@link RULE_BEHAVIOR_PRECEDENCE}
* is that order as a table, and {@link adjudicatePersistedRules} is the one place that walks it).
*
* The type is derived FROM the array so a member added to either side is a compile error; a value read
* off a wire or a file goes through {@link isRuleBehavior} (present but outside the set = refused, never
* defaulted) or {@link readRowBehavior} (the ONE reading of a row that predates the field).
*/
export declare const RULE_BEHAVIORS: readonly ["deny", "ask", "allow"];
export type RuleBehavior = (typeof RULE_BEHAVIORS)[number];
/** Membership test for {@link RuleBehavior}. Exported because the word crosses process boundaries (a
* store row, a sync payload, a settings entry) and a host validating one must not hand-roll the set. */
export declare function isRuleBehavior(v: unknown): v is RuleBehavior;
/**
* The precedence table: the rank at which each behavior decides a call when several rules speak (lower
* decides first). Upstream's arbiter walks deny, then ask, then allow — an explicit deny is never
* outvoted by an allow, and a standing "ask me each time" is never silenced by a remembered yes. Every
* adjudication over a mixed rule table orders its walk by THIS table (never by a hand-written if-chain),
* so the rank of a behavior is stated once.
*/
export declare const RULE_BEHAVIOR_PRECEDENCE: {
readonly deny: 0;
readonly ask: 1;
readonly allow: 2;
};
/** Compile-time fence over the precedence table: `never` while every behavior has a rank. */
export type PrecedenceTableCoversEveryBehavior = AssertAllKeysHandled<Exclude<RuleBehavior, keyof typeof RULE_BEHAVIOR_PRECEDENCE>>;
/** The behaviors in precedence order — the walk every mixed-table adjudication takes. */
export declare const RULE_BEHAVIORS_BY_PRECEDENCE: readonly RuleBehavior[];
/**
* The ONE reading of a persisted row's behavior at every seam that may meet a row written before the
* field existed — the file store's schema migration, the sync wire's inbound picker, a checkpoint's
* rehydrated session rows. An ABSENT behavior reads `"allow"`: every row an engine without the field
* ever wrote was an allow, so that reading is the row's own meaning, not a default. A PRESENT value
* outside the closed set is refused (`undefined`) — a word this engine cannot read must not become an
* allow by falling through (the loud-bad-value doctrine), and must not become a deny either (a
* fabricated tightening is still a fabrication).
*/
export declare function readRowBehavior(v: unknown): RuleBehavior | undefined;
/** The tools the rule lane speaks for — derived per grammar ({@link ruleToolGrammarOf}): the command word, every catalog
* path tool, the name tools, the domain tools.
*
* design/276 §3.3 (doctrine, for whoever widens this set): a future `SendMessage(…)`-form rule MAY
* clear the peer-referral ask — that ask is classifier hesitation by construction (#144: allow rules
* silence the classifier's questions, never a mandated one), so a recorded human yes is exactly what
* clears it. The org/hook/matchedAskRule immunities stay: those conjuncts live in the gate's
* persisted-rule lane and do not loosen with this set. */
export type PersistedRuleTool = string;
/** The command grammar's ONE rule word — the spelling a shell command rule is written in (`Bash(<command>)`, upstream's).
* A rule word, not a tool list: the calls a command rule reaches are the SHELL FACE's — every catalog definition whose
* `command` the command grammar judges ({@link laneToolNamesOf}). */
export declare const COMMAND_RULE_TOOL = "Bash";
/** The read face's rule word: a `Read(<path>)` rule speaks for every call that reads a path ({@link PATH_RULE_FACES}). */
export declare const READ_RULE_TOOL = "Read";
/**
* The path rules that speak for a FACE rather than for one tool — one table, one column per tightening behavior (the
* reference implementation's reading: a Read rule covers every built-in tool that reads a file, an Edit rule every tool
* that edits one — Write and NotebookEdit included, deny and ask alike — and a Read DENY also refuses writing the same path; a Read ASK asks for reads only). Such a rule
* reaches any call whose declared path target is on one of its behavior's faces — the Read tool, the search tools'
* roots, the write tools — judged on the path that call reads, searches or writes. A path rule on any other tool keeps
* its tool's name as its reach (`Write(<path>)` is judged on the Write tool, a stricter reading the reference does not make).
*
* Both tightening columns are judged at the gate, on the call's own path target, before the call runs — by the rule
* policy's lane and by the persisted lane alike, one column read for deny and for ask. A deny row is exported to the
* engine's deny tables as well (`ToolPolicy.pathRules` bound where the policy is installed, the person's rows at
* prepare), which do what the gate cannot: refuse inside the tool at execution time, descend into a search's results,
* reach the shell's file commands' operands, and keep a refused target out of the out-of-root question. An ask does not
* descend (a search rooted above the asked path is not asked, and its results are not filtered).
*
* The ALLOW column is the reference's too: an allow by path is read on these two tools only — a `Read(<path>)` allow speaks
* for the read face, an `Edit(<path>)` allow for the write face and the read face (an edit allowed is a read allowed) — and
* an allow by path on any other tool has no rule form. It is judged at the gate with the narrow reading
* ({@link adjudicatePersistedPathRules}): every view of the call's target must lie where the rule names.
*/
export declare const PATH_RULE_FACES: Readonly<Record<string, Readonly<Partial<Record<RuleBehavior, readonly DenyFace[]>>>>>;
/** The face a call's protective path target is on: `read` for a read slot, `write` for every other access; none without
* one — the face a {@link PATH_RULE_FACES} rule is judged against, in a rule policy and in the persisted rule lane alike. */
export declare function callPathFaceOf(call: ToolCallLike): DenyFace | undefined;
/** Does some {@link PATH_RULE_FACES} tightening column (deny or ask) hold `face` — may a face-keyed row speak for a call on
* it, whatever the call's tool is called (a caller's tool declaring a read path target is on the read face too)? */
export declare function faceRowsSpeakFor(face: DenyFace | undefined): boolean;
/**
* The tools whose rule content NAMES THE VALUE of one parameter instead of a command or a path — ONE grammar (the name grammar,
* match `name`), one row per tool, the row declaring the tool's SPELLING of it:
* · BARE — the reference's `Skill(<name>)` / `Skill(<prefix>:*)`: `param` is the argument the rule's content is read against,
* `params` every scalar argument the tool carries (the parameter spelling `Tool(<param>:<pattern>)` those names used to reach
* is retired for such a tool, see {@link parseNameRuleContent}). The Skill tool's real schema keys are pinned equal to
* `params` (test/backlog1318).
* · KEYED — `words` present: the reference's `Artifact(action:<word>)`, the content carrying the argument's own key. `params`
* is that one key; `words` is the closed set of values the tool itself declares for it (the row points at the tool's own
* set, never a copy), and a rule names exactly one of them — no prefix form, no `*`.
* Whatever the spelling, a rule of this grammar says "the call's value of `param` is this value", read by the one subject
* reader ({@link nameRuleSubjectOf}), the one predicate ({@link nameRuleAdmits}), the one precedence walk
* ({@link adjudicatePersistedNameRules}) and the one offer ({@link suggestRuleForName}).
*/
export declare const NAME_RULE_FACES: Readonly<Record<string, {
readonly param: string;
readonly params: readonly string[];
readonly words?: readonly string[];
}>>;
/**
* Why a call of a KEYED value tool that names no value for the key cannot be read against that key's deny / ask rules, or
* undefined (a bare-spelling tool, or no value tool). Every judge reads a call's arguments as the tool reads them
* (`argsAsTheToolReads`: its argument preparation, then the schema's coercions); a call that STILL names no value for the key
* runs a value no argument shows — a default filled in inside the tool's execution (a caller's tool mounted under the keyed
* name, or answering to it, with no argument preparation of its own). Whether a rule by the key reaches such a call is
* unknown, so every reader REFUSES it on the key's deny or ask rule — a refusal, never an ask a person could approve past a
* deny, and no allow by the key reaches it. A tool whose reading always names the key (the built-in `Artifact`: an absent
* action reads as `publish`) never meets this. A bare-spelling tool's call that names nothing is reached by nothing: no
* default stands behind it.
*/
export declare function keyedValueAbsentReason(tool: string): string | undefined;
/**
* Does the name grammar read this content of this tool? Every content of a bare-spelling tool; on a keyed tool only content
* keyed by its own parameter (`action:…` on `Artifact`) — another key (`Artifact(url:…)`) is a rule on another argument,
* which the persisted grammar has no form for and the caller policy reads with its generic parameter lane. Undefined grammar
* ⇒ false.
*/
export declare function nameGrammarReads(tool: string, content: string): boolean;
/**
* The tools whose rule content names the HOST of the URL one parameter carries — the reference's `WebFetch(domain:<host>)`
* form, one row per tool: `param` is the URL argument the host is read from ({@link domainRuleSubjectOf}). Read by
* {@link parseDomainRuleContent}; an allow only (see there).
*/
export declare const DOMAIN_RULE_FACES: Readonly<Record<string, {
readonly param: string;
}>>;
/**
* Which tools a rule may name — the model's tool set is DERIVED, never a hand-kept list: the command rule word
* ({@link COMMAND_RULE_TOOL}) takes the COMMAND grammar — the other shell-face tools take none of their own, their command is
* judged by the `Bash(…)` rules ({@link laneToolNamesOf}); every tool the catalog declares a path target for (Read / Edit / Write /
* Glob / NotebookEdit / …, `pathTargetOf`) takes the PATH grammar; the tools of {@link NAME_RULE_FACES} take the NAME grammar
* (each in its row's spelling: bare `Skill(<name>)`, keyed `Artifact(action:<word>)`); the tools of {@link DOMAIN_RULE_FACES} the
* DOMAIN grammar.
* A rule on any other tool has no content this model can judge and is refused (`unsupported.tool`). The tool set therefore
* moves with the catalog's declarations, exactly as the protective fences' covered set does.
*/
export type RuleGrammar = "command" | "path" | "name" | "domain";
export declare function ruleToolGrammarOf(toolName: string): RuleGrammar | undefined;
/**
* The HOST a domain-grammar tool's call fetches — the string every DOMAIN rule is read against — or undefined: not a domain
* tool; no URL argument, or one that is not a string; a URL that does not parse, is not http(s), or carries credentials (the
* fetch tool refuses those itself). The host is the URL parser's (lower case, IDN in its ASCII form, an IPv4 shorthand
* expanded — the host the fetch connects to), its trailing dots read off.
*/
export declare function domainRuleSubjectOf(toolName: string, args: unknown): string | undefined;
/** The value a name-grammar tool's call carries in its name argument — the string every NAME rule is read against, the one a
* `Skill` call is resolved by — or undefined (not a name-grammar tool; no such argument; not a string). */
export declare function nameRuleSubjectOf(toolName: string, args: unknown): string | undefined;
/** The rule WORDS a call is judged under, once each: for every name the call answers to (the model's spelling, the wire
* name, the other aliases that resolve to it), the word its rules are written in — a shell-face name's is the command
* grammar's {@link COMMAND_RULE_TOOL} (a `Bash(<command>)` rule reaches `Monitor` as it reaches `Bash`), a path, name or
* domain tool's its own name — so a stored `Bash(…)` or `Edit(…)` row reaches a tool that answers to that name under another wire
* name, as a policy's rule does. Every lane that judges a call by a rule's word reads it here: the policy compiler, the
* person's stored rows, the org partition, the allow lists. Empty ⇒ no grammar speaks for the call. */
export declare function laneToolNamesOf(req: Pick<import("./tool-policy.js").ToolCallRequest, "toolName" | "face">): readonly string[];
/**
* Match forms, one family per grammar (`prefix` and `wildcard` are one predicate):
* · `exact` — admits the one string the rule spells.
* · `wildcard` — the TRAILING SPACE-STAR spelling (`Bash(npm run *)`): the body, or the body followed
* by a space and more, inside the body's own segment count. This is the spelling upstream itself
* mints for its prefix suggestions (its classifier compiles one trailing ` *` into an optional
* suffix `^body( .*)?$`), so it is the form this lane's SUGGESTION face mints too (#510).
* · `prefix` — the historical COLON-STAR spelling (`Bash(npm run:*)`), match-equivalent to `wildcard`
* on the single-command body. Kept as a fully supported COMPATIBILITY READ — stored rules are never
* migrated or rewritten — and it remains the ONLY spelling of the compound-prefix form
* (`Bash(cd /tmp && adb pull:*)`): the trailing-star form deliberately has no compound reading
* (see {@link parseRuleText}), because upstream's own trailing-star pattern is inert on a
* compound and importing/minting one as a chain licence would grant what nobody approved.
* · `subpath` — the directory form (design/382 §2.2): `Tool(//abs-dir/**)`, reaching every path under
* one absolute directory. Never admits any command; its one predicate is {@link directoryRuleAdmits}.
* As an ALLOW it exists on the two face tools ({@link PATH_RULE_FACES}); as a deny/ask on every
* path-targeting tool.
* · `path` — the PATH PATTERN form: upstream's file-rule spelling over a tool's path target
* (`Edit(~/.sema/settings.json)`, `Write(src/**)`, `Read(//etc/*)`), stored AS SPELLED and
* resolved at match time against the call's bases (`//` absolute, `~/` home, `/` project root,
* otherwise the working directory). Its predicate is {@link pathRuleReachOf}. As an ALLOW it exists
* on the two face tools, judged with the narrow reading (every view of the target inside the pattern).
* · `name` / `name_prefix` — the NAME grammar ({@link parseNameRuleContent}).
* · `host` / `subdomain` — the DOMAIN grammar ({@link parseDomainRuleContent}): one exact host, or every
* host below one domain.
*/
export type PersistedRuleMatch = "exact" | "prefix" | "wildcard" | "subpath" | "path" | "name" | "name_prefix" | "host" | "subdomain";
/**
* Where a rule applies — design/382 §4.1, the THREE consent dimensions.
*
* · `global` — the personal-global dimension: this OWNER's rules, everywhere (the bucket is already
* per-owner, {@link import("./permission-rule-store.js").RuleOwner}). The member keeps its historical
* spelling on purpose: renaming it `personal-global` would be a no-benefit migration of every stored
* row; the "personal" half lives in documentation and card wording, not in the wire byte.
* · `project` — applies ONLY inside `root`. `root` is expected to be already canonicalized (realpath)
* by whoever constructs the scope; the matcher compares with word-boundary containment, never a bare
* `startsWith` (`/a` must not reach `/ab`).
* · `session` — applies ONLY to calls of the ONE session it names, and lives in that session's own
* state (the host's session-rule overlay), NEVER in the persisted store: every durable entrance —
* the write deltas, sync in both directions, at-rest bytes — refuses or drops a session row
* (design/382 §4.3, the durable two-member face). `sessionId` uniqueness is the HOST's duty (the
* `principal` posture): reusing an id is a declaration that it is the same session. A session row
* cannot be individually deleted — it dies with its session (design/382 §10, ruled; the durable
* removal entry refuses it rather than answering a false no-op).
*
* ORG is deliberately NOT a member (design/382 §4.1): an approval card must be structurally unable to
* spell an org-wide grant — org governance is a deny/ask snapshot published by an admin surface, with
* no allow bucket at all. Unspellable beats runtime-refused; the consent validators refuse the spelling
* anyway if a foreign writer plants it.
*
* TWO NAMED VALIDATION FACES read this union (design/382 §4.3): the CONSENT face (prepare candidates,
* approval records, confirm/redeem, batch outcomes) admits all three members
* ({@link import("./permission-rule-store.js").isValidConsentScope}); the DURABLE face (the persisted
* store's write deltas, rule-sync in both directions, at-rest decode) admits only
* `{global, project}` ({@link import("./permission-rule-store.js").isValidDurableScope}). One table
* "for both" is exactly what would let a session row leak into the store silently.
*/
export type RuleScope = {
kind: "global";
} | {
kind: "project";
root: string;
} | {
kind: "session";
sessionId: string;
};
/** The immutable causal identity of one add: a replica identity plus a monotonic counter. */
export interface RuleDot {
actor: string;
counter: number;
}
/** Where one add came from. Stored PER ADD — never folded across dots (a folded provenance cannot
* follow a dot that is later deleted on its own). */
export type RuleAddOrigin = "user" | "imported-cc" | "starter";
/** One add of one logical rule, carrying its own dot and its own provenance. */
export interface RuleAdd {
dot: RuleDot;
origin: RuleAddOrigin;
createdAt: string;
}
/**
* A persisted rule for one tool, in one of the three behaviors.
*
* The logical identity of a rule is the TRIPLE (behavior, rule text, scope): the same text may stand as
* an allow and as a deny in one bucket (upstream keeps the three lists side by side and lets precedence
* decide), so a tombstone, a quarantine row and a join group all key on the triple — a key on (text,
* scope) alone would let a delete aimed at the allow take the deny with it.
*
* `adds` is a real set, not a scalar: two concurrently approved records for the same identity each
* redeem their own dot, and a rule is live iff at least one of its adds survives the tombstones.
* Organization rules are a separate partition (a server-published snapshot, no CRDT) and never enter
* this type; the personal deny/ask rows here are the PERSON's own standing instructions (their settings'
* deny/ask lists, imported through the same consent door the allow list takes).
*/
export interface PersistedRule {
/** What a match of this rule says about the call — REQUIRED on every row this engine writes. A row
* read without one is a pre-field row and reads `allow` through {@link readRowBehavior}, at the
* reading seams only (the store's schema migration, the sync picker, a checkpoint's session rows). */
behavior: RuleBehavior;
/** Canonical rule text: `Bash(git status)` (exact) or `Bash(git status:*)` (prefix). A prefix body
* may itself be a connector chain (`Bash(cd /tmp && adb pull:*)`) — see the module note on
* connectors for what such a rule reaches. */
rule: string;
tool: PersistedRuleTool;
match: PersistedRuleMatch;
/** What the matcher compares against — parsed out of `rule`, stored so no consumer re-parses. */
command: string;
scope: RuleScope;
adds: RuleAdd[];
}
/**
* A delete intent, in observed-remove form: it carries EVERY add dot for its (rule, scope) that was
* visible to the deleting replica at delete time. A single-dot tombstone cannot delete cleanly —
* concurrent replicas add the same rule text under different dots, and a tombstone that names only one
* of them lets the deleted rule come back. The merge criterion is dot IDENTITY, not a causal-order
* comparison (single dots across replicas have no order to compare): an add is live iff its dot appears
* in no tombstone for the same (rule, scope).
*/
export interface RuleTombstone {
/** The identity's behavior member — a tombstone deletes adds of ONE (behavior, text, scope). */
behavior: RuleBehavior;
rule: string;
scope: RuleScope;
removedDots: RuleDot[];
/** The delete operation's own dot — the operation's persistent identity. */
deletedBy: RuleDot;
}
/** Why a rule text was refused. Codes are stable so an import report can group by them.
* `invalid.path_traversal` (design/382 §2.2, B5): a Read directory body carrying a `.` or `..`
* segment — the lexical-normal spelling is the ONLY spelling, so a traversal segment is not
* normalized away but refused. */
export type RuleRejectCode = "invalid.grammar" | "invalid.empty_command" | "invalid.not_simple_command" | "invalid.bare_interpreter_prefix" | "invalid.unbalanced_quotes" | "invalid.control_chars" | "invalid.too_long" | "invalid.path_traversal" | "unsupported.tool" | "unsupported.wildcard";
export interface RuleReject {
code: RuleRejectCode;
message: string;
}
/** The parsed, canonical shape of one rule text under one behavior — everything but the scope and the
* provenance. The behavior is the parser's INPUT (it decides which validator floor applies), carried
* out so a consumer holding the parse never re-derives which list the text came from. */
export interface ParsedRule {
behavior: RuleBehavior;
rule: string;
tool: PersistedRuleTool;
match: PersistedRuleMatch;
command: string;
}
/** Upper bound on a rule text. Rules are human-authored one-liners; anything longer is a mistake or an
* attempt to make the store unreadable. */
export declare const MAX_RULE_TEXT_CHARS = 512;
/**
* The most code points of a rule's ORIGINAL text a notice's machine-read slot carries: twice the grammar's own bound. A text past
* {@link MAX_RULE_TEXT_CHARS} reads as no rule whatever follows its first characters, so the slot keeps room to show it as far as a person
* needs to recognise it and no further; every text a rule can be is carried whole. Not exported from the package root: a consumer reads
* the slot and never judges its length (it is not a relay that must refuse what the engine did not mint).
*/
export declare const NOTICE_RULE_TEXT_MAX: number;
/**
* A rule's (or allow-list entry's) text as a notice's machine-read slot carries it: the original string, untouched, when it is at most
* {@link NOTICE_RULE_TEXT_MAX} code points; a longer one is cut to that many and ends in the engine's one truncation mark
* ({@link INLINE_TRUNCATION_MARK}), so the longest slot is `NOTICE_RULE_TEXT_MAX + INLINE_TRUNCATION_MARK_CODEPOINTS` (1 025) code points.
* The ONE reading both `permission.allowed_tool_not_granted` (`detail.entries[].entry`) and `permission.rule_row_dropped`
* (`detail.rows[].rule`) hand their rule text over through — what spells it for a person is the sentence, never this slot.
*/
export declare function noticeRuleText(text: string): string;
/**
* Interpreter names whose PREFIX form is refused everywhere a rule can enter the store (minting,
* import, file reload, every backend). `Bash(node:*)` reads as one narrow-looking rule and means "run
* any program you like" — that is not a shape a single click can be understood to have authorized.
*
* The check is on argv[0] of the prefix, not on the prefix being exactly one word: `node -e:*` is the
* same licence spelled longer. It is on argv[0]'s BASENAME, not its spelling: `/usr/bin/node:*` and
* `./node:*` grant precisely what `node:*` grants, and a table consulted with the full path would be a
* table any path prefix walks around (upstream takes the same basename before consulting its own
* interpreter set). Deliberately strict-side — it costs the ability to persist a rule like
* `python manage.py migrate:*` (which an import reports as skipped rather than dropping silently), and
* an EXACT rule naming a whole interpreter command line stays legal, since it authorizes one command.
*
* TWO READERS, and they consult this table with DIFFERENT questions — do not read either as the other.
* {@link parseRuleText} asks "is this rule TEXT's own head a row?" and compares the basename as
* SPELLED, so an alias spelling (`SSH host:*`, `ssh.exe host:*`) is a different name to it and enters
* the store like any other rule. {@link genericPrefixBody} asks "should the engine PROPOSE a body with
* this head?" and normalizes first ({@link screenedHeadName}: case, trailing periods, executable
* suffix), because a proposal is the engine's own act and must not hand a person an alias of a row.
* The asymmetry is deliberate — normalizing at the validator would also refuse rule texts already
* stored, imported or hand-authored — and it is the reason the residual paragraph below is about this
* table's reach rather than about either reader.
*
* TWO groups, and the distinction matters when the table is next edited:
* · LANGUAGE interpreters (`node`, `python`, `ruby`, …) — the argument IS a program. This half is
* wider than upstream's own set, deliberately, and is the argument the paragraph above makes.
* · LAUNCHERS — a program whose argument is another program to run: the shells, the environment and
* privilege wrappers, the schedulers and resource wrappers, the tracers, and the shell BUILTINS
* that dispatch (`command`, `builtin`). This half is upstream's set, adopted verbatim rather than
* reasoned out row by row: it is the same question upstream answers with the same mechanism (its
* set is consulted on argv[0]'s basename too), and every row passes this table's own first axis —
* a body whose use is naming a program to execute is refused. They were missing, and each was one
* token in front of the licence the language half already refuses: `command node:*`, `timeout 9
* node:*` and `nice node:*` all grant exactly what `node:*` grants.
*
* KNOWN RESIDUAL, stated rather than hidden, and it is a property of the instrument rather than of this
* particular list: a positive list of NAMES cannot be made complete, and two independent review rounds
* enumerated escapes faster than rows could be added. Three shapes, none of which a longer list fixes:
* · a version- or distribution-suffixed spelling of a listed interpreter (`python3.12`, `nodejs`);
* · a launcher nobody listed — the set of programs that run another program has no boundary, and each
* round produced more of them;
* · a spelling that defeats the NAME question entirely: `/proc/self/exe` (basename `exe`, the running
* shell), a symlink or a renamed binary, a BusyBox applet, a dynamic loader invoked directly.
* Matching a family would need a pattern rather than a set, with its own false-positive surface
* (`node-gyp`, `timeout-monitor`), and no pattern addresses the third shape at all. What carries the
* residue is therefore NOT this table: it is the three standing fences — the org deny/ask layer runs
* ahead of this lane and cannot be silenced by it, a mandated ask is not rule-clearable, and the
* narrower exact candidate (plus minting nothing) is always on the same card. This table's job is to
* keep the OBVIOUS one-token licence off a card a person clicks once, not to be a boundary.
*/
export declare const BARE_INTERPRETER_NAMES: ReadonlySet<string>;
/**
* Shell KEYWORDS, refused as a segment's `argv[0]` everywhere this lane reads a command.
*
* The floor this lane stands on extracts the first TOKEN of a segment and calls it the command name.
* That identification is what every comparison here rests on — the interpreter refusal and a person's
* reading of the rule text. For a control structure it is simply false: `for x in once; do curl
* evil.example; done` splits into three segments whose first tokens are `for`, `do` and `done`; all
* three are ordinary bare words, so the floor accepts each, and a rule text could name a keyword as
* though it were a program.
*
* Refusing the keyword puts the whole command outside the ALLOW lane, which is the honest answer: no rule
* can be minted for it and no allow matches it, so it asks. The DENY/ASK direction reads these shapes
* with its own lexer (`shell-lexer.ts`: a prefixing keyword is stripped so `do curl evil.example` runs
* `curl`; a `for`/`case` header, whose commands need a syntax tree, is `unreadable` and asks).
*
* `!` is here for the same reason with a sharper edge: it is a keyword that PREFIXES a real command, so
* `! node -e …` is the `node` licence the interpreter table exists to refuse, wearing one extra token.
* `{`/`}`/`[[`/`]]` are already refused by the floor's argv[0] metacharacter rule; listed anyway,
* because that rule belongs to another module and is not this lane's to depend on.
*
* The words are the shell-word table's `rule_reserved` role (`src/shell/shell-words.ts`): the table is where a word's
* grammar is stated once, and this lane only reads the projection.
*/
export declare const SHELL_RESERVED_WORDS: ReadonlySet<string>;
/**
* design/185 §1 — the DEEPER-THAN-TWO-WORDS half of the prefix suggestion grammar.
*
* The prefix candidate itself is produced by {@link genericPrefixBody}: a general two-word rule that
* covers every command whose second token reads as a subcommand. This table exists for the one thing
* that rule cannot express — a body DEEPER than two words — so every row here makes a suggestion
* NARROWER than it would otherwise be, and never wider.
*
* A flat set of BODIES — word sequences, each at least THREE words. A body takes the seat iff it is a
* word-boundary prefix of the command's folded form, and the LONGEST hit wins; with no hit the
* two-word generic body is used, and with neither there is no prefix candidate at all.
*
* Review criterion, one axis, and it is a PRECISION axis rather than a safety one: list a deeper body
* when the two-word form names a NAMESPACE rather than an action. `gh pr`, `kubectl rollout`,
* `docker compose`, `git stash`, `go mod`, `git submodule` and `yarn workspaces` are families of
* verbs, and the verb is what a person reads as the thing they are approving. Listing a row costs
* nothing and narrows one family; NOT listing one is not a refusal, since the generic two-word body
* still fills the seat.
*
* WHAT THIS TABLE NO LONGER DOES, stated because two review axes used to live here and their removal
* is a WIDENING no reader should have to reconstruct. Rows were once gated on "runs what it is told
* to" (`docker run`, `npm exec`, `cargo install`, `gh extension`) and on "rewrites what others
* execute" (`git config`, `kubectl config`, `npm config`, `go env`), and a head outside the table
* produced NO prefix at all. Under the generic rule those bodies are produced like any other, which
* is upstream's own behaviour and the adjudicated form: a positive table of two-word bodies cannot
* cover the long tail of real CLIs, and the price of its closure was saying nothing at all for
* everything outside nine families — the field report that drove this change was every `adb`,
* `gradle` and `terraform` invocation asking forever, with an exact rule for one full command line as
* the only offer and no reuse the moment an argument changed.
*
* What carries that residue is therefore NOT this table, and never was — it is the three standing
* fences: the org deny/ask layer runs ahead of the rule lane and cannot be silenced by it; a mandated
* ask (egress/irreversibility marks, shellGate:"always") is not rule-clearable either; and the
* narrower exact candidate — plus minting no rule at all — is always on the same card. A prefix rule
* also still admits ANY arguments after its body and never a second command, which is the property
* that makes a body's width readable off its text.
*
* Maintenance: adding a row is a one-line narrowing of one family. The integrity pins (every body
* ≥ 3 words, lowercase word shape, no interpreter heads, no duplicates) are enforced by this module's
* test suite. This table and the read-only classifier's allowlists are DIFFERENT instruments and must
* never be merged or cross-referenced: that one is a machine auto-allow face whose criterion is
* "provably read-only"; this one is a human suggestion face whose criterion is "width a person can
* read off the rule text". One guards against a machine loosening; the other against a person being
* misled.
*/
export declare const SUGGESTION_LEXICON: readonly string[];
/**
* Render a peer- or file-controlled value for a DISCLOSURE line (a warning, a refusal message, an
* operator log). Takes `unknown` on purpose: most of these values are typed but arrive off a wire or a
* file, so the runtime value can be anything, and a signature that demanded a string would push a bare
* `String(x)` to every call site — the exact step that gets forgotten. Rule texts are the motivating case; the same treatment is owed to every untrusted
* field a line interpolates (a wire `reason`, an actor id), since the hazard is the character class,
* not which field carries it.
*
* Quoting a refused text verbatim would carry the exact sequence the refusal exists to keep off a
* display surface, and would print the two texts a reader has to tell apart — `Bash(echo hi)` and
* `Bash(echo<ZWSP>hi)` — identically, so the report could not name WHICH rule it means. Every character
* {@link CONTROL_CHARS_RE} covers is therefore printed as its `\uXXXX` escape (`\u{XXXXX}` above the
* BMP — the TAG block U+E0020–U+E007F is a `\p{Cf}` family that lives there); everything else passes
* through, so an ordinary rule text reads normally. The result is length-bounded.
*/
export declare function escapeForDisclosure(value: unknown): string;
/**
* The zero-width half of {@link renderUntrustedCommandText} on its own: every `\p{Cf}` character
* REMOVED, nothing else touched. Exposed for a seat that must run a PATTERN pass (a secret scanner)
* before the display render — a credential the text splits with a format character is invisible to
* the scanner while it runs, and the render's own strip would then glue it back together in clear
* AFTER the scanner missed it. Strip first, scan, then render: the render's strip is idempotent over
* text this already stripped, so the final bytes are the same as a single render, minus the leak.
*
* It knows nothing of the engine's envelope defusal and needs to know nothing: a defused tag or
* fence sentinel (untrusted-text.ts) is spelled with printable ASCII, so this pass — and any other
* format-character cleaning a downstream layer applies to a defused string — leaves it as it was.
*/
export declare function stripFormatCharacters(text: string): string;
/**
* Render raw, untrusted COMMAND text for a display surface — the minimal safe baseline for the seats
* that carry post-rewrite command bytes verbatim ({@link SegmentRuleSuggestion.segment} is the
* motivating one; a card's own `command`/`args` echo has the same hazard).
*
* WHAT IT DOES, and why the two character families are not handled the same way:
* · `\p{Cf}` (the bidirectional controls U+061C/U+200E/U+200F/U+202A–U+202E/U+2066–U+2069 and the
* zero-width formatters ZWSP/ZWNJ/ZWJ/WORD JOINER/SOFT HYPHEN/BOM) is REMOVED. These occupy no
* width, so deleting them is what makes the rendered line agree with what a reader believes they
* are reading — a `rm -rf x` that DISPLAYS as something benign is exactly the deception the seat
* would otherwise hand on. This is the same family
* {@link import("./tool-policy.js").AskRequest.hasBidiControls} raises its warning bit for; the
* bit says "the raw bytes carry them", this function is what a surface renders instead.
* · Controls and line/paragraph separators (C0 incl. TAB and NEWLINE, DEL, C1, U+2028/U+2029) are
* FOLDED TO A SPACE with runs collapsed, not removed: they occupy width (or a whole line), and
* deleting them would glue two tokens into one word that reads as a different command. TAB is not
* excepted here — a one-line display value has no use for one, and a rule text's foldable-tab
* exception belongs to the VALIDATOR class, not to this one.
* The result is then capped by code point (never splitting a surrogate pair) with a `…` marker, and
* carries the shared authority-envelope neutralization every untrusted inline value gets — this text
* can land on a model-visible line as easily as on a human one.
*
* WHAT IT IS NOT. A DISPLAY baseline, never an identity channel and never adjudication input: the
* transform is LOSSY and NOT injective (two distinct commands can render identically — that is the
* price of a readable line). Correlate, match, store and adjudicate on the RAW bytes, which the
* carrying seats keep unchanged: this function neither changes what those seats contain nor what any
* of them means. A caller that must instead tell two adversarial texts APART wants
* {@link escapeForDisclosure} (which escapes rather than folds, so nothing collides) or
* {@link hasUnrenderableCharacters} (refuse instead of render).
*
* @param text the raw untrusted text (a `String(x)` is applied to anything that is not one).
* @param maxLen code-point cap for the rendered result; defaults to the disclosure-line budget.
*/
export declare function renderUntrustedCommandText(text: unknown, maxLen?: number): string;
/**
* Does this text carry a character of the class {@link escapeForDisclosure} exists for — the class
* {@link CONTROL_CHARS_RE} names? Asked by a caller that must REFUSE such a value rather than render
* it: the two answers to "these bytes cannot honestly ride a display line" are escape it or do not
* take it, and a call site with nothing to display picks the second. Reads the same source regex as
* the escape pass, so the screen and the escape cannot drift into two character classes.
*
* Not part of the package's public surface — an internal screen for lane entry points.
*/
export declare function hasUnrenderableCharacters(text: string): boolean;
/**
* Parse one rule text into its canonical shape, or refuse it with a reason.
*
* This is the ONE validator every entry point shares — minting a suggestion, importing another
* product's settings, reloading a store file, and any future backend. A shape refused here cannot enter
* the store through any door.
*
* `behavior` is REQUIRED — there is no default, because the two directions have different floors and
* a defaulted allow is exactly the silent-widening shape a validator must not have: the
* bare-interpreter-prefix refusal is an ALLOW-side argument (one approval click cannot be read as
* authorizing arbitrary programs), while a deny/ask has the opposite polarity — `Bash(node:*)` as a DENY
* is a legitimately wide tightening, and refusing it would silently disarm exactly the rule a person
* most wants. The tightening behaviors therefore admit the interpreter-headed prefix form and change
* nothing else; the allow floor is unchanged.
*/
export declare function parseRuleText(text: string, behavior: RuleBehavior): {
rule: ParsedRule;
} | {
reject: RuleReject;
};
/** The canonical spelling of a rule for a (already folded) command and match form on `tool`. For
* `subpath` the command is the lexical-normal absolute directory (one leading slash), re-spelled in the
* double-slash directory form; for `path` it is the pattern as spelled. */
export declare function formatRuleText(command: string, match: PersistedRuleMatch, tool: PersistedRuleTool): string;
/** Does a name rule reach this name? `name`: equal; `name_prefix`: begins with it (no boundary — the reference's `startsWith`).
* A rule of any other family reaches no name. */
export declare function nameRuleAdmits(rule: Pick<PersistedRule, "match" | "command">, name: string): boolean;
/** Does a domain rule reach this host (as {@link domainRuleSubjectOf} reads it)? `host`: equal; `subdomain`: a name ending in
* `.<domain>` with at least one label before it and no empty label. A rule of any other family reaches no host. */
export declare function domainRuleAdmits(rule: Pick<PersistedRule, "match" | "command">, host: string): boolean;
/**
* The closed set of BASES a path-form pattern resolves against — facts of the CALL (its execution
* environment and its task), never of the rule:
* - `cwd` — a relative pattern (`dist/**`, `./x`, a bare `x` read at any depth): the live tracked working directory; when no tracker
* moved (or the caller keeps none) the task root stands in for it — that stand-in is the WORD's
* own meaning (the same reading `TOOL_PATH_BASES.cwd` gives a tool's relative slot), not a
* fallback to a different base;
* - `root` — a root-relative pattern (`/dist/**`): the task's project root;
* - `home` — a home-relative pattern (`~/.ssh/**`): the home directory OF THE EXECUTION ENVIRONMENT the
* call runs in (`ExecutionEnv.homeDir`, declared by the adapter). NEVER the engine process's
* own home: on a remote or sandboxed leg the two differ, and a `~/` deny resolved against the
* engine host's home silently guarded the wrong directory (#644).
* A pattern whose base is ABSENT (or not an absolute path) cannot be judged: the reach is `unreadable`
* (fail-closed — a standing deny/ask the lane cannot read becomes an ask a person clears), never
* silence and never a guess. `//abs` patterns need no base. {@link ruleBasesNeeded} tells a host BEFORE
* prepare which base a rule will need, so it can declare it (or expect the ask).
*/
export declare const PATH_RULE_BASES: readonly ["cwd", "root", "home"];
export type PathRuleBase = (typeof PATH_RULE_BASES)[number];
/** The bases a call supplies, keyed by {@link PathRuleBase}; every member optional (absent = not supplied). */
export type PathRuleBases = {
readonly [K in PathRuleBase]?: string;
};
/** The noun a refusal or an unreadable reason names each base by — the disposition table over the set
* (a base without a row does not compile: {@link PathRuleBaseLabelCoversEveryBase}). */
export declare const PATH_RULE_BASE_LABEL: {
readonly cwd: "the working directory";
readonly root: "the project root";
readonly home: "the home directory of the execution environment";
};
/** The fence over the label table: `never` while every base has a row. */
export type PathRuleBaseLabelCoversEveryBase = AssertAllKeysHandled<Exclude<PathRuleBase, keyof typeof PATH_RULE_BASE_LABEL>>;
/**
* WHICH base a rule's pattern resolves against — the one classification `resolvePathPattern`, the rule
* compiler's compile-time check and a host's pre-prepare "can this environment judge this rule?" all read.
* Empty for a rule that needs none: a `//abs` pattern, a `subpath` (directory) rule, a command rule. The
* relative form names `cwd`, whose own meaning includes "the root stands in when no tracker moved" — a
* host that supplies `root` has therefore supplied `cwd`'s stand-in too.
*/
export declare function ruleBasesNeeded(rule: Pick<PersistedRule, "match" | "command">): readonly PathRuleBase[];
/** Is this base usable by {@link resolvePathPattern} — an absolute path with a lexical normal form? A rule
* compiler asks this at compile time so a pattern is refused rather than compiled inert. */
export declare function isUsablePathBase(base: string | undefined, family: PathFamily | undefined): boolean;
/**
* The PATH family's tightening reach — three-valued like the command family's ({@link programRunReachOf}):
* does this deny/ask rule reach the call's target path? The target is the caller's ALREADY-RESOLVED
* lexical-normal absolute path (the same identity the read/write fences judge with). A `subpath` rule
* reaches the directory and everything under it ({@link directoryRuleAdmits}); a `path` rule resolves its
* spelling against the call's bases ({@link resolvePathPattern} — a cwd-relative bare name reads at any
* depth, a trailing `/**` peeled first and, for that peeled form alone, by the rule's BEHAVIOR:
* `path_rule.bare_name`) and matches segment-wise (`*` within a segment, `**` across
* segments), reaching the path it names and everything under a directory it names. A command-family rule
* reaches no path. `unreadable` (#644): the pattern needs a base the call did not supply (or supplied as
* a non-absolute spelling) — the rule CANNOT be judged, and the answer says which base; a lane that must
* fail closed on it turns it into the ask a person clears (the persisted lane's unreadable answer, the
* content lane's unreadable ask), exactly as an unreadable command is treated. Silence here was the #644
* defect (a `~/` deny falling back to the engine host's home guarded the wrong directory; a `/…` deny with
* no root reached nothing at all).
*/
export declare function pathRuleReachOf(rule: Pick<PersistedRule, "match" | "command" | "behavior">, target: PathIdentity, bases: PathRuleBases, treeFamily?: PathFamily | undefined): ProgramRunReachOutcome;
/**
* Where a path rule's reach STARTS under the call's bases — its HEAD: the location the resolved pattern names before
* its first segment carrying `*` (the whole path when none does), or a directory rule's directory. Everything the rule
* reaches lies at or below the head, so a spelling of the head that the environment resolves elsewhere (a link, a
* firmlink, `/var` → `/private/var`) is a second spelling of everything the rule reaches — the deny tables judge the
* rule at both ({@link import("../tools/fs/read-deny.js").withRuleRowsWhereTheyLead}; the reference's "physical twin"
* of a rule prefix, cli280.js 9766583). The head may be the bare anchor (`/`, a drive or share root — `Read(/**)` at a
* drive root): an environment can resolve a drive or share root elsewhere. `undefined`: the pattern needs a base the
* call does not supply (the rule then reads `unreadable`, a reach, everywhere), or a directory rule names the bare
* anchor (it admits nothing).
*/
export declare function pathRuleHeadOf(rule: Pick<PersistedRule, "match" | "command" | "behavior">, bases: PathRuleBases): PathIdentity | undefined;
/**
* Does this rule's command pattern admit `command`?
*
* `command` is the raw tool argument: the floor and the folding happen HERE, in that order, so no
* caller can match a command this lane has not read. Returns false for every redirection, substitution,
* subshell, backgrounded or escaped form.
*
* **A prefix rule reaches past its body only inside its FINAL segment.** This is the single
* load-bearing line of the connector widening, and it is checked on the MATCH side rather than left to
* the mint side: the command must read as exactly as many segments as the rule's own body. For the
* single-command body that is the historical contract verbatim — `Bash(npm:*)` is an ordinary,
* legitimately mintable rule, and if the "does this command start with `npm `" arm were allowed to see
* a compound at all, that rule would admit `npm test && curl evil.example` — one stored yes to a build
* command turned into a standing yes to whatever is chained behind it. For a compound-prefix body the
* same count equality is the containment: `Bash(a && b:*)` admits `a && b x`, never `a && b x && c`.
* An EXACT rule has no such reach by construction: it admits one string, the one it spells.
*/
export declare function ruleAdmitsCommand(rule: Pick<PersistedRule, "match" | "command">, command: string): boolean;
/**
* What one TIGHTENING (deny/ask) rule can say about a call — the closed three-word answer of
* {@link programRunReachOf}:
* - `reached` — the text PROVABLY performs a run the rule names (the whole chain, or any one segment);
* - `not_reached` — the text provably does not (every compared word is literal and differs, or the
* command is shorter than the rule's body);
* - `unreadable` — the text MAY perform it but cannot be read: an expansion sits where the rule reads a
* word, a quote or substitution never closes, a syntax error, a control structure whose commands need
* a syntax tree, a wrapper handed its command as one string.
* The three lanes that consume it (the org partition, the personal deny/ask rows, the rule DSL's
* content-form rules) dispose of the words identically: `reached` speaks with the rule's behavior,
* `not_reached` is silence, and `unreadable` is a FAIL-CLOSED ask — never silence, which is what once let
* `rm -r x; echo "$HOME"` walk past a deny on `rm -r` because one expansion took the whole line out of
* the reader. Registered in docs/CLOSED-SETS.md.
*/
export declare const PROGRAM_RUN_REACHES: readonly ["reached", "not_reached", "unreadable"];
export type ProgramRunReach = (typeof PROGRAM_RUN_REACHES)[number];
/** The answer of {@link programRunReachOf}: the word, and for `unreadable` the reason a surface can show. */
export type ProgramRunReachOutcome = {
readonly reach: "reached" | "not_reached";
} | {
readonly reach: "unreadable";
readonly reason: string;
};
/**
* Does one TIGHTENING (deny/ask) rule speak about this call — as a whole, or about any single program
* run in it — or can the call not be read against it at all? The ONE reach predicate of the tightening
* direction, shared by the org partition, the personal deny/ask rows and the rule DSL's content-form
* deny/ask rules, so no two of them can disagree about what a deny reaches.
*
* The allow lane asks a different question and gets a different answer, deliberately. An allow rule is
* one recorded human yes and must cover the WHOLE call to clear it ({@link ruleAdmitsCommand}:
* `Bash(npm test)` does not clear `npm test && curl evil.example`, because nobody said yes to the second
* half), and it refuses every shell construct outright. A deny/ask rule is a standing statement about a
* program run, and a call that MAY perform that run is a call the rule speaks about — so this direction
* reads the command with a real lexer ({@link readShellCommand}): redirections are stripped at the
* token level, quotes are removed on BOTH sides before words are compared (`rm "-r" x` runs `rm -r x`),
* wrappers are peeled (`sudo env X=1 nohup rm -r x` runs `rm -r x`), every connector — `&` and a newline
* included — bounds a segment, and a comment is not a run. Deliberately an OVER-approximation, in the
* tightening direction: the segments are the runs the text can reach, not the runs that will happen, so
* a short-circuited branch (`true || curl evil.example`) is judged as though it ran. Answering "does this
* definitely execute" needs evaluation, not parsing; over-firing costs an approval prompt, and the
* opposite error costs the rule.
*
* Words are compared ARGV-wise, position by position, against the rule body read by the same lexer —
* over every run CANDIDATE a segment carries: as spelled, then each peeled layer (`sudo rm -r x` is a run
* of `sudo …` AND of `rm -r x`; a deny on either reaches; a rule body is never peeled, so
* `Bash(sudo rm -r x)` denies exactly that). A literal word that differs settles `not_reached`; a word
* carrying an expansion at a position the rule reads settles `unreadable` (the text could spell the
* rule's word there — unless it is the SAME glob/brace spelling the body wrote); a prefix body whose
* every word is matched is `reached` whatever follows; an exact body needs equal length — a longer
* command is `not_reached` unless an extra word is an UNQUOTED expansion (or `"$@"` / an array
* expansion), which may expand to nothing. The commands inside a substitution and an unquoted heredoc
* body are segments of their own (`echo "$(rm -r x)"` runs `rm`). A compound
* body (`Bash(echo ok && rm -r:*)`) reaches only a chain of the same shape — same segment count, same
* connectors, every leading segment equal, the final one by the body's match kind; a single-segment
* body reaches any segment. A segment the lexer could not read makes the answer `unreadable` when no
* other segment already reached.
*
* A rule body the lexer cannot read (foreign store bytes; every entrance validates against exactly
* this) reaches nothing. A rule of another family (path: `subpath` / `path`; name: `name` / `name_prefix`) reaches no
* command: its predicates are {@link directoryRuleAdmits}, {@link pathRuleReachOf} and {@link nameRuleAdmits}.
*/
export declare function programRunReachOf(rule: Pick<PersistedRule, "match" | "command">, command: string): ProgramRunReachOutcome;
/** The boolean face of {@link programRunReachOf}: `reached` only. The shadow view and the pins that
* assert a reach read this; a lane that must fail closed on `unreadable` reads the outcome. */
export declare function ruleReachesProgramRun(rule: Pick<PersistedRule, "match" | "command">, command: string): boolean;
/** design/382 §3.3-5 — one BREADTH note about a rule a person is about to persist: which wide shape
* it has, said in words a surface can show beside the input line. A warning is never a refusal —
* the closed refusal set is untouched — and never adjudication input. */
export interface EditedRuleBreadthWarning {
readonly code: "broad_prefix" | "compound_prefix";
/** The baseline sentence (a surface may reword it, never invert it — the same contract as the
* §3.5 reason baselines). */
readonly message: string;
}
/**
* design/382 §3.3-5 — the breadth classifier behind the card-edit face's warning seat: does this
* (accepted) rule carry a width worth saying out loud before a person persists it?
*
* · `broad_prefix` — a prefix rule whose body is a SINGLE token (a bare verb, `Bash(mkdir:*)`):
* it admits every argument form of that command, not just the one on the card. This is the
* legal wide grant of §3.4 — the offer face deliberately never proposes it, the edit /
* hand-written channels legally accept it, and this sentence is what makes that acceptance an
* INFORMED one.
* · `compound_prefix` — a prefix rule whose body is a connector CHAIN (`Bash(cd /tmp && npm:*)`):
* one rule admitting several program runs, with anything extending its final segment.
*
* Judged on the CANONICAL rule (post-normalization, the spelling that would persist), with the
* lane's own instruments — the same shape reader the matcher uses, never a second grammar. A body
* this lane cannot read yields no warning: every such spelling is already the validator's refusal,
* and a warning beside a refusal would be noise about a rule that is not going to exist.
*
* An EXACT rule never warns (it admits one string, the one it spells — there is no width to name),
* and a SUBPATH rule is not this classifier's subject (no command reach at all). The two
* reach-bearing forms take the same two questions: `prefix` (colon-star) and `wildcard`
* (trailing space-star, #510 — match-equivalent reach on a single-command body; its grammar has no
* chain reading, so only the single-token question can fire for it).
* MAINTENANCE DUTY, stated where it binds: every match form with PREFIX REACH belongs here — a
* new reach-bearing member added to {@link PersistedRuleMatch} owes this classifier its arm, judged
* by the same "single token / chain body" questions (an exact-only form never warns).
* The upstream editor validates edited text but shows no breadth evidence at all; this seat is a
* superset disclosure (registered divergence, design/382 §6-4).
*/
export declare function ruleBreadthWarningsOf(rule: Pick<PersistedRule, "match" | "command">): readonly EditedRuleBreadthWarning[];
/**
* Is `path` inside (or equal to) `root`? Word-boundary containment on the path separator, so `/a` does
* not contain `/ab`. Both sides are expected to be canonical already.
*/
export declare function pathWithinRoot(path: string, root: string): boolean;
/**
* The LEXICAL NORMALIZER for a CALL's target path (design/382 §2.2 — the rule family's one path
* identity is the lexical normal form, zero IO): collapse slash runs and `.` segments, resolve `..`
* segments textually (a `..` at the root stays at the root, as POSIX resolves it), and answer the
* one-leading-slash absolute spelling — or `undefined` for a relative path. RULE
* bodies never come through here (their canonical spelling is unique and non-normal spellings are
* refused at the validator); this is for the comparison INPUT a caller derives from a live call.
*/
export declare function lexicalNormalAbsolutePathOf(path: string, family: PathFamily | undefined, anchor?: string): PathIdentity | undefined;
/**
* design/382 §2.5 — the ONE admission predicate of the Read directory rule family, beside
* {@link ruleAdmitsCommand} as its path-lane sibling. Four consumers share it (the minting round
* trip, the coverage table, the gate conjunction arm, and the gate's Read lane) so there is no
* second fact source anywhere.
*
* `path` is the caller's ALREADY-RESOLVED lexical-normal absolute path — the same path identity the
* deployment's read adjudication judged the call with (for the Read tool face), or the cd resolver's
* output (for the Bash cd-segment face). This predicate NORMALIZES NOTHING: a non-normal spelling
* (a `.`/`..` segment, a doubled slash, a relative path) is refused outright, fail-closed, so a
* caller cannot smuggle a second path identity through the comparison. Containment is the
* word-boundary one ({@link pathWithinRoot}: `/a` never reaches `/ab`).
*
* What it never does, stated as loudly as what it does (§2.5): as an ALLOW it admits READS ONLY — the
* TOOL conjunct is the caller's (every consumer judges eligibility with the call's tool, and
* {@link adjudicatePersistedPathRules} takes the allow arm for the Read tool alone), so a directory allow
* never admits a write, never a whole Bash command, never a non-cd segment — and it clears ASKS only,
* never a filesystem fence: workspace containment, the sensitive-read deny face and every violation
* refusal judge exactly as they would without the rule (a fence refusal is structural, not an ask, so
* there is nothing here for it to clear). As a DENY/ASK the same containment refuses or questions every
* path under the directory, on whichever path tool the rule names.
*/
export declare function directoryRuleAdmits(rule: Pick<PersistedRule, "match" | "command">, path: PathIdentity, bases: PathRuleBases, treeFamily?: PathFamily | undefined): boolean;
/** The identity of a directory SPELLING (the cd resolver's `/`-rooted output) under the call's tree, for the
* consumers that hand {@link directoryRuleAdmits} a spelling; a rule minted from it stores this form. */
export declare function directoryIdentityOf(spelling: string, bases: PathRuleBases): PathIdentity | undefined;
/** Does a rule's scope cover a task running in `cwd` (and, for the session dimension, in the session
* named by `sessionId`)? A project rule needs a cwd to compare against; without one it covers nothing
* (fail-closed). A session rule (design/382 §4.3) covers a call iff the call's `sessionId` equals the
* scope's — and a call that carries NO session identity is covered by no session rule, the same
* fail-closed arm as project-without-cwd: an axis the caller did not thread is an axis the rule
* cannot speak on. `sessionId` is threaded from the ORIGINAL call context by the caller (the 375
* r3-② cwd posture), never inferred from the process. ONE function on purpose: this is the scope
* conjunct of the single eligibility predicate ({@link eligibleContext}), and a sister predicate
* would be the second fact source the 375 §5.1 discipline exists to forbid. */
export declare function scopeCoversCwd(scope: RuleScope, cwd: string | undefined, sessionId?: string, identity?: ScopeRootIdentity): boolean;
/**
* A leg's root as an IDENTITY for project coverage: its canonical spelling, and the leg's resolution of a project row's
* scope root to the location it leads to. A DENY or ASK project row written under one spelling of a directory (a link the
* host spelled, a firmlink) covers a leg rooted at another spelling of the same directory: the row applies by where the two
* roots ARE, not only by how they are spelled. An allow row never reads it ({@link spelledScope}). A caller that holds no
* resolution (no leg, no environment) compares the spellings alone.
*/
export interface ScopeRootIdentity {
readonly canonicalRoot: string;
readonly canonicalOf: (root: string) => string | undefined;
}
/** Is this rule live — i.e. does it still carry at least one add? Deleted adds are removed by the store
* when tombstones are applied, so a rule with an empty `adds` is a rule that no longer exists. */
export declare function isRuleLive(rule: PersistedRule): boolean;
/**
* design/375 §5.1 — MAY this rule speak about this call's CONTEXT? Tool identity plus scope coverage,
* and nothing about the rule's liveness: the CONTEXT question is the half a not-yet-persisted rule (a
* card's proposed candidate, which has no add dots yet) can already answer.
*
* The tool conjunct is not decoration: more than one tool takes a shell command string, and matching on
* the command alone would let an approval given on one surface resolve a call on another. The scope
* conjunct is the project fence — project A's approval is not project B's, and a project rule with no
* cwd to compare against covers nothing (fail-closed, {@link scopeCoversCwd}).
*
* `scope` rides beside the rule rather than inside it for the PROPOSED half's sake: a
* {@link ParsedRule} carries no scope, so a proposal is judged against the scope it would LAND in.
*
* design/382 §4.3 — the call context carries a THIRD axis, `sessionId` (optional: an existing caller
* that threads only `{tool, cwd}` still compiles and still means what it meant — no session rule can
* cover its calls, the fail-closed direction). It is the session dimension's whole eligibility story:
* every consumer of this ONE predicate (the gate's conjunction arm, the coverage table, the consent
* prepare) inherits it without a second judge existing anywhere.
*/
export declare function eligibleContext(rule: {
tool: string;
scope: RuleScope;
}, call: {
tool: string;
cwd: string | undefined;
sessionId?: string;
scopeIdentity?: ScopeRootIdentity;
}): boolean;
/**
* design/375 §5.1 — MAY this PERSISTED rule participate in admitting this call at all? The liveness
* layer on top of {@link eligibleContext}: a tombstoned rule (zero surviving adds) is a rule that no
* longer exists and must not count as coverage anywhere.
*
* THE single eligibility predicate for every consumer that reads the persisted store — the gate's
* conjunction arm and its coverage-set selection here, the suggestion face's coverage judgement and the
* consent prepare's ({@link segmentCoverageOf} is the shared instrument). One predicate, so a rule that
* one consumer refuses cannot be a rule another counts: the historically inlined trio of checks
* (live / tool / scope) is this function, verbatim — plus the BEHAVIOR conjunct: a mixed table holds
* allow, deny and ask rows, and a consumer asking "which allow rules cover this" must not be answered
* by a deny row that happens to spell the same text. The behavior is a REQUIRED argument for the same
* reason the parser's is: a defaulted allow here is a silent widening.
*/
export declare function eligiblePersisted(rule: PersistedRule, call: {
tool: string;
cwd: string | undefined;
sessionId?: string;
scopeIdentity?: ScopeRootIdentity;
}, behavior: RuleBehavior): boolean;
/**
* The call context an ALLOW row's scope is judged in: the spellings alone. A deny or ask row applies wherever its project's
* directory is (its scope root resolved where it leads — more rows, only ever more protection); an allow row applies only
* where its scope root is SPELLED, so a link someone can re-point never carries a standing approval to another directory.
*/
export declare function spelledScope<C extends {
scopeIdentity?: ScopeRootIdentity;
}>(call: C): Omit<C, "scopeIdentity">;
/**
* Find the rules that admit this command for a task in `cwd`, or `undefined`.
*
* THE ANSWER IS A COVERAGE SET (design/375 §5.1, the upstream per-segment form): a single rule that
* admits the whole command is a one-element set — the historical arm, table order preserved verbatim —
* and a COMPOUND whose every segment is admitted by some eligible rule is covered by the CONJUNCTION of
* those rules, returned as the set that covered it. The judging ladder, in order:
* 1. the fail-closed floor (a shape the lane cannot read matches nothing), evaluated once;
* 2. the single-rule arm — whole-string exact, single/compound-prefix — exactly the historical loop:
* first table-order match wins and is what the decision names. A single rule naming the command is
* found BEFORE any conjunction: reporting one rule is narrower than reporting a set;
* 3. the conjunction arm, compounds only: every segment must be admitted by some eligible rule under
* the MATCH reading (the allow side's own under-approximating instrument — never the deny side's
* wider PROGRAM_RUNS reading), or be a read-only segment that allows itself ({@link readsOnlyAsSegment});
* at least one rule must speak. Per segment the FIRST table-order admitting rule is selected, and
* the coverage set is those selections in segment order, de-duplicated — the same command against
* the same rule table always names the same set (reporting-order determinism, the historical
* "order is defined only for reporting" contract extended to sets).
*
* What the conjunction arm deliberately does NOT change: {@link ruleAdmitsCommand} itself. A
* single-command prefix body still never admits a chain — `Bash(npm:*)` does not admit
* `npm test && curl evil.example` — but the chain is now admitted when EVERY segment has a standing
* yes, which is the upstream decision form (per-segment allow, any-segment deny decided strictly
* earlier at the gate's org layer). A trailing-terminator spelling (`a && b ;`) keeps the conservative
* answer by construction: under the MATCH reading its tail is an empty segment the floor refuses, so
* the whole command is outside the lane and asks.
*
* Order is defined only for reporting (what the decision names); no rule is stronger than another —
* they are all the same one answer, "the person already said yes to this".
*
* ALLOW rows only: this is the allow direction's question, and a deny/ask row in the same table is not
* coverage (the eligibility predicate's behavior conjunct). The mixed-table walk that orders the three
* behaviors is {@link adjudicatePersistedRules}.
*/
export declare function findAdmittingRule(rules: readonly PersistedRule[], call: AdmissionCall): readonly PersistedRule[] | undefined;
/** Whose admission a list's allow rows answer: a GRANT (a person's recorded yes, a list that releases a call — the reference's
* per-segment form, where a read-only segment of a compound allows itself) or a NARROWING (a list outside of which nothing
* runs — every segment must be named by a row; nothing allows itself). The posture is the list's, never the call's. */
export type AdmissionPosture = "grant" | "narrowing";
/** The call {@link findAdmittingRule} / {@link commandCoverageOf} judge. */
export interface AdmissionCall {
tool: string;
command: string;
cwd: string | undefined;
sessionId?: string;
/** The directory RELATIVE cd targets resolve against — the live TRACKED working directory of the
* execution seat (adversarial-review P1: after an earlier observable `cd`, the shell resolves
* `./x` against the moved cursor, not the task root). Defaults to `cwd`; the scope/eligibility
* axis stays `cwd` either way. */
execCwd?: string;
/** {@link AdmissionPosture}; absent = `grant`. */
posture?: AdmissionPosture;
}
/** {@link findAdmittingRule}'s answer with its miss named: the rules that cover the command, or the first segment no row covers
* (`undefined` when the command cannot be read at all — the fail-closed floor). One walk; the miss is for a sentence only. */
export declare function commandCoverageOf(rules: readonly PersistedRule[], call: AdmissionCall): {
readonly covering: readonly PersistedRule[];
} | {
readonly uncovered: string | undefined;
};
/** The answer of {@link adjudicatePersistedRules}: which behavior decided, and the rule(s) it stands
* on — a one-element set for a deny/ask (the first table-order rule of the deciding behavior that
* reaches the call; for a keyed tool's call naming no value for its key, a DENY standing on the first deny — else ask —
* row of that key, which the call cannot be read against: {@link adjudicatePersistedNameRules}) and the allow
* direction's coverage SET ({@link findAdmittingRule}). */
export interface PersistedRuleVerdict {
readonly behavior: RuleBehavior;
readonly rules: readonly PersistedRule[];
/** The call could not be READ against the person's deny/ask rows (see {@link programRunReachOf}):
* `behavior` is `ask`, `rules` names the first tightening row that could not be judged, and this is
* the lexer's reason. The lane projects it as an UNREADABLE answer carrying that reason (it fails closed)
* — never as a matched ask rule. */
readonly unreadable?: string;
/** A path DENY's coordinates: the view of the target the reaching row met and the face the call is on. Minted here,
* with the verdict; the gate's exits mint the refusal's card from it and judge nothing a second time. */
readonly denial?: PathDenialEvidence;
}
/**
* The ONE adjudication over a MIXED persisted-rule table for a shell call: walk the behaviors in
* {@link RULE_BEHAVIOR_PRECEDENCE} order and answer with the first behavior whose eligible rules speak
* for the call — deny before ask before allow, the upstream arbiter's order across its three lists.
*
* Each behavior is judged with ITS direction's instrument: a deny/ask row through
* {@link programRunReachOf} (the wide tightening reading: whole command or any program run in it,
* three-valued), an allow through {@link findAdmittingRule} (the narrow reading: the whole call, or
* every segment of it, covered by recorded yeses). The asymmetry is the point and runs one way only: a
* deny reads MORE of a command than an allow does, so an exact allow naming a whole chain can never
* outrun a deny on one of its parts. A call the lexer cannot read against a standing deny/ask row is an
* UNREADABLE verdict (`behavior: "ask"` + `unreadable`), returned before any allow is consulted: the
* question "does one of my rules forbid this?" is unanswered, and a recorded yes must not answer it.
* Eligibility (live, this tool, scope covers the call, THIS behavior) is the one predicate every
* consumer shares. `undefined` ⇒ no rule of any behavior speaks.
*/
export declare function adjudicatePersistedRules(rules: readonly PersistedRule[], call: {
tool: string;
command: string;
cwd: string | undefined;
sessionId?: string;
execCwd?: string;
scopeIdentity?: ScopeRootIdentity;
}): PersistedRuleVerdict | undefined;
/**
* The path-family sibling of {@link adjudicatePersistedRules}: the same precedence walk over the rules
* of one path-targeting tool, judged on the call's target views by the one judgment every path rule meets
* (`judgePathRows`: rows outer, views inner).
*
* TWO readings, because the two directions read the call's path slot differently (design/388 B6/B17): the
* tightening arms judge the PROTECTIVE reading's views (`views.tighten` — the object face's declaration, the catalog's
* for the name as the floor: reading the catalog for an undeclared same-name tool can only tighten a deny), every row
* also where its head leads (`leads`, the judging leg's resolution); the allow arm judges the DECLARED reading's views
* (`views.allow` — the face's OWN declaration only: a same-name tool that declares no path target is unconfirmable, and
* no grant admits it) — the NARROW reading: a row speaks for a call on a face its {@link PATH_RULE_FACES} allow column
* holds, and admits it only when it admits EVERY view (a link out of the named place is not admitted through its
* spelling, a case twin is another spelling, no row is judged where its head leads, an unreadable view is admitted by
* nothing). Either reading may be absent (no resolvable path in that reading); the arm that needs it then speaks for nothing.
*
* A row is anchored by `anchorsOf` (the person's rows: a project row at its scope root, the rest at the chain's top leg).
*
* `call.face` is the face the call's protective target is on (`read` / `write`): a tightening row (deny or ask) speaks for
* the call when it is the call's tool's row, or when {@link PATH_RULE_FACES} puts that face in the row's column of THAT
* behavior (a `Read(<path>)` deny row refuses, a `Read(<path>)` ask row asks for, a Grep / Glob / RepoMap / caller's reader
* searching or reading under the path) — the one sentence the rule policy's lane speaks too. Both directions judge the call's
* own target here; the run's deny tables are a different job (a search's results, a shell command's operands, the
* execution-time re-judgment).
*/
export declare function adjudicatePersistedPathRules(rules: readonly PersistedRule[], call: {
tool: string;
cwd: string | undefined;
sessionId?: string;
face?: DenyFace | undefined;
scopeIdentity?: ScopeRootIdentity;
}, views: {
readonly tighten?: TargetViews;
readonly allow?: TargetViews;
}, anchorsOf: (rule: PersistedRule) => PathRuleBases, leads: ReadonlyMap<string, string | undefined>): PersistedRuleVerdict | undefined;
/**
* The name-family sibling of {@link adjudicatePersistedRules}: the same precedence walk over the rules of one name tool
* ({@link NAME_RULE_FACES}), judged on the one string the call names (`nameRuleSubjectOf`). A rule of the tool speaks for the call
* when it is live, of this behavior, in scope ({@link eligiblePersisted}, the one predicate) and {@link nameRuleAdmits} the name; the
* first behavior in {@link RULE_BEHAVIORS_BY_PRECEDENCE} whose rules speak decides, the first table-order rule of it is the answer.
* Every behavior is judged with the one predicate — a name has no "program run" the tightening direction could read wider.
* A call that names nothing (read as the tool reads it): on a bare-spelling tool it is reached by no rule; on a KEYED tool it
* cannot be read against the key's rules ({@link keyedValueAbsentReason}) and is REFUSED — a deny verdict standing on the
* first eligible deny row of the key, else its first ask row (an ask a person could approve would let a deny be approved
* past) — and no allow row reaches it.
*/
export declare function adjudicatePersistedNameRules(rules: readonly PersistedRule[], call: {
tool: string;
name: string | undefined;
cwd: string | undefined;
sessionId?: string;
scopeIdentity?: ScopeRootIdentity;
}): PersistedRuleVerdict | undefined;
/**
* The domain-family sibling of {@link adjudicatePersistedNameRules}: the rules of one domain tool ({@link DOMAIN_RULE_FACES})
* judged on the host the call fetches ({@link domainRuleSubjectOf}) by {@link domainRuleAdmits}; the first table-order rule
* that is eligible and admits the host is the answer. The grammar has an allow form only, so only an allow can speak.
*/
export declare function adjudicatePersistedDomainRules(rules: readonly PersistedRule[], call: {
tool: string;
host: string | undefined;
cwd: string | undefined;
sessionId?: string;
scopeIdentity?: ScopeRootIdentity;
}): PersistedRuleVerdict | undefined;
/**
* The ONE offer a name tool's approval card carries: the exact rule for the name the call carries (`Skill(<name>)`, the reference's
* "don't ask again for <skill>" row), or undefined when no rule spells exactly that name (the round-trip law: the canonical text,
* parsed back as an allow, must be the exact name rule over the very name — a leading slash, an interior star or an unreadable
* spelling offers nothing rather than a rule that would not clear the ask it was minted on). The reference's second row, the first
* word as a prefix, is offered only for a name containing a space; it is not minted here (it would widen past the name shown).
*/
export declare function suggestRuleForName(tool: string, name: string): Extract<RuleOffer, {
kind: "single";
}> | undefined;
/**
* design/375 §5.2 — the per-segment coverage table for `command`: which segments an eligible rule
* already admits, judged with the SAME split and the same eligibility predicates as the gate's
* conjunction arm — one instrument, so the suggestion face and the consent prepare can never call a
* segment covered that the gate would ask about (or vice versa).
*
* The split is the OFFER reading ({@link OFFER_READING}, #490 修①), which is the MATCH reading on
* every axis but where a redirection is refused — this table exists to align, row for row, with
* {@link suggestRulesForCommand}'s own segmentation, and a table that stopped one command short of the
* face it aligns with is a table that face discards (its positional belt), leaving every segment
* reading UNCOVERED. Each ROW's verdict is still the matcher's, unchanged: `covered` is
* {@link ruleAdmitsCommand} under the MATCH reading, so a segment carrying a redirection is admitted
* by nothing and reports `false`. The gate's own conjunction arm keeps the whole-string floor and is
* not consulted here: for a redirection-bearing compound it has no segments at all, and this table
* saying "an eligible rule admits `npm test`" is a true statement about that segment either way — it
* is read only to keep an already-covered segment out of a batch offer, never as an admission.
*
* TWO SOURCES, two predicates, and the seat labels are the contract:
* · `persisted` — rules already in the store, judged {@link eligiblePersisted} ∧ admit: a tombstoned
* rule, another project's rule and another tool's rule are uniformly NOT coverage;
* · `proposed` — rules of the CURRENT batch that are not yet persisted (no add dots yet), judged
* {@link eligibleContext} ∧ admit against the scope each would land in. Judging these with the
* persisted predicate would make "this batch, once redeemed, covers the command" permanently false
* — the liveness layer is the one thing a proposal legitimately lacks.
*
* Returns `undefined` for a command the lane cannot read at all (the fail-closed floor — the caller's
* honest answer is then "no coverage table", which every consumer must treat as all-uncovered). The
* reported `segment` texts are the FOLDED spellings, trimmed — display/correlation seats, never
* adjudication inputs.
*/
export declare function segmentCoverageOf(command: string, rules: {
persisted: readonly PersistedRule[];
proposed?: readonly {
rule: ParsedRule;
scope: RuleScope;
}[];
}, call: {
tool: string;
cwd: string | undefined;
sessionId?: string;
/** The relative-cd resolution base (see {@link findAdmittingRule}'s member of the same name) —
* defaults to `cwd`; scope/eligibility stays `cwd`. */
execCwd?: string;
/** The leg's root as an identity for project coverage ({@link ScopeRootIdentity}). */
scopeIdentity?: ScopeRootIdentity;
}): readonly SegmentCoverage[] | undefined;
/** One row of a per-segment coverage table (see {@link segmentCoverageOf}): the FOLDED, trimmed
* segment text (a display/correlation seat, never adjudication input) and whether an eligible rule
* already admits it. */
export interface SegmentCoverage {
readonly segment: string;
readonly covered: boolean;
}
/** One member of a {@link RuleOffer} batch: a rule the engine offers to mint for ONE segment of a
* compound command. `segment` is the FOLDED segment text the member was minted from — a rendering
* seat, never adjudication input. */
export interface SegmentRuleSuggestion {
/** The canonical rule text the host would redeem. */
readonly rule: string;
readonly match: PersistedRuleMatch;
/** The command pattern in the rule, for a host that wants to render it without re-parsing. */
readonly command: string;
/** The (folded) segment this suggestion came from. Raw post-rewrite command bytes — a substring
* of the same call's `args`/`command` values riding beside it on every surface that carries
* this seat — so render it with contextual escaping, exactly like those.
*
* RAW is the contract here and does not change: this seat carries the bytes, because the bytes are
* what a rule would be minted from and what a correlation keys on. The rendering side of that
* contract is {@link renderUntrustedCommandText} — the shipped display baseline (bidi and
* zero-width controls removed, controls/line separators folded, length capped, envelope-neutralized)
* a surface can adopt instead of writing its own, since raw command bytes displayed unprocessed can
* show a reader something other than what they authorize. */
readonly segment: string;
}
/**
* design/382 §2.3 (B3, BREAKING) — one member of a batch offer, a DISCRIMINATED union keyed by
* `kind`:
*
* · `kind: "command"` — the historical shape: a per-segment Bash rule
* ({@link SegmentRuleSuggestion}'s four seats, plus the discriminant).
* · `kind: "directoryRead"` — a DIRECTORY READ AUTHORIZATION minted from a cd segment (the §2.4
* floor): `rule` is the canonical `Read(//dir/**)` text, `directory` the lexical-normal absolute
* directory (a render seat, spared a re-parse), `segment` the folded cd segment it came from (a
* rendering-context seat like its command sibling's — never adjudication input).
*
* CONSUMER DEGRADE ARM (normative, design/382 §2.3): a batch member whose `kind` is outside this set
* drops the WHOLE batch offer — never the single member (a conjunction batch silently one member
* short renders "yes to N" as "yes to N−1", worse than not rendering), and never the whole card (the
* exact single beside it is intact and honest). Selection indices stay keyed on the ORIGINAL wire
* offer order.
*/
export type RuleOfferBatchMember = (SegmentRuleSuggestion & {
readonly kind: "command";
}) | {
readonly kind: "directoryRead";
/** The canonical rule text the host would redeem — `Read(//a/b/**)`. */
readonly rule: string;
/** The lexical-normal absolute directory the rule names — a render seat, no re-parse needed. */
readonly directory: string;
/** The (folded) cd segment this authorization came from — rendering context, never adjudication input. */
readonly segment: string;
};
/**
* design/375 §3.1 — ONE "don't ask again" option on an approval card. A CLOSED discriminated union
* (`kind`); consumers parse it per element, and an element whose `kind` they do not know is dropped
* (single-row degrade) while keeping the ORIGINAL wire index for every element they keep — selection
* and redemption are keyed on original indices, so a consumer that cannot preserve the mapping must
* suppress its persistence actions entirely (fail toward asking).
*
* · `kind: "single"` — one rule covering the WHOLE command (the exact spelling, or a broader
* reviewed prefix form for a simple command).
* · `kind: "batch"` — a CONJUNCTION batch of per-segment rules for a compound command. Choosing it
* is one yes to ALL of `rules` at once — there is no per-member selection inside a batch; a
* consumer that wants a narrower answer picks the single offer or approves once without a rule.
* `rules` has 1..5 members (the card-lane minting cap), in segment order, deduplicated by rule
* text, each a {@link RuleOfferBatchMember} (design/382 B3: the member form is a discriminated
* union — a cd segment of a project-scope card mints a `directoryRead` member, one accept lands
* commands and directory authorization together, the upstream single-line merged-consent form).
* `uncoveredSegments` is the honest surplus disclosure of the cap: judged at MINT time
* against the coverage snapshot the card was drawn from ∪ this batch's FINAL (deduplicated,
* capped) rules, it counts the violating segments still admitted by neither — 0 means "once this
* batch is redeemed, this compound is fully covered by that snapshot's lights". It is a statement
* about the minting snapshot, not a standing guarantee: a concurrent rule removal can date it.
*
* Array contract (`ruleOffers`, the durable park row, and the consent record's projection while
* the card is PENDING all share it): at most 2 offers; the whole-string exact single is index 0
* whenever present; the batch offer — at most one — is always last. It is a draw-time contract,
* not a settled-record invariant: confirming an EDITED candidate appends a third single offer
* AFTER the batch in the same write that settles the record, so a SETTLED record's projection may
* carry it. ADVISORY display metadata, never adjudication input, and never a
* rule by itself: minting is a separate act that goes through the approval-record protocol, so a
* surface that ignores offers entirely changes nothing about what is allowed. The authoritative
* render source for a card is the PREPARED record's own offers (record-is-what-you-see); the ask-time
* projection here may drift from it across a coverage change, and the drift direction is harmless
* (the record is what gets confirmed).
*/
export type RuleOffer = {
readonly kind: "single";
/** The canonical rule text the host would redeem. */
readonly rule: string;
readonly match: PersistedRuleMatch;
/** The command pattern in the rule, for a host that wants to render it without re-parsing. */
readonly command: string;
} | {
readonly kind: "batch";
/** 1..5 per-segment members, segment order, deduplicated by rule text (design/382 B3: the
* member form is the {@link RuleOfferBatchMember} discriminated union). */
readonly rules: readonly RuleOfferBatchMember[];
/** Mint-time honest surplus: violating segments admitted by neither the coverage snapshot nor
* this batch's final rules (see the union doc above for the exact arithmetic). */
readonly uncoveredSegments: number;
/**
* design/382 §3.5 — WHY each of those segments stays uncovered, one row per counted segment,
* segment order (same text repeated = two rows; the correspondence is positional, never a raw
* text dedup). Minted in the SAME pass as `uncoveredSegments` off the same snapshot, so
* `uncoveredDetail.length === uncoveredSegments` whenever the seat is present; the count seat
* stays the truth source, and ABSENCE (an older minter, a degraded consumer's re-emit) is not
* a claim. `segment` is the folded, trimmed segment text — a display/correlation seat carrying
* raw command bytes (render like its siblings, {@link renderUntrustedCommandText}); `reason`
* is the closed set {@link UncoveredSegmentDetail} documents. Additive on the wire (an unknown
* key inside a known kind — old consumers read the batch exactly as before); on the CONSENT
* RECORD's card batch the seat is REQUIRED (schema: 3) — see the record's own contract.
*/
readonly uncoveredDetail?: readonly UncoveredSegmentDetail[];
};
/** design/382 §3.5 — the closed reason set for one uncovered segment of a batch offer.
*
* · `"redirection"` — the segment carries a shell redirection: refused by discipline (#490 修①,
* §3.2), PERMANENTLY per-ask — no rule text can cover this spelling; the honest outlet is a rule
* for its redirection-free form, if the person trusts that form.
* · `"no_rule_form"` — neither the segment's prefix nor its exact form survives the mint round
* trip (a glob argument, an over-length line): nothing coverable exists for these bytes, and
* guidance deliberately does NOT point at hand-writing a rule — the same grammar refuses the
* same text on every door.
* · `"cap_overflow"` — the segment minted a rule, but the batch's dedup+cap left it out and no
* surviving member admits it: covered next time it comes up, asked about until then.
*
* These are WIRE-STRUCTURED facts, and that is the whole §3.6 point (recorded here so the design's
* half-radius is visible in the code): the reader is not only a person. A model or harness that
* keeps meeting `redirection` on a spelling it retries can re-run the redirection-free form —
* which HAS a coverable rule — with zero new mechanism on this side; a structured honest refusal
* is programmable, exactly like a loud bad-value error. The baseline person-facing sentences are
* {@link UNCOVERED_SEGMENT_REASON_BASELINE}. */
export interface UncoveredSegmentDetail {
/** The folded, trimmed segment text — raw post-rewrite command bytes, same contract as
* {@link SegmentRuleSuggestion.segment}. */
readonly segment: string;
readonly reason: "redirection" | "no_rule_form" | "cap_overflow";
}
/**
* design/382 §3.5-2 — the BASELINE "why + outlet" sentence per uncovered-segment reason: the
* one-source copy a surface renders beside the §3.5 detail rows. A surface may reword these, never
* invert them (the same contract as {@link renderUntrustedCommandText}: adopt the baseline, or own
* an equivalent-strength rewrite). Load-bearing asymmetries, deliberate:
* · `redirection` points at the redirection-FREE form's outlets (that form is coverable);
* · `no_rule_form` points at NOTHING but per-use approval — hand-writing is not suggested,
* because the same grammar refuses the same text on every door, and pointing a person at a rule
* that can never take effect is the exact defect this surface exists to remove;
* · `cap_overflow` promises only honesty: not covered by this batch, asked again when met.
*/
export declare const UNCOVERED_SEGMENT_REASON_BASELINE: Readonly<Record<UncoveredSegmentDetail["reason"], string>>;
/**
* design/382 §3.5-2 — the BASELINE sentence per card-level offer-ABSENCE reason (`ruleOffersAbsence` on the ask and its
* durable park twin; the closed set is {@link import("./ask-origin.js").RULE_OFFERS_ABSENCES}). Same reword-never-invert
* contract as the table above. The load-bearing asymmetries: `mandated` and `allow_outranked` must NOT point at writing
* rules — on a mandated ask no rule is consulted, an outranked allow shows another allow would be outranked the same way, and
* directing a person to mint one that can never take effect is the #457④ anti-shape; the other two reasons name their real
* outlets.
*/
export declare const RULE_OFFERS_ABSENCE_BASELINE: {
readonly mandated: "This approval is required by the deployment's policy each time; a rule cannot waive it.";
readonly allow_outranked: "An allow rule already matches this call, and this question outranks it — another allow rule would be outranked the same way; it is approved per use.";
readonly ask_rule_matched: "An ask-each-time rule matches this call and keeps asking — a new allow rule changes nothing while it stands; to stop being asked, review the existing ask rules.";
readonly lane_cannot_speak: "This card has no rule to offer for this exact command; it can be approved per use. A coverable form of what you trust can still get a rule — through a card drawn for that form, or settings.";
};
/** The fence over the baseline table: `never` while every word has a sentence. */
export type OfferAbsenceBaselineCoversEveryWord = AssertAllKeysHandled<Exclude<RuleOffersAbsence, keyof typeof RULE_OFFERS_ABSENCE_BASELINE>>;
/**
* The offers presented on an approval card for `command` (design/375 §5.2).
*
* **Array order is a documented CONTRACT, not an implementation accident** (see {@link RuleOffer}):
* the whole-string EXACT single is index 0 whenever present; a SIMPLE command may carry a broader
* reviewed PREFIX single after it; a COMPOUND carries at most one BATCH offer, always last.
* Selection indices are keyed against this order, so consumers may rely on it; redemption TICKETS
* are NOT — they key on the consent record's flat CANDIDATE list, and a chosen offer is redeemed
* via `redeemRuleBatch` (see the consent record's own doc for the two index spaces).
*
* The prefix single is the LONGEST body two sources agree to offer: {@link SUGGESTION_LEXICON}'s
* deeper-than-two-words rows first, and otherwise {@link genericPrefixBody}'s general two-word form
* (`argv[0]` + a token shaped like a subcommand). Read those two for the grammar, its adopted origin,
* and the operand false positive it accepts; the short version is that a bare verb (`git`), an
* interpreter or wrapper head (`sudo apt`, `node build.js`), a flag in second position
* (`git -C /repo status`) and a quoted or dotted token all still yield NO prefix. Naive spacing note:
* `folded` keeps quoted whitespace, so splitting on single spaces can shear a quoted segment —
* harmless in this direction, because the sheared pieces carry quote characters and can therefore
* match neither a lexicon word nor the generic subcommand shape; every suspicious form lands on
* "no prefix".
*
* Every produced candidate must survive the round trip — parse as a rule, come back as the match form
* its seat is offering, AND admit the very text it was minted from (the whole command for a single,
* its own segment for a batch member). Enforced on EVERY seat, fail-closed: a candidate that would
* not round-trip is silently not offered, since offering an option redemption would refuse is worse
* than offering one fewer.
*
* The FORM half of that check is not decoration. A command may end in the rule grammar's own prefix
* marker — `rm :*` is a legal thing to type — and wrapping it as an exact rule produces the text
* `Bash(rm :*)`, which the validator reads back as the PREFIX rule `Bash(rm:*)` over the command `rm`.
* Trusting the exact seat's own intent and labelling that result `match: "exact"` put a blanket
* every-`rm` rule on the card under the narrowest option's name, one click from being persisted (and,
* for a lexicon command like `git status :*`, emitted twice — once mislabelled, once as the real prefix
* candidate). The seat therefore believes the PARSER about what it got back, never its own request.
*
* A COMPOUND (`./gradlew build && ./gradlew test`) fills the exact single — the offered rule spells
* the whole chain and admits exactly it — and a BATCH offer besides: every segment NOT already
* covered (per `ctx.coverage`) yields its own single-segment best form — a prefix when the
* single-command grammar grants one for that segment's tokens (the interception screens apply
* unchanged), otherwise the segment's exact — deduplicated by rule text in segment order and capped
* at FIVE (upstream's own batch cap; the overflow is disclosed via `uncoveredSegments` rather than
* silently truncated away). A segment whose every form the floor refuses simply contributes no
* member (and stays counted as uncovered — the honest gap). The COMPOUND-PREFIX candidate this seat
* used to mint here is deliberately NO LONGER OFFERED (design/375 §5.3): the batch takes its card
* position, while the compound-prefix GRAMMAR remains a fully supported rule family — the matcher,
* the hand-written/edited/import channels and every persisted such rule are untouched.
*
* `ctx.coverage` is the per-segment coverage table minted by the SAME store read that adjudicated
* the call (see {@link segmentCoverageOf} and the gate's engine-stamped seat) — positional over this
* function's own folded segments, belt-checked by text: a table whose length or segment texts do not
* line up is discarded whole, and an absent or discarded table reads as ALL-UNCOVERED (fail toward
* over-offering; every offered rule still passes the validator at redemption, so an over-offer widens
* nothing).
*
* Returns an empty array for anything the rule lane cannot speak for (substitutions, subshells,
* backgrounding, an unreadable segment) — the card then simply carries no "don't ask again" option,
* which is the honest answer.
*
* A REDIRECTION is the one member of that list judged per SEGMENT rather than over the whole string
* (#490 修①, {@link OFFER_READING}): `a && b 2>/dev/null && c` offers a batch for `a` and `c` with
* `uncoveredSegments: 1`, exactly as the same compound dirtied by a glob always has. A SIMPLE command
* carrying one still offers nothing — its only segment is the dirty one — and no offer this function
* mints ever admits the redirection-bearing text: the minting grammar and the matcher keep the
* whole-string floor, so the compound stays un-admitted and the honest `uncoveredSegments` count says
* so (design/382 §3.2: neither a sanitized rule text nor a widened matcher is on the table).
*/
export declare function suggestRulesForCommand(command: string, ctx?: {
readonly coverage?: readonly SegmentCoverage[];
/**
* design/382 §2.4-7 — the SCOPE the card's candidates would land in, the directoryRead mint
* gate's first input: a cd segment mints a directory member ONLY when this scope is a PROJECT
* or SESSION scope that also COVERS the adjudicated call (the second conjunct, through the one
* scope predicate — a scope that does not cover the deciding call would mint a member that is
* never eligible where it was minted, the offer-that-never-works shape). The SESSION arm is the
* §8-Q3 re-ruling (opened at final sign-off: the upstream session destination is exactly this
* grant's home, and a session-scope member is a pure narrowing of the project one). GLOBAL
* stays closed — a directory grant is place-and-time consent, and the everywhere spelling is
* the explicit channels' business. Absent ⇒ the gate is closed and a cd segment falls back to
* its command member (the pre-382 behaviour, byte-for-byte).
*/
readonly scope?: RuleScope;
/**
* The adjudicated call's working directory (threaded from the original call context, never the
* process — the same value the coverage read and the gate judged with). Two consumers: the
* `./`-relative cd resolution, and the scope gate's coverage conjunct. Absent ⇒ no relative cd
* resolves and a project-scope gate is closed.
*/
readonly cwd?: string;
/**
* The relative-cd RESOLUTION base — the live tracked working directory of the execution seat
* (adversarial-review P1: after an earlier observable `cd`, the shell resolves `./x` against the
* moved cursor, not the task root — a member minted off the wrong base names a directory the cd
* never enters). Defaults to `cwd`; the scope gate's coverage conjunct stays on `cwd`.
*/
readonly execCwd?: string;
/**
* The adjudicated call's SESSION identity (threaded like `cwd`, never inferred) — the session
* arm's coverage conjunct: a session-scope card mints a directory member only when its scope
* names THIS session. Absent ⇒ no session scope covers anything (fail-closed, the shared
* predicate's own arm).
*/
readonly sessionId?: string;
/**
* design/382 §2.4-6 — the deployment's sensitive-read negative control, SAME-SOURCE with its
* compiled read-deny judge (`ReadDenyMatcher.matchPath !== null` is the canonical wiring): the
* engine never PROPOSES a directory authorization for a directory this judge names. Absent ⇒
* {@link defaultDeniesDirectoryRead} stands in (refusal-only; every hands leg passes its OWN judge). Explicit-consent doors (hand-written,
* import) are deliberately NOT behind this — they have their own preview and validation.
*/
readonly deniesDirectoryRead?: (directory: string) => boolean;
}): RuleOffer[];