@remnic/core 9.3.724 → 9.3.726

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 (170) hide show
  1. package/dist/access-boundary.js +26 -25
  2. package/dist/access-cli.js +64 -62
  3. package/dist/access-cli.js.map +1 -1
  4. package/dist/access-http.js +33 -32
  5. package/dist/access-mcp.js +30 -29
  6. package/dist/access-operations-batch.js +27 -26
  7. package/dist/access-operations.js +29 -28
  8. package/dist/access-schema.js +3 -3
  9. package/dist/access-service.js +25 -24
  10. package/dist/active-recall.js +1 -1
  11. package/dist/adapters/index.js +4 -4
  12. package/dist/adapters/registry.js +2 -2
  13. package/dist/briefing.js +6 -5
  14. package/dist/{capsule-crypto-7FJQINUR.js → capsule-crypto-GWVG7LGC.js} +2 -2
  15. package/dist/causal-consolidation.js +7 -6
  16. package/dist/causal-consolidation.js.map +1 -1
  17. package/dist/{chunk-C6BWLYW5.js → chunk-2NBP25BG.js} +7 -7
  18. package/dist/{chunk-YUYPWJD3.js → chunk-3MS3QVX6.js} +2 -2
  19. package/dist/{chunk-FVZ2ISIX.js → chunk-3TUT4PWB.js} +2 -2
  20. package/dist/{chunk-HCVWF657.js → chunk-53EEQYE5.js} +7 -7
  21. package/dist/{chunk-TAPSNIMA.js → chunk-5CEJH5ZN.js} +4 -4
  22. package/dist/{chunk-D7EBTQ24.js → chunk-5NWYQUQC.js} +2 -2
  23. package/dist/{chunk-NT36GU6X.js → chunk-5OOA7SGX.js} +2 -2
  24. package/dist/{chunk-KQAFEZQX.js → chunk-5W7CTZ2E.js} +4 -4
  25. package/dist/{chunk-QR67FU5G.js → chunk-6AFVIOVZ.js} +2 -2
  26. package/dist/{chunk-UBHVAGO7.js → chunk-6WAJQKBS.js} +2 -2
  27. package/dist/{chunk-6CWGS2LY.js → chunk-7NS7AWMZ.js} +3 -3
  28. package/dist/{chunk-T55EGZSJ.js → chunk-BB57OZWC.js} +2 -2
  29. package/dist/{chunk-YQMPUYHE.js → chunk-BBIE7W52.js} +2 -2
  30. package/dist/{chunk-WAFDHKES.js → chunk-BEUCACTV.js} +2 -2
  31. package/dist/{chunk-4SF232E6.js → chunk-BIA7ELIB.js} +17 -5
  32. package/dist/chunk-BIA7ELIB.js.map +1 -0
  33. package/dist/{chunk-GZQXOQQ2.js → chunk-DCKNMLEQ.js} +4 -4
  34. package/dist/{chunk-ZUDM75KG.js → chunk-DCWIQFNA.js} +4 -4
  35. package/dist/{chunk-VI5SGGXB.js → chunk-DQXPJ6HD.js} +4 -4
  36. package/dist/{chunk-YNDLCWXS.js → chunk-EZ25VE3G.js} +4 -4
  37. package/dist/{chunk-Z3FGC3QE.js → chunk-EZNDNMRH.js} +139 -30
  38. package/dist/chunk-EZNDNMRH.js.map +1 -0
  39. package/dist/{chunk-FACTVMXQ.js → chunk-FO3HO52S.js} +2 -2
  40. package/dist/{chunk-RKNJBZ55.js → chunk-JBPKEARU.js} +4 -4
  41. package/dist/{chunk-JJJCVCCQ.js → chunk-JPUT5NGH.js} +2 -2
  42. package/dist/{chunk-5OPYYPJN.js → chunk-K4ZNARDO.js} +2 -2
  43. package/dist/{chunk-4WZ3J7M6.js → chunk-KJR66XOM.js} +2 -2
  44. package/dist/{chunk-LD53WPMU.js → chunk-LQ6JI4VH.js} +4 -4
  45. package/dist/chunk-LTHVM4XN.js +109 -0
  46. package/dist/chunk-LTHVM4XN.js.map +1 -0
  47. package/dist/{chunk-4R3CGCI5.js → chunk-MQVKHFIU.js} +16 -13
  48. package/dist/chunk-MQVKHFIU.js.map +1 -0
  49. package/dist/{chunk-FXCIOLRB.js → chunk-NPL7VDM6.js} +2 -2
  50. package/dist/{chunk-KLFYKC3K.js → chunk-OAHOL3ZC.js} +112 -72
  51. package/dist/chunk-OAHOL3ZC.js.map +1 -0
  52. package/dist/{chunk-2NLLXCJG.js → chunk-OWHERGF2.js} +2 -2
  53. package/dist/{chunk-I2HL5M6T.js → chunk-PLG6JHQS.js} +42 -42
  54. package/dist/{chunk-KGZF2VO3.js → chunk-Q6D75LVH.js} +15 -15
  55. package/dist/{chunk-SC2YAWRF.js → chunk-RRHHQYJS.js} +2 -2
  56. package/dist/{chunk-J334N6QV.js → chunk-TA3Y6GEX.js} +2 -2
  57. package/dist/{chunk-FN6QP7JQ.js → chunk-TQQH2FTX.js} +4 -4
  58. package/dist/{chunk-DF3I6JGP.js → chunk-UKTDXTWX.js} +15 -15
  59. package/dist/{chunk-ARV3AUOM.js → chunk-UNNDFYUX.js} +5 -5
  60. package/dist/{chunk-ZK2L72VN.js → chunk-UQSVUEN2.js} +2 -2
  61. package/dist/{chunk-X7Y7WX73.js → chunk-UVYI6VIX.js} +1 -1
  62. package/dist/{chunk-H7HUQIY7.js → chunk-V7CMKMHM.js} +3 -3
  63. package/dist/chunk-W5H6ZRPT.js +14 -0
  64. package/dist/chunk-W5H6ZRPT.js.map +1 -0
  65. package/dist/{chunk-TT5U3UDO.js → chunk-WGTWGDR5.js} +5 -5
  66. package/dist/{chunk-YQGHW5ML.js → chunk-WRFKZEO6.js} +4 -4
  67. package/dist/{chunk-WM7DNKBD.js → chunk-YTZ3VGC7.js} +2 -2
  68. package/dist/cli.js +54 -53
  69. package/dist/compounding/engine.js +6 -5
  70. package/dist/config.js +1 -1
  71. package/dist/connectors/codex-materialize-runner.js +6 -5
  72. package/dist/connectors/index.js +6 -5
  73. package/dist/content-hash.d.ts +14 -0
  74. package/dist/content-hash.js +10 -0
  75. package/dist/content-hash.js.map +1 -0
  76. package/dist/conversation-index/backend.js +2 -2
  77. package/dist/dashboard-runtime.js +2 -2
  78. package/dist/entity-retrieval.js +6 -5
  79. package/dist/extraction-redaction-rules.d.ts +33 -0
  80. package/dist/extraction-redaction-rules.js +14 -0
  81. package/dist/extraction-redaction-rules.js.map +1 -0
  82. package/dist/extraction.js +2 -2
  83. package/dist/{forget-OEE5OELK.js → forget-WYLR4ZFV.js} +2 -2
  84. package/dist/index.js +102 -100
  85. package/dist/index.js.map +1 -1
  86. package/dist/lcm/index.js +3 -3
  87. package/dist/maintenance/memory-governance.js +6 -5
  88. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +6 -5
  89. package/dist/maintenance/rebuild-memory-projection.js +7 -6
  90. package/dist/namespaces/migrate.js +16 -15
  91. package/dist/namespaces/search.js +9 -9
  92. package/dist/namespaces/storage.js +6 -5
  93. package/dist/operator-toolkit.js +24 -23
  94. package/dist/orchestration/maintenance.js +8 -7
  95. package/dist/orchestrator.js +48 -46
  96. package/dist/resume-bundles.js +2 -2
  97. package/dist/search/factory.js +8 -8
  98. package/dist/search/index.js +12 -12
  99. package/dist/search/lancedb-backend.js +2 -2
  100. package/dist/search/meilisearch-backend.js +2 -2
  101. package/dist/search/orama-backend.js +2 -2
  102. package/dist/semantic-consolidation.js +7 -6
  103. package/dist/semantic-rule-promotion.js +6 -5
  104. package/dist/semantic-rule-verifier.js +6 -5
  105. package/dist/storage.d.ts +2 -2
  106. package/dist/storage.js +6 -5
  107. package/dist/transfer/backup.js +2 -2
  108. package/dist/transfer/capsule-export.js +2 -2
  109. package/dist/transfer/capsule-import.js +2 -2
  110. package/dist/verified-recall.js +6 -5
  111. package/package.json +2 -2
  112. package/src/config.test.ts +50 -0
  113. package/src/config.ts +21 -4
  114. package/src/content-hash.ts +24 -0
  115. package/src/correction/correction-access-wiring.ts +104 -19
  116. package/src/correction/correction-contract.ts +68 -10
  117. package/src/correction/correction-executor.test.ts +112 -2
  118. package/src/correction/correction-executor.ts +65 -5
  119. package/src/correction/correction-planner.test.ts +47 -0
  120. package/src/correction/correction-planner.ts +40 -1
  121. package/src/correction/correction-storage-integration.test.ts +183 -0
  122. package/src/extraction-redaction-rules.test.ts +213 -0
  123. package/src/extraction-redaction-rules.ts +208 -0
  124. package/src/orchestrator.ts +52 -1
  125. package/src/storage.ts +6 -10
  126. package/dist/chunk-4R3CGCI5.js.map +0 -1
  127. package/dist/chunk-4SF232E6.js.map +0 -1
  128. package/dist/chunk-KLFYKC3K.js.map +0 -1
  129. package/dist/chunk-Z3FGC3QE.js.map +0 -1
  130. /package/dist/{capsule-crypto-7FJQINUR.js.map → capsule-crypto-GWVG7LGC.js.map} +0 -0
  131. /package/dist/{chunk-C6BWLYW5.js.map → chunk-2NBP25BG.js.map} +0 -0
  132. /package/dist/{chunk-YUYPWJD3.js.map → chunk-3MS3QVX6.js.map} +0 -0
  133. /package/dist/{chunk-FVZ2ISIX.js.map → chunk-3TUT4PWB.js.map} +0 -0
  134. /package/dist/{chunk-HCVWF657.js.map → chunk-53EEQYE5.js.map} +0 -0
  135. /package/dist/{chunk-TAPSNIMA.js.map → chunk-5CEJH5ZN.js.map} +0 -0
  136. /package/dist/{chunk-D7EBTQ24.js.map → chunk-5NWYQUQC.js.map} +0 -0
  137. /package/dist/{chunk-NT36GU6X.js.map → chunk-5OOA7SGX.js.map} +0 -0
  138. /package/dist/{chunk-KQAFEZQX.js.map → chunk-5W7CTZ2E.js.map} +0 -0
  139. /package/dist/{chunk-QR67FU5G.js.map → chunk-6AFVIOVZ.js.map} +0 -0
  140. /package/dist/{chunk-UBHVAGO7.js.map → chunk-6WAJQKBS.js.map} +0 -0
  141. /package/dist/{chunk-6CWGS2LY.js.map → chunk-7NS7AWMZ.js.map} +0 -0
  142. /package/dist/{chunk-T55EGZSJ.js.map → chunk-BB57OZWC.js.map} +0 -0
  143. /package/dist/{chunk-YQMPUYHE.js.map → chunk-BBIE7W52.js.map} +0 -0
  144. /package/dist/{chunk-WAFDHKES.js.map → chunk-BEUCACTV.js.map} +0 -0
  145. /package/dist/{chunk-GZQXOQQ2.js.map → chunk-DCKNMLEQ.js.map} +0 -0
  146. /package/dist/{chunk-ZUDM75KG.js.map → chunk-DCWIQFNA.js.map} +0 -0
  147. /package/dist/{chunk-VI5SGGXB.js.map → chunk-DQXPJ6HD.js.map} +0 -0
  148. /package/dist/{chunk-YNDLCWXS.js.map → chunk-EZ25VE3G.js.map} +0 -0
  149. /package/dist/{chunk-FACTVMXQ.js.map → chunk-FO3HO52S.js.map} +0 -0
  150. /package/dist/{chunk-RKNJBZ55.js.map → chunk-JBPKEARU.js.map} +0 -0
  151. /package/dist/{chunk-JJJCVCCQ.js.map → chunk-JPUT5NGH.js.map} +0 -0
  152. /package/dist/{chunk-5OPYYPJN.js.map → chunk-K4ZNARDO.js.map} +0 -0
  153. /package/dist/{chunk-4WZ3J7M6.js.map → chunk-KJR66XOM.js.map} +0 -0
  154. /package/dist/{chunk-LD53WPMU.js.map → chunk-LQ6JI4VH.js.map} +0 -0
  155. /package/dist/{chunk-FXCIOLRB.js.map → chunk-NPL7VDM6.js.map} +0 -0
  156. /package/dist/{chunk-2NLLXCJG.js.map → chunk-OWHERGF2.js.map} +0 -0
  157. /package/dist/{chunk-I2HL5M6T.js.map → chunk-PLG6JHQS.js.map} +0 -0
  158. /package/dist/{chunk-KGZF2VO3.js.map → chunk-Q6D75LVH.js.map} +0 -0
  159. /package/dist/{chunk-SC2YAWRF.js.map → chunk-RRHHQYJS.js.map} +0 -0
  160. /package/dist/{chunk-J334N6QV.js.map → chunk-TA3Y6GEX.js.map} +0 -0
  161. /package/dist/{chunk-FN6QP7JQ.js.map → chunk-TQQH2FTX.js.map} +0 -0
  162. /package/dist/{chunk-DF3I6JGP.js.map → chunk-UKTDXTWX.js.map} +0 -0
  163. /package/dist/{chunk-ARV3AUOM.js.map → chunk-UNNDFYUX.js.map} +0 -0
  164. /package/dist/{chunk-ZK2L72VN.js.map → chunk-UQSVUEN2.js.map} +0 -0
  165. /package/dist/{chunk-X7Y7WX73.js.map → chunk-UVYI6VIX.js.map} +0 -0
  166. /package/dist/{chunk-H7HUQIY7.js.map → chunk-V7CMKMHM.js.map} +0 -0
  167. /package/dist/{chunk-TT5U3UDO.js.map → chunk-WGTWGDR5.js.map} +0 -0
  168. /package/dist/{chunk-YQGHW5ML.js.map → chunk-WRFKZEO6.js.map} +0 -0
  169. /package/dist/{chunk-WM7DNKBD.js.map → chunk-YTZ3VGC7.js.map} +0 -0
  170. /package/dist/{forget-OEE5OELK.js.map → forget-WYLR4ZFV.js.map} +0 -0
@@ -0,0 +1,213 @@
1
+ /**
2
+ * extraction-redaction-rules.test.ts — issue #1669 (#1580 follow-up).
3
+ *
4
+ * Proves the extraction-layer redaction-rule helper reads persisted
5
+ * `redaction_rule` patterns and withholds matching content. The orchestrator
6
+ * consults these rules before the storage write chokepoint so a never_store
7
+ * correction actually blocks future extraction of matching content.
8
+ */
9
+ import { strict as assert } from "node:assert";
10
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
11
+ import path from "node:path";
12
+ import { tmpdir } from "node:os";
13
+ import { test } from "node:test";
14
+ import {
15
+ REDACTION_RULES_SUBDIR,
16
+ compileRedactionPattern,
17
+ contentMatchesRedactionRules,
18
+ loadRedactionRules,
19
+ } from "./extraction-redaction-rules.js";
20
+ import { validateRedactionPattern } from "./correction/correction-contract.js";
21
+
22
+ async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
23
+ const dir = await mkdtemp(path.join(tmpdir(), "remnic-redact-"));
24
+ try {
25
+ return await fn(dir);
26
+ } finally {
27
+ await rm(dir, { recursive: true, force: true });
28
+ }
29
+ }
30
+
31
+ async function writeRule(stateDir: string, pattern: string): Promise<void> {
32
+ const dir = path.join(stateDir, REDACTION_RULES_SUBDIR);
33
+ await mkdir(dir, { recursive: true });
34
+ const slug = pattern.replace(/[^a-zA-Z0-9]+/g, "-").slice(0, 64) || "rule";
35
+ await writeFile(
36
+ path.join(dir, `${slug}.json`),
37
+ `${JSON.stringify({ pattern, namespace: "default", createdAt: "2026-07-06T00:00:00Z" })}\n`,
38
+ "utf-8",
39
+ );
40
+ }
41
+
42
+ test("compileRedactionPattern: literal pattern matches by substring", () => {
43
+ const rule = compileRedactionPattern("secret-token-123");
44
+ assert.ok(rule.matcher("the secret-token-123 leaked here"));
45
+ assert.ok(!rule.matcher("nothing here"));
46
+ });
47
+
48
+ test("compileRedactionPattern: regex-like pattern compiles to a RegExp", () => {
49
+ // Only /.../-wrapped patterns compile as RegExp (review thread P1).
50
+ const rule = compileRedactionPattern("/secret-token-\\d+/");
51
+ assert.ok(rule.matcher("found secret-token-999 in logs"));
52
+ assert.ok(!rule.matcher("found secret-token-abc in logs"));
53
+ });
54
+
55
+ test("compileRedactionPattern (P1): unwrapped pattern with metacharacters is literal", () => {
56
+ // abc+def must match the LITERAL string, not the regex abccccdef.
57
+ const rule = compileRedactionPattern("abc+def");
58
+ assert.ok(rule.matcher("the value is abc+def here"),
59
+ "unwrapped metachar pattern must match the literal substring");
60
+ assert.ok(!rule.matcher("abccccdef"),
61
+ "unwrapped metachar pattern must NOT be compiled as a regex");
62
+ // An API-token-shaped literal like foo(bar) also works.
63
+ const rule2 = compileRedactionPattern("foo(bar)");
64
+ assert.ok(rule2.matcher("contains foo(bar) text"));
65
+ assert.ok(!rule2.matcher("contains foobar text"));
66
+ });
67
+
68
+ test("compileRedactionPattern: /wrapped/ regex strips delimiters", () => {
69
+ const rule = compileRedactionPattern("/internal-only-[a-z]+/");
70
+ assert.ok(rule.matcher("this is internal-only-foo data"));
71
+ assert.ok(!rule.matcher("this is public data"));
72
+ });
73
+
74
+ test("compileRedactionPattern: malformed /wrapped/ regex falls back to literal", () => {
75
+ // /.../-wrapped with an unbalanced bracket — RegExp() throws, falls back
76
+ // to literal substring on the BODY (without delimiters).
77
+ const rule = compileRedactionPattern("/[unclosed/");
78
+ assert.ok(rule.matcher("contains [unclosed literally"));
79
+ assert.ok(!rule.matcher("no match"));
80
+ });
81
+
82
+ test("loadRedactionRules: empty when the dir does not exist (cold install)", async () => {
83
+ await withTempDir(async (dir) => {
84
+ const rules = await loadRedactionRules(dir);
85
+ assert.equal(rules.length, 0);
86
+ });
87
+ });
88
+
89
+ test("loadRedactionRules: reads persisted literal + regex patterns", async () => {
90
+ await withTempDir(async (dir) => {
91
+ await writeRule(dir, "secret-token-123");
92
+ await writeRule(dir, "/api-key-[a-f0-9]{8}/");
93
+ const rules = await loadRedactionRules(dir);
94
+ assert.equal(rules.length, 2);
95
+ assert.ok(contentMatchesRedactionRules("leaked secret-token-123 here", rules));
96
+ assert.ok(contentMatchesRedactionRules("api-key-deadbeef was used", rules));
97
+ assert.ok(!contentMatchesRedactionRules("clean content", rules));
98
+ });
99
+ });
100
+
101
+ test("loadRedactionRules: skips a corrupt rule file without failing the pass", async () => {
102
+ await withTempDir(async (dir) => {
103
+ await writeRule(dir, "good-pattern");
104
+ const dir2 = path.join(dir, REDACTION_RULES_SUBDIR);
105
+ // A non-JSON file masquerading as a rule.
106
+ await writeFile(path.join(dir2, "broken.json"), "{not valid json", "utf-8");
107
+ // A rule file missing the `pattern` field.
108
+ await writeFile(path.join(dir2, "nopattern.json"), JSON.stringify({ foo: "bar" }), "utf-8");
109
+ const rules = await loadRedactionRules(dir);
110
+ assert.equal(rules.length, 1, "only the well-formed rule compiles");
111
+ assert.ok(contentMatchesRedactionRules("good-pattern here", rules));
112
+ });
113
+ });
114
+
115
+ test("contentMatchesRedactionRules: empty rule set never matches", () => {
116
+ assert.equal(contentMatchesRedactionRules("anything", []), false);
117
+ });
118
+
119
+ test("regression (#1669): a persisted redaction rule blocks matching content", async () => {
120
+ await withTempDir(async (dir) => {
121
+ // Simulate a `redaction_rule` correction that persisted a never-store pattern.
122
+ await writeRule(dir, "do-not-store-this-exact-phrase");
123
+ const rules = await loadRedactionRules(dir);
124
+ // Content matching the persisted pattern is withheld.
125
+ assert.ok(
126
+ contentMatchesRedactionRules("a fact containing do-not-store-this-exact-phrase inside", rules),
127
+ "matching content must be withheld",
128
+ );
129
+ // Non-matching content passes through.
130
+ assert.ok(
131
+ !contentMatchesRedactionRules("a perfectly fine durable fact", rules),
132
+ "non-matching content must pass",
133
+ );
134
+ });
135
+ });
136
+
137
+ test("#1669 thread #3: catastrophic-backtracking regex falls back to literal (ReDoS guard)", () => {
138
+ // A pattern like (a+)+ is a classic ReDoS shape. It must NOT compile as a
139
+ // RegExp — it would hang persistExtraction on a near-miss fact. Instead it
140
+ // falls back to literal substring match.
141
+ const rule = compileRedactionPattern("(a+)+");
142
+ // The literal fallback matches when the exact string "(a+)+" appears:
143
+ assert.equal(rule.matcher("a fact with (a+)+ inside"), true,
144
+ "literal fallback must still match the pattern as a substring");
145
+ // A long near-miss string that would cause exponential backtracking on the
146
+ // regex must return quickly (literal match, no regex evaluation):
147
+ const nearMiss = "a".repeat(100) + "!";
148
+ assert.equal(rule.matcher(nearMiss), false,
149
+ "near-miss must not hang — literal fallback, not regex");
150
+ });
151
+
152
+ test("#1669 thread #3: safe regex pattern still compiles as RegExp", () => {
153
+ // A benign anchored pattern compiles normally.
154
+ const rule = compileRedactionPattern("/^secret-key-[a-f0-9]+$/");
155
+ assert.equal(rule.matcher("secret-key-deadbeef"), true);
156
+ assert.equal(rule.matcher("not-a-secret"), false);
157
+ });
158
+
159
+ test("#1669 cursor thread: overly broad /wrapped/ regex falls back to literal (not compiled)", () => {
160
+ // A hand-edited rule file with /.*/ must NOT compile — it would match every
161
+ // fact and withhold all extraction. isSafeRegex now rejects overly-broad
162
+ // bodies, so the rule falls back to literal substring on the body.
163
+ const rule = compileRedactionPattern("/.*/");
164
+ assert.ok(!rule.matcher("any fact content"),
165
+ "overly broad regex must not match arbitrary content");
166
+ assert.ok(rule.matcher("contains .* literally"),
167
+ "literal fallback on the body must still match the substring .*");
168
+ });
169
+
170
+ test("#1669 thread #3 (first-line guard): validateRedactionPattern rejects catastrophic-backtracking shapes", () => {
171
+ // Classic ReDoS shapes must be rejected at apply time — not just fall back
172
+ // to literal at extraction time. Each is a nested quantifier or overlapping
173
+ // alternation that would hang on a near-miss fact.
174
+ const pathological = ["/(a+)+/", "/(a*)*/", "/(a?)+/", "/(a|a)+/"];
175
+ for (const p of pathological) {
176
+ assert.throws(
177
+ () => validateRedactionPattern(p),
178
+ { message: /unsafe/ },
179
+ `validateRedactionPattern must reject catastrophic pattern: ${p}`,
180
+ );
181
+ }
182
+ // The overly-broad check still fires for /.../-wrapped .* / .+ patterns.
183
+ assert.throws(() => validateRedactionPattern("/.*/"));
184
+ assert.throws(() => validateRedactionPattern("/.+/"));
185
+ // A near-miss string that would cause exponential backtracking on the
186
+ // regex never reaches extraction — the plan is rejected at apply time.
187
+ const nearMiss = "a".repeat(100) + "!";
188
+ assert.ok(nearMiss.length === 101);
189
+ });
190
+
191
+ test("#1669 thread #3 (first-line guard): valid patterns still pass", () => {
192
+ // Bounded literal + benign anchored regex must NOT be rejected.
193
+ assert.equal(validateRedactionPattern("secret-token-\\d+"), "secret-token-\\d+");
194
+ assert.equal(validateRedactionPattern("(a|b)+"), "(a|b)+");
195
+ assert.equal(validateRedactionPattern("/^secret-key-[a-f0-9]+$/"), "/^secret-key-[a-f0-9]+$/");
196
+ });
197
+
198
+ test("#1669 P2: validateRedactionPattern rejects zero-width regex", () => {
199
+ // Empty body or regex that matches the empty string would withhold every fact.
200
+ assert.throws(() => validateRedactionPattern("//"));
201
+ assert.throws(() => validateRedactionPattern("/a?/"));
202
+ assert.throws(() => validateRedactionPattern("/a*/"));
203
+ assert.throws(() => validateRedactionPattern("/(a|)/"));
204
+ // A bounded non-zero-width regex still passes.
205
+ assert.equal(validateRedactionPattern("/^secret-[a-f]+$/"), "/^secret-[a-f]+$/");
206
+ });
207
+
208
+ test("#1669 P2: compileRedactionPattern empty body never matches", () => {
209
+ // A hand-edited // rule file must not withhold all extraction.
210
+ const rule = compileRedactionPattern("//");
211
+ assert.equal(rule.matcher("anything"), false);
212
+ assert.equal(rule.matcher(""), false);
213
+ });
@@ -0,0 +1,208 @@
1
+ /**
2
+ * extraction-redaction-rules.ts — consults persisted correction redaction
3
+ * rules during the extraction→persist path so a `never_store` / redaction_rule
4
+ * correction actually blocks future extraction of matching content (issue
5
+ * #1669, #1580 follow-up).
6
+ *
7
+ * The Correction Contract persists `redaction_rule` patterns to
8
+ * `<storage>/state/corrections/redaction-rules/<slug>.json` (one file per
9
+ * rule, idempotent on pattern). This module reads those patterns at the start
10
+ * of each extraction persist pass and exposes a pure matcher the orchestrator
11
+ * consults BEFORE a fact reaches the storage write chokepoint — mirroring how
12
+ * tombstones are consulted at the write chokepoint (#1579), but one stage
13
+ * earlier so the content never even lands as `pending_review`.
14
+ *
15
+ * Scope (issue #1669): the extraction layer consults the rules. Persistence
16
+ * stays owned by the correction module. This helper is the read surface.
17
+ *
18
+ * Safety: patterns are validated at correction-apply time
19
+ * (`validateRedactionPattern` — bounded literal/safe-regex, no catastrophic
20
+ * shapes). We compile defensively here too: a pattern that fails to compile
21
+ * as a regex is treated as a literal substring, and any matcher error is
22
+ * swallowed (fail-open to "no match") so a malformed rule can never block the
23
+ * entire extraction pipeline.
24
+ */
25
+ import { readdir, readFile } from "node:fs/promises";
26
+ import path from "node:path";
27
+
28
+ /**
29
+ * Directory (relative to a namespace storage dir) where redaction rules live.
30
+ * Kept in sync with `registerRedactionRuleFn` in correction-access-wiring.ts.
31
+ */
32
+ export const REDACTION_RULES_SUBDIR = path.join("state", "corrections", "redaction-rules");
33
+
34
+ /** Maximum number of rule files read per pass (defense in depth). */
35
+ const MAX_RULE_FILES = 256;
36
+
37
+ /**
38
+ * A compiled redaction rule. `pattern` is the original (validated) pattern;
39
+ * `matcher` returns true when content should be withheld.
40
+ */
41
+ export interface CompiledRedactionRule {
42
+ pattern: string;
43
+ matcher: (content: string) => boolean;
44
+ }
45
+
46
+ /** Shape persisted by `registerRedactionRuleFn`. */
47
+ interface RedactionRuleFile {
48
+ pattern: string;
49
+ namespace?: string;
50
+ createdAt?: string;
51
+ }
52
+
53
+ /**
54
+ * Only `/…/`-wrapped patterns compile as RegExp; everything else is a literal
55
+ * substring (review thread P1). An unwrapped pattern like `abc+def` must match
56
+ * the literal string `abc+def`, NOT the regex `abccccdef`.
57
+ */
58
+ function isRegexLike(pattern: string): boolean {
59
+ return pattern.startsWith("/") && pattern.endsWith("/") && pattern.length >= 2;
60
+ }
61
+
62
+ /**
63
+ * Reject patterns prone to catastrophic backtracking (ReDoS, review thread #3).
64
+ * Mirrors the safe-regex heuristic: a quantifier inside a group that is itself
65
+ * quantified creates exponential blowup on near-miss inputs. Also rejects
66
+ * patterns with overlapping alternation under repetition
67
+ * (e.g. (a|a)*). Returns true when the pattern is safe to compile.
68
+ *
69
+ * This is a second line of defense — validateRedactionPattern runs first at
70
+ * apply time, but a hand-edited rule file or an edge case can still slip past.
71
+ */
72
+ function isSafeRegex(source: string): boolean {
73
+ if (source.length === 0 || source.length > 512) return false;
74
+ // Reject overly-broad patterns (.*) that would match every fact and
75
+ // withhold all extraction (cursor Bugbot thread — mirrors
76
+ // isUnsafeRedactionRegex in correction-contract.ts).
77
+ if (/(?:^|[^\\])\(\.\*\)|(?:^|[^\\])\.\*|(?:^|[^\\])\.\+|^\.([^*+]?)$/.test(source)) {
78
+ return false;
79
+ }
80
+ // group body itself ends with a quantifier. This catches (a+)+, (a*)*, etc.
81
+ // Also catches overlapping alternation like (a|a)+.
82
+ for (let i = 0; i < source.length; i++) {
83
+ if (source[i] !== "(") continue;
84
+ // Find the matching close paren
85
+ let depth = 1;
86
+ let j = i + 1;
87
+ while (j < source.length && depth > 0) {
88
+ if (source[j] === "\\") { j += 2; continue; }
89
+ if (source[j] === "(") depth++;
90
+ else if (source[j] === ")") depth--;
91
+ j++;
92
+ }
93
+ if (depth !== 0) continue; // unbalanced — will fail RegExp compile anyway
94
+ // Check if the char after the closing paren is a quantifier
95
+ const afterGroup = source[j];
96
+ if (afterGroup !== "+" && afterGroup !== "*" && afterGroup !== "{") continue;
97
+ const groupBody = source.slice(i + 1, j - 1);
98
+ // The group body ends with a quantifier on a non-escape char
99
+ if (/[+*?]$/.test(groupBody) || /\{\d+,?\d*\}[+*?]?$/.test(groupBody)) {
100
+ // Nested quantifier → potential ReDoS
101
+ return false;
102
+ }
103
+ // Overlapping alternation: (a|a)+ where branches share a common prefix
104
+ if (groupBody.includes("|")) {
105
+ const branches = groupBody.split("|");
106
+ if (branches.length >= 2) {
107
+ const firstChars = new Set(branches.map((b) => b[0]).filter(Boolean));
108
+ if (firstChars.size < branches.filter((b) => b.length > 0).length) {
109
+ return false;
110
+ }
111
+ }
112
+ }
113
+ i = j;
114
+ }
115
+ return true;
116
+ }
117
+
118
+ /**
119
+ * Compile a single pattern into a matcher. A literal pattern matches by
120
+ * case-sensitive substring; a `/…/`-wrapped pattern compiles into a RegExp
121
+ * anchored to search (global flag off — we only need a boolean). Compilation
122
+ * failures fall back to literal substring so a bad pattern never throws here.
123
+ */
124
+ export function compileRedactionPattern(pattern: string): CompiledRedactionRule {
125
+ const trimmed = pattern.trim();
126
+ if (!isRegexLike(trimmed)) {
127
+ return { pattern: trimmed, matcher: (content) => content.includes(trimmed) };
128
+ }
129
+ const body = trimmed.slice(1, -1);
130
+ try {
131
+ if (!isSafeRegex(body)) {
132
+ if (body.length === 0) return { pattern: trimmed, matcher: () => false };
133
+ return { pattern: trimmed, matcher: (content) => content.includes(body) };
134
+ }
135
+ const re = new RegExp(body);
136
+ if (re.test("")) {
137
+ // Zero-width regex matches every string — would withhold all extraction.
138
+ return { pattern: trimmed, matcher: (content) => content.includes(body) };
139
+ }
140
+ return { pattern: trimmed, matcher: (content) => re.test(content) };
141
+ } catch {
142
+ // Malformed regex despite validation (e.g. rule file hand-edited) —
143
+ // treat as a literal on the BODY so the rule still does something useful.
144
+ return { pattern: trimmed, matcher: (content) => content.includes(body) };
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Load + compile every redaction rule persisted under a namespace's storage
150
+ * dir. Returns an empty array when the directory is absent or unreadable
151
+ * (fail-open — a missing or corrupt rules dir must never block extraction).
152
+ */
153
+ export async function loadRedactionRules(stateDir: string): Promise<CompiledRedactionRule[]> {
154
+ const dir = path.join(stateDir, REDACTION_RULES_SUBDIR);
155
+ let names: string[];
156
+ try {
157
+ names = await readdir(dir);
158
+ } catch {
159
+ // ENOENT (cold install, no rules yet) or permission error — no rules.
160
+ return [];
161
+ }
162
+ // Sort deterministically (alphabetical by filename) so the set of rules
163
+ // that survives the MAX_RULE_FILES cap is NOT filesystem-dependent (review
164
+ // thread P2). Without this, readdir order varies by OS/FS and the silently
165
+ // truncated rules are arbitrary — a never-store pattern could be dropped.
166
+ const jsonFiles = names.filter((n) => n.endsWith(".json")).sort();
167
+ if (jsonFiles.length > MAX_RULE_FILES) {
168
+ // Visible failure mode: log a warning so the operator knows rules were
169
+ // truncated, rather than silently dropping enforcement.
170
+ console.warn(
171
+ `extraction-redaction: ${jsonFiles.length} redaction rules in ${dir} exceed the ${MAX_RULE_FILES} cap; ` +
172
+ `${jsonFiles.length - MAX_RULE_FILES} rules will NOT be enforced. Remove unused rules or raise the cap.`,
173
+ );
174
+ }
175
+ const rules: CompiledRedactionRule[] = [];
176
+ for (const name of jsonFiles.slice(0, MAX_RULE_FILES)) {
177
+ try {
178
+ const raw = await readFile(path.join(dir, name), "utf-8");
179
+ const parsed = JSON.parse(raw) as RedactionRuleFile;
180
+ if (typeof parsed?.pattern !== "string" || parsed.pattern.trim().length === 0) continue;
181
+ rules.push(compileRedactionPattern(parsed.pattern));
182
+ } catch {
183
+ // Skip a corrupt rule file rather than failing the pass (rule 34 —
184
+ // visible skip, never a silent pipeline block).
185
+ }
186
+ }
187
+ return rules;
188
+ }
189
+
190
+ /**
191
+ * Test content against a set of compiled rules. Returns true if ANY rule
192
+ * matches (the content should be withheld). An empty rule set never matches.
193
+ */
194
+ export function contentMatchesRedactionRules(
195
+ content: string,
196
+ rules: readonly CompiledRedactionRule[],
197
+ ): boolean {
198
+ for (const rule of rules) {
199
+ try {
200
+ if (rule.matcher(content)) return true;
201
+ } catch {
202
+ // A matcher should not throw (compilation already fell back), but if a
203
+ // RegExp blows up on pathological input we skip that rule rather than
204
+ // failing the whole gate.
205
+ }
206
+ }
207
+ return false;
208
+ }
@@ -44,6 +44,11 @@ import {
44
44
  createFaithfulnessCounters,
45
45
  runFaithfulnessGateBatch,
46
46
  } from "./extraction-faithfulness.js";
47
+ import {
48
+ contentMatchesRedactionRules,
49
+ loadRedactionRules,
50
+ type CompiledRedactionRule,
51
+ } from "./extraction-redaction-rules.js";
47
52
  import type { FaithfulnessGateCounters } from "./extraction-faithfulness.js";
48
53
  import {
49
54
  EXTRACTION_JUDGE_VERDICT_CATEGORY,
@@ -14119,6 +14124,23 @@ export class Orchestrator {
14119
14124
  // (declared here, inside persistExtraction) so a transient hiccup in one
14120
14125
  // batch does not permanently disable dedup in future batches.
14121
14126
  let batchBackendUnavailable = false;
14127
+ // #1669: per-namespace redaction-rule cache for this persist pass. A
14128
+ // `never_store` / redaction_rule correction persists patterns under each
14129
+ // namespace's state/corrections/redaction-rules/ dir; we consult them
14130
+ // before a fact reaches the storage write chokepoint so matching content
14131
+ // is withheld entirely rather than landing as pending_review. Cached per
14132
+ // dir so a multi-fact batch over one namespace reads the dir once.
14133
+ let redactionGatedCount = 0;
14134
+ const redactionRulesByDir = new Map<string, CompiledRedactionRule[]>();
14135
+ const redactionRulesFor = async (...dirs: string[]): Promise<CompiledRedactionRule[]> => {
14136
+ const out: CompiledRedactionRule[] = [];
14137
+ for (const d of dirs) {
14138
+ let r = redactionRulesByDir.get(d);
14139
+ if (!r) { r = await loadRedactionRules(d); redactionRulesByDir.set(d, r); }
14140
+ out.push(...r);
14141
+ }
14142
+ return out;
14143
+ };
14122
14144
  const behaviorSignalsByStorage = new Map<
14123
14145
  string,
14124
14146
  { storage: StorageManager; events: BehaviorSignalEvent[] }
@@ -14846,6 +14868,10 @@ export class Orchestrator {
14846
14868
  // into the caller's buffer-retention decision.
14847
14869
  this.lastPersistExtractionDeferredCount = 0;
14848
14870
  if (this.config.extractionJudgeEnabled) {
14871
+ // #1669 P1: pre-filter redacted facts from judge candidates so never-store
14872
+ // content is not persisted as judge training data.
14873
+ let preJudgeRedactionRules: CompiledRedactionRule[] = [];
14874
+ try { preJudgeRedactionRules = await redactionRulesFor(storage.dir); } catch { /* fail open */ }
14849
14875
  try {
14850
14876
  const judgeCandidates: JudgeCandidate[] = [];
14851
14877
  const candidateToFactIndex: number[] = [];
@@ -14886,6 +14912,11 @@ export class Orchestrator {
14886
14912
  ) {
14887
14913
  continue;
14888
14914
  }
14915
+ if (preJudgeRedactionRules.length > 0) {
14916
+ const rc = f.content + (f.structuredAttributes ? " " + JSON.stringify(f.structuredAttributes) : "")
14917
+ + (f.procedureSteps ? " " + f.procedureSteps.map((s) => `${s.intent} ${s.expectedOutcome ?? ""} ${s.toolCall ? `${s.toolCall.kind} ${s.toolCall.signature}` : ""}`.trim()).join(" ") : "");
14918
+ if (contentMatchesRedactionRules(rc, preJudgeRedactionRules)) continue;
14919
+ }
14889
14920
  judgeCandidates.push({
14890
14921
  text: f.content,
14891
14922
  category: judgeCategory,
@@ -15064,6 +15095,7 @@ export class Orchestrator {
15064
15095
  // affect both the dedup fingerprint and importance (issue #519 procedure routing).
15065
15096
  let writeCategory = fact.category;
15066
15097
  let targetStorage = storage;
15098
+ const sourceStorageDir = storage.dir; // #1669 thread #2: pre-routing source ns for redaction gate
15067
15099
  // Track the KNOWN target namespace NAME alongside targetStorage (round 6,
15068
15100
  // codex P2 — NCQI0). Re-deriving it from `targetStorage.dir` mangles a raw
15069
15101
  // namespace literally named like a canonical token (e.g. `ns-616c706861`
@@ -15138,6 +15170,23 @@ export class Orchestrator {
15138
15170
  );
15139
15171
  }
15140
15172
  }
15173
+ // #1669 redaction-rule gate: consult BOTH source and target namespace
15174
+ // rules before any write. A never-store pattern registered under the
15175
+ // source namespace must survive scope-routing to a different target
15176
+ // (review thread #2). Fails open on read error.
15177
+ try {
15178
+ const redactionRules = await redactionRulesFor(sourceStorageDir, targetStorage.dir);
15179
+ const redactionCandidate = fact.content
15180
+ + (fact.structuredAttributes ? " " + JSON.stringify(fact.structuredAttributes) : "")
15181
+ + (fact.procedureSteps ? " " + fact.procedureSteps.map((s) => `${s.intent} ${s.expectedOutcome ?? ""} ${s.toolCall ? `${s.toolCall.kind} ${s.toolCall.signature}` : ""}`.trim()).join(" ") : "");
15182
+ if (redactionRules.length > 0 && contentMatchesRedactionRules(redactionCandidate, redactionRules)) {
15183
+ redactionGatedCount++;
15184
+ log.debug(`extraction: redaction-rule withheld fact #${redactionGatedCount} in ${targetStorage.dir}`);
15185
+ continue;
15186
+ }
15187
+ } catch (redactionErr) {
15188
+ log.warn(`extraction: redaction-rule gate failed open: ${redactionErr}`);
15189
+ }
15141
15190
 
15142
15191
  // Procedures: fingerprint the full serialized body (title + steps), not
15143
15192
  // the title alone, so distinct step lists are not collapsed (issue #519).
@@ -16263,8 +16312,10 @@ export class Orchestrator {
16263
16312
  importanceGatedCount > 0 ? ` (${importanceGatedCount} gated)` : "";
16264
16313
  const judgeSuffix =
16265
16314
  judgeGatedCount > 0 ? ` (${judgeGatedCount} judge-rejected)` : "";
16315
+ const redactionSuffix =
16316
+ redactionGatedCount > 0 ? ` (${redactionGatedCount} redacted)` : "";
16266
16317
  log.info(
16267
- `persisted: ${facts.length - dedupedCount - importanceGatedCount - judgeGatedCount} facts${dedupSuffix}${gatedSuffix}${judgeSuffix}, ${entities.length} entities, ${questions.length} questions, ${profileUpdates.length} profile updates`,
16318
+ `persisted: ${facts.length - dedupedCount - importanceGatedCount - judgeGatedCount - redactionGatedCount} facts${dedupSuffix}${gatedSuffix}${judgeSuffix}${redactionSuffix}, ${entities.length} entities, ${questions.length} questions, ${profileUpdates.length} profile updates`,
16268
16319
  );
16269
16320
 
16270
16321
  // Update temporal + tag indexes (v8.1) — fire-and-forget, fail-open
package/src/storage.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { access, lstat, readdir, readFile, realpath, stat, writeFile, mkdir, unlink, rename, appendFile, open } from "node:fs/promises";
2
2
  import { appendFileSync, createReadStream, mkdirSync, readFileSync, statSync, type Dirent } from "node:fs";
3
- import { createHash } from "node:crypto";
3
+ import { createHash } from "node:crypto"
4
+ import { normalizeContent, computeContentHash } from "./content-hash.js";;
4
5
  import path from "node:path";
5
6
  import { log } from "./logger.js";
6
7
  import { isErrnoCode } from "./utils/errno.js";
@@ -1189,19 +1190,14 @@ export class ContentHashIndex {
1189
1190
  }
1190
1191
  }
1191
1192
 
1192
- /** Normalize content and compute SHA-256 hash. */
1193
+ /** Normalize content (delegates to content-hash.ts for a single source of truth). */
1193
1194
  static normalizeContent(content: string): string {
1194
- return content
1195
- .toLowerCase()
1196
- .replace(/[^a-z0-9\s]/g, " ")
1197
- .replace(/\s+/g, " ")
1198
- .trim();
1195
+ return normalizeContent(content);
1199
1196
  }
1200
1197
 
1201
- /** Normalize content and compute SHA-256 hash. */
1198
+ /** Compute SHA-256 hash (delegates to content-hash.ts for a single source of truth). */
1202
1199
  static computeHash(content: string): string {
1203
- const normalized = ContentHashIndex.normalizeContent(content);
1204
- return createHash("sha256").update(normalized).digest("hex");
1200
+ return computeContentHash(content);
1205
1201
  }
1206
1202
  }
1207
1203