@sema-agent/core 7.8.0 → 7.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/CHANGELOG.md +75 -1
  2. package/dist/agents/subagent.d.ts +1 -1
  3. package/dist/core/ask-origin.d.ts +16 -1
  4. package/dist/core/ask-origin.js +5 -1
  5. package/dist/core/checkpoint-store.d.ts +36 -35
  6. package/dist/core/effective-path-target.d.ts +43 -0
  7. package/dist/core/effective-path-target.js +56 -0
  8. package/dist/core/engine-notice.d.ts +8 -0
  9. package/dist/core/fs-write-gate-policy.js +2 -1
  10. package/dist/core/gate-lanes.d.ts +1 -0
  11. package/dist/core/gate-lanes.js +60 -28
  12. package/dist/core/gate-outcome.d.ts +8 -4
  13. package/dist/core/gate-outcome.js +2 -1
  14. package/dist/core/governance-codes.d.ts +1 -1
  15. package/dist/core/governance-codes.js +2 -0
  16. package/dist/core/hooks.d.ts +66 -32
  17. package/dist/core/mcp-injection-drop.d.ts +74 -0
  18. package/dist/core/mcp-injection-drop.js +27 -0
  19. package/dist/core/permission-rule-consent.d.ts +29 -36
  20. package/dist/core/permission-rule-consent.js +110 -60
  21. package/dist/core/permission-rule-model.d.ts +311 -96
  22. package/dist/core/permission-rule-model.js +309 -50
  23. package/dist/core/permission-rule-org.d.ts +19 -12
  24. package/dist/core/permission-rule-org.js +13 -16
  25. package/dist/core/permission-rule-provider.d.ts +10 -6
  26. package/dist/core/permission-rule-provider.js +12 -8
  27. package/dist/core/permission-rule-session.d.ts +7 -6
  28. package/dist/core/permission-rule-session.js +29 -10
  29. package/dist/core/permission-rule-store.d.ts +40 -18
  30. package/dist/core/permission-rule-store.js +68 -42
  31. package/dist/core/permission-rule-sync.d.ts +9 -3
  32. package/dist/core/permission-rule-sync.js +29 -25
  33. package/dist/core/permission-rule-syntax.d.ts +30 -0
  34. package/dist/core/permission-rule-syntax.js +44 -0
  35. package/dist/core/permission-rules.d.ts +56 -33
  36. package/dist/core/permission-rules.js +88 -55
  37. package/dist/core/persisted-rule-arms.d.ts +61 -0
  38. package/dist/core/persisted-rule-arms.js +50 -0
  39. package/dist/core/runner/active-skill-scope.js +2 -1
  40. package/dist/core/runner/contracts.d.ts +21 -2
  41. package/dist/core/runner/permission-rule-lanes.d.ts +42 -27
  42. package/dist/core/runner/permission-rule-lanes.js +25 -21
  43. package/dist/core/runner/prepare-gate-stations.js +1 -1
  44. package/dist/core/runner/prepare-safety-scan.js +8 -2
  45. package/dist/core/runner/prepare-task.js +1 -1
  46. package/dist/core/runner/resume-admission.d.ts +53 -0
  47. package/dist/core/runner/resume-admission.js +83 -0
  48. package/dist/core/runner/resume-apply.d.ts +50 -0
  49. package/dist/core/runner/resume-apply.js +184 -0
  50. package/dist/core/runner/resume-checkpoint-screen.d.ts +18 -0
  51. package/dist/core/runner/resume-checkpoint-screen.js +108 -0
  52. package/dist/core/runner/resume-claim.d.ts +32 -0
  53. package/dist/core/runner/resume-claim.js +27 -0
  54. package/dist/core/runner/resume-internals-and-config.d.ts +33 -0
  55. package/dist/core/runner/resume-internals-and-config.js +50 -0
  56. package/dist/core/runner/resume-policy-outcome.d.ts +31 -0
  57. package/dist/core/runner/resume-policy-outcome.js +127 -0
  58. package/dist/core/runner/resume-preflight.d.ts +40 -0
  59. package/dist/core/runner/resume-preflight.js +122 -0
  60. package/dist/core/runner/resume-review-outcome.d.ts +30 -0
  61. package/dist/core/runner/resume-review-outcome.js +88 -0
  62. package/dist/core/runner/run-harness-handlers.js +1 -1
  63. package/dist/core/runner/runtask.d.ts +20 -0
  64. package/dist/core/runner/runtask.js +85 -734
  65. package/dist/core/runner/session-rule-policy.js +2 -1
  66. package/dist/core/runner/tool-end-body.d.ts +12 -5
  67. package/dist/core/runner/tool-end-body.js +5 -5
  68. package/dist/core/runner/tool-face-overlay.js +22 -3
  69. package/dist/core/runner/tool-output-projection.d.ts +9 -6
  70. package/dist/core/runner/tool-output-projection.js +1 -18
  71. package/dist/core/runner/turn-attachments.d.ts +2 -2
  72. package/dist/core/sensitive-path-policy.js +5 -3
  73. package/dist/core/shell-lexer.d.ts +47 -0
  74. package/dist/core/shell-lexer.js +478 -0
  75. package/dist/core/shell-scan.d.ts +60 -0
  76. package/dist/core/shell-scan.js +183 -0
  77. package/dist/core/shell-wrapper-table.d.ts +297 -0
  78. package/dist/core/shell-wrapper-table.js +58 -0
  79. package/dist/core/store-contracts/permission-rule-sync-contract.js +29 -10
  80. package/dist/core/tool-catalog-entries.js +6 -6
  81. package/dist/core/tool-face.d.ts +80 -4
  82. package/dist/core/tool-face.js +10 -0
  83. package/dist/core/tool-policy.d.ts +1 -6
  84. package/dist/core/tool-registry.d.ts +8 -11
  85. package/dist/core/tool-registry.js +5 -2
  86. package/dist/core/tool-roster.d.ts +20 -2
  87. package/dist/core/tool-roster.js +36 -8
  88. package/dist/index.d.ts +7 -4
  89. package/dist/index.js +6 -3
  90. package/dist/stores/file/checkpoint-store.js +1 -1
  91. package/dist/stores/file/permission-rule-store.d.ts +28 -23
  92. package/dist/stores/file/permission-rule-store.js +64 -16
  93. package/dist/tools/fs/bash-readonly-classifier.d.ts +1 -1
  94. package/dist/tools/fs/fs-pdf.d.ts +1 -1
  95. package/dist/tools/fs/fs-search-tools.d.ts +3 -2
  96. package/dist/tools/fs/fs-search-tools.js +17 -9
  97. package/dist/tools/fs/index.js +1 -1
  98. package/dist/tools/fs/notebook.d.ts +1 -1
  99. package/dist/tools/fs/safety.d.ts +1 -1
  100. package/dist/tools/fs/search.d.ts +0 -8
  101. package/dist/tools/fs/search.js +0 -23
  102. package/package.json +1 -1
  103. package/test/export-surface.snapshot.json +99 -11
@@ -1,13 +1,25 @@
1
1
  /**
2
- * design/179 §2 — the persisted ALLOW rule model and its matcher.
3
- *
4
- * A persisted allow rule is the standing form of ONE recorded human approval: the person confirmed a
5
- * command once on an approval card and asked not to be asked again, and the rule is what that answer
6
- * becomes. This module owns the rule's SHAPE (text grammar, canonical spelling, scope) and its MATCH
7
- * semantics; who may mint one, where it is stored, and where in the gate it is consumed live in
8
- * `permission-rule-store.ts`, `permission-rule-consent.ts` and `hooks.ts` respectively.
9
- *
10
- * ## The floor: what a rule may name, and what a rule may match
2
+ * design/179 §2 — the persisted permission-rule model and its matchers.
3
+ *
4
+ * A persisted rule is one person's standing instruction about a tool call, in one of THREE behaviors
5
+ * ({@link RULE_BEHAVIORS}): an `allow` is the standing form of ONE recorded human approval (the person
6
+ * confirmed a command once on an approval card and asked not to be asked again); a `deny` and an `ask`
7
+ * are the standing forms of "never run this" and "ask me every time" the same content-form rules
8
+ * upstream reads out of its settings' deny/ask lists. ONE grammar, ONE canonical spelling and ONE
9
+ * matcher family serve all three; the behavior is a FIELD beside the text, never part of it, so
10
+ * `Bash(rm -r:*)` is one spelling whether it allows, denies or asks. This module owns the rule's SHAPE
11
+ * (text grammar, canonical spelling, scope, behavior) and its MATCH semantics; who may mint one, where
12
+ * it is stored, and where in the gate it is consumed live in `permission-rule-store.ts`,
13
+ * `permission-rule-consent.ts` and `gate-lanes.ts` respectively.
14
+ *
15
+ * The two DIRECTIONS read a command differently, on purpose: an allow must cover the WHOLE call
16
+ * ({@link ruleAdmitsCommand} — one recorded yes clears exactly what it spells), while a deny/ask
17
+ * speaks about any PROGRAM RUN the call may perform ({@link programRunReachOf} — whole command or
18
+ * any segment of a chain, read with a real shell lexer, `shell-lexer.ts`, and answering `unreadable`
19
+ * for what it cannot read rather than silence). Precedence across behaviors is the upstream one,
20
+ * deny > ask > allow ({@link RULE_BEHAVIOR_PRECEDENCE}, the one table every adjudication reads).
21
+ *
22
+ * ## The floor: what a rule may name, and what a rule may match (the ALLOW direction)
11
23
  *
12
24
  * Everything a shell can use to run a program the text does not NAME — substitution, subshells,
13
25
  * backgrounding, escapes, line breaks, and **redirection** — puts the command outside this lane
@@ -41,7 +53,9 @@
41
53
  * The floor is `parseLeadingCommandName` + `splitShellCompoundSegments` — the one simple-command parser
42
54
  * and the one segmentation, both already shared with the read-only classifier, the reversibility probe,
43
55
  * the coarse command-name policy and the skill tool specifier. A second tokenizer would drift, and drift
44
- * on a loosening face shows up as a circumvention rather than as a test failure.
56
+ * on a LOOSENING face shows up as a circumvention rather than as a test failure. The tightening
57
+ * direction's lexer is not that second tokenizer: it reads MORE than the floor (never less), and a
58
+ * drift there costs an approval prompt, never a standing approval.
45
59
  *
46
60
  * argv[0] may carry a PATH prefix here (`./gradlew`, `/usr/bin/git`) — opt-in at the shared parser and
47
61
  * used by this lane alone. The bare-name requirement belongs to the argv[0]-NAME filters, which compare
@@ -58,6 +72,48 @@
58
72
  * here) and no leading-env-assignment stripping (`FOO=1 git status` simply does not match
59
73
  * `Bash(git status:*)`) — the last two are strict-side divergences from upstream, registered as such.
60
74
  */
75
+ import type { AssertAllKeysHandled } from "./ask-origin.js";
76
+ /**
77
+ * The closed set of rule BEHAVIORS — what a matching rule says about the call. The upstream vocabulary
78
+ * verbatim (`allow` / `deny` / `ask`, the three settings lists), in PRECEDENCE order: when rules of more
79
+ * than one behavior speak for one call, the earliest member here decides ({@link RULE_BEHAVIOR_PRECEDENCE}
80
+ * is that order as a table, and {@link adjudicatePersistedRules} is the one place that walks it).
81
+ *
82
+ * The type is derived FROM the array so a member added to either side is a compile error; a value read
83
+ * off a wire or a file goes through {@link isRuleBehavior} (present but outside the set = refused, never
84
+ * defaulted) or {@link readRowBehavior} (the ONE reading of a row that predates the field).
85
+ */
86
+ export declare const RULE_BEHAVIORS: readonly ["deny", "ask", "allow"];
87
+ export type RuleBehavior = (typeof RULE_BEHAVIORS)[number];
88
+ /** Membership test for {@link RuleBehavior}. Exported because the word crosses process boundaries (a
89
+ * store row, a sync payload, a settings entry) and a host validating one must not hand-roll the set. */
90
+ export declare function isRuleBehavior(v: unknown): v is RuleBehavior;
91
+ /**
92
+ * The precedence table: the rank at which each behavior decides a call when several rules speak (lower
93
+ * decides first). Upstream's arbiter walks deny, then ask, then allow — an explicit deny is never
94
+ * outvoted by an allow, and a standing "ask me each time" is never silenced by a remembered yes. Every
95
+ * adjudication over a mixed rule table orders its walk by THIS table (never by a hand-written if-chain),
96
+ * so the rank of a behavior is stated once.
97
+ */
98
+ export declare const RULE_BEHAVIOR_PRECEDENCE: {
99
+ readonly deny: 0;
100
+ readonly ask: 1;
101
+ readonly allow: 2;
102
+ };
103
+ /** Compile-time fence over the precedence table: `never` while every behavior has a rank. */
104
+ export type PrecedenceTableCoversEveryBehavior = AssertAllKeysHandled<Exclude<RuleBehavior, keyof typeof RULE_BEHAVIOR_PRECEDENCE>>;
105
+ /** The behaviors in precedence order — the walk every mixed-table adjudication takes. */
106
+ export declare const RULE_BEHAVIORS_BY_PRECEDENCE: readonly RuleBehavior[];
107
+ /**
108
+ * The ONE reading of a persisted row's behavior at every seam that may meet a row written before the
109
+ * field existed — the file store's schema migration, the sync wire's inbound picker, a checkpoint's
110
+ * rehydrated session rows. An ABSENT behavior reads `"allow"`: every row an engine without the field
111
+ * ever wrote was an allow, so that reading is the row's own meaning, not a default. A PRESENT value
112
+ * outside the closed set is refused (`undefined`) — a word this engine cannot read must not become an
113
+ * allow by falling through (the loud-bad-value doctrine), and must not become a deny either (a
114
+ * fabricated tightening is still a fabrication).
115
+ */
116
+ export declare function readRowBehavior(v: unknown): RuleBehavior | undefined;
61
117
  /** The tools the rule lane speaks for. `"Bash"` is the historical member; `"Read"` (design/382 §2.2)
62
118
  * speaks EXACTLY ONE grammar — the double-slash directory form `Read(//abs/**)`, the directory
63
119
  * read-authorization a compound card's cd segment mints — and its admission is the CLOSED two-point
@@ -70,7 +126,19 @@
70
126
  * silence the classifier's questions, never a mandated one), so a recorded human yes is exactly what
71
127
  * clears it. The org/hook/matchedAskRule immunities stay: those conjuncts live in the gate's
72
128
  * persisted-rule lane and do not loosen with this set. */
73
- export type PersistedRuleTool = "Bash" | "Read";
129
+ export type PersistedRuleTool = string;
130
+ /** The one shell tool the command grammar speaks for. */
131
+ export declare const COMMAND_RULE_TOOL = "Bash";
132
+ /** The one tool whose path family has an ALLOW form (the `Read(//abs-dir/**)` directory grant). */
133
+ export declare const READ_RULE_TOOL = "Read";
134
+ /**
135
+ * Which tools a rule may name — the model's tool set is DERIVED, never a hand-kept list: the shell tool
136
+ * takes the COMMAND grammar; every tool the catalog declares a path target for (Read / Edit / Write /
137
+ * Glob / NotebookEdit / …, `pathTargetOf`) takes the PATH grammar. A rule on any other tool has no
138
+ * content this model can judge and is refused (`unsupported.tool`). The tool set therefore moves with the
139
+ * catalog's declarations, exactly as the protective fences' covered set does.
140
+ */
141
+ export declare function ruleToolGrammarOf(toolName: string): "command" | "path" | undefined;
74
142
  /**
75
143
  * Match forms. Four members, two of them one predicate:
76
144
  * · `exact` — admits the one string the rule spells.
@@ -82,13 +150,19 @@ export type PersistedRuleTool = "Bash" | "Read";
82
150
  * on the single-command body. Kept as a fully supported COMPATIBILITY READ — stored rules are never
83
151
  * migrated or rewritten — and it remains the ONLY spelling of the compound-prefix form
84
152
  * (`Bash(cd /tmp && adb pull:*)`): the trailing-star form deliberately has no compound reading
85
- * (see {@link parseAllowRuleText}), because upstream's own trailing-star pattern is inert on a
153
+ * (see {@link parseRuleText}), because upstream's own trailing-star pattern is inert on a
86
154
  * compound and importing/minting one as a chain licence would grant what nobody approved.
87
- * · `subpath` — the Read directory form (design/382 §2.2): `Read(//abs/**)`, admitting reads under
88
- * one absolute directory. Never admits any command; its one predicate is
89
- * {@link directoryRuleAdmits}.
155
+ * · `subpath` — the directory form (design/382 §2.2): `Tool(//abs-dir/**)`, reaching every path under
156
+ * one absolute directory. Never admits any command; its one predicate is {@link directoryRuleAdmits}.
157
+ * As an ALLOW it exists for the Read tool only (a standing yes to reads under a directory); as a
158
+ * deny/ask it is legal on every path-targeting tool.
159
+ * · `path` — the PATH PATTERN form, deny/ask only: upstream's file-rule spelling over a tool's path
160
+ * target (`Edit(~/.sema/settings.json)`, `Write(src/**)`, `Read(//etc/*)`), stored AS SPELLED and
161
+ * resolved at match time against the call's bases (`//` absolute, `~/` home, `/` project root,
162
+ * otherwise the working directory). Its predicate is {@link pathRuleReaches}. Not an allow form: a
163
+ * standing approval by path pattern would be a wider grant than any card ever shows a person.
90
164
  */
91
- export type PersistedRuleMatch = "exact" | "prefix" | "wildcard" | "subpath";
165
+ export type PersistedRuleMatch = "exact" | "prefix" | "wildcard" | "subpath" | "path";
92
166
  /**
93
167
  * Where a rule applies — design/382 §4.1, the THREE consent dimensions.
94
168
  *
@@ -143,13 +217,24 @@ export interface RuleAdd {
143
217
  createdAt: string;
144
218
  }
145
219
  /**
146
- * A persisted ALLOW rule for one tool.
147
- *
148
- * `adds` is a real set, not a scalar: two concurrently approved records for the same (rule, scope)
149
- * each redeem their own dot, and a rule is live iff at least one of its adds survives the tombstones.
150
- * Organization rules have the opposite polarity (deny/ask only) and never enter this type.
220
+ * A persisted rule for one tool, in one of the three behaviors.
221
+ *
222
+ * The logical identity of a rule is the TRIPLE (behavior, rule text, scope): the same text may stand as
223
+ * an allow and as a deny in one bucket (upstream keeps the three lists side by side and lets precedence
224
+ * decide), so a tombstone, a quarantine row and a join group all key on the triple — a key on (text,
225
+ * scope) alone would let a delete aimed at the allow take the deny with it.
226
+ *
227
+ * `adds` is a real set, not a scalar: two concurrently approved records for the same identity each
228
+ * redeem their own dot, and a rule is live iff at least one of its adds survives the tombstones.
229
+ * Organization rules are a separate partition (a server-published snapshot, no CRDT) and never enter
230
+ * this type; the personal deny/ask rows here are the PERSON's own standing instructions (their settings'
231
+ * deny/ask lists, imported through the same consent door the allow list takes).
151
232
  */
152
- export interface PersistedAllowRule {
233
+ export interface PersistedRule {
234
+ /** What a match of this rule says about the call — REQUIRED on every row this engine writes. A row
235
+ * read without one is a pre-field row and reads `allow` through {@link readRowBehavior}, at the
236
+ * reading seams only (the store's schema migration, the sync picker, a checkpoint's session rows). */
237
+ behavior: RuleBehavior;
153
238
  /** Canonical rule text: `Bash(git status)` (exact) or `Bash(git status:*)` (prefix). A prefix body
154
239
  * may itself be a connector chain (`Bash(cd /tmp && adb pull:*)`) — see the module note on
155
240
  * connectors for what such a rule reaches. */
@@ -170,6 +255,8 @@ export interface PersistedAllowRule {
170
255
  * in no tombstone for the same (rule, scope).
171
256
  */
172
257
  export interface RuleTombstone {
258
+ /** The identity's behavior member — a tombstone deletes adds of ONE (behavior, text, scope). */
259
+ behavior: RuleBehavior;
173
260
  rule: string;
174
261
  scope: RuleScope;
175
262
  removedDots: RuleDot[];
@@ -185,8 +272,11 @@ export interface RuleReject {
185
272
  code: RuleRejectCode;
186
273
  message: string;
187
274
  }
188
- /** The parsed, canonical shape of one rule text — everything but the scope and the provenance. */
189
- export interface ParsedAllowRule {
275
+ /** The parsed, canonical shape of one rule text under one behavior — everything but the scope and the
276
+ * provenance. The behavior is the parser's INPUT (it decides which validator floor applies), carried
277
+ * out so a consumer holding the parse never re-derives which list the text came from. */
278
+ export interface ParsedRule {
279
+ behavior: RuleBehavior;
190
280
  rule: string;
191
281
  tool: PersistedRuleTool;
192
282
  match: PersistedRuleMatch;
@@ -209,7 +299,7 @@ export declare const MAX_RULE_TEXT_CHARS = 512;
209
299
  * an EXACT rule naming a whole interpreter command line stays legal, since it authorizes one command.
210
300
  *
211
301
  * TWO READERS, and they consult this table with DIFFERENT questions — do not read either as the other.
212
- * {@link parseAllowRuleText} asks "is this rule TEXT's own head a row?" and compares the basename as
302
+ * {@link parseRuleText} asks "is this rule TEXT's own head a row?" and compares the basename as
213
303
  * SPELLED, so an alias spelling (`SSH host:*`, `ssh.exe host:*`) is a different name to it and enters
214
304
  * the store like any other rule. {@link genericPrefixBody} asks "should the engine PROPOSE a body with
215
305
  * this head?" and normalizes first ({@link screenedHeadName}: case, trailing periods, executable
@@ -250,18 +340,16 @@ export declare const BARE_INTERPRETER_NAMES: ReadonlySet<string>;
250
340
  * Shell KEYWORDS, refused as a segment's `argv[0]` everywhere this lane reads a command.
251
341
  *
252
342
  * The floor this lane stands on extracts the first TOKEN of a segment and calls it the command name.
253
- * That identification is what every comparison here rests on — the interpreter refusal, the deny/ask
254
- * layer's per-segment judgement, and a person's reading of the rule text. For a control structure it is
255
- * simply false. `for x in once; do curl evil.example; done` splits into three segments whose first
256
- * tokens are `for`, `do` and `done`; all three are ordinary bare words, so the floor accepts each,
257
- * `curl` is named by nothing, and a published `deny Bash(curl:*)` matches none of them while bash runs
258
- * curl. A whole loop body — any number of programs — hides behind three tokens that name no program.
259
- *
260
- * Refusing the keyword puts the whole command outside the lane, which is the honest answer: no rule can
261
- * be minted for it and no rule matches it, so it asks. Recovering these shapes properly needs a real
262
- * shell grammar (upstream has one it parses to a syntax tree and reads the commands out of the
263
- * structure, so a keyword is never mistaken for a program); a token-level lane cannot, and pretending
264
- * otherwise is the loosening direction.
343
+ * That identification is what every comparison here rests on — the interpreter refusal and a person's
344
+ * reading of the rule text. For a control structure it is simply false: `for x in once; do curl
345
+ * evil.example; done` splits into three segments whose first tokens are `for`, `do` and `done`; all
346
+ * three are ordinary bare words, so the floor accepts each, and a rule text could name a keyword as
347
+ * though it were a program.
348
+ *
349
+ * Refusing the keyword puts the whole command outside the ALLOW lane, which is the honest answer: no rule
350
+ * can be minted for it and no allow matches it, so it asks. The DENY/ASK direction reads these shapes
351
+ * with its own lexer (`shell-lexer.ts`: a prefixing keyword is stripped so `do curl evil.example` runs
352
+ * `curl`; a `for`/`case` header, whose commands need a syntax tree, is `unreadable` and asks).
265
353
  *
266
354
  * `!` is here for the same reason with a sharper edge: it is a keyword that PREFIXES a real command, so
267
355
  * `! node -e …` is the `node` licence the interpreter table exists to refuse, wearing one extra token.
@@ -391,23 +479,46 @@ export declare function hasUnrenderableCharacters(text: string): boolean;
391
479
  * product's settings, reloading a store file, and any future backend. A shape refused here cannot enter
392
480
  * the store through any door.
393
481
  *
394
- * `direction` (design/182 §7, additive): the bare-interpreter-prefix refusal is an ALLOW-side argument
395
- * one approval click cannot be read as authorizing arbitrary programs. An org deny/ask rule has the
396
- * opposite polarity: `Bash(node:*)` as a DENY is a legitimately wide tightening, and refusing it would
397
- * silently disarm exactly the policy an administrator most wants. `"tighten"` therefore admits the
398
- * interpreter-headed prefix form and changes nothing else; the default stays the allow-side floor.
482
+ * `behavior` is REQUIRED — there is no default, because the two directions have different floors and
483
+ * a defaulted allow is exactly the silent-widening shape a validator must not have: the
484
+ * bare-interpreter-prefix refusal is an ALLOW-side argument (one approval click cannot be read as
485
+ * authorizing arbitrary programs), while a deny/ask has the opposite polarity — `Bash(node:*)` as a DENY
486
+ * is a legitimately wide tightening, and refusing it would silently disarm exactly the rule a person
487
+ * most wants. The tightening behaviors therefore admit the interpreter-headed prefix form and change
488
+ * nothing else; the allow floor is unchanged.
399
489
  */
400
- export declare function parseAllowRuleText(text: string, opts?: {
401
- direction?: "allow" | "tighten";
402
- }): {
403
- rule: ParsedAllowRule;
490
+ export declare function parseRuleText(text: string, behavior: RuleBehavior): {
491
+ rule: ParsedRule;
404
492
  } | {
405
493
  reject: RuleReject;
406
494
  };
407
- /** The canonical spelling of a rule for a (already folded) command and match form. For `subpath` the
408
- * command is the lexical-normal absolute directory (one leading slash), re-spelled in the
409
- * double-slash directory form. */
410
- export declare function formatAllowRuleText(command: string, match: PersistedRuleMatch): string;
495
+ /** The canonical spelling of a rule for a (already folded) command and match form on `tool`. For
496
+ * `subpath` the command is the lexical-normal absolute directory (one leading slash), re-spelled in the
497
+ * double-slash directory form; for `path` it is the pattern as spelled. */
498
+ export declare function formatRuleText(command: string, match: PersistedRuleMatch, tool: PersistedRuleTool): string;
499
+ /**
500
+ * The bases a path pattern resolves against at match time — facts of the CALL, never of the rule:
501
+ * `home` for `~/…` (absent ⇒ `os.homedir()`), `root` for `/…` (the task's project root), `cwd` for a
502
+ * relative pattern (the live working directory; absent ⇒ `root`). A pattern whose base is absent
503
+ * resolves to nothing and reaches nothing.
504
+ */
505
+ export interface PathRuleBases {
506
+ readonly root?: string;
507
+ readonly cwd?: string;
508
+ readonly home?: string;
509
+ }
510
+ /** Is this base usable by {@link resolvePathPattern} — an absolute path with a lexical normal form? A rule
511
+ * compiler asks this at compile time so a pattern is refused rather than compiled inert. */
512
+ export declare function isUsablePathBase(base: string | undefined): boolean;
513
+ /**
514
+ * The PATH family's tightening predicate: does this deny/ask rule reach the call's target path? The
515
+ * target is the caller's ALREADY-RESOLVED lexical-normal absolute path (the same identity the read/write
516
+ * fences judge with). A `subpath` rule reaches the directory and everything under it
517
+ * ({@link directoryRuleAdmits}); a `path` rule resolves its spelling against the call's bases and
518
+ * matches segment-wise (`*` within a segment, `**` across segments), reaching the path it names and
519
+ * everything under a directory it names. A command-family rule reaches no path.
520
+ */
521
+ export declare function pathRuleReaches(rule: Pick<PersistedRule, "match" | "command">, target: string, bases: PathRuleBases): boolean;
411
522
  /**
412
523
  * Does this rule's command pattern admit `command`?
413
524
  *
@@ -425,22 +536,74 @@ export declare function formatAllowRuleText(command: string, match: PersistedRul
425
536
  * same count equality is the containment: `Bash(a && b:*)` admits `a && b x`, never `a && b x && c`.
426
537
  * An EXACT rule has no such reach by construction: it admits one string, the one it spells.
427
538
  */
428
- export declare function ruleAdmitsCommand(rule: Pick<PersistedAllowRule, "match" | "command">, command: string): boolean;
539
+ export declare function ruleAdmitsCommand(rule: Pick<PersistedRule, "match" | "command">, command: string): boolean;
429
540
  /**
430
- * The same match, asked the DENY/ASK layer's question "does this rule speak about this program run?".
431
- *
432
- * Split from {@link ruleAdmitsCommand} because the two differ on one axis that decides real cases: a
433
- * quoted operator. `curl "https://x/?a=1&b=2"` is ONE command bash runs, and the `&` in a query string
434
- * is not a connector; the matching side refuses it anyway (a rule text is a spelling with no operator
435
- * characters in it at all the historical rule-face contract), and the deny side inheriting that
436
- * refusal made `deny Bash(curl:*)` silent on the commonest spelling of the very program it names
437
- * (adversarial round 3).
438
- *
439
- * The asymmetry only ever runs one way: this predicate reads MORE commands than the matching one, never
440
- * fewer. A shape only the ALLOW side could read would be a standing approval no published policy could
441
- * see the inversion this whole ticket exists to prevent.
541
+ * What one TIGHTENING (deny/ask) rule can say about a call the closed three-word answer of
542
+ * {@link programRunReachOf}:
543
+ * - `reached` the text PROVABLY performs a run the rule names (the whole chain, or any one segment);
544
+ * - `not_reached` the text provably does not (every compared word is literal and differs, or the
545
+ * command is shorter than the rule's body);
546
+ * - `unreadable` the text MAY perform it but cannot be read: an expansion sits where the rule reads a
547
+ * word, a quote or substitution never closes, a syntax error, a control structure whose commands need
548
+ * a syntax tree, a wrapper handed its command as one string.
549
+ * The three lanes that consume it (the org partition, the personal deny/ask rows, the rule DSL's
550
+ * content-form rules) dispose of the words identically: `reached` speaks with the rule's behavior,
551
+ * `not_reached` is silence, and `unreadable` is a FAIL-CLOSED ask never silence, which is what once let
552
+ * `rm -r x; echo "$HOME"` walk past a deny on `rm -r` because one expansion took the whole line out of
553
+ * the reader. Registered in docs/CLOSED-SETS.md.
554
+ */
555
+ export declare const PROGRAM_RUN_REACHES: readonly ["reached", "not_reached", "unreadable"];
556
+ export type ProgramRunReach = (typeof PROGRAM_RUN_REACHES)[number];
557
+ /** The answer of {@link programRunReachOf}: the word, and for `unreadable` the reason a surface can show. */
558
+ export type ProgramRunReachOutcome = {
559
+ readonly reach: "reached" | "not_reached";
560
+ } | {
561
+ readonly reach: "unreadable";
562
+ readonly reason: string;
563
+ };
564
+ /**
565
+ * Does one TIGHTENING (deny/ask) rule speak about this call — as a whole, or about any single program
566
+ * run in it — or can the call not be read against it at all? The ONE reach predicate of the tightening
567
+ * direction, shared by the org partition, the personal deny/ask rows and the rule DSL's content-form
568
+ * deny/ask rules, so no two of them can disagree about what a deny reaches.
569
+ *
570
+ * The allow lane asks a different question and gets a different answer, deliberately. An allow rule is
571
+ * one recorded human yes and must cover the WHOLE call to clear it ({@link ruleAdmitsCommand}:
572
+ * `Bash(npm test)` does not clear `npm test && curl evil.example`, because nobody said yes to the second
573
+ * half), and it refuses every shell construct outright. A deny/ask rule is a standing statement about a
574
+ * program run, and a call that MAY perform that run is a call the rule speaks about — so this direction
575
+ * reads the command with a real lexer ({@link readShellCommand}): redirections are stripped at the
576
+ * token level, quotes are removed on BOTH sides before words are compared (`rm "-r" x` runs `rm -r x`),
577
+ * wrappers are peeled (`sudo env X=1 nohup rm -r x` runs `rm -r x`), every connector — `&` and a newline
578
+ * included — bounds a segment, and a comment is not a run. Deliberately an OVER-approximation, in the
579
+ * tightening direction: the segments are the runs the text can reach, not the runs that will happen, so
580
+ * a short-circuited branch (`true || curl evil.example`) is judged as though it ran. Answering "does this
581
+ * definitely execute" needs evaluation, not parsing; over-firing costs an approval prompt, and the
582
+ * opposite error costs the rule.
583
+ *
584
+ * Words are compared ARGV-wise, position by position, against the rule body read by the same lexer —
585
+ * over every run CANDIDATE a segment carries: as spelled, then each peeled layer (`sudo rm -r x` is a run
586
+ * of `sudo …` AND of `rm -r x`; a deny on either reaches; a rule body is never peeled, so
587
+ * `Bash(sudo rm -r x)` denies exactly that). A literal word that differs settles `not_reached`; a word
588
+ * carrying an expansion at a position the rule reads settles `unreadable` (the text could spell the
589
+ * rule's word there — unless it is the SAME glob/brace spelling the body wrote); a prefix body whose
590
+ * every word is matched is `reached` whatever follows; an exact body needs equal length — a longer
591
+ * command is `not_reached` unless an extra word is an UNQUOTED expansion (or `"$@"` / an array
592
+ * expansion), which may expand to nothing. The commands inside a substitution and an unquoted heredoc
593
+ * body are segments of their own (`echo "$(rm -r x)"` runs `rm`). A compound
594
+ * body (`Bash(echo ok && rm -r:*)`) reaches only a chain of the same shape — same segment count, same
595
+ * connectors, every leading segment equal, the final one by the body's match kind; a single-segment
596
+ * body reaches any segment. A segment the lexer could not read makes the answer `unreadable` when no
597
+ * other segment already reached.
598
+ *
599
+ * A rule body the lexer cannot read (foreign store bytes; every entrance validates against exactly
600
+ * this) reaches nothing. A path-family rule (`subpath` / `path`) reaches no command: its predicates are
601
+ * {@link directoryRuleAdmits} and {@link pathRuleReaches}.
442
602
  */
443
- export declare function ruleAdmitsProgramRun(rule: Pick<PersistedAllowRule, "match" | "command">, command: string): boolean;
603
+ export declare function programRunReachOf(rule: Pick<PersistedRule, "match" | "command">, command: string): ProgramRunReachOutcome;
604
+ /** The boolean face of {@link programRunReachOf}: `reached` only. The shadow view and the pins that
605
+ * assert a reach read this; a lane that must fail closed on `unreadable` reads the outcome. */
606
+ export declare function ruleReachesProgramRun(rule: Pick<PersistedRule, "match" | "command">, command: string): boolean;
444
607
  /** design/382 §3.3-5 — one BREADTH note about a rule a person is about to persist: which wide shape
445
608
  * it has, said in words a surface can show beside the input line. A warning is never a refusal —
446
609
  * the closed refusal set is untouched — and never adjudication input. */
@@ -478,21 +641,7 @@ export interface EditedRuleBreadthWarning {
478
641
  * The upstream editor validates edited text but shows no breadth evidence at all; this seat is a
479
642
  * superset disclosure (registered divergence, design/382 §6-4).
480
643
  */
481
- export declare function ruleBreadthWarningsOf(rule: Pick<PersistedAllowRule, "match" | "command">): readonly EditedRuleBreadthWarning[];
482
- /**
483
- * The segments of `command` as the deny/ask layer must judge them, or `undefined` for a command this
484
- * lane cannot read.
485
- *
486
- * A tightening rule speaks about a PROGRAM RUN, and a compound runs several. `Bash(curl:*)` published
487
- * as a deny means "this machine does not make that call", and reading `npm test && curl evil.example`
488
- * as one unmatched blob answered that with silence — the shape the widening above would otherwise make
489
- * permanently approvable. Exported (rather than folded into a matcher here) because the layer that
490
- * needs it holds the rules: this module owns what a command IS, `permission-rule-org.ts` owns what the
491
- * organization says about each part of it.
492
- *
493
- * A single simple command yields a one-element list, so a deny that matched before matches identically.
494
- */
495
- export declare function ruleLaneSegmentsOf(command: string): readonly string[] | undefined;
644
+ export declare function ruleBreadthWarningsOf(rule: Pick<PersistedRule, "match" | "command">): readonly EditedRuleBreadthWarning[];
496
645
  /**
497
646
  * Is `path` inside (or equal to) `root`? Word-boundary containment on the path separator, so `/a` does
498
647
  * not contain `/ab`. Both sides are expected to be canonical already.
@@ -501,8 +650,8 @@ export declare function pathWithinRoot(path: string, root: string): boolean;
501
650
  /**
502
651
  * The LEXICAL NORMALIZER for a CALL's target path (design/382 §2.2 — the rule family's one path
503
652
  * identity is the lexical normal form, zero IO): collapse slash runs and `.` segments, resolve `..`
504
- * segments textually, and answer the one-leading-slash absolute spelling or `undefined` when no
505
- * such spelling exists (a relative path, a `..` climbing past the root, an empty result). RULE
653
+ * segments textually (a `..` at the root stays at the root, as POSIX resolves it), and answer the
654
+ * one-leading-slash absolute spelling or `undefined` for a relative path. RULE
506
655
  * bodies never come through here (their canonical spelling is unique and non-normal spellings are
507
656
  * refused at the validator); this is for the comparison INPUT a caller derives from a live call.
508
657
  */
@@ -520,13 +669,16 @@ export declare function lexicalNormalAbsolutePathOf(path: string): string | unde
520
669
  * caller cannot smuggle a second path identity through the comparison. Containment is the
521
670
  * word-boundary one ({@link pathWithinRoot}: `/a` never reaches `/ab`).
522
671
  *
523
- * What it never does, stated as loudly as what it does (§2.5): admits READS ONLY — a rule of this
524
- * family never admits a write, never a whole Bash command, never a non-cd segment — and it clears
525
- * ASKS only, never a filesystem fence: workspace containment, the sensitive-read deny face and every
526
- * violation refusal judge exactly as they would without the rule (a fence refusal is structural, not
527
- * an ask, so there is nothing here for it to clear).
672
+ * What it never does, stated as loudly as what it does (§2.5): as an ALLOW it admits READS ONLY — the
673
+ * TOOL conjunct is the caller's (every consumer judges eligibility with the call's tool, and
674
+ * {@link adjudicatePersistedPathRules} takes the allow arm for the Read tool alone), so a directory allow
675
+ * never admits a write, never a whole Bash command, never a non-cd segment and it clears ASKS only,
676
+ * never a filesystem fence: workspace containment, the sensitive-read deny face and every violation
677
+ * refusal judge exactly as they would without the rule (a fence refusal is structural, not an ask, so
678
+ * there is nothing here for it to clear). As a DENY/ASK the same containment refuses or questions every
679
+ * path under the directory, on whichever path tool the rule names.
528
680
  */
529
- export declare function directoryRuleAdmits(rule: Pick<PersistedAllowRule, "tool" | "match" | "command">, path: string): boolean;
681
+ export declare function directoryRuleAdmits(rule: Pick<PersistedRule, "match" | "command">, path: string): boolean;
530
682
  /** Does a rule's scope cover a task running in `cwd` (and, for the session dimension, in the session
531
683
  * named by `sessionId`)? A project rule needs a cwd to compare against; without one it covers nothing
532
684
  * (fail-closed). A session rule (design/382 §4.3) covers a call iff the call's `sessionId` equals the
@@ -539,7 +691,7 @@ export declare function directoryRuleAdmits(rule: Pick<PersistedAllowRule, "tool
539
691
  export declare function scopeCoversCwd(scope: RuleScope, cwd: string | undefined, sessionId?: string): boolean;
540
692
  /** Is this rule live — i.e. does it still carry at least one add? Deleted adds are removed by the store
541
693
  * when tombstones are applied, so a rule with an empty `adds` is a rule that no longer exists. */
542
- export declare function isRuleLive(rule: PersistedAllowRule): boolean;
694
+ export declare function isRuleLive(rule: PersistedRule): boolean;
543
695
  /**
544
696
  * design/375 §5.1 — MAY this rule speak about this call's CONTEXT? Tool identity plus scope coverage,
545
697
  * and nothing about the rule's liveness: the CONTEXT question is the half a not-yet-persisted rule (a
@@ -551,7 +703,7 @@ export declare function isRuleLive(rule: PersistedAllowRule): boolean;
551
703
  * cwd to compare against covers nothing (fail-closed, {@link scopeCoversCwd}).
552
704
  *
553
705
  * `scope` rides beside the rule rather than inside it for the PROPOSED half's sake: a
554
- * {@link ParsedAllowRule} carries no scope, so a proposal is judged against the scope it would LAND in.
706
+ * {@link ParsedRule} carries no scope, so a proposal is judged against the scope it would LAND in.
555
707
  *
556
708
  * design/382 §4.3 — the call context carries a THIRD axis, `sessionId` (optional: an existing caller
557
709
  * that threads only `{tool, cwd}` still compiles and still means what it meant — no session rule can
@@ -576,13 +728,16 @@ export declare function eligibleContext(rule: {
576
728
  * conjunction arm and its coverage-set selection here, the suggestion face's coverage judgement and the
577
729
  * consent prepare's ({@link segmentCoverageOf} is the shared instrument). One predicate, so a rule that
578
730
  * one consumer refuses cannot be a rule another counts: the historically inlined trio of checks
579
- * (live / tool / scope) is this function, verbatim.
731
+ * (live / tool / scope) is this function, verbatim — plus the BEHAVIOR conjunct: a mixed table holds
732
+ * allow, deny and ask rows, and a consumer asking "which allow rules cover this" must not be answered
733
+ * by a deny row that happens to spell the same text. The behavior is a REQUIRED argument for the same
734
+ * reason the parser's is: a defaulted allow here is a silent widening.
580
735
  */
581
- export declare function eligiblePersisted(rule: PersistedAllowRule, call: {
736
+ export declare function eligiblePersisted(rule: PersistedRule, call: {
582
737
  tool: string;
583
738
  cwd: string | undefined;
584
739
  sessionId?: string;
585
- }): boolean;
740
+ }, behavior: RuleBehavior): boolean;
586
741
  /**
587
742
  * Find the rules that admit this command for a task in `cwd`, or `undefined`.
588
743
  *
@@ -611,8 +766,12 @@ export declare function eligiblePersisted(rule: PersistedAllowRule, call: {
611
766
  *
612
767
  * Order is defined only for reporting (what the decision names); no rule is stronger than another —
613
768
  * they are all the same one answer, "the person already said yes to this".
769
+ *
770
+ * ALLOW rows only: this is the allow direction's question, and a deny/ask row in the same table is not
771
+ * coverage (the eligibility predicate's behavior conjunct). The mixed-table walk that orders the three
772
+ * behaviors is {@link adjudicatePersistedRules}.
614
773
  */
615
- export declare function findAdmittingRule(rules: readonly PersistedAllowRule[], call: {
774
+ export declare function findAdmittingRule(rules: readonly PersistedRule[], call: {
616
775
  tool: string;
617
776
  command: string;
618
777
  cwd: string | undefined;
@@ -622,7 +781,63 @@ export declare function findAdmittingRule(rules: readonly PersistedAllowRule[],
622
781
  * `./x` against the moved cursor, not the task root). Defaults to `cwd`; the scope/eligibility
623
782
  * axis stays `cwd` either way. */
624
783
  execCwd?: string;
625
- }): readonly PersistedAllowRule[] | undefined;
784
+ }): readonly PersistedRule[] | undefined;
785
+ /** The answer of {@link adjudicatePersistedRules}: which behavior decided, and the rule(s) it stands
786
+ * on — a one-element set for a deny/ask (the first table-order rule of the deciding behavior that
787
+ * reaches the call) and the allow direction's coverage SET ({@link findAdmittingRule}). */
788
+ export interface PersistedRuleVerdict {
789
+ readonly behavior: RuleBehavior;
790
+ readonly rules: readonly PersistedRule[];
791
+ /** The call could not be READ against the person's deny/ask rows (see {@link programRunReachOf}):
792
+ * `behavior` is `ask`, `rules` names the first tightening row that could not be judged, and this is
793
+ * the lexer's reason. The lane projects it as an UNREADABLE answer — the same fail-closed shape an
794
+ * unreadable store takes — never as a matched ask rule. */
795
+ readonly unreadable?: string;
796
+ }
797
+ /**
798
+ * The ONE adjudication over a MIXED persisted-rule table for a shell call: walk the behaviors in
799
+ * {@link RULE_BEHAVIOR_PRECEDENCE} order and answer with the first behavior whose eligible rules speak
800
+ * for the call — deny before ask before allow, the upstream arbiter's order across its three lists.
801
+ *
802
+ * Each behavior is judged with ITS direction's instrument: a deny/ask row through
803
+ * {@link programRunReachOf} (the wide tightening reading: whole command or any program run in it,
804
+ * three-valued), an allow through {@link findAdmittingRule} (the narrow reading: the whole call, or
805
+ * every segment of it, covered by recorded yeses). The asymmetry is the point and runs one way only: a
806
+ * deny reads MORE of a command than an allow does, so an exact allow naming a whole chain can never
807
+ * outrun a deny on one of its parts. A call the lexer cannot read against a standing deny/ask row is an
808
+ * UNREADABLE verdict (`behavior: "ask"` + `unreadable`), returned before any allow is consulted: the
809
+ * question "does one of my rules forbid this?" is unanswered, and a recorded yes must not answer it.
810
+ * Eligibility (live, this tool, scope covers the call, THIS behavior) is the one predicate every
811
+ * consumer shares. `undefined` ⇒ no rule of any behavior speaks.
812
+ */
813
+ export declare function adjudicatePersistedRules(rules: readonly PersistedRule[], call: {
814
+ tool: string;
815
+ command: string;
816
+ cwd: string | undefined;
817
+ sessionId?: string;
818
+ execCwd?: string;
819
+ }): PersistedRuleVerdict | undefined;
820
+ /**
821
+ * The path-family sibling of {@link adjudicatePersistedRules}: the same precedence walk over the rules
822
+ * of one path-targeting tool, judged on the call's resolved target path.
823
+ *
824
+ * TWO targets, because the two directions read the call's path slot differently (design/388 B6/B17): the
825
+ * tightening arms judge the PROTECTIVE target (`protectivePathTargetOf` — the object face's declaration,
826
+ * the catalog's for the name as the floor: reading the catalog for an undeclared same-name tool can only
827
+ * tighten a deny) with {@link pathRuleReaches}; the allow arm judges the DECLARED target
828
+ * (`declaredPathTargetOf` — the face's OWN declaration only: a same-name tool that declares no path
829
+ * target is unconfirmable, and no grant admits it) with {@link directoryRuleAdmits}, and for the Read tool
830
+ * ONLY — a directory allow is a standing yes to reads, never to a write. Either target may be absent
831
+ * (no resolvable path in that reading); the arm that needs it then speaks for nothing.
832
+ */
833
+ export declare function adjudicatePersistedPathRules(rules: readonly PersistedRule[], call: {
834
+ tool: string;
835
+ cwd: string | undefined;
836
+ sessionId?: string;
837
+ }, targets: {
838
+ readonly tighten?: string;
839
+ readonly allow?: string;
840
+ }, bases: PathRuleBases): PersistedRuleVerdict | undefined;
626
841
  /**
627
842
  * design/375 §5.2 — the per-segment coverage table for `command`: which segments an eligible rule
628
843
  * already admits, judged with the SAME split and the same eligibility predicates as the gate's
@@ -654,9 +869,9 @@ export declare function findAdmittingRule(rules: readonly PersistedAllowRule[],
654
869
  * adjudication inputs.
655
870
  */
656
871
  export declare function segmentCoverageOf(command: string, rules: {
657
- persisted: readonly PersistedAllowRule[];
872
+ persisted: readonly PersistedRule[];
658
873
  proposed?: readonly {
659
- rule: ParsedAllowRule;
874
+ rule: ParsedRule;
660
875
  scope: RuleScope;
661
876
  }[];
662
877
  }, call: {