@sema-agent/core 5.26.0 → 5.28.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 (58) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/dist/agents/agent-transcript-tool.d.ts +5 -2
  3. package/dist/agents/agent-transcript-tool.js +2 -1
  4. package/dist/agents/send-message-tool.d.ts +4 -1
  5. package/dist/agents/subagent.d.ts +5 -2
  6. package/dist/core/checkpoint-store.d.ts +7 -2
  7. package/dist/core/hooks.d.ts +61 -4
  8. package/dist/core/hooks.js +37 -15
  9. package/dist/core/memory-engine/engine.d.ts +8 -5
  10. package/dist/core/memory-engine/engine.js +18 -6
  11. package/dist/core/memory-engine/file-backend.d.ts +144 -4
  12. package/dist/core/memory-engine/file-backend.js +304 -36
  13. package/dist/core/memory-engine/layout.d.ts +31 -2
  14. package/dist/core/memory-engine/layout.js +132 -8
  15. package/dist/core/memory-engine/types.d.ts +9 -1
  16. package/dist/core/memory-vector.d.ts +6 -1
  17. package/dist/core/memory-vector.js +14 -4
  18. package/dist/core/memory.js +1 -6
  19. package/dist/core/permission-rule-consent.d.ts +82 -8
  20. package/dist/core/permission-rule-consent.js +92 -1
  21. package/dist/core/permission-rule-model.d.ts +87 -6
  22. package/dist/core/permission-rule-model.js +79 -0
  23. package/dist/core/permission-rule-org.d.ts +22 -3
  24. package/dist/core/permission-rule-org.js +67 -20
  25. package/dist/core/permission-rule-store.js +2 -2
  26. package/dist/core/permission-rule-sync.d.ts +15 -1
  27. package/dist/core/permission-rule-sync.js +89 -47
  28. package/dist/core/runner/prepare-memory.js +14 -9
  29. package/dist/core/runner/prepare-task.d.ts +9 -3
  30. package/dist/core/runner/prepare-task.js +37 -11
  31. package/dist/core/runner/runtask.d.ts +8 -1
  32. package/dist/core/runner/runtask.js +8 -1
  33. package/dist/core/task-registry-agent.d.ts +13 -3
  34. package/dist/core/task-registry-agent.js +51 -21
  35. package/dist/core/task-registry-monitor.js +1 -1
  36. package/dist/core/task-registry-shared.d.ts +9 -0
  37. package/dist/core/task-registry.d.ts +6 -3
  38. package/dist/core/tool-policy.d.ts +44 -4
  39. package/dist/core/tool-policy.js +37 -3
  40. package/dist/core/tool-result-store.d.ts +108 -7
  41. package/dist/core/tool-result-store.js +95 -15
  42. package/dist/core/types.d.ts +115 -17
  43. package/dist/core/types.js +30 -1
  44. package/dist/engine/loop/types.d.ts +10 -3
  45. package/dist/index.d.ts +2 -2
  46. package/dist/index.js +1 -1
  47. package/dist/orchestration/run-workflow-tool.d.ts +5 -3
  48. package/dist/orchestration/workflow.d.ts +9 -6
  49. package/dist/stores/file/checkpoint-store.d.ts +2 -1
  50. package/dist/stores/file/index.d.ts +1 -1
  51. package/dist/stores/file/tool-result-store.d.ts +41 -1
  52. package/dist/stores/file/tool-result-store.js +107 -19
  53. package/dist/tools/fs/fs-bash.d.ts +7 -0
  54. package/dist/tools/fs/fs-shared.d.ts +5 -0
  55. package/dist/tools/fs/fs-shared.js +11 -7
  56. package/dist/tools/fs/index.d.ts +6 -0
  57. package/dist/tools/fs/index.js +2 -0
  58. package/package.json +1 -1
@@ -6,6 +6,54 @@ export const BARE_INTERPRETER_NAMES = new Set([
6
6
  "node", "deno", "bun", "python", "python2", "python3", "perl", "ruby", "php",
7
7
  "osascript", "env", "eval", "exec", "xargs", "nohup", "sudo", "doas", "su", "ssh",
8
8
  ]);
9
+ export const SUGGESTION_LEXICON = [
10
+ "git status", "git log", "git diff", "git show", "git branch", "git checkout", "git switch",
11
+ "git add", "git commit", "git push", "git pull", "git fetch", "git merge", "git rebase",
12
+ "git tag", "git blame", "git describe", "git cherry-pick", "git restore", "git reset",
13
+ "git rev-parse", "git ls-files", "git grep",
14
+ "git stash list", "git stash show", "git stash push", "git stash pop", "git stash drop",
15
+ "git remote show", "git worktree list", "git submodule update", "git submodule status",
16
+ "npm run", "npm test", "npm ci", "npm ls", "npm view", "npm outdated", "npm audit",
17
+ "npm pack", "npm publish", "npm version", "npm why",
18
+ "pnpm run", "pnpm test", "pnpm ls", "pnpm outdated", "pnpm audit", "pnpm why",
19
+ "yarn run", "yarn test", "yarn workspaces list",
20
+ "cargo build", "cargo test", "cargo run", "cargo check", "cargo clippy", "cargo fmt",
21
+ "cargo doc", "cargo tree", "cargo bench", "cargo update", "cargo metadata",
22
+ "go build", "go test", "go vet", "go fmt", "go doc",
23
+ "go mod tidy", "go mod download", "go mod verify", "go mod graph",
24
+ "docker ps", "docker images", "docker logs", "docker inspect", "docker build", "docker pull",
25
+ "docker push", "docker stop", "docker start", "docker restart", "docker rm", "docker rmi",
26
+ "docker compose up", "docker compose down", "docker compose ps", "docker compose logs",
27
+ "docker compose build", "docker compose pull",
28
+ "kubectl get", "kubectl describe", "kubectl logs", "kubectl apply", "kubectl delete",
29
+ "kubectl diff", "kubectl explain", "kubectl top",
30
+ "kubectl rollout status", "kubectl rollout restart", "kubectl rollout history",
31
+ "gh pr view", "gh pr list", "gh pr diff", "gh pr checks", "gh pr status", "gh pr create",
32
+ "gh pr merge", "gh issue view", "gh issue list", "gh issue create", "gh repo view",
33
+ "gh repo clone", "gh release list", "gh release view", "gh run list", "gh run view",
34
+ "gh run watch", "gh workflow list", "gh workflow view", "gh auth status",
35
+ "gh search repos", "gh search issues", "gh search prs", "gh search code",
36
+ ];
37
+ const LEXICON_BODIES = SUGGESTION_LEXICON.map((b) => b.split(" "));
38
+ function longestReviewedBody(tokens) {
39
+ let best;
40
+ for (const words of LEXICON_BODIES) {
41
+ if (words.length > tokens.length)
42
+ continue;
43
+ if (best !== undefined && words.length <= best.length)
44
+ continue;
45
+ let hit = true;
46
+ for (let i = 0; i < words.length; i++) {
47
+ if (words[i] !== tokens[i]) {
48
+ hit = false;
49
+ break;
50
+ }
51
+ }
52
+ if (hit)
53
+ best = words;
54
+ }
55
+ return best?.join(" ");
56
+ }
9
57
  function foldSpacing(s) {
10
58
  let out = "";
11
59
  let quote;
@@ -43,10 +91,31 @@ function foldSpacing(s) {
43
91
  function reject(code, message) {
44
92
  return { reject: { code, message } };
45
93
  }
94
+ const CONTROL_CHARS_RE = /[\u0000-\u0008\u000A-\u001F\u007F-\u009F\p{Cf}\u2028\u2029]/u;
95
+ const CONTROL_CHARS_GLOBAL_RE = new RegExp(CONTROL_CHARS_RE.source, "gu");
96
+ const DISCLOSED_RULE_TEXT_MAX_CHARS = 120;
97
+ export function escapeForDisclosure(value) {
98
+ let text;
99
+ try {
100
+ text = typeof value === "string" ? value : String(value);
101
+ }
102
+ catch {
103
+ return "<unprintable>";
104
+ }
105
+ const escaped = text.replace(CONTROL_CHARS_GLOBAL_RE, (ch) => {
106
+ const cp = ch.codePointAt(0) ?? 0;
107
+ const hex = cp.toString(16).toUpperCase();
108
+ return cp > 0xffff ? `\\u{${hex}}` : `\\u${hex.padStart(4, "0")}`;
109
+ });
110
+ return escaped.length <= DISCLOSED_RULE_TEXT_MAX_CHARS ? escaped : `${escaped.slice(0, DISCLOSED_RULE_TEXT_MAX_CHARS)}…`;
111
+ }
46
112
  export function parseAllowRuleText(text, opts) {
47
113
  if (text.length > MAX_RULE_TEXT_CHARS) {
48
114
  return reject("invalid.too_long", `rule text exceeds ${MAX_RULE_TEXT_CHARS} characters`);
49
115
  }
116
+ if (CONTROL_CHARS_RE.test(text)) {
117
+ return reject("invalid.control_chars", "rule text contains control characters (C0/C1, tab excepted), which cannot be part of a readable command");
118
+ }
50
119
  const parsed = parsePermissionRule(text);
51
120
  if (parsed.ruleContent === undefined) {
52
121
  return reject("invalid.grammar", `"${text}" is not a Tool(content) rule — a bare tool name claims the whole tool and is not a command rule`);
@@ -131,5 +200,15 @@ export function suggestRulesForCommand(command) {
131
200
  const exact = parseAllowRuleText(formatAllowRuleText(folded, "exact"));
132
201
  if ("rule" in exact)
133
202
  out.push({ rule: exact.rule.rule, match: "exact", command: exact.rule.command });
203
+ if (out.length === 1) {
204
+ const body = longestReviewedBody(folded.split(" "));
205
+ if (body !== undefined) {
206
+ const text = formatAllowRuleText(body, "prefix");
207
+ const parsed = parseAllowRuleText(text);
208
+ if ("rule" in parsed && parsed.rule.match === "prefix" && ruleAdmitsCommand(parsed.rule, command)) {
209
+ out.push({ rule: parsed.rule.rule, match: "prefix", command: parsed.rule.command });
210
+ }
211
+ }
212
+ }
134
213
  return out;
135
214
  }
@@ -34,7 +34,7 @@
34
34
  * accept revision N−1 after a restart. Signature / predecessor chaining belongs to the privilege-
35
35
  * separation ticket; the high-water mark is the floor that needs no key distribution.
36
36
  */
37
- import { type RuleScope } from "./permission-rule-model.js";
37
+ import { type RuleRejectCode, type RuleScope } from "./permission-rule-model.js";
38
38
  import { type PermissionRuleStore, type PermissionRuleStoreProvider, type RuleOwner } from "./permission-rule-store.js";
39
39
  /** One org rule. There is structurally no allow bucket (design/179 §9: the org layer only tightens). */
40
40
  export interface OrgPermissionRule {
@@ -160,13 +160,32 @@ export declare function createOrgRuleOverlay(cfg: {
160
160
  stalenessBoundMs: number;
161
161
  now?: () => number;
162
162
  }): OrgRuleOverlay;
163
+ /**
164
+ * Which entries of an org snapshot the CURRENT rule validator refuses — the published deny/ask rules
165
+ * that can match nothing on this build. Empty ⟺ every rule is enforceable.
166
+ *
167
+ * Separate from {@link validateOrgSnapshot} because BOTH gates need it: the install gate (a fetched
168
+ * snapshot) and the serving gate (backlog #177 — a snapshot persisted by an earlier build is read back
169
+ * and served without ever having met this validator). It reports every offender rather than the first,
170
+ * since a refusal an administrator cannot act on names no line to fix.
171
+ */
172
+ export declare function unenforceableOrgRules(rules: readonly OrgPermissionRule[]): Array<{
173
+ rule: string;
174
+ code: RuleRejectCode;
175
+ }>;
163
176
  /**
164
177
  * design/182 §7.2 — which org rule speaks for this command, if any. Deny outranks ask; within a
165
178
  * behavior the first textual match wins (reporting order only — all denies are the same one answer).
166
179
  * Matching uses the SAME parser and matcher the personal lane uses (tighten direction: an
167
180
  * interpreter-headed prefix deny like `Bash(node:*)` is a legitimately wide tightening and matches).
168
- * A rule text the validator refuses never gets here — snapshot validation refuses the whole snapshot,
169
- * so an unenforceable deny cannot install as policy; the skip below is a defensive floor only.
181
+ *
182
+ * The skip below is a defensive floor, and what stands behind it is worth stating precisely: rules that
183
+ * arrive from {@link OrgRuleOverlay.resolve} have passed {@link validateOrgSnapshot} on BOTH the install
184
+ * and the serving path (backlog #177), so an unenforceable entry cannot reach here through the overlay
185
+ * — the whole snapshot is refused, or the resolution reports `unavailable`, and either way it is
186
+ * disclosed. A caller that hands this function a snapshot it obtained ELSEWHERE gets no such guarantee:
187
+ * the skip is silent, and naming the dead entries is that caller's job — {@link unenforceableOrgRules}
188
+ * is the shared way to do it.
170
189
  */
171
190
  export declare function orgRuleVerdictFor(rules: readonly OrgPermissionRule[], call: {
172
191
  tool: string;
@@ -1,4 +1,4 @@
1
- import { parseAllowRuleText, ruleAdmitsCommand } from "./permission-rule-model.js";
1
+ import { escapeForDisclosure, parseAllowRuleText, ruleAdmitsCommand } from "./permission-rule-model.js";
2
2
  import { sameScope, writerOf } from "./permission-rule-store.js";
3
3
  export function orgRuleStatePersistenceOf(store) {
4
4
  const s = store;
@@ -46,7 +46,7 @@ export function createOrgRuleOverlay(cfg) {
46
46
  throw new Error("org-governed is declared but no OrgRuleSnapshotProvider is wired — a governed deployment without an org source is a configuration contradiction; refusing to boot rather than running silently ungoverned");
47
47
  }
48
48
  if (typeof cfg.stalenessBoundMs !== "number" || !Number.isFinite(cfg.stalenessBoundMs) || cfg.stalenessBoundMs <= 0) {
49
- throw new Error(`org stalenessBoundMs must be a finite positive number of milliseconds (got ${String(cfg.stalenessBoundMs)})`);
49
+ throw new Error(`org stalenessBoundMs must be a finite positive number of milliseconds (got ${escapeForDisclosure(cfg.stalenessBoundMs)})`);
50
50
  }
51
51
  const provider = cfg.provider;
52
52
  const now = cfg.now ?? Date.now;
@@ -75,11 +75,11 @@ export function createOrgRuleOverlay(cfg) {
75
75
  let fetched;
76
76
  let fetchFailed = false;
77
77
  try {
78
- fetched = await provider.current();
78
+ fetched = structuredClone(await provider.current());
79
79
  }
80
80
  catch (err) {
81
81
  fetchFailed = true;
82
- disclosures.push(`org snapshot provider failed: ${err instanceof Error ? err.message : String(err)}`);
82
+ disclosures.push(`org snapshot provider failed: ${escapeForDisclosure(err instanceof Error ? err.message : err)}`);
83
83
  }
84
84
  return await serialize(async () => {
85
85
  const nowMs = now();
@@ -88,7 +88,7 @@ export function createOrgRuleOverlay(cfg) {
88
88
  return await readState();
89
89
  }
90
90
  catch (err) {
91
- disclosures.push(`org state ${context} read failed: ${err instanceof Error ? err.message : String(err)}`);
91
+ disclosures.push(`org state ${context} read failed: ${escapeForDisclosure(err instanceof Error ? err.message : err)}`);
92
92
  return undefined;
93
93
  }
94
94
  };
@@ -101,8 +101,12 @@ export function createOrgRuleOverlay(cfg) {
101
101
  disclosures.push(`org snapshot refused: ${invalid}; the previously installed snapshot (if any) remains in force`);
102
102
  return persisted;
103
103
  }
104
- if (persisted !== undefined && fetched.revision < persisted.revisionHighWater) {
105
- disclosures.push(`org snapshot revision ${fetched.revision} is below the installed high-water mark ${persisted.revisionHighWater} — refusing the rollback; the current deny set is unchanged`);
104
+ const persistedFloor = persisted === undefined ? undefined : antiRollbackFloor(persisted);
105
+ if (persisted !== undefined && !persistedMarkIsCoherent(persisted)) {
106
+ disclosures.push(`the durable org state carries a high-water mark (${escapeForDisclosure(persisted.revisionHighWater)}) that is not a finite number at or above its own snapshot revision (${escapeForDisclosure(persisted.snapshot?.revision)}) — the anti-rollback fence is being held at ${escapeForDisclosure(persistedFloor)} instead, and the durable state should be repaired`);
107
+ }
108
+ if (persistedFloor !== undefined && fetched.revision < persistedFloor) {
109
+ disclosures.push(`org snapshot revision ${fetched.revision} is below the installed high-water mark ${escapeForDisclosure(persistedFloor)} — refusing the rollback; the current deny set is unchanged`);
106
110
  return persisted;
107
111
  }
108
112
  if (persisted !== undefined && fetched.revision === persisted.snapshot.revision && !sameOrgPolicyContent(fetched, persisted.snapshot)) {
@@ -110,7 +114,7 @@ export function createOrgRuleOverlay(cfg) {
110
114
  return persisted;
111
115
  }
112
116
  const state = {
113
- revisionHighWater: Math.max(persisted?.revisionHighWater ?? 0, fetched.revision),
117
+ revisionHighWater: Math.max(persistedFloor !== undefined && Number.isFinite(persistedFloor) ? persistedFloor : 0, fetched.revision),
114
118
  snapshot: fetched,
115
119
  installedAtMs: nowMs,
116
120
  };
@@ -119,11 +123,11 @@ export function createOrgRuleOverlay(cfg) {
119
123
  }
120
124
  catch (err) {
121
125
  const reread = await guardedRead("reconciliation");
122
- if (reread !== undefined && reread.revisionHighWater >= state.revisionHighWater) {
123
- disclosures.push(`org snapshot install was superseded by a concurrent install at revision ${reread.snapshot.revision} — continuing on the newer installed state`);
126
+ if (reread !== undefined && antiRollbackFloor(reread) >= state.revisionHighWater) {
127
+ disclosures.push(`org snapshot install was superseded by a concurrent install at revision ${escapeForDisclosure(reread.snapshot.revision)} — continuing on the newer installed state`);
124
128
  return reread;
125
129
  }
126
- disclosures.push(`org snapshot install failed (${err instanceof Error ? err.message : String(err)}) and no superseding installed state exists — org adjudication is UNAVAILABLE`);
130
+ disclosures.push(`org snapshot install failed (${escapeForDisclosure(err instanceof Error ? err.message : err)}) and no superseding installed state exists — org adjudication is UNAVAILABLE`);
127
131
  return undefined;
128
132
  }
129
133
  return state;
@@ -132,7 +136,12 @@ export function createOrgRuleOverlay(cfg) {
132
136
  const withinBound = (snap) => decisionMs - snap.fetchedAtMs <= cfg.stalenessBoundMs && snap.fetchedAtMs <= decisionMs + ORG_FETCHED_AT_SKEW_ALLOWANCE_MS;
133
137
  if (installed !== undefined) {
134
138
  if (installed.snapshot.fetchedAtMs > decisionMs + ORG_FETCHED_AT_SKEW_ALLOWANCE_MS) {
135
- disclosures.push(`the installed org snapshot claims a FUTURE observation time (${installed.snapshot.fetchedAtMs} vs now ${decisionMs}) — org adjudication is UNAVAILABLE; the consuming gate must tighten every terminal allow to a real-approval ask`);
139
+ disclosures.push(`the installed org snapshot claims a FUTURE observation time (${escapeForDisclosure(installed.snapshot.fetchedAtMs)} vs now ${decisionMs}) — org adjudication is UNAVAILABLE; the consuming gate must tighten every terminal allow to a real-approval ask`);
140
+ return { status: "unavailable", rules: [], disclosures };
141
+ }
142
+ const servingRefusal = validateOrgSnapshot(installed.snapshot, decisionMs);
143
+ if (servingRefusal !== undefined) {
144
+ disclosures.push(`the installed org snapshot (revision ${escapeForDisclosure(installed.snapshot.revision)}) does not meet this version's validator: ${servingRefusal} — org adjudication is UNAVAILABLE; the consuming gate must tighten every terminal allow to a real-approval ask`);
136
145
  return { status: "unavailable", rules: [], disclosures };
137
146
  }
138
147
  if (withinBound(installed.snapshot)) {
@@ -156,16 +165,50 @@ export function createOrgRuleOverlay(cfg) {
156
165
  };
157
166
  return { resolve };
158
167
  }
168
+ function antiRollbackFloor(state) {
169
+ const mark = typeof state.revisionHighWater === "number" && Number.isFinite(state.revisionHighWater) ? state.revisionHighWater : undefined;
170
+ const own = typeof state.snapshot?.revision === "number" && Number.isFinite(state.snapshot.revision) ? state.snapshot.revision : undefined;
171
+ if (mark === undefined)
172
+ return own ?? Number.NEGATIVE_INFINITY;
173
+ return own === undefined ? mark : Math.max(mark, own);
174
+ }
175
+ function persistedMarkIsCoherent(state) {
176
+ return (typeof state.revisionHighWater === "number" &&
177
+ Number.isFinite(state.revisionHighWater) &&
178
+ typeof state.snapshot?.revision === "number" &&
179
+ Number.isFinite(state.snapshot.revision) &&
180
+ state.revisionHighWater >= state.snapshot.revision);
181
+ }
159
182
  function sameOrgPolicyContent(a, b) {
160
183
  if (a.rules.length !== b.rules.length)
161
184
  return false;
162
185
  return a.rules.every((r, i) => b.rules[i]?.rule === r.rule && b.rules[i]?.behavior === r.behavior);
163
186
  }
187
+ const DISCLOSED_UNENFORCEABLE_RULES = 20;
188
+ export function unenforceableOrgRules(rules) {
189
+ const out = [];
190
+ for (const r of rules) {
191
+ if (typeof r?.rule !== "string" || r.rule === "")
192
+ continue;
193
+ const parsed = parseAllowRuleText(r.rule, { direction: "tighten" });
194
+ if ("reject" in parsed)
195
+ out.push({ rule: r.rule, code: parsed.reject.code });
196
+ }
197
+ return out;
198
+ }
199
+ function describeUnenforceable(unenforceable) {
200
+ const named = unenforceable
201
+ .slice(0, DISCLOSED_UNENFORCEABLE_RULES)
202
+ .map((u) => `"${escapeForDisclosure(u.rule)}" (${u.code})`)
203
+ .join(", ");
204
+ const more = unenforceable.length - DISCLOSED_UNENFORCEABLE_RULES;
205
+ return more > 0 ? `${named} and ${more} more` : named;
206
+ }
164
207
  function validateOrgSnapshot(s, nowMs) {
165
208
  if (typeof s.revision !== "number" || !Number.isFinite(s.revision))
166
- return `revision is not a finite number (${String(s.revision)})`;
209
+ return `revision is not a finite number (${escapeForDisclosure(s.revision)})`;
167
210
  if (typeof s.fetchedAtMs !== "number" || !Number.isFinite(s.fetchedAtMs))
168
- return `fetchedAtMs is not a finite number (${String(s.fetchedAtMs)})`;
211
+ return `fetchedAtMs is not a finite number (${escapeForDisclosure(s.fetchedAtMs)})`;
169
212
  if (s.fetchedAtMs > nowMs + ORG_FETCHED_AT_SKEW_ALLOWANCE_MS) {
170
213
  return `fetchedAtMs is ${s.fetchedAtMs - nowMs}ms in the future (allowance ${ORG_FETCHED_AT_SKEW_ALLOWANCE_MS}ms) — a future-dated observation would satisfy the staleness bound indefinitely`;
171
214
  }
@@ -174,11 +217,15 @@ function validateOrgSnapshot(s, nowMs) {
174
217
  for (const r of s.rules) {
175
218
  if (typeof r?.rule !== "string" || r.rule === "")
176
219
  return "a rule entry carries no rule text";
177
- if (r.behavior !== "deny" && r.behavior !== "ask")
178
- return `rule "${r.rule}" carries behavior "${String(r.behavior)}" — the org layer has no allow bucket`;
179
- const parsed = parseAllowRuleText(r.rule, { direction: "tighten" });
180
- if ("reject" in parsed)
181
- return `rule "${r.rule}" does not parse (${parsed.reject.code}) — an unenforceable ${r.behavior} must not install as policy`;
220
+ if (r.behavior !== "deny" && r.behavior !== "ask") {
221
+ return `rule "${escapeForDisclosure(r.rule)}" carries behavior "${escapeForDisclosure(r.behavior)}" — the org layer has no allow bucket`;
222
+ }
223
+ }
224
+ const unenforceable = unenforceableOrgRules(s.rules);
225
+ const first = unenforceable[0];
226
+ if (first !== undefined) {
227
+ const rest = unenforceable.length > 1 ? ` (${unenforceable.length} in all — ${describeUnenforceable(unenforceable)})` : "";
228
+ return `rule "${escapeForDisclosure(first.rule)}" does not parse (${first.code}) — an unenforceable deny/ask must not stand as policy${rest}`;
182
229
  }
183
230
  return undefined;
184
231
  }
@@ -231,7 +278,7 @@ function resolveIntrospectionStore(opts) {
231
278
  return store;
232
279
  }
233
280
  if (opts.owner !== undefined && opts.owner.kind === "principal" && opts.principal !== undefined && opts.owner.principal !== opts.principal) {
234
- throw new Error(`contradictory identity: principal "${opts.principal}" and owner principal "${opts.owner.principal}" disagree`);
281
+ throw new Error(`contradictory identity: principal "${escapeForDisclosure(opts.principal)}" and owner principal "${escapeForDisclosure(opts.owner.principal)}" disagree`);
235
282
  }
236
283
  const principal = opts.owner?.kind === "principal" ? opts.owner.principal : opts.principal;
237
284
  return opts.provider.forPrincipal(principal);
@@ -1,6 +1,6 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { canonicalize } from "./canonical-json.js";
3
- import { isRuleLive, parseAllowRuleText } from "./permission-rule-model.js";
3
+ import { escapeForDisclosure, isRuleLive, parseAllowRuleText } 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
  }
@@ -191,7 +191,7 @@ export function applySyncJoin(cur, delta, nowMs) {
191
191
  }
192
192
  const canonicalTomb = parseAllowRuleText(t.rule);
193
193
  if ("reject" in canonicalTomb || canonicalTomb.rule.rule !== t.rule) {
194
- throw new Error(`an inbound tombstone names a non-canonical or invalid rule text ("${t.rule}") — refusing the whole sync-join (the client's partitioning must withhold the round's adds alongside a malformed tombstone)`);
194
+ 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)`);
195
195
  }
196
196
  }
197
197
  const droppedInbound = [];
@@ -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, RuleScope, RuleTombstone } from "./permission-rule-model.js";
28
+ import type { PersistedAllowRule, RuleDot, RuleRejectCode, RuleScope, RuleTombstone } from "./permission-rule-model.js";
29
29
  import type { PermissionRuleStoreProvider, RuleOwner, RuleSyncDrop, RuleSyncFrontier, RuleSyncState } from "./permission-rule-store.js";
30
30
  import { type TracerHook } from "./trace.js";
31
31
  /** The injected HTTP seam — core never bundles a fetch. The deployment owns base URL, auth, TLS and
@@ -125,6 +125,15 @@ interface ParsedRuleSyncResponse {
125
125
  dot: RuleDot;
126
126
  reason: string;
127
127
  }>;
128
+ /** Tombstones whose SHAPE is whole but whose rule TEXT the current validator refuses — the third
129
+ * outcome, kept OUT of `merged.tombstones` so it does not read as malformed. See {@link
130
+ * parseRuleSyncResponse} for why this one does not withhold the round's adds (backlog #176). */
131
+ textRefusedTombstones: Array<{
132
+ rule: string;
133
+ scope: RuleScope;
134
+ dot: RuleDot;
135
+ code: RuleRejectCode;
136
+ }>;
128
137
  warnings: string[];
129
138
  }
130
139
  /**
@@ -134,6 +143,11 @@ interface ParsedRuleSyncResponse {
134
143
  * reaches the local disk); an unshapeable tombstone is surfaced as `undefined` so the caller's
135
144
  * tighten-only partitioning withholds the round's adds. Unknown keys never flow anywhere — every field
136
145
  * below is PICKED off the response, the object itself is discarded.
146
+ *
147
+ * A tombstone has THREE outcomes, not two (backlog #176): sound; malformed (bad shape, or a spelling
148
+ * that parses but is not canonical — the withholding arm); and shape-whole-but-TEXT-refused, reported
149
+ * on {@link ParsedRuleSyncResponse.textRefusedTombstones}. The third is a legacy row a tightened
150
+ * validator no longer admits — permanent, so treating it as the second would stall inbound adds forever.
137
151
  */
138
152
  export declare function parseRuleSyncResponse(raw: unknown, expectedPrincipal: string): ParsedRuleSyncResponse;
139
153
  export {};
@@ -1,4 +1,4 @@
1
- import { parseAllowRuleText } from "./permission-rule-model.js";
1
+ import { escapeForDisclosure, parseAllowRuleText } from "./permission-rule-model.js";
2
2
  import { applyTombstones, errText, joinFrontiers, joinRuleStates, normalizePersistedRule, ruleSyncVector, sameScope, 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";
@@ -9,12 +9,13 @@ function refuseLocalOwnerSync() {
9
9
  throw e;
10
10
  }
11
11
  const SYNC_MAX_ATTEMPTS = 8;
12
+ const DISCLOSED_REFUSED_TOMBSTONES = 5;
12
13
  export async function syncPermissionRules(opts) {
13
14
  if (opts.owner !== undefined) {
14
15
  if (opts.owner.kind === "local-owner")
15
16
  refuseLocalOwnerSync();
16
17
  if (opts.owner.principal !== opts.principal) {
17
- throw new Error(`contradictory identity: principal "${opts.principal}" and owner principal "${opts.owner.principal}" disagree`);
18
+ throw new Error(`contradictory identity: principal "${escapeForDisclosure(opts.principal)}" and owner principal "${escapeForDisclosure(opts.owner.principal)}" disagree`);
18
19
  }
19
20
  }
20
21
  if (typeof opts.principal !== "string" || opts.principal === "") {
@@ -83,6 +84,18 @@ export async function syncPermissionRules(opts) {
83
84
  if (tombstoneWithholding) {
84
85
  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)");
85
86
  }
87
+ for (const t of response.textRefusedTombstones) {
88
+ dropped.push({ rule: t.rule, scope: t.scope, dot: t.dot, reason: "invalid_rule_text" });
89
+ }
90
+ if (response.textRefusedTombstones.length > 0) {
91
+ const named = response.textRefusedTombstones
92
+ .slice(0, DISCLOSED_REFUSED_TOMBSTONES)
93
+ .map((t) => `"${escapeForDisclosure(t.rule)}" (${t.code})`)
94
+ .join(", ");
95
+ const more = response.textRefusedTombstones.length - DISCLOSED_REFUSED_TOMBSTONES;
96
+ warnings.push(`the response carried ${response.textRefusedTombstones.length} tombstone(s) whose rule text this version's validator refuses — ${named}${more > 0 ? ` and ${more} more` : ""}. ` +
97
+ "They are refused here and never land; this round's inbound adds are NOT withheld for them (an add under the same text is refused by the same validator, so such a tombstone covers nothing that could go live). The peer still holds them — removing them there is what ends this notice");
98
+ }
86
99
  const inboundRules = [];
87
100
  if (!tombstoneWithholding) {
88
101
  for (const r of response.merged.rules) {
@@ -120,7 +133,7 @@ export async function syncPermissionRules(opts) {
120
133
  dropped.push({ rule: d.rule, scope: d.scope, dot: d.dot, reason });
121
134
  }
122
135
  else {
123
- warnings.push(`the server dropped an inbound-side record (${d.rule}, dot ${d.dot.actor}#${d.dot.counter}): ${d.reason}`);
136
+ warnings.push(`the server dropped an inbound-side record (${escapeForDisclosure(d.rule)}, dot ${escapeForDisclosure(d.dot.actor)}#${d.dot.counter}): ${escapeForDisclosure(d.reason)}`);
124
137
  }
125
138
  }
126
139
  const clean = !tombstoneWithholding;
@@ -147,7 +160,7 @@ export async function syncPermissionRules(opts) {
147
160
  resurrected: [],
148
161
  dropped,
149
162
  rev: current.rev,
150
- warnings: [...warnings, `the store refused the sync landing: ${errText(err)} — nothing landed, the local state is unchanged`],
163
+ warnings: [...warnings, `the store refused the sync landing: ${escapeForDisclosure(errText(err))} — nothing landed, the local state is unchanged`],
151
164
  });
152
165
  }
153
166
  if ("conflict" in res) {
@@ -205,9 +218,11 @@ export async function syncPermissionRules(opts) {
205
218
  warnings: [...warnings, `optimistic-concurrency retries exhausted after ${SYNC_MAX_ATTEMPTS} attempts — nothing landed, the local state is unchanged`],
206
219
  });
207
220
  }
208
- function isDot(v) {
221
+ function pickDot(v) {
209
222
  const d = v;
210
- return typeof d?.actor === "string" && d.actor !== "" && typeof d.counter === "number" && Number.isFinite(d.counter);
223
+ const actor = d?.actor;
224
+ const counter = d?.counter;
225
+ return typeof actor === "string" && actor !== "" && typeof counter === "number" && Number.isFinite(counter) ? { actor, counter } : undefined;
211
226
  }
212
227
  function pickScope(v) {
213
228
  const s = v;
@@ -219,13 +234,16 @@ function pickScope(v) {
219
234
  }
220
235
  function pickAdd(v) {
221
236
  const a = v;
222
- if (!isDot(a?.dot))
237
+ const dot = pickDot(a?.dot);
238
+ const origin = a?.origin;
239
+ const createdAt = a?.createdAt;
240
+ if (dot === undefined)
223
241
  return undefined;
224
- if (a.origin !== "user" && a.origin !== "imported-cc" && a.origin !== "starter")
242
+ if (origin !== "user" && origin !== "imported-cc" && origin !== "starter")
225
243
  return undefined;
226
- if (typeof a.createdAt !== "string")
244
+ if (typeof createdAt !== "string")
227
245
  return undefined;
228
- return { dot: { actor: a.dot.actor, counter: a.dot.counter }, origin: a.origin, createdAt: a.createdAt };
246
+ return { dot, origin, createdAt };
229
247
  }
230
248
  function pickFrontier(v) {
231
249
  if (v === null || typeof v !== "object" || Array.isArray(v))
@@ -243,35 +261,44 @@ export function parseRuleSyncResponse(raw, expectedPrincipal) {
243
261
  throw new Error("the sync response is not an object — protocol error, nothing landed");
244
262
  }
245
263
  const r = raw;
246
- if (typeof r.principal !== "string") {
264
+ const echoedPrincipal = r.principal;
265
+ if (typeof echoedPrincipal !== "string") {
247
266
  throw new Error("the sync response carries no principal echo — protocol error, nothing landed");
248
267
  }
249
- if (r.principal !== expectedPrincipal) {
250
- throw new Error(`the sync response echoes principal "${r.principal}" but this round synced "${expectedPrincipal}" — the transport is wired to the wrong bucket; refusing the whole round`);
268
+ if (echoedPrincipal !== expectedPrincipal) {
269
+ throw new Error(`the sync response echoes principal "${escapeForDisclosure(echoedPrincipal)}" but this round synced "${escapeForDisclosure(expectedPrincipal)}" — the transport is wired to the wrong bucket; refusing the whole round`);
251
270
  }
252
271
  const merged = r.merged;
253
- if (merged === null || typeof merged !== "object" || !Array.isArray(merged.rules) || !Array.isArray(merged.tombstones)) {
272
+ const mergedRules = merged?.rules;
273
+ const mergedTombstones = merged?.tombstones;
274
+ if (merged === null || typeof merged !== "object" || !Array.isArray(mergedRules) || !Array.isArray(mergedTombstones)) {
254
275
  throw new Error("the sync response carries no merged state — protocol error, nothing landed");
255
276
  }
277
+ const rawGcFrontier = r.gcFrontier;
256
278
  let gcFrontier;
257
- if (r.gcFrontier !== undefined) {
258
- gcFrontier = pickFrontier(r.gcFrontier);
279
+ if (rawGcFrontier !== undefined) {
280
+ gcFrontier = pickFrontier(rawGcFrontier);
259
281
  if (gcFrontier === undefined) {
260
282
  throw new Error("the sync response carries an unparseable gcFrontier — refusing the whole round (a garbled frontier must not drive the fence arm)");
261
283
  }
262
284
  }
263
285
  const warnings = [];
264
286
  const rules = [];
265
- for (const entry of merged.rules) {
287
+ for (const entry of mergedRules) {
266
288
  const e = entry;
267
289
  const scope = pickScope(e?.scope);
268
- if (typeof e?.rule !== "string" || scope === undefined || !Array.isArray(e.adds) || typeof e.command !== "string" || typeof e.tool !== "string" || typeof e.match !== "string") {
290
+ const eRule = e?.rule;
291
+ const eCommand = e?.command;
292
+ const eTool = e?.tool;
293
+ const eMatch = e?.match;
294
+ const eAdds = e?.adds;
295
+ if (typeof eRule !== "string" || scope === undefined || !Array.isArray(eAdds) || typeof eCommand !== "string" || typeof eTool !== "string" || typeof eMatch !== "string") {
269
296
  warnings.push("the response carried an unshapeable rule entry — skipped (it never reaches the local store)");
270
297
  continue;
271
298
  }
272
299
  const adds = [];
273
300
  let addDamaged = false;
274
- for (const a of e.adds) {
301
+ for (const a of eAdds) {
275
302
  const picked = pickAdd(a);
276
303
  if (picked === undefined) {
277
304
  addDamaged = true;
@@ -280,64 +307,79 @@ export function parseRuleSyncResponse(raw, expectedPrincipal) {
280
307
  adds.push(picked);
281
308
  }
282
309
  if (addDamaged)
283
- warnings.push(`rule "${e.rule}" carried at least one unshapeable add — skipped those adds`);
310
+ warnings.push(`rule "${escapeForDisclosure(eRule)}" carried at least one unshapeable add — skipped those adds`);
284
311
  if (adds.length === 0)
285
312
  continue;
286
313
  rules.push({
287
- rule: e.rule,
288
- tool: e.tool,
289
- match: e.match,
290
- command: e.command,
314
+ rule: eRule,
315
+ tool: eTool,
316
+ match: eMatch,
317
+ command: eCommand,
291
318
  scope,
292
319
  adds,
293
320
  });
294
321
  }
295
322
  const tombstones = [];
296
- for (const entry of merged.tombstones) {
323
+ const textRefusedTombstones = [];
324
+ for (const entry of mergedTombstones) {
297
325
  const t = entry;
298
326
  const scope = pickScope(t?.scope);
299
- const canonicalTomb = typeof t?.rule === "string" ? parseAllowRuleText(t.rule) : undefined;
300
- if (typeof t?.rule !== "string" ||
301
- canonicalTomb === undefined ||
302
- "reject" in canonicalTomb ||
303
- canonicalTomb.rule.rule !== t.rule ||
304
- scope === undefined ||
305
- !isDot(t.deletedBy) ||
306
- !Array.isArray(t.removedDots) ||
307
- t.removedDots.length === 0 ||
308
- !t.removedDots.every(isDot)) {
327
+ const ruleText = t?.rule;
328
+ const deletedBy = pickDot(t?.deletedBy);
329
+ const removedRaw = t?.removedDots;
330
+ const removedDots = [];
331
+ if (Array.isArray(removedRaw)) {
332
+ for (const d of removedRaw) {
333
+ const dot = pickDot(d);
334
+ if (dot === undefined) {
335
+ removedDots.length = 0;
336
+ break;
337
+ }
338
+ removedDots.push(dot);
339
+ }
340
+ }
341
+ if (typeof ruleText !== "string" || scope === undefined || deletedBy === undefined || removedDots.length === 0) {
309
342
  tombstones.push(undefined);
310
343
  continue;
311
344
  }
312
- tombstones.push({
313
- rule: t.rule,
314
- scope,
315
- removedDots: t.removedDots.map((d) => ({ actor: d.actor, counter: d.counter })),
316
- deletedBy: { actor: t.deletedBy.actor, counter: t.deletedBy.counter },
317
- });
345
+ const canonicalTomb = parseAllowRuleText(ruleText);
346
+ if ("reject" in canonicalTomb) {
347
+ textRefusedTombstones.push({ rule: ruleText, scope, dot: deletedBy, code: canonicalTomb.reject.code });
348
+ continue;
349
+ }
350
+ if (canonicalTomb.rule.rule !== ruleText) {
351
+ tombstones.push(undefined);
352
+ continue;
353
+ }
354
+ tombstones.push({ rule: ruleText, scope, removedDots, deletedBy });
318
355
  }
319
356
  const dropped = [];
320
- if (r.dropped !== undefined) {
321
- if (!Array.isArray(r.dropped)) {
357
+ const rawDropped = r.dropped;
358
+ if (rawDropped !== undefined) {
359
+ if (!Array.isArray(rawDropped)) {
322
360
  warnings.push("the response carried a non-array dropped field — ignored");
323
361
  }
324
362
  else {
325
- for (const entry of r.dropped) {
363
+ for (const entry of rawDropped) {
326
364
  const d = entry;
327
365
  const scope = pickScope(d?.scope);
328
- if (typeof d?.rule !== "string" || scope === undefined || !isDot(d.dot) || typeof d.reason !== "string") {
366
+ const dRule = d?.rule;
367
+ const dDot = pickDot(d?.dot);
368
+ const dReason = d?.reason;
369
+ if (typeof dRule !== "string" || scope === undefined || dDot === undefined || typeof dReason !== "string") {
329
370
  warnings.push("the response carried an unshapeable dropped entry — ignored (the server's own drop discipline is its contract obligation)");
330
371
  continue;
331
372
  }
332
- dropped.push({ rule: d.rule, scope, dot: { actor: d.dot.actor, counter: d.dot.counter }, reason: d.reason });
373
+ dropped.push({ rule: dRule, scope, dot: dDot, reason: dReason });
333
374
  }
334
375
  }
335
376
  }
336
377
  return {
337
- principal: r.principal,
378
+ principal: echoedPrincipal,
338
379
  merged: { rules, tombstones },
339
380
  ...(gcFrontier !== undefined ? { gcFrontier } : {}),
340
381
  dropped,
382
+ textRefusedTombstones,
341
383
  warnings,
342
384
  };
343
385
  }