@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
@@ -27,11 +27,14 @@
27
27
  * this by editing a file backend's file. That is the settings-file trust model, stated rather than
28
28
  * defended against: for a file backend, host = user, no more and no less.
29
29
  */
30
- import { type EditedRuleBreadthWarning, type UncoveredSegmentDetail, type RuleOffer, type RuleRejectCode, type RuleScope, type RuleDot } from "./permission-rule-model.js";
30
+ import { type EditedRuleBreadthWarning, type UncoveredSegmentDetail, type RuleBehavior, type RuleOffer, type RuleRejectCode, type RuleScope, type RuleDot } from "./permission-rule-model.js";
31
31
  import type { RuleOwner } from "./permission-rule-store.js";
32
32
  import { type PermissionRuleStoreProvider } from "./permission-rule-provider.js";
33
- /** One candidate rule inside an approval record: the exact text and where it would apply. */
33
+ /** One candidate rule inside an approval record: what it says (`behavior`), the exact text and where it
34
+ * would apply. A card mints `allow` candidates only (a card is one person's yes); the settings import
35
+ * carries all three behaviors, each entry under the list it was read from. */
34
36
  export interface RuleCandidate {
37
+ behavior: RuleBehavior;
35
38
  rule: string;
36
39
  scope: RuleScope;
37
40
  }
@@ -95,9 +98,10 @@ export interface RuleApprovalRecord {
95
98
  * as {@link StaleRuleApprovalRecord}. `3` (design/382 §3.5, B8/B7): the card batch's
96
99
  * `uncoveredDetail` seat became REQUIRED and `edited.warnings` became storable — a `schema: 2`
97
100
  * row is read back stale exactly like the pre-stamp rows before it (the short-lived-pending
98
- * bargain, unchanged).
101
+ * bargain, unchanged). `4`: every candidate carries its `behavior` (the settings import now carries
102
+ * the deny/ask lists too) — a `schema: 3` row is stale by the same equality gate.
99
103
  */
100
- schema: 3;
104
+ schema: 4;
101
105
  kind: RuleApprovalKind;
102
106
  state: "pending" | "approved" | "redeemed";
103
107
  candidates: RuleCandidate[];
@@ -167,7 +171,7 @@ export interface RuleApprovalRecord {
167
171
  }
168
172
  /**
169
173
  * design/375 §4.5 — the minimal ENVELOPE a store hands back for a durable row that predates the
170
- * CURRENT (`schema: 3`) record form. A typed read cannot honestly return such a row as a
174
+ * CURRENT (`schema: 4`) record form. A typed read cannot honestly return such a row as a
171
175
  * {@link RuleApprovalRecord}, and dropping it at the store layer would misreport "stale" as
172
176
  * corruption — so the store contract is this variant instead: `staleSchema: true` plus the
173
177
  * IDENTITY fields, which are spelled the same way in every version and therefore safe to read.
@@ -375,7 +379,7 @@ export declare function confirmRuleApproval(opts: {
375
379
  /** Why a confirmation did not land. A closed set so a host can branch (re-present, re-fetch, give up).
376
380
  * The two `record_*` members past `record_not_found` are the durable-row gates (design/375 §4.5/§4.7),
377
381
  * disclosed to the record's own owner only — a non-owner gets `record_not_found` for every row:
378
- * - `"record_schema_stale"` — the record predates the current (`schema: 3`) form; the decision it carried
382
+ * - `"record_schema_stale"` — the record predates the current (`schema: 4`) form; the decision it carried
379
383
  * is void, and the recovery path is re-triggering the command for a fresh card (no
380
384
  * compatibility read, no migration — `detail.message` says so);
381
385
  * - `"record_malformed"` — the record's offer structure failed the read-side integrity checks
@@ -443,7 +447,7 @@ export type EditedRuleTextPrecheck = {
443
447
  * for a surface that wants to answer while the person is still typing instead of after a round trip.
444
448
  *
445
449
  * It is the SAME body the confirmation arm runs (`checkEditedRuleText`), which is the whole point: a
446
- * boundary that re-implements the check — or calls `parseAllowRuleText` directly — installs a SECOND,
450
+ * boundary that re-implements the check — or calls `parseRuleText` directly — installs a SECOND,
447
451
  * STRICTER judge that refuses spellings the edit face accepts (the muscle-memory `Bash(adb *)` form is
448
452
  * exactly such a case: the validator alone refuses it as a wildcard; the edit face normalizes it first).
449
453
  *
@@ -476,6 +480,7 @@ export declare function precheckEditedRuleText(text: string, command: string): E
476
480
  * durable revision is reporting, not the landing's identity). */
477
481
  export type RedeemResult = {
478
482
  status: "redeemed";
483
+ behavior: RuleBehavior;
479
484
  rule: string;
480
485
  scope: RuleScope;
481
486
  dot: RuleDot;
@@ -547,29 +552,13 @@ export interface ImportPreview {
547
552
  layer: ImportedSettingsLayer;
548
553
  found: boolean;
549
554
  }>;
555
+ /** The two SETTINGS LAYERS this version does not read at all. The three permission lists of the layers it
556
+ * does read — allow, deny AND ask — are all imported (each candidate carries its `behavior`), so there
557
+ * is no longer a "left in place" count beside them: an entry that did not become a candidate is in
558
+ * `skipped` with its reason. */
550
559
  uncovered: {
551
560
  flagSettings: "not-imported-v1";
552
561
  policySettings: "not-imported-v1";
553
- /**
554
- * The deny/ask buckets of the layers actually read: how many entries are SITTING THERE, across all
555
- * layers, that this import deliberately leaves in place (they are the tightening direction and have
556
- * their own channel — importing them through a loosening lane would be the wrong door).
557
- *
558
- * Always present, zero when the buckets are absent or empty: "this settings file has 14 deny entries
559
- * we did not touch" and "there was nothing there" are different facts, and a key that appears only in
560
- * the first case makes them indistinguishable for a reader who sees one preview. The count is of
561
- * ENTRIES IN PLACE, not of a decision — nothing here is skipped-and-lost; `skipped` stays the list of
562
- * allow-bucket entries that did not become candidates.
563
- *
564
- * A bucket that is PRESENT but not an array cannot be counted; it contributes 0 and is disclosed in
565
- * `skipped` under the layer's path, so a zero is never the report for something unreadable. Same for
566
- * a layer whose whole `permissions` member is not an object — every bucket in it reads as absent,
567
- * which is exactly the shape that would otherwise report two clean zeros.
568
- */
569
- denyAskBuckets: {
570
- deny: number;
571
- ask: number;
572
- };
573
562
  };
574
563
  }
575
564
  /**
@@ -591,6 +580,7 @@ export interface ImportPreview {
591
580
  */
592
581
  export type RedeemedBatchMember = {
593
582
  readonly candidateIndex: number;
583
+ readonly behavior: RuleBehavior;
594
584
  readonly rule: string;
595
585
  readonly scope: RuleScope;
596
586
  readonly status: "persisted" | "deduped";
@@ -598,22 +588,25 @@ export type RedeemedBatchMember = {
598
588
  readonly dot: RuleDot;
599
589
  } | {
600
590
  readonly candidateIndex: number;
591
+ readonly behavior: RuleBehavior;
601
592
  readonly rule: string;
602
593
  readonly scope: RuleScope;
603
594
  readonly status: "refused";
604
595
  readonly reason: string;
605
596
  };
606
597
  /**
607
- * Read the allow buckets of the user-editable settings layers and produce a preview plus a PENDING
608
- * approval record. Nothing is stored until someone confirms that record and the batch is redeemed.
609
- * A preview with zero importable candidates returns NO `approvalId` — there is nothing to confirm,
610
- * and the absence says so (design/375 §4.1).
598
+ * Read the THREE permission lists (allow, deny, ask) of the user-editable settings layers and produce a
599
+ * preview plus a PENDING approval record. Nothing is stored until someone confirms that record and the
600
+ * batch is redeemed. A preview with zero importable candidates returns NO `approvalId` — there is nothing
601
+ * to confirm, and the absence says so (design/375 §4.1).
611
602
  *
612
- * Only the allow bucket is IMPORTED. The deny/ask buckets are the tightening direction and have their own
613
- * channel; importing them through a loosening lane would be the wrong door. They are still COUNTED and
614
- * reported (`preview.uncovered.denyAskBuckets`): by-design-not-imported and unnoticed look identical to
615
- * the person confirming the batch, and only one of them is true here the same reason the non-Bash allow
616
- * entries are disclosed rather than dropped silently.
603
+ * Every list is read through the ONE validator under its own behavior: a deny/ask entry takes the
604
+ * tightening floor (`Bash(node:*)` as a deny is a legitimately wide tightening and imports), an allow
605
+ * entry the allow floor. A content-form deny/ask so imported is the person's own standing instruction
606
+ * and adjudicates in the persisted-rule lane beside their allows deny > ask > allow instead of
607
+ * being escorted by a shell into a bare tool-name ask that widens `Bash(rm -r:*)` into "every Bash call
608
+ * asks". An entry that does not import (a spelling with no rule form, a tool the lane does not speak
609
+ * for, a non-string) is DISCLOSED in `skipped` with its reason and stays in the settings file.
617
610
  */
618
611
  export declare function prepareCcImport(opts: {
619
612
  layers: CcImportLayer[];
@@ -1,5 +1,5 @@
1
1
  import { randomBytes } from "node:crypto";
2
- import { escapeForDisclosure, hasUnrenderableCharacters, parseAllowRuleText, ruleAdmitsCommand, ruleBreadthWarningsOf, segmentCoverageOf, suggestRulesForCommand, } from "./permission-rule-model.js";
2
+ import { escapeForDisclosure, hasUnrenderableCharacters, parseRuleText, ruleAdmitsCommand, ruleBreadthWarningsOf, segmentCoverageOf, suggestRulesForCommand, RULE_BEHAVIORS, RULE_BEHAVIOR_PRECEDENCE, isRuleBehavior, } from "./permission-rule-model.js";
3
3
  import { effectiveOrThrow } from "./permission-rule-provider.js";
4
4
  import { errText, isValidConsentScope, sameRuleOwner, sameScope, writerOf } from "./permission-rule-store.js";
5
5
  export class InMemoryRuleApprovalRecordStore {
@@ -8,7 +8,7 @@ export class InMemoryRuleApprovalRecordStore {
8
8
  const r = this.rows.get(id);
9
9
  if (r === undefined)
10
10
  return undefined;
11
- if (r.schema !== 3) {
11
+ if (r.schema !== 4) {
12
12
  return {
13
13
  staleSchema: true,
14
14
  id: r.id,
@@ -122,6 +122,12 @@ function approvalRecordDamageOf(rec) {
122
122
  if (typeof cand !== "object" || cand === null || typeof cand.rule !== "string") {
123
123
  return `offer ${at} references candidate ${member}, which is not a candidate row`;
124
124
  }
125
+ if (!isRuleBehavior(cand.behavior)) {
126
+ return `offer ${at} references candidate ${member}, whose behavior is not one of ${RULE_BEHAVIORS.join("/")}`;
127
+ }
128
+ if (rec.kind === "card" && cand.behavior !== "allow") {
129
+ return `card offer ${at} references candidate ${member}, a ${cand.behavior} rule — a card mints allow candidates only`;
130
+ }
125
131
  if (!isValidConsentScope(cand.scope)) {
126
132
  return `offer ${at} references candidate ${member}, whose scope is not a consent-face scope (global, project, or session)`;
127
133
  }
@@ -198,10 +204,11 @@ function approvalRecordDamageOf(rec) {
198
204
  }
199
205
  export function ruleOffersOfRecord(rec) {
200
206
  const tripleOf = (index) => {
201
- const text = rec.candidates[index]?.rule;
202
- if (text === undefined)
207
+ const cand = rec.candidates[index];
208
+ const text = cand?.rule;
209
+ if (cand === undefined || text === undefined)
203
210
  throw new Error(`approval-record offer references candidate ${index}, which the record does not carry`);
204
- const parsed = parseAllowRuleText(text);
211
+ const parsed = parseRuleText(text, cand.behavior);
205
212
  if ("reject" in parsed) {
206
213
  throw new Error(`approval-record candidate ${index} does not parse as a rule (${parsed.reject.code}) — refusing to render it as one`);
207
214
  }
@@ -318,7 +325,7 @@ export async function prepareCardApproval(opts) {
318
325
  if (claimedBy.has(text))
319
326
  throw new Error(`engine defect: rule "${text}" was minted into two offers on one card`);
320
327
  const index = candidates.length;
321
- candidates.push({ rule: text, scope });
328
+ candidates.push({ behavior: "allow", rule: text, scope });
322
329
  claimedBy.set(text, index);
323
330
  return index;
324
331
  };
@@ -336,7 +343,7 @@ export async function prepareCardApproval(opts) {
336
343
  const record = {
337
344
  id: mintId(opts.deps, "rar"),
338
345
  ...recordIdentityOf(owner),
339
- schema: 3,
346
+ schema: 4,
340
347
  kind: "card",
341
348
  state: "pending",
342
349
  rev: 0,
@@ -427,7 +434,7 @@ function normalizeEditedSpelling(text) {
427
434
  return `${head}(${body}:*)`;
428
435
  }
429
436
  function parseSubmittedSpelling(text) {
430
- return parseAllowRuleText(normalizeEditedSpelling(text));
437
+ return parseRuleText(normalizeEditedSpelling(text), "allow");
431
438
  }
432
439
  function checkEditedRuleText(text, command) {
433
440
  const parsed = parseSubmittedSpelling(text);
@@ -514,7 +521,7 @@ async function confirmEditedCandidate(rec, edit, deps) {
514
521
  ...rec,
515
522
  rev: rec.rev + 1,
516
523
  state: "approved",
517
- candidates: [...rec.candidates, { rule: checked.canonicalRule, scope }],
524
+ candidates: [...rec.candidates, { behavior: "allow", rule: checked.canonicalRule, scope }],
518
525
  offers: [...rec.offers, { kind: "single", candidate: index }],
519
526
  selectedOffer: rec.offers.length,
520
527
  edited: { index, text: edit.text, at: nowIso(deps), ...(checked.warnings !== undefined ? { warnings: checked.warnings.map((w) => w.code) } : {}) },
@@ -559,7 +566,8 @@ export async function redeemRuleTicket(opts) {
559
566
  if (!offerMembersOf(chosenOffer).includes(parsed.index)) {
560
567
  return { status: "refused", reason: "the ticket names an option the person did not choose" };
561
568
  }
562
- const parsedRule = parseAllowRuleText(candidate.rule);
569
+ const siblingsOf = candidate.behavior === "allow" ? { rec, offer: chosenOffer, index: parsed.index } : undefined;
570
+ const parsedRule = parseRuleText(candidate.rule, candidate.behavior);
563
571
  if ("reject" in parsedRule)
564
572
  return { status: "refused", reason: `${parsedRule.reject.code}: ${parsedRule.reject.message}` };
565
573
  let store;
@@ -587,12 +595,29 @@ export async function redeemRuleTicket(opts) {
587
595
  const theirs = again === undefined || isStale(again) ? undefined : again.redeemedDots?.[parsed.index];
588
596
  if (theirs === undefined)
589
597
  return { status: "refused", reason: "the approval record changed state concurrently" };
590
- return await applyRedemption({ deps: opts.deps, owner: caller, candidate, parsedRule: parsedRule.rule, dot: theirs, recordId: rec.id, kind: rec.kind, replay: true });
598
+ return await applyRedemption({ deps: opts.deps, owner: caller, candidate, parsedRule: parsedRule.rule, dot: theirs, recordId: rec.id, kind: rec.kind, replay: true, ...(siblingsOf !== undefined ? { siblingsOf } : {}) });
591
599
  }
592
600
  }
593
- return await applyRedemption({ deps: opts.deps, owner: caller, candidate, parsedRule: parsedRule.rule, dot, recordId: rec.id, kind: rec.kind, replay: known !== undefined });
601
+ return await applyRedemption({ deps: opts.deps, owner: caller, candidate, parsedRule: parsedRule.rule, dot, recordId: rec.id, kind: rec.kind, replay: known !== undefined, ...(siblingsOf !== undefined ? { siblingsOf } : {}) });
594
602
  }
595
603
  const REDEEM_MAX_ATTEMPTS = 8;
604
+ async function tighteningSiblingsStand(rec, offer, index, store, durableSnapshot) {
605
+ for (const member of offerMembersOf(offer)) {
606
+ if (member === index)
607
+ continue;
608
+ const sibling = rec.candidates[member];
609
+ if (sibling === undefined || sibling.behavior === "allow")
610
+ continue;
611
+ const dot = rec.redeemedDots?.[member];
612
+ if (dot === undefined)
613
+ return false;
614
+ const view = sibling.scope.kind === "session" ? await effectiveOrThrow(store, { sessionId: sibling.scope.sessionId }) : durableSnapshot;
615
+ const live = view.rules.some((r) => r.behavior === sibling.behavior && r.rule === sibling.rule && sameScope(r.scope, sibling.scope) && r.adds.some((a) => a.dot.actor === dot.actor && a.dot.counter === dot.counter));
616
+ if (!live)
617
+ return false;
618
+ }
619
+ return true;
620
+ }
596
621
  async function applyRedemption(args) {
597
622
  let store;
598
623
  try {
@@ -608,7 +633,14 @@ async function applyRedemption(args) {
608
633
  for (let attempt = 0; attempt < REDEEM_MAX_ATTEMPTS; attempt++) {
609
634
  let rev;
610
635
  try {
611
- rev = (await effectiveOrThrow(store)).rev;
636
+ const snapshot = await effectiveOrThrow(store);
637
+ rev = snapshot.rev;
638
+ if (args.siblingsOf !== undefined && !(await tighteningSiblingsStand(args.siblingsOf.rec, args.siblingsOf.offer, args.siblingsOf.index, store, snapshot))) {
639
+ return {
640
+ status: "refused",
641
+ reason: "withheld: this allow belongs to a batch whose deny/ask members do not all stand in the store — an allow must not stand wider than the list that was confirmed; redeem the batch (redeemRuleBatch) so the tightening members land first, or draw a fresh card",
642
+ };
643
+ }
612
644
  }
613
645
  catch (err) {
614
646
  return { status: "refused", reason: `could not read the permission-rule store: ${errText(err)}` };
@@ -617,6 +649,7 @@ async function applyRedemption(args) {
617
649
  try {
618
650
  res = await writer.apply({
619
651
  kind: "redemption-add",
652
+ behavior: args.parsedRule.behavior,
620
653
  rule: args.parsedRule.rule,
621
654
  scope: args.candidate.scope,
622
655
  tool: args.parsedRule.tool,
@@ -639,7 +672,7 @@ async function applyRedemption(args) {
639
672
  };
640
673
  }
641
674
  if (!("conflict" in res)) {
642
- return { status: "redeemed", rule: args.parsedRule.rule, scope: args.candidate.scope, dot: args.dot, rev: res.rev, alreadyRedeemed: args.replay };
675
+ return { status: "redeemed", behavior: args.parsedRule.behavior, rule: args.parsedRule.rule, scope: args.candidate.scope, dot: args.dot, rev: res.rev, alreadyRedeemed: args.replay };
643
676
  }
644
677
  }
645
678
  return { status: "refused", reason: `optimistic-concurrency retries exhausted after ${REDEEM_MAX_ATTEMPTS} attempts` };
@@ -654,7 +687,6 @@ export async function prepareCcImport(opts) {
654
687
  const skipped = [];
655
688
  const translated = [];
656
689
  const layers = [];
657
- const denyAskBuckets = { deny: 0, ask: 0 };
658
690
  for (const layer of opts.layers) {
659
691
  let raw;
660
692
  try {
@@ -700,66 +732,55 @@ export async function prepareCcImport(opts) {
700
732
  });
701
733
  continue;
702
734
  }
703
- for (const bucket of ["deny", "ask"]) {
704
- const entries = permissions?.[bucket];
705
- if (Array.isArray(entries))
706
- denyAskBuckets[bucket] += entries.length;
707
- else if (entries !== undefined && entries !== null) {
735
+ const scope = layer.layer === "userSettings" ? { kind: "global" } : { kind: "project", root: layer.root };
736
+ for (const behavior of RULE_BEHAVIORS) {
737
+ const entries = permissions?.[behavior];
738
+ if (entries === undefined || entries === null)
739
+ continue;
740
+ if (!Array.isArray(entries)) {
708
741
  skipped.push({
709
742
  rule: layer.path,
710
- reason: `settings ${bucket} bucket is not an array — its entries could not be counted for the not-imported report (it stays in the settings file either way)`,
743
+ reason: `settings ${behavior} bucket is not an array — no entry in it could be read (it stays in the settings file either way)`,
711
744
  });
712
- }
713
- }
714
- const allow = permissions?.allow;
715
- if (!Array.isArray(allow))
716
- continue;
717
- const scope = layer.layer === "userSettings" ? { kind: "global" } : { kind: "project", root: layer.root };
718
- for (const entry of allow) {
719
- if (typeof entry !== "string") {
720
- skipped.push({ rule: String(entry), reason: "settings entry is not a string" });
721
745
  continue;
722
746
  }
723
- if (entry.startsWith("Read(")) {
724
- const parsed = parseAllowRuleText(entry);
725
- if ("reject" in parsed) {
747
+ for (const entry of entries) {
748
+ if (typeof entry !== "string") {
749
+ skipped.push({ rule: String(entry), reason: `settings ${behavior} entry is not a string` });
750
+ continue;
751
+ }
752
+ if (behavior === "allow" && entry.startsWith("Read(") && "reject" in parseRuleText(entry, behavior)) {
726
753
  skipped.push({
727
754
  rule: entry,
728
755
  reason: "unsupported.form: only the Read(//abs-dir/**) directory form imports in v1 — this entry stays in the settings file, unimported",
729
756
  });
730
757
  continue;
731
758
  }
732
- if (!candidates.some((c) => c.rule === parsed.rule.rule && sameScope(c.scope, scope))) {
733
- candidates.push({ rule: parsed.rule.rule, scope });
734
- }
735
- continue;
736
- }
737
- if (!entry.startsWith("Bash(")) {
738
- const reason = /^[A-Za-z][A-Za-z0-9_]*\(.*\)$/.test(entry)
739
- ? "unsupported.tool: only Bash(...) command rules and Read(//abs-dir/**) directory rules import in v1 — this entry stays in the settings file, unimported"
740
- : /^[A-Za-z][A-Za-z0-9_-]*$/.test(entry)
759
+ if (!/^[A-Za-z][A-Za-z0-9_]*\(.*\)$/.test(entry)) {
760
+ const reason = /^[A-Za-z][A-Za-z0-9_-]*$/.test(entry)
741
761
  ? "unsupported.form: a bare tool-name entry is a name-set item, not a command rule — it stays in the settings file, unimported"
742
762
  : "unsupported.form: not a command rule spelling this version imports — it stays in the settings file, unimported";
743
- skipped.push({ rule: entry, reason });
744
- continue;
745
- }
746
- const parsed = parseAllowRuleText(entry);
747
- if ("reject" in parsed) {
748
- skipped.push({ rule: entry, reason: `${parsed.reject.code}: ${parsed.reject.message}` });
749
- continue;
763
+ skipped.push({ rule: entry, reason });
764
+ continue;
765
+ }
766
+ const parsed = parseRuleText(entry, behavior);
767
+ if ("reject" in parsed) {
768
+ skipped.push({ rule: entry, reason: `${parsed.reject.code}: ${parsed.reject.message}` });
769
+ continue;
770
+ }
771
+ if (candidates.some((c) => c.behavior === behavior && c.rule === parsed.rule.rule && sameScope(c.scope, scope)))
772
+ continue;
773
+ candidates.push({ behavior, rule: parsed.rule.rule, scope });
750
774
  }
751
- if (candidates.some((c) => c.rule === parsed.rule.rule && sameScope(c.scope, scope)))
752
- continue;
753
- candidates.push({ rule: parsed.rule.rule, scope });
754
775
  }
755
776
  }
756
- const preview = { candidates, skipped, translated, layers, uncovered: { ...IMPORT_UNCOVERED_LAYERS, denyAskBuckets } };
777
+ const preview = { candidates, skipped, translated, layers, uncovered: { ...IMPORT_UNCOVERED_LAYERS } };
757
778
  if (candidates.length === 0)
758
779
  return { preview };
759
780
  const record = {
760
781
  id: mintId(opts.deps, "rar"),
761
782
  ...recordIdentityOf(owner),
762
- schema: 3,
783
+ schema: 4,
763
784
  kind: "import",
764
785
  state: "pending",
765
786
  rev: 0,
@@ -785,15 +806,15 @@ export async function prepareStarterBatch(opts) {
785
806
  const owner = resolveCallerOwner(opts.principal, opts.owner, "prepareStarterBatch");
786
807
  const candidates = [];
787
808
  for (const text of STARTER_RULES) {
788
- const parsed = parseAllowRuleText(text);
809
+ const parsed = parseRuleText(text, "allow");
789
810
  if ("reject" in parsed)
790
811
  throw new Error(`starter rule "${text}" is not a valid rule: ${parsed.reject.message}`);
791
- candidates.push({ rule: parsed.rule.rule, scope: { kind: "global" } });
812
+ candidates.push({ behavior: "allow", rule: parsed.rule.rule, scope: { kind: "global" } });
792
813
  }
793
814
  const record = {
794
815
  id: mintId(opts.deps, "rar"),
795
816
  ...recordIdentityOf(owner),
796
- schema: 3,
817
+ schema: 4,
797
818
  kind: "starter",
798
819
  state: "pending",
799
820
  rev: 0,
@@ -833,18 +854,30 @@ export async function redeemRuleBatch(opts) {
833
854
  }
834
855
  const members = [];
835
856
  let rev = 0;
836
- for (const candidateIndex of offerMembersOf(chosenOffer)) {
857
+ const ordered = [...offerMembersOf(chosenOffer)].sort((a, b) => {
858
+ const ra = RULE_BEHAVIOR_PRECEDENCE[rec.candidates[a].behavior];
859
+ const rb = RULE_BEHAVIOR_PRECEDENCE[rec.candidates[b].behavior];
860
+ return ra !== rb ? ra - rb : a - b;
861
+ });
862
+ let tighteningFailed;
863
+ for (const candidateIndex of ordered) {
837
864
  const candidate = rec.candidates[candidateIndex];
838
- const row = { candidateIndex, rule: candidate.rule, scope: candidate.scope };
865
+ const row = { candidateIndex, behavior: candidate.behavior, rule: candidate.rule, scope: candidate.scope };
866
+ if (candidate.behavior === "allow" && tighteningFailed !== undefined) {
867
+ members.push({ ...row, status: "refused", reason: `withheld: candidate ${tighteningFailed} (a deny/ask member of this batch) did not land, and an allow must not stand wider than the list that was confirmed — redeem the batch again once the store accepts the tightening member` });
868
+ continue;
869
+ }
839
870
  let alreadyThere = false;
840
871
  {
841
872
  try {
842
873
  const snap = await effectiveOrThrow(store, candidate.scope.kind === "session" ? { sessionId: candidate.scope.sessionId } : undefined);
843
874
  rev = snap.rev;
844
- alreadyThere = snap.rules.some((r) => r.rule === candidate.rule && sameScope(r.scope, candidate.scope));
875
+ alreadyThere = snap.rules.some((r) => r.behavior === candidate.behavior && r.rule === candidate.rule && sameScope(r.scope, candidate.scope));
845
876
  }
846
877
  catch (err) {
847
878
  members.push({ ...row, status: "refused", reason: `could not read the permission-rule store: ${errText(err)}` });
879
+ if (candidate.behavior !== "allow")
880
+ tighteningFailed ??= candidateIndex;
848
881
  continue;
849
882
  }
850
883
  }
@@ -855,8 +888,25 @@ export async function redeemRuleBatch(opts) {
855
888
  });
856
889
  if (res.status === "refused") {
857
890
  members.push({ ...row, status: "refused", reason: res.reason });
891
+ if (candidate.behavior !== "allow")
892
+ tighteningFailed ??= candidateIndex;
858
893
  continue;
859
894
  }
895
+ if (candidate.behavior !== "allow") {
896
+ let live = false;
897
+ try {
898
+ const snap = await effectiveOrThrow(store, candidate.scope.kind === "session" ? { sessionId: candidate.scope.sessionId } : undefined);
899
+ live = snap.rules.some((r) => r.behavior === candidate.behavior && r.rule === candidate.rule && sameScope(r.scope, candidate.scope) && r.adds.some((a) => a.dot.actor === res.dot.actor && a.dot.counter === res.dot.counter));
900
+ }
901
+ catch {
902
+ live = false;
903
+ }
904
+ if (!live) {
905
+ members.push({ ...row, status: "refused", reason: `this ${candidate.behavior} member's redeemed dot ${res.dot.actor}#${res.dot.counter} is not live in the store (removed since it was redeemed) — a removed rule is not re-landed by replaying its record; a new consent is required` });
906
+ tighteningFailed ??= candidateIndex;
907
+ continue;
908
+ }
909
+ }
860
910
  rev = candidate.scope.kind === "session" ? Math.max(rev, res.rev) : res.rev;
861
911
  members.push({ ...row, status: alreadyThere ? "deduped" : "persisted", alreadyRedeemed: res.alreadyRedeemed, dot: res.dot });
862
912
  }