@sema-agent/core 7.7.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 (186) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/dist/agents/agent-transcript-tool.js +2 -2
  3. package/dist/agents/list-agents-tool.js +2 -3
  4. package/dist/agents/observer.js +2 -2
  5. package/dist/agents/send-message-tool.js +2 -1
  6. package/dist/agents/subagent-steps.js +5 -4
  7. package/dist/agents/subagent.d.ts +1 -1
  8. package/dist/agents/subagent.js +2 -3
  9. package/dist/core/ask-origin.d.ts +12 -1
  10. package/dist/core/ask-origin.js +5 -1
  11. package/dist/core/ask-question.js +2 -2
  12. package/dist/core/checkpoint-store.d.ts +64 -24
  13. package/dist/core/context-edit.d.ts +0 -22
  14. package/dist/core/context-edit.js +2 -11
  15. package/dist/core/engine-notice.d.ts +6 -0
  16. package/dist/core/fs-write-gate-policy.d.ts +7 -1
  17. package/dist/core/fs-write-gate-policy.js +7 -7
  18. package/dist/core/gate-fold.js +2 -2
  19. package/dist/core/gate-lanes.js +53 -26
  20. package/dist/core/gate-outcome.d.ts +8 -4
  21. package/dist/core/gate-outcome.js +2 -1
  22. package/dist/core/governance-codes.d.ts +1 -1
  23. package/dist/core/governance-codes.js +6 -0
  24. package/dist/core/hooks.d.ts +64 -33
  25. package/dist/core/hooks.js +1 -1
  26. package/dist/core/lsp.js +2 -2
  27. package/dist/core/mcp-server-spec.d.ts +17 -0
  28. package/dist/core/mcp.js +3 -1
  29. package/dist/core/memory-engine/tools.js +4 -12
  30. package/dist/core/permission-rule-consent.d.ts +29 -36
  31. package/dist/core/permission-rule-consent.js +110 -60
  32. package/dist/core/permission-rule-model.d.ts +254 -55
  33. package/dist/core/permission-rule-model.js +323 -43
  34. package/dist/core/permission-rule-org.d.ts +9 -8
  35. package/dist/core/permission-rule-org.js +6 -15
  36. package/dist/core/permission-rule-provider.d.ts +10 -6
  37. package/dist/core/permission-rule-provider.js +12 -8
  38. package/dist/core/permission-rule-session.d.ts +7 -6
  39. package/dist/core/permission-rule-session.js +29 -10
  40. package/dist/core/permission-rule-store.d.ts +40 -18
  41. package/dist/core/permission-rule-store.js +68 -42
  42. package/dist/core/permission-rule-sync.d.ts +9 -3
  43. package/dist/core/permission-rule-sync.js +29 -25
  44. package/dist/core/permission-rule-syntax.d.ts +30 -0
  45. package/dist/core/permission-rule-syntax.js +44 -0
  46. package/dist/core/permission-rules.d.ts +85 -33
  47. package/dist/core/permission-rules.js +104 -88
  48. package/dist/core/persisted-rule-arms.d.ts +56 -0
  49. package/dist/core/persisted-rule-arms.js +48 -0
  50. package/dist/core/present-plan-tool.js +3 -4
  51. package/dist/core/runner/active-skill-scope.js +7 -4
  52. package/dist/core/runner/compaction-call-options.d.ts +1 -1
  53. package/dist/core/runner/contracts.d.ts +46 -3
  54. package/dist/core/runner/permission-rule-lanes.d.ts +33 -26
  55. package/dist/core/runner/permission-rule-lanes.js +27 -21
  56. package/dist/core/runner/prepare-ask-lane.d.ts +1 -1
  57. package/dist/core/runner/prepare-caps-and-workflow.d.ts +5 -4
  58. package/dist/core/runner/prepare-caps-and-workflow.js +16 -11
  59. package/dist/core/runner/prepare-defer-classify.d.ts +5 -26
  60. package/dist/core/runner/prepare-defer-classify.js +10 -10
  61. package/dist/core/runner/prepare-delegation-surface.d.ts +5 -4
  62. package/dist/core/runner/prepare-delegation-surface.js +15 -17
  63. package/dist/core/runner/prepare-gate-stations.d.ts +4 -1
  64. package/dist/core/runner/prepare-gate-stations.js +4 -2
  65. package/dist/core/runner/prepare-hands-readface.d.ts +3 -2
  66. package/dist/core/runner/prepare-hands-readface.js +3 -2
  67. package/dist/core/runner/prepare-inherited-gate.d.ts +1 -1
  68. package/dist/core/runner/prepare-inherited-gate.js +1 -0
  69. package/dist/core/runner/prepare-lsp.d.ts +4 -3
  70. package/dist/core/runner/prepare-lsp.js +3 -2
  71. package/dist/core/runner/prepare-memory-engine-session.d.ts +4 -4
  72. package/dist/core/runner/prepare-memory-engine-session.js +4 -2
  73. package/dist/core/runner/prepare-offload-wrappers.js +7 -5
  74. package/dist/core/runner/prepare-park-ask.d.ts +4 -0
  75. package/dist/core/runner/prepare-park-ask.js +4 -1
  76. package/dist/core/runner/prepare-project-context.d.ts +5 -23
  77. package/dist/core/runner/prepare-project-context.js +7 -5
  78. package/dist/core/runner/prepare-prompt-assembly.d.ts +1 -1
  79. package/dist/core/runner/prepare-protocol-tools.d.ts +5 -1
  80. package/dist/core/runner/prepare-protocol-tools.js +38 -21
  81. package/dist/core/runner/prepare-question-face.d.ts +4 -3
  82. package/dist/core/runner/prepare-question-face.js +5 -3
  83. package/dist/core/runner/prepare-run-refs.d.ts +16 -0
  84. package/dist/core/runner/prepare-run-refs.js +6 -0
  85. package/dist/core/runner/prepare-safety-scan.js +48 -2
  86. package/dist/core/runner/prepare-task.js +37 -19
  87. package/dist/core/runner/prepare-tool-disclosure-mount.d.ts +5 -2
  88. package/dist/core/runner/prepare-tool-disclosure-mount.js +3 -1
  89. package/dist/core/runner/prepare-wiring-manifest.d.ts +4 -0
  90. package/dist/core/runner/prepare-wiring-manifest.js +3 -8
  91. package/dist/core/runner/resume-admission.d.ts +53 -0
  92. package/dist/core/runner/resume-admission.js +83 -0
  93. package/dist/core/runner/resume-apply.d.ts +50 -0
  94. package/dist/core/runner/resume-apply.js +184 -0
  95. package/dist/core/runner/resume-checkpoint-screen.d.ts +18 -0
  96. package/dist/core/runner/resume-checkpoint-screen.js +108 -0
  97. package/dist/core/runner/resume-claim.d.ts +32 -0
  98. package/dist/core/runner/resume-claim.js +27 -0
  99. package/dist/core/runner/resume-internals-and-config.d.ts +33 -0
  100. package/dist/core/runner/resume-internals-and-config.js +50 -0
  101. package/dist/core/runner/resume-policy-outcome.d.ts +31 -0
  102. package/dist/core/runner/resume-policy-outcome.js +127 -0
  103. package/dist/core/runner/resume-preflight.d.ts +40 -0
  104. package/dist/core/runner/resume-preflight.js +122 -0
  105. package/dist/core/runner/resume-review-outcome.d.ts +30 -0
  106. package/dist/core/runner/resume-review-outcome.js +88 -0
  107. package/dist/core/runner/run-harness-handlers.d.ts +46 -0
  108. package/dist/core/runner/run-harness-handlers.js +345 -0
  109. package/dist/core/runner/run-turn-boundary.d.ts +69 -0
  110. package/dist/core/runner/run-turn-boundary.js +694 -0
  111. package/dist/core/runner/runtask.d.ts +20 -0
  112. package/dist/core/runner/runtask.js +129 -1774
  113. package/dist/core/runner/session-rule-policy.js +9 -4
  114. package/dist/core/runner/synthetic-tools.js +5 -8
  115. package/dist/core/runner/tool-disclosure.d.ts +4 -0
  116. package/dist/core/runner/tool-disclosure.js +9 -5
  117. package/dist/core/runner/tool-end-body.d.ts +12 -5
  118. package/dist/core/runner/tool-end-body.js +8 -7
  119. package/dist/core/runner/tool-face-overlay.d.ts +40 -0
  120. package/dist/core/runner/tool-face-overlay.js +125 -0
  121. package/dist/core/runner/tool-output-projection.d.ts +14 -1
  122. package/dist/core/runner/tool-output-projection.js +24 -18
  123. package/dist/core/runner/turn-attachments.d.ts +2 -2
  124. package/dist/core/sensitive-path-policy.d.ts +3 -0
  125. package/dist/core/sensitive-path-policy.js +6 -5
  126. package/dist/core/shared-memory/tools.js +3 -6
  127. package/dist/core/store-contracts/checkpoint-store-contract.js +23 -0
  128. package/dist/core/store-contracts/permission-rule-sync-contract.js +29 -10
  129. package/dist/core/task-event.d.ts +10 -0
  130. package/dist/core/task-registry.js +4 -5
  131. package/dist/core/task-spec.d.ts +8 -0
  132. package/dist/core/tool-catalog-entries.d.ts +48 -0
  133. package/dist/core/tool-catalog-entries.js +174 -0
  134. package/dist/core/tool-conformance.d.ts +22 -0
  135. package/dist/core/tool-conformance.js +37 -0
  136. package/dist/core/tool-face.d.ts +171 -0
  137. package/dist/core/tool-face.js +25 -0
  138. package/dist/core/tool-policy.d.ts +24 -0
  139. package/dist/core/tool-policy.js +4 -2
  140. package/dist/core/tool-registry.d.ts +114 -0
  141. package/dist/core/tool-registry.js +114 -0
  142. package/dist/core/tool-result-budget.d.ts +0 -3
  143. package/dist/core/tool-result-budget.js +4 -3
  144. package/dist/core/tool-result-store.d.ts +0 -18
  145. package/dist/core/tool-result-store.js +2 -18
  146. package/dist/core/tool-roster.d.ts +459 -0
  147. package/dist/core/tool-roster.js +507 -0
  148. package/dist/core/tool-spec.d.ts +54 -3
  149. package/dist/core/tools.js +7 -0
  150. package/dist/core/wiring-manifest.d.ts +13 -0
  151. package/dist/core/wiring-manifest.js +2 -1
  152. package/dist/core/write-protect.d.ts +2 -1
  153. package/dist/core/write-protect.js +5 -4
  154. package/dist/engine/compaction/utils.d.ts +6 -0
  155. package/dist/engine/compaction/utils.js +19 -19
  156. package/dist/engine/loop/types.d.ts +6 -0
  157. package/dist/index.d.ts +17 -6
  158. package/dist/index.js +17 -6
  159. package/dist/orchestration/goal.js +2 -1
  160. package/dist/orchestration/run-spec.js +3 -5
  161. package/dist/orchestration/run-workflow-tool.js +2 -2
  162. package/dist/prompt-assembly/event-registry.js +3 -3
  163. package/dist/prompt-assembly/tool-catalog.d.ts +2 -2
  164. package/dist/stores/file/checkpoint-store.js +1 -1
  165. package/dist/stores/file/permission-rule-store.d.ts +28 -23
  166. package/dist/stores/file/permission-rule-store.js +64 -16
  167. package/dist/tools/fs/bash-readonly-classifier.d.ts +1 -1
  168. package/dist/tools/fs/fs-bash.d.ts +1 -1
  169. package/dist/tools/fs/fs-bash.js +7 -11
  170. package/dist/tools/fs/fs-pdf.d.ts +1 -1
  171. package/dist/tools/fs/fs-read.js +2 -2
  172. package/dist/tools/fs/fs-search-tools.d.ts +0 -7
  173. package/dist/tools/fs/fs-search-tools.js +3 -16
  174. package/dist/tools/fs/fs-write.js +4 -6
  175. package/dist/tools/fs/notebook.d.ts +1 -1
  176. package/dist/tools/fs/repo-map.js +2 -2
  177. package/dist/tools/fs/safety.d.ts +3 -2
  178. package/dist/tools/fs/safety.js +6 -5
  179. package/dist/tools/monitor.js +2 -2
  180. package/dist/tools/scheduler-tools.js +5 -8
  181. package/dist/tools/task-list.js +5 -8
  182. package/dist/tools/todo.js +2 -2
  183. package/dist/tools/web.js +3 -6
  184. package/dist/tools/worktree.js +3 -4
  185. package/package.json +4 -1
  186. package/test/export-surface.snapshot.json +189 -17
@@ -1,6 +1,6 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { canonicalize } from "./canonical-json.js";
3
- import { escapeForDisclosure, isRuleLive, parseAllowRuleText } from "./permission-rule-model.js";
3
+ import { escapeForDisclosure, isRuleBehavior, isRuleLive, parseRuleText } from "./permission-rule-model.js";
4
4
  export function sameRuleOwner(a, b) {
5
5
  return a.kind === "principal" ? b.kind === "principal" && a.principal === b.principal : b.kind === "local-owner";
6
6
  }
@@ -19,19 +19,22 @@ export function sameScope(a, b) {
19
19
  function sameDot(a, b) {
20
20
  return a.actor === b.actor && a.counter === b.counter;
21
21
  }
22
+ export function sameRuleIdentity(a, b) {
23
+ return a.behavior === b.behavior && a.rule === b.rule && sameScope(a.scope, b.scope);
24
+ }
22
25
  export function applyTombstones(rules, tombstones) {
23
26
  return rules
24
27
  .map((r) => {
25
- const removed = tombstones.filter((t) => t.rule === r.rule && sameScope(t.scope, r.scope)).flatMap((t) => t.removedDots);
28
+ const removed = tombstones.filter((t) => sameRuleIdentity(t, r)).flatMap((t) => t.removedDots);
26
29
  return removed.length === 0 ? r : { ...r, adds: r.adds.filter((a) => !removed.some((d) => sameDot(d, a.dot))) };
27
30
  })
28
31
  .filter(isRuleLive);
29
32
  }
30
33
  export function foldDelta(rules, delta) {
31
34
  const next = rules.map((r) => ({ ...r, adds: [...r.adds] }));
32
- const existing = next.find((r) => r.rule === delta.rule && sameScope(r.scope, delta.scope));
35
+ const existing = next.find((r) => sameRuleIdentity(r, delta));
33
36
  if (existing === undefined) {
34
- next.push({ rule: delta.rule, tool: delta.tool, match: delta.match, command: delta.command, scope: delta.scope, adds: [delta.add] });
37
+ next.push({ behavior: delta.behavior, rule: delta.rule, tool: delta.tool, match: delta.match, command: delta.command, scope: delta.scope, adds: [delta.add] });
35
38
  return next;
36
39
  }
37
40
  if (!existing.adds.some((a) => sameDot(a.dot, delta.add.dot)))
@@ -41,7 +44,7 @@ export function foldDelta(rules, delta) {
41
44
  export function addDotsOf(rules) {
42
45
  return rules.flatMap((r) => r.adds.map((a) => a.dot));
43
46
  }
44
- const ADD_ONLY_KEYS = ["add", "redemption", "rule", "command", "match", "tool"];
47
+ const ADD_ONLY_KEYS = ["add", "redemption", "rule", "command", "match", "tool", "behavior"];
45
48
  export function assertDeleteDeltaCarriesNoAdd(delta) {
46
49
  const smuggled = ADD_ONLY_KEYS.filter((k) => Object.hasOwn(delta, k));
47
50
  if (smuggled.length > 0) {
@@ -87,8 +90,8 @@ function scopeKey(scope) {
87
90
  function dotKey(dot) {
88
91
  return JSON.stringify([dot.actor, dot.counter]);
89
92
  }
90
- function ruleIdentityKey(rule, scope) {
91
- return JSON.stringify([rule, scopeKey(scope)]);
93
+ function ruleIdentityKey(behavior, rule, scope) {
94
+ return JSON.stringify([behavior, rule, scopeKey(scope)]);
92
95
  }
93
96
  function compareDots(a, b) {
94
97
  return a.actor < b.actor ? -1 : a.actor > b.actor ? 1 : a.counter - b.counter;
@@ -124,7 +127,9 @@ export function joinFrontiers(a, b) {
124
127
  return out;
125
128
  }
126
129
  export function normalizePersistedRule(r) {
127
- const parsed = parseAllowRuleText(r.rule);
130
+ if (!isRuleBehavior(r.behavior))
131
+ return { reject: "invalid_rule_text" };
132
+ const parsed = parseRuleText(r.rule, r.behavior);
128
133
  if ("reject" in parsed)
129
134
  return { reject: "invalid_rule_text" };
130
135
  const p = parsed.rule;
@@ -142,13 +147,13 @@ export function screenRuleSyncState(state) {
142
147
  continue;
143
148
  if (r.scope?.kind === "session") {
144
149
  for (const a of r.adds)
145
- rejected.push({ rule: r.rule, scope: r.scope, dot: a.dot, reason: "session_scope_not_durable" });
150
+ rejected.push({ behavior: r.behavior, rule: r.rule, scope: r.scope, dot: a.dot, reason: "session_scope_not_durable" });
146
151
  continue;
147
152
  }
148
153
  const n = normalizePersistedRule(r);
149
154
  if ("reject" in n) {
150
155
  for (const a of r.adds)
151
- rejected.push({ rule: r.rule, scope: r.scope, dot: a.dot, reason: n.reject });
156
+ rejected.push({ behavior: r.behavior, rule: r.rule, scope: r.scope, dot: a.dot, reason: n.reject });
152
157
  continue;
153
158
  }
154
159
  rules.push(r);
@@ -156,7 +161,11 @@ export function screenRuleSyncState(state) {
156
161
  const tombstones = [];
157
162
  for (const t of state.tombstones) {
158
163
  if (t?.scope?.kind === "session") {
159
- rejected.push({ rule: t.rule, scope: t.scope, dot: t.deletedBy, reason: "session_scope_not_durable" });
164
+ rejected.push({ behavior: t.behavior, rule: t.rule, scope: t.scope, dot: t.deletedBy, reason: "session_scope_not_durable" });
165
+ continue;
166
+ }
167
+ if (!isRuleBehavior(t.behavior)) {
168
+ rejected.push({ behavior: t.behavior, rule: t.rule, scope: t.scope, dot: t.deletedBy, reason: "invalid_rule_text" });
160
169
  continue;
161
170
  }
162
171
  tombstones.push(t);
@@ -168,7 +177,7 @@ export function joinRuleStates(a, b) {
168
177
  const sb = screenRuleSyncState(b).state;
169
178
  const ruleGroups = new Map();
170
179
  for (const r of [...sa.rules, ...sb.rules]) {
171
- const key = ruleIdentityKey(r.rule, r.scope);
180
+ const key = ruleIdentityKey(r.behavior, r.rule, r.scope);
172
181
  const existing = ruleGroups.get(key);
173
182
  if (existing === undefined) {
174
183
  ruleGroups.set(key, { ...r, adds: [...r.adds] });
@@ -184,12 +193,12 @@ export function joinRuleStates(a, b) {
184
193
  }
185
194
  const rules = [...ruleGroups.values()]
186
195
  .map((r) => ({ ...r, adds: [...r.adds].sort((x, y) => compareDots(x.dot, y.dot)) }))
187
- .sort((x, y) => (x.rule < y.rule ? -1 : x.rule > y.rule ? 1 : scopeKey(x.scope) < scopeKey(y.scope) ? -1 : scopeKey(x.scope) > scopeKey(y.scope) ? 1 : 0));
196
+ .sort((x, y) => compareIdentity(x, y));
188
197
  const tombGroups = new Map();
189
198
  for (const t of [...sa.tombstones, ...sb.tombstones]) {
190
199
  if (!Array.isArray(t.removedDots) || t.removedDots.length === 0)
191
200
  continue;
192
- const key = JSON.stringify([t.rule, scopeKey(t.scope), dotKey(t.deletedBy)]);
201
+ const key = JSON.stringify([t.behavior, t.rule, scopeKey(t.scope), dotKey(t.deletedBy)]);
193
202
  const existing = tombGroups.get(key);
194
203
  if (existing === undefined) {
195
204
  tombGroups.set(key, { ...t, removedDots: [...t.removedDots] });
@@ -201,9 +210,18 @@ export function joinRuleStates(a, b) {
201
210
  }
202
211
  const tombstones = [...tombGroups.values()]
203
212
  .map((t) => ({ ...t, removedDots: [...t.removedDots].sort(compareDots) }))
204
- .sort((x, y) => x.rule < y.rule ? -1 : x.rule > y.rule ? 1 : scopeKey(x.scope) < scopeKey(y.scope) ? -1 : scopeKey(x.scope) > scopeKey(y.scope) ? 1 : compareDots(x.deletedBy, y.deletedBy));
213
+ .sort((x, y) => compareIdentity(x, y) || compareDots(x.deletedBy, y.deletedBy));
205
214
  return { rules, tombstones };
206
215
  }
216
+ function compareIdentity(x, y) {
217
+ if (x.rule !== y.rule)
218
+ return x.rule < y.rule ? -1 : 1;
219
+ const sx = scopeKey(x.scope);
220
+ const sy = scopeKey(y.scope);
221
+ if (sx !== sy)
222
+ return sx < sy ? -1 : 1;
223
+ return x.behavior === y.behavior ? 0 : x.behavior < y.behavior ? -1 : 1;
224
+ }
207
225
  export function collectBelowFrontier(state, frontier) {
208
226
  const collectable = state.tombstones.filter((t) => dotAtOrBelowFrontier(t.deletedBy, frontier) && t.removedDots.every((d) => dotAtOrBelowFrontier(d, frontier)));
209
227
  if (collectable.length === 0)
@@ -211,7 +229,7 @@ export function collectBelowFrontier(state, frontier) {
211
229
  const keep = state.tombstones.filter((t) => !collectable.includes(t));
212
230
  const rules = state.rules
213
231
  .map((r) => {
214
- const covered = collectable.filter((t) => t.rule === r.rule && sameScope(t.scope, r.scope)).flatMap((t) => t.removedDots);
232
+ const covered = collectable.filter((t) => sameRuleIdentity(t, r)).flatMap((t) => t.removedDots);
215
233
  return covered.length === 0 ? r : { ...r, adds: r.adds.filter((a) => !covered.some((d) => sameDot(d, a.dot))) };
216
234
  })
217
235
  .filter((r) => r.adds.length > 0);
@@ -230,7 +248,10 @@ export function applySyncJoin(cur, delta, nowMs) {
230
248
  if (!isValidDurableScope(t.scope) || !isValidDot(t.deletedBy) || !t.removedDots.every(isValidDot)) {
231
249
  throw new Error("an inbound tombstone carries a malformed scope or dot — refusing the whole sync-join (a tombstone that cannot identity-match what it deletes would land its covered adds live)");
232
250
  }
233
- const canonicalTomb = parseAllowRuleText(t.rule);
251
+ if (!isRuleBehavior(t.behavior)) {
252
+ throw new Error("an inbound tombstone carries a behavior word outside deny/ask/allow — refusing the whole sync-join (a tombstone that cannot identity-match what it deletes would land its covered adds live)");
253
+ }
254
+ const canonicalTomb = parseRuleText(t.rule, t.behavior);
234
255
  if ("reject" in canonicalTomb || canonicalTomb.rule.rule !== t.rule) {
235
256
  throw new Error(`an inbound tombstone names a non-canonical or invalid rule text ("${escapeForDisclosure(t.rule)}") — refusing the whole sync-join (the client's partitioning must withhold the round's adds alongside a malformed tombstone)`);
236
257
  }
@@ -243,12 +264,12 @@ export function applySyncJoin(cur, delta, nowMs) {
243
264
  const dotIdentity = new Map();
244
265
  for (const r of cur.rules)
245
266
  for (const a of r.adds)
246
- dotIdentity.set(dotKey(a.dot), ruleIdentityKey(r.rule, r.scope));
267
+ dotIdentity.set(dotKey(a.dot), ruleIdentityKey(r.behavior, r.rule, r.scope));
247
268
  for (const q of cur.quarantined)
248
- dotIdentity.set(dotKey(q.add.dot), ruleIdentityKey(q.rule, q.scope));
269
+ dotIdentity.set(dotKey(q.add.dot), ruleIdentityKey(q.behavior, q.rule, q.scope));
249
270
  const inboundClaims = new Map();
250
271
  for (const r of screened.state.rules) {
251
- const identity = ruleIdentityKey(r.rule, r.scope);
272
+ const identity = ruleIdentityKey(r.behavior, r.rule, r.scope);
252
273
  for (const a of r.adds) {
253
274
  const claims = inboundClaims.get(dotKey(a.dot)) ?? new Set();
254
275
  claims.add(identity);
@@ -259,7 +280,7 @@ export function applySyncJoin(cur, delta, nowMs) {
259
280
  const forged = forgedDot(t.deletedBy) ? t.deletedBy : t.removedDots.find(forgedDot);
260
281
  if (forged === undefined)
261
282
  return true;
262
- droppedInbound.push({ rule: t.rule, scope: t.scope, dot: forged, reason: "own_actor_forged" });
283
+ droppedInbound.push({ behavior: t.behavior, rule: t.rule, scope: t.scope, dot: forged, reason: "own_actor_forged" });
263
284
  return false;
264
285
  });
265
286
  const tombstoneRejected = inboundTombstones.length !== screened.state.tombstones.length;
@@ -272,22 +293,22 @@ export function applySyncJoin(cur, delta, nowMs) {
272
293
  }
273
294
  if (!isValidDurableScope(r.scope)) {
274
295
  for (const a of r.adds)
275
- droppedInbound.push({ rule: r.rule, scope: r.scope, dot: a.dot, reason: "metadata_mismatch" });
296
+ droppedInbound.push({ behavior: r.behavior, rule: r.rule, scope: r.scope, dot: a.dot, reason: "metadata_mismatch" });
276
297
  continue;
277
298
  }
278
- const identity = ruleIdentityKey(r.rule, r.scope);
299
+ const identity = ruleIdentityKey(r.behavior, r.rule, r.scope);
279
300
  const kept = r.adds.filter((a) => {
280
301
  if (!isValidDot(a.dot)) {
281
- droppedInbound.push({ rule: r.rule, scope: r.scope, dot: a.dot, reason: "metadata_mismatch" });
302
+ droppedInbound.push({ behavior: r.behavior, rule: r.rule, scope: r.scope, dot: a.dot, reason: "metadata_mismatch" });
282
303
  return false;
283
304
  }
284
305
  if (forgedDot(a.dot)) {
285
- droppedInbound.push({ rule: r.rule, scope: r.scope, dot: a.dot, reason: "own_actor_forged" });
306
+ droppedInbound.push({ behavior: r.behavior, rule: r.rule, scope: r.scope, dot: a.dot, reason: "own_actor_forged" });
286
307
  return false;
287
308
  }
288
309
  const known = dotIdentity.get(dotKey(a.dot));
289
310
  if ((known !== undefined && known !== identity) || (inboundClaims.get(dotKey(a.dot))?.size ?? 0) > 1) {
290
- droppedInbound.push({ rule: r.rule, scope: r.scope, dot: a.dot, reason: "dot_identity_conflict" });
311
+ droppedInbound.push({ behavior: r.behavior, rule: r.rule, scope: r.scope, dot: a.dot, reason: "dot_identity_conflict" });
291
312
  return false;
292
313
  }
293
314
  return true;
@@ -298,18 +319,18 @@ export function applySyncJoin(cur, delta, nowMs) {
298
319
  const inbound = { rules: inboundRules, tombstones: inboundTombstones };
299
320
  const localScreen = screenRuleSyncState({ rules: cur.rules, tombstones: cur.tombstones });
300
321
  const quarantined = [...cur.quarantined];
301
- const quarantineKey = (rule, scope, dot) => JSON.stringify([rule, scopeKey(scope), dotKey(dot)]);
302
- const pushQuarantine = (rule, scope, add, reason) => {
303
- if (quarantined.some((q) => q.rule === rule && sameScope(q.scope, scope) && sameDot(q.add.dot, add.dot)))
322
+ const quarantineKey = (behavior, rule, scope, dot) => JSON.stringify([behavior, rule, scopeKey(scope), dotKey(dot)]);
323
+ const pushQuarantine = (behavior, rule, scope, add, reason) => {
324
+ if (quarantined.some((q) => sameRuleIdentity(q, { behavior, rule, scope }) && sameDot(q.add.dot, add.dot)))
304
325
  return;
305
- quarantined.push({ rule, scope, add, reason, atMs: nowMs });
306
- quarantinedNow.push({ rule, scope, dot: add.dot, reason });
326
+ quarantined.push({ behavior, rule, scope, add, reason, atMs: nowMs });
327
+ quarantinedNow.push({ behavior, rule, scope, dot: add.dot, reason });
307
328
  };
308
329
  for (const rej of localScreen.rejected) {
309
- const row = cur.rules.find((r) => r.rule === rej.rule && sameScope(r.scope, rej.scope));
330
+ const row = cur.rules.find((r) => sameRuleIdentity(r, rej));
310
331
  const add = row?.adds.find((a) => sameDot(a.dot, rej.dot));
311
332
  if (add !== undefined && (rej.reason === "invalid_rule_text" || rej.reason === "metadata_mismatch")) {
312
- pushQuarantine(rej.rule, rej.scope, add, rej.reason);
333
+ pushQuarantine(rej.behavior, rej.rule, rej.scope, add, rej.reason);
313
334
  }
314
335
  }
315
336
  let joined = joinRuleStates(localScreen.state, inbound);
@@ -323,7 +344,7 @@ export function applySyncJoin(cur, delta, nowMs) {
323
344
  for (const r of live) {
324
345
  for (const a of r.adds) {
325
346
  if (dotAtOrBelowFrontier(a.dot, delta.gcFrontier) && !inboundDots.has(dotKey(a.dot))) {
326
- pushQuarantine(r.rule, r.scope, a, "below_gc_frontier");
347
+ pushQuarantine(r.behavior, r.rule, r.scope, a, "below_gc_frontier");
327
348
  }
328
349
  }
329
350
  }
@@ -338,16 +359,16 @@ export function applySyncJoin(cur, delta, nowMs) {
338
359
  if (joined.tombstones.some((t) => sameDot(t.deletedBy, dot))) {
339
360
  throw new Error("a quarantine instruction may not touch a tombstone — it moves adds only");
340
361
  }
341
- const row = joined.rules.find((r) => r.rule === q.rule && sameScope(r.scope, q.scope));
362
+ const row = joined.rules.find((r) => sameRuleIdentity(r, q));
342
363
  const add = row?.adds.find((a) => sameDot(a.dot, dot));
343
364
  if (add === undefined)
344
365
  continue;
345
- pushQuarantine(q.rule, q.scope, add, q.reason);
366
+ pushQuarantine(q.behavior, q.rule, q.scope, add, q.reason);
346
367
  }
347
368
  }
348
- const quarantinedRows = new Set(quarantined.map((q) => quarantineKey(q.rule, q.scope, q.add.dot)));
369
+ const quarantinedRows = new Set(quarantined.map((q) => quarantineKey(q.behavior, q.rule, q.scope, q.add.dot)));
349
370
  const rules = joined.rules
350
- .map((r) => ({ ...r, adds: r.adds.filter((a) => !quarantinedRows.has(quarantineKey(r.rule, r.scope, a.dot))) }))
371
+ .map((r) => ({ ...r, adds: r.adds.filter((a) => !quarantinedRows.has(quarantineKey(r.behavior, r.rule, r.scope, a.dot))) }))
351
372
  .filter((r) => r.adds.length > 0);
352
373
  return {
353
374
  next: {
@@ -373,9 +394,14 @@ export async function ruleStoreChecksum(payload) {
373
394
  }
374
395
  const REMOVE_MAX_ATTEMPTS = 8;
375
396
  export async function removePersistedRule(opts) {
397
+ const behavior = opts.behavior;
376
398
  const rule = opts.rule;
377
399
  const scope = structuredClone(opts.scope);
378
400
  const target = opts.principal;
401
+ if (!isRuleBehavior(behavior)) {
402
+ return { status: "failed", error: `removePersistedRule: behavior ${escapeForDisclosure(behavior)} is not one of ${["deny", "ask", "allow"].join("/")} — refusing to name a rule identity nothing can carry` };
403
+ }
404
+ const identity = { behavior, rule, scope };
379
405
  const owner = typeof target === "string" ? { kind: "principal", principal: target } : structuredClone(target);
380
406
  const provider = opts.provider;
381
407
  if (scope?.kind === "session") {
@@ -401,11 +427,11 @@ export async function removePersistedRule(opts) {
401
427
  }
402
428
  if (snapshot.unreadable !== undefined)
403
429
  return { status: "failed", error: `could not read the permission-rule store: ${snapshot.unreadable}` };
404
- const live = snapshot.rules.filter((r) => r.rule === rule && sameScope(r.scope, scope));
430
+ const live = snapshot.rules.filter((r) => sameRuleIdentity(r, identity));
405
431
  const removedDots = live.flatMap((r) => r.adds.map((a) => a.dot));
406
432
  if (removedDots.length === 0)
407
433
  return { status: "no-op", rev: snapshot.rev };
408
- const tombstone = { rule, scope, removedDots, deletedBy: await writer.nextDot() };
434
+ const tombstone = { behavior, rule, scope, removedDots, deletedBy: await writer.nextDot() };
409
435
  let expectedRev = snapshot.rev;
410
436
  let lastError = "";
411
437
  for (let attempt = 0; attempt < REMOVE_MAX_ATTEMPTS; attempt++) {
@@ -420,7 +446,7 @@ export async function removePersistedRule(opts) {
420
446
  const after = await store.effective();
421
447
  if (after.unreadable !== undefined)
422
448
  throw new Error(after.unreadable);
423
- stillLive = after.rules.some((r) => r.rule === rule && sameScope(r.scope, scope) && isRuleLive(r));
449
+ stillLive = after.rules.some((r) => sameRuleIdentity(r, identity) && isRuleLive(r));
424
450
  }
425
451
  catch {
426
452
  stillLive = false;
@@ -439,7 +465,7 @@ export async function removePersistedRule(opts) {
439
465
  const after = await writer.readRaw();
440
466
  const landed = after.tombstones.some((t) => t.deletedBy.actor === tombstone.deletedBy.actor && t.deletedBy.counter === tombstone.deletedBy.counter);
441
467
  return landed
442
- ? { status: "removed", rev: after.rev, stillLive: applyTombstones(after.rules, after.tombstones).some((r) => r.rule === rule && sameScope(r.scope, scope) && isRuleLive(r)) }
468
+ ? { status: "removed", rev: after.rev, stillLive: applyTombstones(after.rules, after.tombstones).some((r) => sameRuleIdentity(r, identity) && isRuleLive(r)) }
443
469
  : { status: "failed", error: `${reason} (read-back confirms no tombstone was written)` };
444
470
  }
445
471
  catch (err) {
@@ -25,7 +25,7 @@
25
25
  * privilege-separation ticket adds device-bound signatures. Deployments should connect knowing which
26
26
  * level they are buying.
27
27
  */
28
- import type { PersistedAllowRule, RuleDot, RuleRejectCode, RuleScope, RuleTombstone } from "./permission-rule-model.js";
28
+ import type { PersistedRule, RuleBehavior, RuleDot, RuleRejectCode, RuleScope, RuleTombstone } from "./permission-rule-model.js";
29
29
  import type { PermissionRuleStoreProvider } from "./permission-rule-provider.js";
30
30
  import type { RuleOwner, RuleSyncDrop, RuleSyncFrontier, RuleSyncState } from "./permission-rule-store.js";
31
31
  import { type TracerHook } from "./trace.js";
@@ -50,7 +50,9 @@ export interface RuleSyncResponseBody {
50
50
  principal: string;
51
51
  merged: RuleSyncState;
52
52
  gcFrontier?: RuleSyncFrontier;
53
+ /** Server-side refusals of rows we sent. `behavior` is part of the row identity the twin names. */
53
54
  dropped?: Array<{
55
+ behavior: import("./permission-rule-model.js").RuleBehavior;
54
56
  rule: string;
55
57
  scope: RuleScope;
56
58
  dot: RuleDot;
@@ -70,8 +72,9 @@ export interface PermissionRuleSyncResult {
70
72
  newAdds: number;
71
73
  newTombstones: number;
72
74
  };
73
- /** (rule, scope) pairs that went removed → live in this round — add-wins made visible (§4.6). */
75
+ /** (behavior, rule, scope) identities that went removed → live in this round — add-wins made visible (§4.6). */
74
76
  resurrected: Array<{
77
+ behavior: import("./permission-rule-model.js").RuleBehavior;
75
78
  rule: string;
76
79
  scope: RuleScope;
77
80
  byDot: RuleDot;
@@ -116,11 +119,12 @@ export declare function syncPermissionRules(opts: {
116
119
  interface ParsedRuleSyncResponse {
117
120
  principal: string;
118
121
  merged: {
119
- rules: PersistedAllowRule[];
122
+ rules: PersistedRule[];
120
123
  tombstones: Array<RuleTombstone | undefined>;
121
124
  };
122
125
  gcFrontier?: RuleSyncFrontier;
123
126
  dropped: Array<{
127
+ behavior: RuleBehavior;
124
128
  rule: string;
125
129
  scope: RuleScope;
126
130
  dot: RuleDot;
@@ -130,6 +134,7 @@ interface ParsedRuleSyncResponse {
130
134
  * outcome, kept OUT of `merged.tombstones` so it does not read as malformed. See {@link
131
135
  * parseRuleSyncResponse} for why this one does not withhold the round's adds (backlog #176). */
132
136
  textRefusedTombstones: Array<{
137
+ behavior: RuleBehavior;
133
138
  rule: string;
134
139
  scope: RuleScope;
135
140
  dot: RuleDot;
@@ -140,6 +145,7 @@ interface ParsedRuleSyncResponse {
140
145
  * DAMAGED one as generic-malformed would withhold every valid inbound add on every round — one
141
146
  * peer row poisoning sync forever, the exact shape the per-row drop arm exists to avoid. */
142
147
  sessionScopeTombstones: Array<{
148
+ behavior: RuleBehavior;
143
149
  rule: string;
144
150
  scope: RuleScope;
145
151
  dot: RuleDot;
@@ -1,5 +1,5 @@
1
- import { escapeForDisclosure, parseAllowRuleText } from "./permission-rule-model.js";
2
- import { applyTombstones, errText, joinFrontiers, joinRuleStates, normalizePersistedRule, ruleSyncVector, sameScope, screenRuleSyncState, writerOf, } from "./permission-rule-store.js";
1
+ import { escapeForDisclosure, isRuleBehavior, parseRuleText } from "./permission-rule-model.js";
2
+ import { applyTombstones, errText, joinFrontiers, joinRuleStates, normalizePersistedRule, ruleSyncVector, sameRuleIdentity, screenRuleSyncState, writerOf, } from "./permission-rule-store.js";
3
3
  import { emitTrace } from "./trace.js";
4
4
  export const PERMISSION_RULE_SYNC_PATH = "/v1/permission-rules/sync";
5
5
  export const LOCAL_OWNER_UNSYNCABLE_CODE = "permission_rules.local_owner_unsyncable";
@@ -79,7 +79,7 @@ export async function syncPermissionRules(opts) {
79
79
  }
80
80
  const forged = forgedDot(t.deletedBy) ? t.deletedBy : t.removedDots.find(forgedDot);
81
81
  if (forged !== undefined) {
82
- dropped.push({ rule: t.rule, scope: t.scope, dot: forged, reason: "own_actor_forged" });
82
+ dropped.push({ behavior: t.behavior, rule: t.rule, scope: t.scope, dot: forged, reason: "own_actor_forged" });
83
83
  tombstoneWithholding = true;
84
84
  continue;
85
85
  }
@@ -89,10 +89,10 @@ export async function syncPermissionRules(opts) {
89
89
  warnings.push("the response carried at least one malformed tombstone — every inbound add is withheld this round (legal tombstones land; the adds re-arrive once the peer repairs the tombstone: full-state exchange has no missed-forever window)");
90
90
  }
91
91
  for (const t of response.textRefusedTombstones) {
92
- dropped.push({ rule: t.rule, scope: t.scope, dot: t.dot, reason: "invalid_rule_text" });
92
+ dropped.push({ behavior: t.behavior, rule: t.rule, scope: t.scope, dot: t.dot, reason: "invalid_rule_text" });
93
93
  }
94
94
  for (const t of response.sessionScopeTombstones) {
95
- dropped.push({ rule: t.rule, scope: t.scope, dot: t.dot, reason: "session_scope_not_durable" });
95
+ dropped.push({ behavior: t.behavior, rule: t.rule, scope: t.scope, dot: t.dot, reason: "session_scope_not_durable" });
96
96
  }
97
97
  if (response.textRefusedTombstones.length > 0) {
98
98
  const named = response.textRefusedTombstones
@@ -109,12 +109,12 @@ export async function syncPermissionRules(opts) {
109
109
  const n = normalizePersistedRule(r);
110
110
  if ("reject" in n) {
111
111
  for (const a of r.adds)
112
- dropped.push({ rule: r.rule, scope: r.scope, dot: a.dot, reason: n.reject });
112
+ dropped.push({ behavior: r.behavior, rule: r.rule, scope: r.scope, dot: a.dot, reason: n.reject });
113
113
  continue;
114
114
  }
115
115
  const kept = r.adds.filter((a) => {
116
116
  if (a.dot.actor === raw.actor && a.dot.counter > raw.counter) {
117
- dropped.push({ rule: r.rule, scope: r.scope, dot: a.dot, reason: "own_actor_forged" });
117
+ dropped.push({ behavior: r.behavior, rule: r.rule, scope: r.scope, dot: a.dot, reason: "own_actor_forged" });
118
118
  return false;
119
119
  }
120
120
  return true;
@@ -128,7 +128,7 @@ export async function syncPermissionRules(opts) {
128
128
  const localScreen = screenRuleSyncState({ rules: raw.rules, tombstones: raw.tombstones });
129
129
  for (const rej of localScreen.rejected) {
130
130
  if (rej.reason === "invalid_rule_text" || rej.reason === "metadata_mismatch") {
131
- quarantine.push({ rule: rej.rule, scope: rej.scope, dots: [rej.dot], reason: rej.reason });
131
+ quarantine.push({ behavior: rej.behavior, rule: rej.rule, scope: rej.scope, dots: [rej.dot], reason: rej.reason });
132
132
  dropped.push(rej);
133
133
  }
134
134
  }
@@ -140,8 +140,8 @@ export async function syncPermissionRules(opts) {
140
140
  }
141
141
  if (localDots.has(JSON.stringify([d.dot.actor, d.dot.counter]))) {
142
142
  const reason = d.reason === "below_gc_frontier" ? "below_gc_frontier" : "server_rejected";
143
- quarantine.push({ rule: d.rule, scope: d.scope, dots: [d.dot], reason });
144
- dropped.push({ rule: d.rule, scope: d.scope, dot: d.dot, reason });
143
+ quarantine.push({ behavior: d.behavior, rule: d.rule, scope: d.scope, dots: [d.dot], reason });
144
+ dropped.push({ behavior: d.behavior, rule: d.rule, scope: d.scope, dot: d.dot, reason });
145
145
  }
146
146
  else {
147
147
  warnings.push(`the server dropped an inbound-side record (${escapeForDisclosure(d.rule)}, dot ${escapeForDisclosure(d.dot.actor)}#${d.dot.counter}): ${escapeForDisclosure(d.reason)}`);
@@ -193,22 +193,22 @@ export async function syncPermissionRules(opts) {
193
193
  const postLive = applyTombstones(landedRaw.rules, landedRaw.tombstones);
194
194
  const resurrected = [];
195
195
  for (const r of postLive) {
196
- const pre = preLive.find((p) => p.rule === r.rule && sameScope(p.scope, r.scope));
196
+ const pre = preLive.find((p) => sameRuleIdentity(p, r));
197
197
  if (pre !== undefined)
198
198
  continue;
199
- const hadAdds = current.rules.some((p) => p.rule === r.rule && sameScope(p.scope, r.scope));
200
- const covered = current.tombstones.some((t) => t.rule === r.rule && sameScope(t.scope, r.scope));
199
+ const hadAdds = current.rules.some((p) => sameRuleIdentity(p, r));
200
+ const covered = current.tombstones.some((t) => sameRuleIdentity(t, r));
201
201
  if (hadAdds && covered) {
202
- const preRuleDots = new Set(current.rules.filter((p) => p.rule === r.rule && sameScope(p.scope, r.scope)).flatMap((p) => p.adds.map((a) => JSON.stringify([a.dot.actor, a.dot.counter]))));
202
+ const preRuleDots = new Set(current.rules.filter((p) => sameRuleIdentity(p, r)).flatMap((p) => p.adds.map((a) => JSON.stringify([a.dot.actor, a.dot.counter]))));
203
203
  const by = r.adds.find((a) => !preRuleDots.has(JSON.stringify([a.dot.actor, a.dot.counter]))) ?? r.adds[0];
204
204
  if (by !== undefined)
205
- resurrected.push({ rule: r.rule, scope: r.scope, byDot: by.dot });
205
+ resurrected.push({ behavior: r.behavior, rule: r.rule, scope: r.scope, byDot: by.dot });
206
206
  }
207
207
  }
208
208
  const dotKeyOf = (d) => JSON.stringify([d.actor, d.counter]);
209
209
  const preDots = new Set(current.rules.flatMap((r) => r.adds.map((a) => dotKeyOf(a.dot))));
210
210
  const newAdds = landedRaw.rules.flatMap((r) => r.adds).filter((a) => !preDots.has(dotKeyOf(a.dot))).length;
211
- const tombKeyOf = (t) => JSON.stringify([t.rule, t.scope.kind, t.scope.kind === "project" ? t.scope.root : t.scope.kind === "session" ? t.scope.sessionId : null, dotKeyOf(t.deletedBy)]);
211
+ const tombKeyOf = (t) => JSON.stringify([t.behavior, t.rule, t.scope.kind, t.scope.kind === "project" ? t.scope.root : t.scope.kind === "session" ? t.scope.sessionId : null, dotKeyOf(t.deletedBy)]);
212
212
  const preTombs = new Set(current.tombstones.map(tombKeyOf));
213
213
  const newTombstones = landedRaw.tombstones.filter((t) => !preTombs.has(tombKeyOf(t))).length;
214
214
  const landedReport = res.sync;
@@ -324,7 +324,8 @@ export function parseRuleSyncResponse(raw, expectedPrincipal) {
324
324
  const eTool = e?.tool;
325
325
  const eMatch = e?.match;
326
326
  const eAdds = e?.adds;
327
- if (typeof eRule !== "string" || scope === undefined || !Array.isArray(eAdds) || typeof eCommand !== "string" || typeof eTool !== "string" || typeof eMatch !== "string") {
327
+ const eBehavior = isRuleBehavior(e?.behavior) ? e.behavior : undefined;
328
+ if (typeof eRule !== "string" || scope === undefined || !Array.isArray(eAdds) || typeof eCommand !== "string" || typeof eTool !== "string" || typeof eMatch !== "string" || eBehavior === undefined) {
328
329
  warnings.push("the response carried an unshapeable rule entry — skipped (it never reaches the local store)");
329
330
  continue;
330
331
  }
@@ -343,6 +344,7 @@ export function parseRuleSyncResponse(raw, expectedPrincipal) {
343
344
  if (adds.length === 0)
344
345
  continue;
345
346
  rules.push({
347
+ behavior: eBehavior,
346
348
  rule: eRule,
347
349
  tool: eTool,
348
350
  match: eMatch,
@@ -359,6 +361,7 @@ export function parseRuleSyncResponse(raw, expectedPrincipal) {
359
361
  const pickedScope = pickScopeCaptured(t?.scope);
360
362
  const scope = pickedScope.scope;
361
363
  const ruleText = t?.rule;
364
+ const behavior = isRuleBehavior(t?.behavior) ? t.behavior : undefined;
362
365
  const deletedBy = pickDot(t?.deletedBy);
363
366
  const removedRaw = t?.removedDots;
364
367
  const removedDots = [];
@@ -373,28 +376,28 @@ export function parseRuleSyncResponse(raw, expectedPrincipal) {
373
376
  }
374
377
  }
375
378
  if (pickedScope.kind === "session") {
376
- if (typeof ruleText === "string" && deletedBy !== undefined && scope !== undefined) {
377
- sessionScopeTombstones.push({ rule: ruleText, scope, dot: deletedBy });
379
+ if (typeof ruleText === "string" && deletedBy !== undefined && scope !== undefined && behavior !== undefined) {
380
+ sessionScopeTombstones.push({ behavior, rule: ruleText, scope, dot: deletedBy });
378
381
  }
379
382
  else {
380
383
  warnings.push("the response carried a session-scope tombstone whose identity could not be read — dropped per-row (a session row never lands here, and nothing is withheld for it)");
381
384
  }
382
385
  continue;
383
386
  }
384
- if (typeof ruleText !== "string" || scope === undefined || deletedBy === undefined || removedDots.length === 0) {
387
+ if (typeof ruleText !== "string" || scope === undefined || deletedBy === undefined || removedDots.length === 0 || behavior === undefined) {
385
388
  tombstones.push(undefined);
386
389
  continue;
387
390
  }
388
- const canonicalTomb = parseAllowRuleText(ruleText);
391
+ const canonicalTomb = parseRuleText(ruleText, behavior);
389
392
  if ("reject" in canonicalTomb) {
390
- textRefusedTombstones.push({ rule: ruleText, scope, dot: deletedBy, code: canonicalTomb.reject.code });
393
+ textRefusedTombstones.push({ behavior, rule: ruleText, scope, dot: deletedBy, code: canonicalTomb.reject.code });
391
394
  continue;
392
395
  }
393
396
  if (canonicalTomb.rule.rule !== ruleText) {
394
397
  tombstones.push(undefined);
395
398
  continue;
396
399
  }
397
- tombstones.push({ rule: ruleText, scope, removedDots, deletedBy });
400
+ tombstones.push({ behavior, rule: ruleText, scope, removedDots, deletedBy });
398
401
  }
399
402
  const dropped = [];
400
403
  const rawDropped = r.dropped;
@@ -409,11 +412,12 @@ export function parseRuleSyncResponse(raw, expectedPrincipal) {
409
412
  const dRule = d?.rule;
410
413
  const dDot = pickDot(d?.dot);
411
414
  const dReason = d?.reason;
412
- if (typeof dRule !== "string" || scope === undefined || dDot === undefined || typeof dReason !== "string") {
415
+ const dBehavior = isRuleBehavior(d?.behavior) ? d.behavior : undefined;
416
+ if (typeof dRule !== "string" || scope === undefined || dDot === undefined || typeof dReason !== "string" || dBehavior === undefined) {
413
417
  warnings.push("the response carried an unshapeable dropped entry — ignored (the server's own drop discipline is its contract obligation)");
414
418
  continue;
415
419
  }
416
- dropped.push({ rule: dRule, scope, dot: dDot, reason: dReason });
420
+ dropped.push({ behavior: dBehavior, rule: dRule, scope, dot: dDot, reason: dReason });
417
421
  }
418
422
  }
419
423
  }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The `Tool(content)` rule SYNTAX — the one reader every rule face shares, as a LEAF module.
3
+ *
4
+ * Two modules read this grammar and, until this file existed, one imported it from the other: the rule
5
+ * DSL compiler (`permission-rules.ts`) declared the parser and the persisted-rule model
6
+ * (`permission-rule-model.ts`) imported it. The model's command/path grammars are now consumed by the
7
+ * compiler too (a content-form `deny`/`ask` rule compiles through the model's own validator and
8
+ * matcher), which would have closed a value-import cycle between the two. The syntax layer has no
9
+ * dependency of its own, so it lives here and both read DOWN.
10
+ *
11
+ * Upstream parity: `Lh` (the split), `Dju`/`Pju` (the escape-aware paren search), `Hju` (the three
12
+ * unescapes) — INCLUDING the lenient downgrades (malformed parens / trailing garbage ⇒ the WHOLE string
13
+ * becomes the bare tool name). The strict complement that refuses the downgrades is the compiler's
14
+ * validator, not this reader.
15
+ */
16
+ /** Parse result — the tool name and, for a `Tool(content)` spelling, its unescaped content. */
17
+ export interface ParsedPermissionRule {
18
+ toolName: string;
19
+ ruleContent?: string;
20
+ }
21
+ /** First index of `ch` preceded by an EVEN number of backslashes. */
22
+ export declare function indexOfUnescaped(s: string, ch: string): number;
23
+ /** Last index of `ch` preceded by an EVEN number of backslashes. */
24
+ export declare function lastIndexOfUnescaped(s: string, ch: string): number;
25
+ /**
26
+ * Parse one rule string, including the lenient downgrades (malformed parens / trailing garbage ⇒ the
27
+ * WHOLE string becomes the bare tool name). Use the compiler's `validatePermissionRules` (or the policy
28
+ * factory) to refuse the downgrades.
29
+ */
30
+ export declare function parsePermissionRule(rule: string): ParsedPermissionRule;
@@ -0,0 +1,44 @@
1
+ export function indexOfUnescaped(s, ch) {
2
+ for (let i = 0; i < s.length; i++) {
3
+ if (s[i] !== ch)
4
+ continue;
5
+ let backslashes = 0;
6
+ for (let j = i - 1; j >= 0 && s[j] === "\\"; j--)
7
+ backslashes++;
8
+ if (backslashes % 2 === 0)
9
+ return i;
10
+ }
11
+ return -1;
12
+ }
13
+ export function lastIndexOfUnescaped(s, ch) {
14
+ for (let i = s.length - 1; i >= 0; i--) {
15
+ if (s[i] !== ch)
16
+ continue;
17
+ let backslashes = 0;
18
+ for (let j = i - 1; j >= 0 && s[j] === "\\"; j--)
19
+ backslashes++;
20
+ if (backslashes % 2 === 0)
21
+ return i;
22
+ }
23
+ return -1;
24
+ }
25
+ function unescapeRuleContent(s) {
26
+ return s.replaceAll("\\(", "(").replaceAll("\\)", ")").replaceAll("\\\\", "\\");
27
+ }
28
+ export function parsePermissionRule(rule) {
29
+ const open = indexOfUnescaped(rule, "(");
30
+ if (open === -1)
31
+ return { toolName: rule.trim() };
32
+ const close = lastIndexOfUnescaped(rule, ")");
33
+ if (close === -1 || close <= open)
34
+ return { toolName: rule.trim() };
35
+ if (close !== rule.length - 1)
36
+ return { toolName: rule.trim() };
37
+ const name = rule.substring(0, open);
38
+ const content = rule.substring(open + 1, close);
39
+ if (!name)
40
+ return { toolName: rule.trim() };
41
+ if (content === "" || content === "*")
42
+ return { toolName: name.trim() };
43
+ return { toolName: name.trim(), ruleContent: unescapeRuleContent(content) };
44
+ }