@sema-agent/core 7.8.0 → 7.9.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 (73) hide show
  1. package/CHANGELOG.md +52 -1
  2. package/dist/agents/subagent.d.ts +1 -1
  3. package/dist/core/ask-origin.d.ts +12 -1
  4. package/dist/core/ask-origin.js +5 -1
  5. package/dist/core/checkpoint-store.d.ts +34 -28
  6. package/dist/core/gate-lanes.js +49 -23
  7. package/dist/core/gate-outcome.d.ts +8 -4
  8. package/dist/core/gate-outcome.js +2 -1
  9. package/dist/core/hooks.d.ts +56 -31
  10. package/dist/core/permission-rule-consent.d.ts +29 -36
  11. package/dist/core/permission-rule-consent.js +110 -60
  12. package/dist/core/permission-rule-model.d.ts +254 -55
  13. package/dist/core/permission-rule-model.js +323 -43
  14. package/dist/core/permission-rule-org.d.ts +9 -8
  15. package/dist/core/permission-rule-org.js +6 -15
  16. package/dist/core/permission-rule-provider.d.ts +10 -6
  17. package/dist/core/permission-rule-provider.js +12 -8
  18. package/dist/core/permission-rule-session.d.ts +7 -6
  19. package/dist/core/permission-rule-session.js +29 -10
  20. package/dist/core/permission-rule-store.d.ts +40 -18
  21. package/dist/core/permission-rule-store.js +68 -42
  22. package/dist/core/permission-rule-sync.d.ts +9 -3
  23. package/dist/core/permission-rule-sync.js +29 -25
  24. package/dist/core/permission-rule-syntax.d.ts +30 -0
  25. package/dist/core/permission-rule-syntax.js +44 -0
  26. package/dist/core/permission-rules.d.ts +55 -33
  27. package/dist/core/permission-rules.js +65 -55
  28. package/dist/core/persisted-rule-arms.d.ts +56 -0
  29. package/dist/core/persisted-rule-arms.js +48 -0
  30. package/dist/core/runner/contracts.d.ts +21 -2
  31. package/dist/core/runner/permission-rule-lanes.d.ts +33 -26
  32. package/dist/core/runner/permission-rule-lanes.js +27 -21
  33. package/dist/core/runner/prepare-gate-stations.js +1 -1
  34. package/dist/core/runner/prepare-safety-scan.js +8 -2
  35. package/dist/core/runner/prepare-task.js +1 -1
  36. package/dist/core/runner/resume-admission.d.ts +53 -0
  37. package/dist/core/runner/resume-admission.js +83 -0
  38. package/dist/core/runner/resume-apply.d.ts +50 -0
  39. package/dist/core/runner/resume-apply.js +184 -0
  40. package/dist/core/runner/resume-checkpoint-screen.d.ts +18 -0
  41. package/dist/core/runner/resume-checkpoint-screen.js +108 -0
  42. package/dist/core/runner/resume-claim.d.ts +32 -0
  43. package/dist/core/runner/resume-claim.js +27 -0
  44. package/dist/core/runner/resume-internals-and-config.d.ts +33 -0
  45. package/dist/core/runner/resume-internals-and-config.js +50 -0
  46. package/dist/core/runner/resume-policy-outcome.d.ts +31 -0
  47. package/dist/core/runner/resume-policy-outcome.js +127 -0
  48. package/dist/core/runner/resume-preflight.d.ts +40 -0
  49. package/dist/core/runner/resume-preflight.js +122 -0
  50. package/dist/core/runner/resume-review-outcome.d.ts +30 -0
  51. package/dist/core/runner/resume-review-outcome.js +88 -0
  52. package/dist/core/runner/run-harness-handlers.js +1 -1
  53. package/dist/core/runner/runtask.d.ts +20 -0
  54. package/dist/core/runner/runtask.js +85 -734
  55. package/dist/core/runner/tool-end-body.d.ts +12 -5
  56. package/dist/core/runner/tool-end-body.js +5 -5
  57. package/dist/core/runner/tool-output-projection.d.ts +9 -6
  58. package/dist/core/runner/tool-output-projection.js +1 -18
  59. package/dist/core/runner/turn-attachments.d.ts +2 -2
  60. package/dist/core/store-contracts/permission-rule-sync-contract.js +29 -10
  61. package/dist/core/tool-roster.d.ts +9 -0
  62. package/dist/core/tool-roster.js +15 -5
  63. package/dist/index.d.ts +2 -2
  64. package/dist/index.js +2 -2
  65. package/dist/stores/file/checkpoint-store.js +1 -1
  66. package/dist/stores/file/permission-rule-store.d.ts +28 -23
  67. package/dist/stores/file/permission-rule-store.js +64 -16
  68. package/dist/tools/fs/bash-readonly-classifier.d.ts +1 -1
  69. package/dist/tools/fs/fs-pdf.d.ts +1 -1
  70. package/dist/tools/fs/notebook.d.ts +1 -1
  71. package/dist/tools/fs/safety.d.ts +1 -1
  72. package/package.json +1 -1
  73. package/test/export-surface.snapshot.json +43 -11
@@ -1022,22 +1022,27 @@ export interface PersistedRuleHitRule {
1022
1022
  readonly dots?: readonly import("./permission-rule-model.js").RuleDot[];
1023
1023
  }
1024
1024
  /**
1025
- * design/252 G-2, re-formed by design/375 (BREAKING) the structured answer a persisted-rule lane
1026
- * gives: the COVERAGE SET that admits the call. A single rule admitting the whole command is a
1027
- * one-element set (the historical case); a compound admitted by the conjunction of per-segment rules
1028
- * (design/375 §5.1) is the set of those rules, in segment order. `rules` is non-empty by contract
1029
- * an empty set covers nothing and is not a hit (the normalizer reads it as a clean negative).
1025
+ * design/252 G-2, re-formed by design/375 and again by the three-behavior model (BREAKING) the
1026
+ * structured answer a persisted-rule lane gives: WHICH behavior spoke for the call, and the rule set it
1027
+ * stands on. For an `allow` the set is the COVERAGE SET that admits the call a single rule admitting
1028
+ * the whole command is a one-element set (the historical case); a compound admitted by the conjunction
1029
+ * of per-segment rules (design/375 §5.1) is the set of those rules, in segment order. For a `deny` or an
1030
+ * `ask` the set is the one rule of that behavior that reaches the call (the lane's precedence walk —
1031
+ * deny > ask > allow — already chose it). `rules` is non-empty by contract — an empty set covers nothing
1032
+ * and is not a hit (the normalizer reads it as a clean negative).
1030
1033
  *
1031
- * Why dots and not an id: a persisted allow rule HAS no scalar id — it is a (text, scope) group whose
1034
+ * Why dots and not an id: a persisted rule HAS no scalar id — it is a (behavior, text, scope) group whose
1032
1035
  * identity is the set of add dots that redeemed it, and two concurrently approved records for one text
1033
1036
  * each carry their own. The set is the identity; a joined string would not be one.
1034
1037
  *
1035
- * The pre-375 single-rule object shape (`{ rule, dots? }`) is NOT accepted: on this loosening seam an
1036
- * unrecognized answer degrades to a clean negative (more asks), never to a match — the safe failure
1037
- * shape for a lane that missed the same-window upgrade.
1038
+ * The pre-375 single-rule object shape (`{ rule, dots? }`) and the pre-behavior `{ rules }` shape are
1039
+ * NOT accepted: on this loosening seam an unrecognized answer degrades to a clean negative (more asks),
1040
+ * never to a match — the safe failure shape for a lane that missed the same-window upgrade.
1038
1041
  */
1039
1042
  export interface PersistedRuleHit {
1040
- /** The coverage set every rule the decision stands on. Non-empty. */
1043
+ /** What the deciding rule(s) say about the call the closed set's word. */
1044
+ readonly behavior: import("./permission-rule-model.js").RuleBehavior;
1045
+ /** The rule set the verdict stands on. Non-empty. */
1041
1046
  readonly rules: readonly PersistedRuleHitRule[];
1042
1047
  }
1043
1048
  /**
@@ -1065,8 +1070,9 @@ export interface PersistedRuleUnreadable {
1065
1070
  export interface PersistedRuleCoverage {
1066
1071
  readonly segmentCoverage: readonly import("./permission-rule-model.js").SegmentCoverage[];
1067
1072
  }
1068
- /** Every shape a lane may answer with. A bare string stays valid and unchanged. */
1069
- export type PersistedRuleAnswer = string | PersistedRuleHit | PersistedRuleCoverage | PersistedRuleUnreadable | undefined;
1073
+ /** Every shape a lane may answer with. The retired bare-string form (a single allow rule's text) is read
1074
+ * by the normalizer as a clean negative, like every other unrecognized shape. */
1075
+ export type PersistedRuleAnswer = PersistedRuleHit | PersistedRuleCoverage | PersistedRuleUnreadable | undefined;
1070
1076
  /** Inputs to the two-phase tool gate. `adjudicate`/`resolveAsk` are pre-bound to the task abort
1071
1077
  * signal; when the caller also supplies {@link ToolGateInput.callSignal}, the Runner's closures
1072
1078
  * additionally bind their waits to that per-call signal (`AbortSignal.any` of the two), so a turn
@@ -1404,15 +1410,27 @@ export interface ToolGateInput {
1404
1410
  */
1405
1411
  isMarkedUnresolvable?: (toolCallId: string) => boolean;
1406
1412
  /**
1407
- * design/179 — the persisted allow-rule lane. Present only when a deployment wired a rule store; absent
1408
- * it, this whole step does not exist and the gate's decision path is byte-identical to what it was.
1413
+ * design/179 — the persisted-rule lane. Present only when a deployment wired a rule store; absent it,
1414
+ * this whole step does not exist and the gate's decision path is byte-identical to what it was.
1409
1415
  *
1410
- * A persisted rule is the standing form of ONE recorded human approval, so it resolves the asks a real
1411
- * person saying "allow" would have resolvedand nothing else. It sits post-fold, at the entry of the
1412
- * `ask` branch, BEFORE the classifier and AFTER every tighten: earlier and a tighten would simply put
1413
- * the ask back; later and the classifier would already have spoken.
1416
+ * The lane answers with the BEHAVIOR that spoke ({@link PersistedRuleHit}), and the gate consumes the
1417
+ * three behaviors at two different placesthe upstream arbiter's order, deny > ask > allow, across
1418
+ * the person's own standing rules:
1419
+ * · a DENY row refuses the call outright, on EVERY decision (an allow from the policy fold included):
1420
+ * it is the person's own "never run this" (their settings' deny list, imported), the personal-store
1421
+ * sibling of an org deny, and it runs right after the org layer — nothing downstream re-opens it;
1422
+ * · an ASK row is the person's standing "ask me each time": it turns a surviving allow into an ask
1423
+ * and stamps `matchedAskRule` on it (the same marker an explicit `ask` permission rule stamps), so
1424
+ * the ask's origin is `ask_rule` — the auto-mode classifier never answers it and a persisted ALLOW
1425
+ * never clears it; on a call that is already an ask it stamps the marker and leaves the rest;
1426
+ * · an ALLOW row is the standing form of ONE recorded human approval, so it resolves the asks a real
1427
+ * person saying "allow" would have resolved — and nothing else. It sits post-fold, at the entry of
1428
+ * the `ask` branch, BEFORE the classifier and AFTER every tighten: earlier and a tighten would
1429
+ * simply put the ask back; later and the classifier would already have spoken.
1414
1430
  *
1415
- * Its consumption predicate is its own, not the classifier's blanket flip:
1431
+ * The deny/ask arms tighten and take no conjunct but the two structural ones every lane shares (the
1432
+ * engine's own content-ask tool is exempt; the lane must be wired). The ALLOW arm's consumption
1433
+ * predicate is its own, not the classifier's blanket flip:
1416
1434
  * · `requiresRealApproval` asks are never consumed — the two integrity gates are the floor, and the
1417
1435
  * same flag is what a v2 organization-level ask will carry;
1418
1436
  * · a PreToolUse hook's ask is never consumed — that is the deployment's own adjudication, and a rule
@@ -1441,19 +1459,26 @@ export interface ToolGateInput {
1441
1459
  * bullets above are what keep integrity/hook/mandated asks out of that set.
1442
1460
  */
1443
1461
  persistedRules?: {
1444
- /** The rule COVERAGE that admits this call, or `undefined`. Must not throw: a store that cannot
1445
- * be read answers "no rule" and discloses on its own channel (fail-closed = more asks).
1462
+ /** The lane's verdict for this call a {@link PersistedRuleHit} naming the behavior that spoke and
1463
+ * the rule set it stands on, a coverage-carrying clean negative, an unreadable-store report, or
1464
+ * `undefined` (the store was READ and no rule speaks). Must not throw — and a store that cannot be
1465
+ * read answers `{ unreadable: true }`, NEVER `undefined`: the gate reads the unreadable report as
1466
+ * fail-closed (an allow tightens to a real-approval ask of origin `rule_store_unavailable`), while
1467
+ * `undefined` is a successful no-match that leaves an allow standing. An adapter that answers
1468
+ * `undefined` on a read failure silently disarms every deny/ask row it holds.
1446
1469
  *
1447
- * TWO ACCEPTED SHAPES (design/252 G-2 / design/375 BREAKING): the canonical rule TEXT (a
1448
- * single-rule hit with no identity), or a {@link PersistedRuleHit} carrying the coverage SET
1449
- * each member's canonical text plus its add DOTS. A single rule admitting the whole command is a
1450
- * one-element set; a compound admitted by the per-segment conjunction (design/375 §5.1) is the
1451
- * set of the covering rules. The dots are a rule's durable identity, and the text is not one (it
1452
- * reaches a card sanitized and capped); a lane that can name them lets the shadowed-ask evidence
1453
- * say WHICH stored rules matched rather than merely what they looked like. The retired pre-375
1454
- * single-rule object shape (`{ rule, dots? }`) is read as a clean negative by the normalizer —
1455
- * a stale lane degrades toward asking, never toward an allow. */
1456
- admits: (req: ToolCallRequest) => Promise<PersistedRuleAnswer>;
1470
+ * For an ALLOW the set is the COVERAGE SET — each member's canonical text plus its add DOTS. A
1471
+ * single rule admitting the whole command is a one-element set; a compound admitted by the
1472
+ * per-segment conjunction (design/375 §5.1) is the set of the covering rules. The dots are a rule's
1473
+ * durable identity, and the text is not one (it reaches a card sanitized and capped); a lane that
1474
+ * can name them lets the shadowed-ask evidence say WHICH stored rules matched rather than merely
1475
+ * what they looked like. The retired shapes a bare rule TEXT, the pre-375 `{ rule, dots? }` object,
1476
+ * a `{ rules }` hit without its behavior are read as a clean negative by the normalizer: a stale
1477
+ * lane degrades toward asking, never toward an allow. Consulted on EVERY decision (a deny/ask row
1478
+ * must be able to tighten an allow), so the lane pays one store read per call on the tools it
1479
+ * speaks for; the runner's lane hands the org layer's read to it, so a governed deployment still
1480
+ * pays one. */
1481
+ adjudicate: (req: ToolCallRequest) => Promise<PersistedRuleAnswer>;
1457
1482
  /** Observation sink for an ask a rule coverage resolved — the allow-side disclosure. `rules` is
1458
1483
  * the coverage set's canonical texts in reporting order (design/375: a conjunction names every
1459
1484
  * rule the decision stands on). Never affects the outcome. */
@@ -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
  }