@sema-agent/core 7.1.0 → 7.2.0

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 (34) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/dist/agents/cross-session-envelope.d.ts +138 -0
  3. package/dist/agents/cross-session-envelope.js +191 -0
  4. package/dist/agents/cross-session-judge.d.ts +119 -0
  5. package/dist/agents/cross-session-judge.js +184 -0
  6. package/dist/agents/cross-session-ref.d.ts +52 -0
  7. package/dist/agents/cross-session-ref.js +64 -0
  8. package/dist/agents/send-message-tool.d.ts +13 -0
  9. package/dist/agents/send-message-tool.js +36 -12
  10. package/dist/core/checkpoint-store.d.ts +189 -3
  11. package/dist/core/checkpoint-store.js +56 -16
  12. package/dist/core/hooks.d.ts +15 -8
  13. package/dist/core/hooks.js +6 -3
  14. package/dist/core/permission-rule-consent.d.ts +72 -23
  15. package/dist/core/permission-rule-consent.js +115 -26
  16. package/dist/core/permission-rule-model.d.ts +245 -51
  17. package/dist/core/permission-rule-model.js +312 -54
  18. package/dist/core/permission-rule-org.js +13 -6
  19. package/dist/core/remote-env.d.ts +8 -1
  20. package/dist/core/runner/assemble-result.js +2 -1
  21. package/dist/core/runner/prepare-task.d.ts +39 -1
  22. package/dist/core/runner/prepare-task.js +278 -113
  23. package/dist/core/runner/prepare-workspace-restore.d.ts +6 -1
  24. package/dist/core/runner/prepare-workspace-restore.js +2 -1
  25. package/dist/core/runner/runtask.js +13 -3
  26. package/dist/core/task-notification.d.ts +64 -5
  27. package/dist/core/task-notification.js +25 -4
  28. package/dist/core/tool-policy.d.ts +11 -0
  29. package/dist/core/types.d.ts +23 -0
  30. package/dist/core/untrusted-text.js +17 -1
  31. package/dist/index.d.ts +6 -3
  32. package/dist/index.js +5 -2
  33. package/package.json +1 -1
  34. package/test/export-surface.snapshot.json +125 -1
@@ -1,5 +1,5 @@
1
1
  import { randomBytes } from "node:crypto";
2
- import { escapeForDisclosure, hasUnrenderableCharacters, parseAllowRuleText, ruleAdmitsCommand, segmentCoverageOf, suggestRulesForCommand, translateImportedWildcardRule, } from "./permission-rule-model.js";
2
+ import { escapeForDisclosure, hasUnrenderableCharacters, parseAllowRuleText, ruleAdmitsCommand, ruleBreadthWarningsOf, segmentCoverageOf, suggestRulesForCommand, } from "./permission-rule-model.js";
3
3
  import { errText, isValidConsentScope, normalizePersistedRule, sameRuleOwner, sameScope, writerOf } from "./permission-rule-store.js";
4
4
  export class InMemoryRuleApprovalRecordStore {
5
5
  rows = new Map();
@@ -7,7 +7,7 @@ export class InMemoryRuleApprovalRecordStore {
7
7
  const r = this.rows.get(id);
8
8
  if (r === undefined)
9
9
  return undefined;
10
- if (r.schema !== 2) {
10
+ if (r.schema !== 3) {
11
11
  return {
12
12
  staleSchema: true,
13
13
  id: r.id,
@@ -139,8 +139,42 @@ function approvalRecordDamageOf(rec) {
139
139
  if (!Number.isInteger(offer.uncoveredSegments) || offer.uncoveredSegments < 0) {
140
140
  return `card batch offer ${at} must carry a non-negative uncovered-segment count`;
141
141
  }
142
+ const detail = offer.uncoveredDetail;
143
+ if (!Array.isArray(detail))
144
+ return `card batch offer ${at} must carry its uncovered-segment reason rows`;
145
+ if (detail.length !== offer.uncoveredSegments) {
146
+ return `card batch offer ${at} carries ${detail.length} reason rows for an uncovered-segment count of ${offer.uncoveredSegments} — one fact, two seats, and they must agree`;
147
+ }
148
+ for (const [ri, row] of detail.entries()) {
149
+ if (typeof row !== "object" ||
150
+ row === null ||
151
+ typeof row.segment !== "string" ||
152
+ (row.reason !== "redirection" && row.reason !== "no_rule_form" && row.reason !== "cap_overflow")) {
153
+ return `card batch offer ${at} reason row ${ri} is not a segment/reason row from the closed reason set`;
154
+ }
155
+ }
156
+ if (detail.length > 0) {
157
+ const rows = detail;
158
+ const table = typeof rec.command === "string" ? segmentCoverageOf(rec.command, { persisted: [] }, { tool: CARD_RULE_TOOL, cwd: undefined }) : undefined;
159
+ if (table === undefined) {
160
+ return `card batch offer ${at} carries reason rows but the record's command cannot back them — a row without a readable source command is a row nobody can audit`;
161
+ }
162
+ let cursor = 0;
163
+ for (const [ri, row] of rows.entries()) {
164
+ let found = -1;
165
+ for (let j = cursor; j < table.length; j++) {
166
+ if (table[j]?.segment === row.segment) {
167
+ found = j;
168
+ break;
169
+ }
170
+ }
171
+ if (found === -1)
172
+ return `card batch offer ${at} reason row ${ri} names a segment the record's command does not carry at or after that position`;
173
+ cursor = found + 1;
174
+ }
175
+ }
142
176
  }
143
- else if (offer.segments !== undefined || offer.uncoveredSegments !== undefined) {
177
+ else if (offer.segments !== undefined || offer.uncoveredSegments !== undefined || offer.uncoveredDetail !== undefined) {
144
178
  return `${escapeForDisclosure(rec.kind)} batch offer ${at} must not carry segment metadata — there is no compound command behind it`;
145
179
  }
146
180
  }
@@ -149,6 +183,16 @@ function approvalRecordDamageOf(rec) {
149
183
  if (!claimed.has(at))
150
184
  return `candidate ${at} belongs to no offer — every candidate row is claimed by exactly one offer`;
151
185
  }
186
+ if (rec.edited !== undefined) {
187
+ if (typeof rec.edited !== "object" || rec.edited === null)
188
+ return "the edited seat is not an object";
189
+ const w = rec.edited.warnings;
190
+ if (w !== undefined) {
191
+ if (!Array.isArray(w) || w.length !== 1 || (w[0] !== "broad_prefix" && w[0] !== "compound_prefix")) {
192
+ return "the edited seat's warnings are not a single-code array from the closed breadth-code set";
193
+ }
194
+ }
195
+ }
152
196
  return undefined;
153
197
  }
154
198
  export function ruleOffersOfRecord(rec) {
@@ -163,21 +207,51 @@ export function ruleOffersOfRecord(rec) {
163
207
  return { rule: parsed.rule.rule, match: parsed.rule.match, command: parsed.rule.command };
164
208
  };
165
209
  return rec.offers.map((offer) => {
166
- if (offer.kind === "single")
167
- return { kind: "single", ...tripleOf(offer.candidate) };
210
+ if (offer.kind === "single") {
211
+ const t = tripleOf(offer.candidate);
212
+ if (t.match === "subpath") {
213
+ throw new Error("approval-record single offer references a directory rule — a shape this engine does not mint; refusing to render it");
214
+ }
215
+ return { kind: "single", ...t };
216
+ }
168
217
  const segments = offer.segments;
169
- if (segments === undefined || offer.uncoveredSegments === undefined) {
218
+ if (segments === undefined || offer.uncoveredSegments === undefined || offer.uncoveredDetail === undefined) {
170
219
  throw new Error("a batch offer without mint-time segment metadata has no card face to project — import/starter batches render as previews, not offer cards");
171
220
  }
221
+ const rawDetail = offer.uncoveredDetail;
222
+ const count = offer.uncoveredSegments;
223
+ const detail = [];
224
+ const detailDamaged = (() => {
225
+ if (!Array.isArray(rawDetail) || !Number.isInteger(count) || count < 0 || rawDetail.length !== count)
226
+ return true;
227
+ for (let i = 0; i < rawDetail.length; i++) {
228
+ const row = rawDetail[i];
229
+ if (typeof row !== "object" || row === null)
230
+ return true;
231
+ const segment = row.segment;
232
+ const reason = row.reason;
233
+ if (typeof segment !== "string" || (reason !== "redirection" && reason !== "no_rule_form" && reason !== "cap_overflow"))
234
+ return true;
235
+ detail.push({ segment, reason });
236
+ }
237
+ return false;
238
+ })();
239
+ if (detailDamaged) {
240
+ throw new Error("a batch offer's uncovered-segment reason rows do not match their count or the closed reason set — refusing to render damaged mint-time facts as a card face");
241
+ }
172
242
  return {
173
243
  kind: "batch",
174
244
  rules: offer.candidates.map((candidate, i) => {
175
245
  const segment = segments[i];
176
246
  if (segment === undefined)
177
247
  throw new Error(`batch offer member ${i} has no source segment recorded`);
178
- return { ...tripleOf(candidate), segment };
248
+ const t = tripleOf(candidate);
249
+ return t.match === "subpath"
250
+ ? { kind: "directoryRead", rule: t.rule, directory: t.command, segment }
251
+ : { kind: "command", rule: t.rule, match: t.match, command: t.command, segment };
179
252
  }),
180
- uncoveredSegments: offer.uncoveredSegments,
253
+ uncoveredSegments: count,
254
+ uncoveredDetail: detail,
181
255
  };
182
256
  });
183
257
  }
@@ -234,12 +308,19 @@ export async function prepareCardApproval(opts) {
234
308
  if (sessionRows.length > 0)
235
309
  table = [...sessionRows, ...listed.rules];
236
310
  }
237
- coverage = segmentCoverageOf(opts.command, { persisted: table }, { tool: CARD_RULE_TOOL, cwd: opts.cwd, sessionId: opts.sessionId });
311
+ coverage = segmentCoverageOf(opts.command, { persisted: table }, { tool: CARD_RULE_TOOL, cwd: opts.cwd, sessionId: opts.sessionId, ...(opts.execCwd !== undefined ? { execCwd: opts.execCwd } : {}) });
238
312
  }
239
313
  catch {
240
314
  coverage = undefined;
241
315
  }
242
- const suggested = suggestRulesForCommand(opts.command, coverage !== undefined ? { coverage } : undefined);
316
+ const suggested = suggestRulesForCommand(opts.command, {
317
+ ...(coverage !== undefined ? { coverage } : {}),
318
+ scope,
319
+ ...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}),
320
+ ...(opts.execCwd !== undefined ? { execCwd: opts.execCwd } : {}),
321
+ ...(opts.sessionId !== undefined ? { sessionId: opts.sessionId } : {}),
322
+ ...(opts.deps.deniesDirectoryRead !== undefined ? { deniesDirectoryRead: opts.deps.deniesDirectoryRead } : {}),
323
+ });
243
324
  const candidates = [];
244
325
  const claimedBy = new Map();
245
326
  const claim = (text) => {
@@ -257,13 +338,14 @@ export async function prepareCardApproval(opts) {
257
338
  candidates: offer.rules.map((r) => claim(r.rule)),
258
339
  segments: offer.rules.map((r) => r.segment),
259
340
  uncoveredSegments: offer.uncoveredSegments,
341
+ uncoveredDetail: offer.uncoveredDetail ?? [],
260
342
  });
261
343
  if (candidates.length === 0)
262
344
  return undefined;
263
345
  const record = {
264
346
  id: mintId(opts.deps, "rar"),
265
347
  ...recordIdentityOf(owner),
266
- schema: 2,
348
+ schema: 3,
267
349
  kind: "card",
268
350
  state: "pending",
269
351
  rev: 0,
@@ -366,7 +448,8 @@ function checkEditedRuleText(text, command) {
366
448
  message: `the edited rule "${parsed.rule.rule}" does not admit the command that was decided ("${escapeForDisclosure(command)}") — a card's edit may widen how much the rule covers, never move it to a different grant`,
367
449
  };
368
450
  }
369
- return { ok: true, canonicalRule: parsed.rule.rule };
451
+ const warnings = ruleBreadthWarningsOf(parsed.rule);
452
+ return { ok: true, canonicalRule: parsed.rule.rule, ...(warnings.length > 0 ? { warnings } : {}) };
370
453
  }
371
454
  export function precheckEditedRuleText(text, command) {
372
455
  if (typeof text !== "string") {
@@ -443,7 +526,7 @@ async function confirmEditedCandidate(rec, edit, deps) {
443
526
  candidates: [...rec.candidates, { rule: checked.canonicalRule, scope }],
444
527
  offers: [...rec.offers, { kind: "single", candidate: index }],
445
528
  selectedOffer: rec.offers.length,
446
- edited: { index, text: edit.text, at: nowIso(deps) },
529
+ edited: { index, text: edit.text, at: nowIso(deps), ...(checked.warnings !== undefined ? { warnings: checked.warnings.map((w) => w.code) } : {}) },
447
530
  };
448
531
  const won = await deps.approvals.cas(rec.id, rec.rev, next);
449
532
  if (!won) {
@@ -675,30 +758,36 @@ export async function prepareCcImport(opts) {
675
758
  skipped.push({ rule: String(entry), reason: "settings entry is not a string" });
676
759
  continue;
677
760
  }
761
+ if (entry.startsWith("Read(")) {
762
+ const parsed = parseAllowRuleText(entry);
763
+ if ("reject" in parsed) {
764
+ skipped.push({
765
+ rule: entry,
766
+ reason: "unsupported.form: only the Read(//abs-dir/**) directory form imports in v1 — this entry stays in the settings file, unimported",
767
+ });
768
+ continue;
769
+ }
770
+ if (!candidates.some((c) => c.rule === parsed.rule.rule && sameScope(c.scope, scope))) {
771
+ candidates.push({ rule: parsed.rule.rule, scope });
772
+ }
773
+ continue;
774
+ }
678
775
  if (!entry.startsWith("Bash(")) {
679
776
  const reason = /^[A-Za-z][A-Za-z0-9_]*\(.*\)$/.test(entry)
680
- ? "unsupported.tool: only Bash(...) command rules import in v1 — this entry stays in the settings file, unimported"
777
+ ? "unsupported.tool: only Bash(...) command rules and Read(//abs-dir/**) directory rules import in v1 — this entry stays in the settings file, unimported"
681
778
  : /^[A-Za-z][A-Za-z0-9_-]*$/.test(entry)
682
779
  ? "unsupported.form: a bare tool-name entry is a name-set item, not a command rule — it stays in the settings file, unimported"
683
- : "unsupported.form: not a Bash(...) command rule — it stays in the settings file, unimported";
780
+ : "unsupported.form: not a command rule spelling this version imports — it stays in the settings file, unimported";
684
781
  skipped.push({ rule: entry, reason });
685
782
  continue;
686
783
  }
687
- const wildcard = translateImportedWildcardRule(entry);
688
- if (wildcard !== undefined && "skip" in wildcard) {
689
- skipped.push({ rule: entry, reason: wildcard.skip });
690
- continue;
691
- }
692
- const text = wildcard?.rule ?? entry;
693
- const parsed = parseAllowRuleText(text);
784
+ const parsed = parseAllowRuleText(entry);
694
785
  if ("reject" in parsed) {
695
786
  skipped.push({ rule: entry, reason: `${parsed.reject.code}: ${parsed.reject.message}` });
696
787
  continue;
697
788
  }
698
789
  if (candidates.some((c) => c.rule === parsed.rule.rule && sameScope(c.scope, scope)))
699
790
  continue;
700
- if (wildcard !== undefined)
701
- translated.push({ from: entry, to: parsed.rule.rule, scope });
702
791
  candidates.push({ rule: parsed.rule.rule, scope });
703
792
  }
704
793
  }
@@ -708,7 +797,7 @@ export async function prepareCcImport(opts) {
708
797
  const record = {
709
798
  id: mintId(opts.deps, "rar"),
710
799
  ...recordIdentityOf(owner),
711
- schema: 2,
800
+ schema: 3,
712
801
  kind: "import",
713
802
  state: "pending",
714
803
  rev: 0,
@@ -742,7 +831,7 @@ export async function prepareStarterBatch(opts) {
742
831
  const record = {
743
832
  id: mintId(opts.deps, "rar"),
744
833
  ...recordIdentityOf(owner),
745
- schema: 2,
834
+ schema: 3,
746
835
  kind: "starter",
747
836
  state: "pending",
748
837
  rev: 0,
@@ -58,16 +58,37 @@
58
58
  * here) and no leading-env-assignment stripping (`FOO=1 git status` simply does not match
59
59
  * `Bash(git status:*)`) — the last two are strict-side divergences from upstream, registered as such.
60
60
  */
61
- /** The one tool the v1 rule lane speaks for. The field exists on the rule so v2 can widen without a shape change.
61
+ /** The tools the rule lane speaks for. `"Bash"` is the historical member; `"Read"` (design/382 §2.2)
62
+ * speaks EXACTLY ONE grammar — the double-slash directory form `Read(//abs/**)`, the directory
63
+ * read-authorization a compound card's cd segment mints — and its admission is the CLOSED two-point
64
+ * one on {@link directoryRuleAdmits}: a Read tool call under the directory, and a compound's cd
65
+ * segment resolving into it. Nothing else — never a write, never a whole Bash command, never a
66
+ * non-cd segment.
62
67
  *
63
68
  * design/276 §3.3 (doctrine, for whoever widens this set): a future `SendMessage(…)`-form rule MAY
64
69
  * clear the peer-referral ask — that ask is classifier hesitation by construction (#144: allow rules
65
70
  * silence the classifier's questions, never a mandated one), so a recorded human yes is exactly what
66
71
  * clears it. The org/hook/matchedAskRule immunities stay: those conjuncts live in the gate's
67
72
  * persisted-rule lane and do not loosen with this set. */
68
- export type PersistedRuleTool = "Bash";
69
- /** v1 match forms. `"wildcard"` is reserved for v2 and is not a value this version ever produces. */
70
- export type PersistedRuleMatch = "exact" | "prefix";
73
+ export type PersistedRuleTool = "Bash" | "Read";
74
+ /**
75
+ * Match forms. Four members, two of them one predicate:
76
+ * · `exact` — admits the one string the rule spells.
77
+ * · `wildcard` — the TRAILING SPACE-STAR spelling (`Bash(npm run *)`): the body, or the body followed
78
+ * by a space and more, inside the body's own segment count. This is the spelling upstream itself
79
+ * mints for its prefix suggestions (its classifier compiles one trailing ` *` into an optional
80
+ * suffix `^body( .*)?$`), so it is the form this lane's SUGGESTION face mints too (#510).
81
+ * · `prefix` — the historical COLON-STAR spelling (`Bash(npm run:*)`), match-equivalent to `wildcard`
82
+ * on the single-command body. Kept as a fully supported COMPATIBILITY READ — stored rules are never
83
+ * migrated or rewritten — and it remains the ONLY spelling of the compound-prefix form
84
+ * (`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
86
+ * 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}.
90
+ */
91
+ export type PersistedRuleMatch = "exact" | "prefix" | "wildcard" | "subpath";
71
92
  /**
72
93
  * Where a rule applies — design/382 §4.1, the THREE consent dimensions.
73
94
  *
@@ -155,8 +176,11 @@ export interface RuleTombstone {
155
176
  /** The delete operation's own dot — the operation's persistent identity. */
156
177
  deletedBy: RuleDot;
157
178
  }
158
- /** Why a rule text was refused. Codes are stable so an import report can group by them. */
159
- 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" | "unsupported.tool" | "unsupported.wildcard";
179
+ /** Why a rule text was refused. Codes are stable so an import report can group by them.
180
+ * `invalid.path_traversal` (design/382 §2.2, B5): a Read directory body carrying a `.` or `..`
181
+ * segment — the lexical-normal spelling is the ONLY spelling, so a traversal segment is not
182
+ * normalized away but refused. */
183
+ 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";
160
184
  export interface RuleReject {
161
185
  code: RuleRejectCode;
162
186
  message: string;
@@ -371,49 +395,10 @@ export declare function parseAllowRuleText(text: string, opts?: {
371
395
  } | {
372
396
  reject: RuleReject;
373
397
  };
374
- /** The canonical spelling of a rule for a (already folded) command and match form. */
398
+ /** The canonical spelling of a rule for a (already folded) command and match form. For `subpath` the
399
+ * command is the lexical-normal absolute directory (one leading slash), re-spelled in the
400
+ * double-slash directory form. */
375
401
  export declare function formatAllowRuleText(command: string, match: PersistedRuleMatch): string;
376
- /**
377
- * #490 件4 — read ANOTHER product's `Bash(<body> *)` SPACE-STAR spelling as this lane's colon-star
378
- * prefix rule, for the IMPORT door only.
379
- *
380
- * Why a translation is owed at all: the other product mints its prefix suggestions in that spelling
381
- * (its own classifier reads `X:*` as its earlier prefix form and anything else containing an unescaped star as
382
- * a wildcard), so a person migrating settings had EVERY prefix rule they had ever approved refused as
383
- * `unsupported.wildcard` — the import silently emptied out exactly the rules that were the point of it.
384
- *
385
- * Translated ONLY for the ONE spelling whose semantics this lane can state exactly: content ending in
386
- * ` *` with that star the only unescaped one. Upstream compiles a single trailing ` *` into an
387
- * OPTIONAL suffix (`^body( .*)?$`), which is precisely "the body, or the body followed by a space and
388
- * more" — this lane's prefix predicate. Any other star placement compiles to a real glob with no rule
389
- * form here, and a bare `*` is a whole-tool grant that must never become a command rule; both are
390
- * refused with a reason of their own so the import can DISCLOSE them rather than drop them.
391
- *
392
- * WHAT THE TRANSLATED RULE GRANTS, against the source spelling, axis by axis (the direction matters,
393
- * and it is one-way with one named exception): identical on the bare body, on body-plus-arguments, on
394
- * the word boundary (`npm run` never reaches `npm run-x`) and on compounds (both sides refuse a chain
395
- * against a single-command body). NARROWER here on four axes, because upstream normalizes the
396
- * CANDIDATE before matching and this lane never does: a candidate carrying a redirection, wrapped in
397
- * `timeout`/`nice`/`nohup`-style words, fed through `xargs`, or prefixed with a `VAR=value` assignment
398
- * matches there and matches nothing here. WIDER on exactly one: this lane folds runs of whitespace on
399
- * both sides, so `npm run build` matches the imported rule where upstream's wildcard arm (which does
400
- * not fold) would refuse it. That axis is NOT introduced by this translation — whitespace folding is
401
- * this lane's universal reading and already applies to every imported EXACT rule, against an upstream
402
- * exact arm that is byte-strict — so it is disclosed as a lane property rather than treated as a
403
- * translation defect. Every other difference runs toward granting less.
404
- *
405
- * Returns `undefined` when the text is not a Bash wildcard spelling at all (nothing to translate; the
406
- * caller proceeds with the text as written), `{ rule }` with the spelling to parse INSTEAD, or
407
- * `{ skip }` with the reason a star spelling has no form here. Never bypasses the validator: the
408
- * returned text goes through {@link parseAllowRuleText} exactly like a hand-written one, so the
409
- * interpreter-head refusal, the control-character screen and the length bound all still apply — a
410
- * translated `Bash(node *)` is refused `invalid.bare_interpreter_prefix` just as a typed one is.
411
- */
412
- export declare function translateImportedWildcardRule(text: string): {
413
- rule: string;
414
- } | {
415
- skip: string;
416
- } | undefined;
417
402
  /**
418
403
  * Does this rule's command pattern admit `command`?
419
404
  *
@@ -447,6 +432,44 @@ export declare function ruleAdmitsCommand(rule: Pick<PersistedAllowRule, "match"
447
432
  * see — the inversion this whole ticket exists to prevent.
448
433
  */
449
434
  export declare function ruleAdmitsProgramRun(rule: Pick<PersistedAllowRule, "match" | "command">, command: string): boolean;
435
+ /** design/382 §3.3-5 — one BREADTH note about a rule a person is about to persist: which wide shape
436
+ * it has, said in words a surface can show beside the input line. A warning is never a refusal —
437
+ * the closed refusal set is untouched — and never adjudication input. */
438
+ export interface EditedRuleBreadthWarning {
439
+ readonly code: "broad_prefix" | "compound_prefix";
440
+ /** The baseline sentence (a surface may reword it, never invert it — the same contract as the
441
+ * §3.5 reason baselines). */
442
+ readonly message: string;
443
+ }
444
+ /**
445
+ * design/382 §3.3-5 — the breadth classifier behind the card-edit face's warning seat: does this
446
+ * (accepted) rule carry a width worth saying out loud before a person persists it?
447
+ *
448
+ * · `broad_prefix` — a prefix rule whose body is a SINGLE token (a bare verb, `Bash(mkdir:*)`):
449
+ * it admits every argument form of that command, not just the one on the card. This is the
450
+ * legal wide grant of §3.4 — the offer face deliberately never proposes it, the edit /
451
+ * hand-written channels legally accept it, and this sentence is what makes that acceptance an
452
+ * INFORMED one.
453
+ * · `compound_prefix` — a prefix rule whose body is a connector CHAIN (`Bash(cd /tmp && npm:*)`):
454
+ * one rule admitting several program runs, with anything extending its final segment.
455
+ *
456
+ * Judged on the CANONICAL rule (post-normalization, the spelling that would persist), with the
457
+ * lane's own instruments — the same shape reader the matcher uses, never a second grammar. A body
458
+ * this lane cannot read yields no warning: every such spelling is already the validator's refusal,
459
+ * and a warning beside a refusal would be noise about a rule that is not going to exist.
460
+ *
461
+ * An EXACT rule never warns (it admits one string, the one it spells — there is no width to name),
462
+ * and a SUBPATH rule is not this classifier's subject (no command reach at all). The two
463
+ * reach-bearing forms take the same two questions: `prefix` (colon-star) and `wildcard`
464
+ * (trailing space-star, #510 — match-equivalent reach on a single-command body; its grammar has no
465
+ * chain reading, so only the single-token question can fire for it).
466
+ * MAINTENANCE DUTY, stated where it binds: every match form with PREFIX REACH belongs here — a
467
+ * new reach-bearing member added to {@link PersistedRuleMatch} owes this classifier its arm, judged
468
+ * by the same "single token / chain body" questions (an exact-only form never warns).
469
+ * The upstream editor validates edited text but shows no breadth evidence at all; this seat is a
470
+ * superset disclosure (registered divergence, design/382 §6-4).
471
+ */
472
+ export declare function ruleBreadthWarningsOf(rule: Pick<PersistedAllowRule, "match" | "command">): readonly EditedRuleBreadthWarning[];
450
473
  /**
451
474
  * The segments of `command` as the deny/ask layer must judge them, or `undefined` for a command this
452
475
  * lane cannot read.
@@ -466,6 +489,35 @@ export declare function ruleLaneSegmentsOf(command: string): readonly string[] |
466
489
  * not contain `/ab`. Both sides are expected to be canonical already.
467
490
  */
468
491
  export declare function pathWithinRoot(path: string, root: string): boolean;
492
+ /**
493
+ * The LEXICAL NORMALIZER for a CALL's target path (design/382 §2.2 — the rule family's one path
494
+ * identity is the lexical normal form, zero IO): collapse slash runs and `.` segments, resolve `..`
495
+ * segments textually, and answer the one-leading-slash absolute spelling — or `undefined` when no
496
+ * such spelling exists (a relative path, a `..` climbing past the root, an empty result). RULE
497
+ * bodies never come through here (their canonical spelling is unique and non-normal spellings are
498
+ * refused at the validator); this is for the comparison INPUT a caller derives from a live call.
499
+ */
500
+ export declare function lexicalNormalAbsolutePathOf(path: string): string | undefined;
501
+ /**
502
+ * design/382 §2.5 — the ONE admission predicate of the Read directory rule family, beside
503
+ * {@link ruleAdmitsCommand} as its path-lane sibling. Four consumers share it (the minting round
504
+ * trip, the coverage table, the gate conjunction arm, and the gate's Read lane) so there is no
505
+ * second fact source anywhere.
506
+ *
507
+ * `path` is the caller's ALREADY-RESOLVED lexical-normal absolute path — the same path identity the
508
+ * deployment's read adjudication judged the call with (for the Read tool face), or the cd resolver's
509
+ * output (for the Bash cd-segment face). This predicate NORMALIZES NOTHING: a non-normal spelling
510
+ * (a `.`/`..` segment, a doubled slash, a relative path) is refused outright, fail-closed, so a
511
+ * caller cannot smuggle a second path identity through the comparison. Containment is the
512
+ * word-boundary one ({@link pathWithinRoot}: `/a` never reaches `/ab`).
513
+ *
514
+ * What it never does, stated as loudly as what it does (§2.5): admits READS ONLY — a rule of this
515
+ * family never admits a write, never a whole Bash command, never a non-cd segment — and it clears
516
+ * ASKS only, never a filesystem fence: workspace containment, the sensitive-read deny face and every
517
+ * violation refusal judge exactly as they would without the rule (a fence refusal is structural, not
518
+ * an ask, so there is nothing here for it to clear).
519
+ */
520
+ export declare function directoryRuleAdmits(rule: Pick<PersistedAllowRule, "tool" | "match" | "command">, path: string): boolean;
469
521
  /** Does a rule's scope cover a task running in `cwd` (and, for the session dimension, in the session
470
522
  * named by `sessionId`)? A project rule needs a cwd to compare against; without one it covers nothing
471
523
  * (fail-closed). A session rule (design/382 §4.3) covers a call iff the call's `sessionId` equals the
@@ -556,6 +608,11 @@ export declare function findAdmittingRule(rules: readonly PersistedAllowRule[],
556
608
  command: string;
557
609
  cwd: string | undefined;
558
610
  sessionId?: string;
611
+ /** The directory RELATIVE cd targets resolve against — the live TRACKED working directory of the
612
+ * execution seat (adversarial-review P1: after an earlier observable `cd`, the shell resolves
613
+ * `./x` against the moved cursor, not the task root). Defaults to `cwd`; the scope/eligibility
614
+ * axis stays `cwd` either way. */
615
+ execCwd?: string;
559
616
  }): readonly PersistedAllowRule[] | undefined;
560
617
  /**
561
618
  * design/375 §5.2 — the per-segment coverage table for `command`: which segments an eligible rule
@@ -597,6 +654,9 @@ export declare function segmentCoverageOf(command: string, rules: {
597
654
  tool: string;
598
655
  cwd: string | undefined;
599
656
  sessionId?: string;
657
+ /** The relative-cd resolution base (see {@link findAdmittingRule}'s member of the same name) —
658
+ * defaults to `cwd`; scope/eligibility stays `cwd`. */
659
+ execCwd?: string;
600
660
  }): readonly SegmentCoverage[] | undefined;
601
661
  /** One row of a per-segment coverage table (see {@link segmentCoverageOf}): the FOLDED, trimmed
602
662
  * segment text (a display/correlation seat, never adjudication input) and whether an eligible rule
@@ -626,6 +686,34 @@ export interface SegmentRuleSuggestion {
626
686
  * show a reader something other than what they authorize. */
627
687
  readonly segment: string;
628
688
  }
689
+ /**
690
+ * design/382 §2.3 (B3, BREAKING) — one member of a batch offer, a DISCRIMINATED union keyed by
691
+ * `kind`:
692
+ *
693
+ * · `kind: "command"` — the historical shape: a per-segment Bash rule
694
+ * ({@link SegmentRuleSuggestion}'s four seats, plus the discriminant).
695
+ * · `kind: "directoryRead"` — a DIRECTORY READ AUTHORIZATION minted from a cd segment (the §2.4
696
+ * floor): `rule` is the canonical `Read(//dir/**)` text, `directory` the lexical-normal absolute
697
+ * directory (a render seat, spared a re-parse), `segment` the folded cd segment it came from (a
698
+ * rendering-context seat like its command sibling's — never adjudication input).
699
+ *
700
+ * CONSUMER DEGRADE ARM (normative, design/382 §2.3): a batch member whose `kind` is outside this set
701
+ * drops the WHOLE batch offer — never the single member (a conjunction batch silently one member
702
+ * short renders "yes to N" as "yes to N−1", worse than not rendering), and never the whole card (the
703
+ * exact single beside it is intact and honest). Selection indices stay keyed on the ORIGINAL wire
704
+ * offer order.
705
+ */
706
+ export type RuleOfferBatchMember = (SegmentRuleSuggestion & {
707
+ readonly kind: "command";
708
+ }) | {
709
+ readonly kind: "directoryRead";
710
+ /** The canonical rule text the host would redeem — `Read(//a/b/**)`. */
711
+ readonly rule: string;
712
+ /** The lexical-normal absolute directory the rule names — a render seat, no re-parse needed. */
713
+ readonly directory: string;
714
+ /** The (folded) cd segment this authorization came from — rendering context, never adjudication input. */
715
+ readonly segment: string;
716
+ };
629
717
  /**
630
718
  * design/375 §3.1 — ONE "don't ask again" option on an approval card. A CLOSED discriminated union
631
719
  * (`kind`); consumers parse it per element, and an element whose `kind` they do not know is dropped
@@ -639,7 +727,10 @@ export interface SegmentRuleSuggestion {
639
727
  * is one yes to ALL of `rules` at once — there is no per-member selection inside a batch; a
640
728
  * consumer that wants a narrower answer picks the single offer or approves once without a rule.
641
729
  * `rules` has 1..5 members (the card-lane minting cap), in segment order, deduplicated by rule
642
- * text. `uncoveredSegments` is the honest surplus disclosure of the cap: judged at MINT time
730
+ * text, each a {@link RuleOfferBatchMember} (design/382 B3: the member form is a discriminated
731
+ * union — a cd segment of a project-scope card mints a `directoryRead` member, one accept lands
732
+ * commands and directory authorization together, the upstream single-line merged-consent form).
733
+ * `uncoveredSegments` is the honest surplus disclosure of the cap: judged at MINT time
643
734
  * against the coverage snapshot the card was drawn from ∪ this batch's FINAL (deduplicated,
644
735
  * capped) rules, it counts the violating segments still admitted by neither — 0 means "once this
645
736
  * batch is redeemed, this compound is fully covered by that snapshot's lights". It is a statement
@@ -666,12 +757,72 @@ export type RuleOffer = {
666
757
  readonly command: string;
667
758
  } | {
668
759
  readonly kind: "batch";
669
- /** 1..5 per-segment rules, segment order, deduplicated by rule text. */
670
- readonly rules: readonly SegmentRuleSuggestion[];
760
+ /** 1..5 per-segment members, segment order, deduplicated by rule text (design/382 B3: the
761
+ * member form is the {@link RuleOfferBatchMember} discriminated union). */
762
+ readonly rules: readonly RuleOfferBatchMember[];
671
763
  /** Mint-time honest surplus: violating segments admitted by neither the coverage snapshot nor
672
764
  * this batch's final rules (see the union doc above for the exact arithmetic). */
673
765
  readonly uncoveredSegments: number;
766
+ /**
767
+ * design/382 §3.5 — WHY each of those segments stays uncovered, one row per counted segment,
768
+ * segment order (same text repeated = two rows; the correspondence is positional, never a raw
769
+ * text dedup). Minted in the SAME pass as `uncoveredSegments` off the same snapshot, so
770
+ * `uncoveredDetail.length === uncoveredSegments` whenever the seat is present; the count seat
771
+ * stays the truth source, and ABSENCE (an older minter, a degraded consumer's re-emit) is not
772
+ * a claim. `segment` is the folded, trimmed segment text — a display/correlation seat carrying
773
+ * raw command bytes (render like its siblings, {@link renderUntrustedCommandText}); `reason`
774
+ * is the closed set {@link UncoveredSegmentDetail} documents. Additive on the wire (an unknown
775
+ * key inside a known kind — old consumers read the batch exactly as before); on the CONSENT
776
+ * RECORD's card batch the seat is REQUIRED (schema: 3) — see the record's own contract.
777
+ */
778
+ readonly uncoveredDetail?: readonly UncoveredSegmentDetail[];
674
779
  };
780
+ /** design/382 §3.5 — the closed reason set for one uncovered segment of a batch offer.
781
+ *
782
+ * · `"redirection"` — the segment carries a shell redirection: refused by discipline (#490 修①,
783
+ * §3.2), PERMANENTLY per-ask — no rule text can cover this spelling; the honest outlet is a rule
784
+ * for its redirection-free form, if the person trusts that form.
785
+ * · `"no_rule_form"` — neither the segment's prefix nor its exact form survives the mint round
786
+ * trip (a glob argument, an over-length line): nothing coverable exists for these bytes, and
787
+ * guidance deliberately does NOT point at hand-writing a rule — the same grammar refuses the
788
+ * same text on every door.
789
+ * · `"cap_overflow"` — the segment minted a rule, but the batch's dedup+cap left it out and no
790
+ * surviving member admits it: covered next time it comes up, asked about until then.
791
+ *
792
+ * These are WIRE-STRUCTURED facts, and that is the whole §3.6 point (recorded here so the design's
793
+ * half-radius is visible in the code): the reader is not only a person. A model or harness that
794
+ * keeps meeting `redirection` on a spelling it retries can re-run the redirection-free form —
795
+ * which HAS a coverable rule — with zero new mechanism on this side; a structured honest refusal
796
+ * is programmable, exactly like a loud bad-value error. The baseline person-facing sentences are
797
+ * {@link UNCOVERED_SEGMENT_REASON_BASELINE}. */
798
+ export interface UncoveredSegmentDetail {
799
+ /** The folded, trimmed segment text — raw post-rewrite command bytes, same contract as
800
+ * {@link SegmentRuleSuggestion.segment}. */
801
+ readonly segment: string;
802
+ readonly reason: "redirection" | "no_rule_form" | "cap_overflow";
803
+ }
804
+ /**
805
+ * design/382 §3.5-2 — the BASELINE "why + outlet" sentence per uncovered-segment reason: the
806
+ * one-source copy a surface renders beside the §3.5 detail rows. A surface may reword these, never
807
+ * invert them (the same contract as {@link renderUntrustedCommandText}: adopt the baseline, or own
808
+ * an equivalent-strength rewrite). Load-bearing asymmetries, deliberate:
809
+ * · `redirection` points at the redirection-FREE form's outlets (that form is coverable);
810
+ * · `no_rule_form` points at NOTHING but per-use approval — hand-writing is not suggested,
811
+ * because the same grammar refuses the same text on every door, and pointing a person at a rule
812
+ * that can never take effect is the exact defect this surface exists to remove;
813
+ * · `cap_overflow` promises only honesty: not covered by this batch, asked again when met.
814
+ */
815
+ export declare const UNCOVERED_SEGMENT_REASON_BASELINE: Readonly<Record<UncoveredSegmentDetail["reason"], string>>;
816
+ /**
817
+ * design/382 §3.5-2 — the BASELINE sentence per card-level offer-ABSENCE reason (#490 修②'s typed
818
+ * seat: `ruleOffersAbsence` on the ask and its durable park twin; the closed vocabulary is declared
819
+ * literally at those two faces, and this table spells the same literals — the copy row is data, not
820
+ * the type's home). Same reword-never-invert contract as the table above. The load-bearing
821
+ * asymmetry: `mandated` must NOT point at writing rules — on a mandated ask no rule is consulted,
822
+ * and directing a person to mint one that can never take effect is the #457④ anti-shape; the other
823
+ * two reasons name their real outlets.
824
+ */
825
+ export declare const RULE_OFFERS_ABSENCE_BASELINE: Readonly<Record<"mandated" | "lane_cannot_speak" | "shadowed", string>>;
675
826
  /**
676
827
  * The offers presented on an approval card for `command` (design/375 §5.2).
677
828
  *
@@ -740,4 +891,47 @@ export type RuleOffer = {
740
891
  */
741
892
  export declare function suggestRulesForCommand(command: string, ctx?: {
742
893
  readonly coverage?: readonly SegmentCoverage[];
894
+ /**
895
+ * design/382 §2.4-7 — the SCOPE the card's candidates would land in, the directoryRead mint
896
+ * gate's first input: a cd segment mints a directory member ONLY when this scope is a PROJECT
897
+ * or SESSION scope that also COVERS the adjudicated call (the second conjunct, through the one
898
+ * scope predicate — a scope that does not cover the deciding call would mint a member that is
899
+ * never eligible where it was minted, the offer-that-never-works shape). The SESSION arm is the
900
+ * §8-Q3 re-ruling (opened at final sign-off: the upstream session destination is exactly this
901
+ * grant's home, and a session-scope member is a pure narrowing of the project one). GLOBAL
902
+ * stays closed — a directory grant is place-and-time consent, and the everywhere spelling is
903
+ * the explicit channels' business. Absent ⇒ the gate is closed and a cd segment falls back to
904
+ * its command member (the pre-382 behaviour, byte-for-byte).
905
+ */
906
+ readonly scope?: RuleScope;
907
+ /**
908
+ * The adjudicated call's working directory (threaded from the original call context, never the
909
+ * process — the same value the coverage read and the gate judged with). Two consumers: the
910
+ * `./`-relative cd resolution, and the scope gate's coverage conjunct. Absent ⇒ no relative cd
911
+ * resolves and a project-scope gate is closed.
912
+ */
913
+ readonly cwd?: string;
914
+ /**
915
+ * The relative-cd RESOLUTION base — the live tracked working directory of the execution seat
916
+ * (adversarial-review P1: after an earlier observable `cd`, the shell resolves `./x` against the
917
+ * moved cursor, not the task root — a member minted off the wrong base names a directory the cd
918
+ * never enters). Defaults to `cwd`; the scope gate's coverage conjunct stays on `cwd`.
919
+ */
920
+ readonly execCwd?: string;
921
+ /**
922
+ * The adjudicated call's SESSION identity (threaded like `cwd`, never inferred) — the session
923
+ * arm's coverage conjunct: a session-scope card mints a directory member only when its scope
924
+ * names THIS session. Absent ⇒ no session scope covers anything (fail-closed, the shared
925
+ * predicate's own arm).
926
+ */
927
+ readonly sessionId?: string;
928
+ /**
929
+ * design/382 §2.4-6 — the deployment's sensitive-read negative control, SAME-SOURCE with its
930
+ * compiled read-deny judge (`ReadDenyMatcher.matchPath !== null` is the canonical wiring): the
931
+ * engine never PROPOSES a directory authorization for a directory this judge names. Absent ⇒
932
+ * the built-in default deny table stands in (refusal-only; a deployment with a wired judge
933
+ * passes its own so the two faces cannot disagree). Explicit-consent doors (hand-written,
934
+ * import) are deliberately NOT behind this — they have their own preview and validation.
935
+ */
936
+ readonly deniesDirectoryRead?: (directory: string) => boolean;
743
937
  }): RuleOffer[];