recallnest 2.3.0 → 2.6.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.
- package/CHANGELOG.md +187 -0
- package/README.md +49 -38
- package/README_CN.md +46 -31
- package/assets/ui/app.js +1031 -0
- package/assets/ui/index.html +224 -0
- package/assets/ui/styles.css +781 -0
- package/config.json.example +4 -1
- package/docs/api-reference.md +1291 -0
- package/docs/architecture.md +168 -0
- package/docs/connector-spec.md +126 -0
- package/eval/cases-canary.json +58 -0
- package/eval/cases-scope-robustness.json +278 -0
- package/eval/cases.json +117 -20
- package/package.json +25 -4
- package/src/__tests__/access-tracker-novelty.test.ts +304 -0
- package/src/__tests__/activity-counter.test.ts +125 -0
- package/src/__tests__/adaptive-fidelity.test.ts +102 -0
- package/src/__tests__/admission-control.test.ts +206 -0
- package/src/__tests__/anchor-generator.test.ts +67 -0
- package/src/__tests__/api-server-security.test.ts +102 -0
- package/src/__tests__/auto-consolidation.test.ts +168 -0
- package/src/__tests__/auto-recall.test.ts +131 -0
- package/src/__tests__/babel-integration.test.ts +144 -0
- package/src/__tests__/batch-dedup.test.ts +65 -0
- package/src/__tests__/belief-history.test.ts +220 -0
- package/src/__tests__/canary-eval.test.ts +114 -0
- package/src/__tests__/capture-engine.test.ts +1328 -0
- package/src/__tests__/capture-heuristic.test.ts +171 -0
- package/src/__tests__/cascade-forget.test.ts +210 -0
- package/src/__tests__/category-min-scores.test.ts +116 -0
- package/src/__tests__/cc8-cc1-essential-context.test.ts +298 -0
- package/src/__tests__/circuit-breaker.test.ts +99 -0
- package/src/__tests__/cluster-consolidation.test.ts +945 -0
- package/src/__tests__/confidence-metadata.test.ts +125 -0
- package/src/__tests__/confidence-tracker.test.ts +261 -0
- package/src/__tests__/conflict-advisor.test.ts +206 -0
- package/src/__tests__/conflict-escalation.test.ts +149 -0
- package/src/__tests__/conflict-lifecycle.test.ts +83 -0
- package/src/__tests__/conflict-output.test.ts +85 -0
- package/src/__tests__/conflict-store.test.ts +418 -0
- package/src/__tests__/connector-v1.test.ts +181 -0
- package/src/__tests__/consolidation-engine.test.ts +430 -0
- package/src/__tests__/constructive-retrieval.test.ts +509 -0
- package/src/__tests__/context-collapse-renderer.test.ts +214 -0
- package/src/__tests__/context-composer-pins.test.ts +64 -0
- package/src/__tests__/context-composer-task-selection.test.ts +24 -0
- package/src/__tests__/context-reconstructor.test.ts +65 -0
- package/src/__tests__/context-renderer.test.ts +91 -0
- package/src/__tests__/continuity-eval.test.ts +275 -0
- package/src/__tests__/continuity-schema.test.ts +97 -0
- package/src/__tests__/contracts.test.ts +262 -0
- package/src/__tests__/conversation-importer.test.ts +692 -0
- package/src/__tests__/core-recall-eval.test.ts +235 -0
- package/src/__tests__/core-summary.test.ts +109 -0
- package/src/__tests__/data-checkup.test.ts +276 -0
- package/src/__tests__/decay-exemption.test.ts +129 -0
- package/src/__tests__/dedup-actions.test.ts +58 -0
- package/src/__tests__/dedup-guard.test.ts +39 -0
- package/src/__tests__/deterministic-id.test.ts +39 -0
- package/src/__tests__/distill-lock.test.ts +305 -0
- package/src/__tests__/distill-session-argshape.test.ts +79 -0
- package/src/__tests__/doctor.test.ts +319 -0
- package/src/__tests__/dream-pipeline.test.ts +780 -0
- package/src/__tests__/drill-down.test.ts +108 -0
- package/src/__tests__/embedder.test.ts +122 -0
- package/src/__tests__/emotion-decay.test.ts +46 -0
- package/src/__tests__/emotion-detector.test.ts +56 -0
- package/src/__tests__/emotion-phase1-integration.test.ts +153 -0
- package/src/__tests__/emotion-retrieval.test.ts +56 -0
- package/src/__tests__/entity-resolver.test.ts +58 -0
- package/src/__tests__/env-config.test.ts +151 -0
- package/src/__tests__/error-signature-boost.test.ts +87 -0
- package/src/__tests__/error-signature.test.ts +95 -0
- package/src/__tests__/error-taxonomy.test.ts +129 -0
- package/src/__tests__/ethics-layer.test.ts +476 -0
- package/src/__tests__/event-segmenter.test.ts +153 -0
- package/src/__tests__/feature-flags-integration.test.ts +54 -0
- package/src/__tests__/frequency-tracker.test.ts +113 -0
- package/src/__tests__/freshness.test.ts +186 -0
- package/src/__tests__/graph-export.test.ts +489 -0
- package/src/__tests__/hotness-blend.test.ts +283 -0
- package/src/__tests__/ingest-chunking.test.ts +44 -0
- package/src/__tests__/ingest.test.ts +360 -0
- package/src/__tests__/interference-detector.test.ts +203 -0
- package/src/__tests__/kg-extractor.test.ts +192 -0
- package/src/__tests__/kg-store.test.ts +300 -0
- package/src/__tests__/llm-client-pivot.test.ts +628 -0
- package/src/__tests__/llm-consolidation.test.ts +226 -0
- package/src/__tests__/maintenance-locks.test.ts +189 -0
- package/src/__tests__/mcp-efficiency.test.ts +288 -0
- package/src/__tests__/mcp-related-scopes.test.ts +151 -0
- package/src/__tests__/memory-boundaries.test.ts +282 -0
- package/src/__tests__/memory-evolution-b2-b3-e1.test.ts +345 -0
- package/src/__tests__/memory-evolution.test.ts +271 -0
- package/src/__tests__/memory-health-rebalance.test.ts +144 -0
- package/src/__tests__/memory-lint.test.ts +421 -0
- package/src/__tests__/memory-output.test.ts +215 -0
- package/src/__tests__/memory-promotion.test.ts +315 -0
- package/src/__tests__/migrate-dedup-ids.test.ts +65 -0
- package/src/__tests__/multi-vector.test.ts +242 -0
- package/src/__tests__/narrative-architecture.test.ts +456 -0
- package/src/__tests__/noise-filter.test.ts +205 -0
- package/src/__tests__/noise-prototype-bank.test.ts +280 -0
- package/src/__tests__/obsidian-connector.test.ts +363 -0
- package/src/__tests__/omitted-reporting.test.ts +98 -0
- package/src/__tests__/pending-review.test.ts +77 -0
- package/src/__tests__/pii-redact.test.ts +333 -0
- package/src/__tests__/pivot-apply.test.ts +373 -0
- package/src/__tests__/pivot-distill-supervisor.test.ts +457 -0
- package/src/__tests__/pivot-distill.test.ts +1178 -0
- package/src/__tests__/post-retrieval-filter.test.ts +178 -0
- package/src/__tests__/ppr-traversal.test.ts +256 -0
- package/src/__tests__/prediction-engine.test.ts +349 -0
- package/src/__tests__/predictive-prospective-memory.test.ts +416 -0
- package/src/__tests__/preference-guard-smoke.test.ts +211 -0
- package/src/__tests__/preference-matcher.test.ts +282 -0
- package/src/__tests__/preference-slots.test.ts +205 -0
- package/src/__tests__/prospective-memory.test.ts +283 -0
- package/src/__tests__/pull-from-macbook-sync-only.test.ts +679 -0
- package/src/__tests__/query-entity-detector.test.ts +171 -0
- package/src/__tests__/query-expander-alias.test.ts +146 -0
- package/src/__tests__/query-expander.test.ts +34 -0
- package/src/__tests__/recall-gate.test.ts +266 -0
- package/src/__tests__/recall-governor.test.ts +227 -0
- package/src/__tests__/recall-mode.test.ts +64 -0
- package/src/__tests__/resolve-db-path.test.ts +130 -0
- package/src/__tests__/resolve-tier.test.ts +64 -0
- package/src/__tests__/result-synthesizer.test.ts +198 -0
- package/src/__tests__/resume-context.test.ts +4898 -0
- package/src/__tests__/retrieval-eval.test.ts +163 -0
- package/src/__tests__/retrieval-trace.test.ts +213 -0
- package/src/__tests__/retriever-boundary-weight.test.ts +136 -0
- package/src/__tests__/retriever-lazy-init-dispatch.test.ts +123 -0
- package/src/__tests__/retroactive-boost.test.ts +223 -0
- package/src/__tests__/salience-scoring.test.ts +106 -0
- package/src/__tests__/scope-inventory.test.ts +200 -0
- package/src/__tests__/scope-policy.test.ts +69 -0
- package/src/__tests__/scope-suggester.test.ts +80 -0
- package/src/__tests__/session-checkpoint.test.ts +291 -0
- package/src/__tests__/session-distiller.test.ts +640 -0
- package/src/__tests__/session-gc.test.ts +215 -0
- package/src/__tests__/skill-engine.test.ts +552 -0
- package/src/__tests__/skill-promotion.test.ts +654 -0
- package/src/__tests__/skill-verifier.test.ts +122 -0
- package/src/__tests__/source-heartbeat.test.ts +250 -0
- package/src/__tests__/stable-context-length-contract.test.ts +62 -0
- package/src/__tests__/store-patch-metadata.test.ts +205 -0
- package/src/__tests__/store-read-consistency.test.ts +122 -0
- package/src/__tests__/store-schema-migration.test.ts +87 -0
- package/src/__tests__/store-scope-audit.test.ts +81 -0
- package/src/__tests__/store-upsert.test.ts +201 -0
- package/src/__tests__/supersede-chain.test.ts +154 -0
- package/src/__tests__/temporal-parser.test.ts +241 -0
- package/src/__tests__/temporal-validity.test.ts +160 -0
- package/src/__tests__/term-registry-terminal-roster.test.ts +127 -0
- package/src/__tests__/term-registry.test.ts +121 -0
- package/src/__tests__/tool-error-surfacing.test.ts +98 -0
- package/src/__tests__/tool-output-compressor.test.ts +160 -0
- package/src/__tests__/topic-tag.test.ts +113 -0
- package/src/__tests__/trusted-memory.test.ts +839 -0
- package/src/__tests__/ui-server-security.test.ts +93 -0
- package/src/__tests__/usage-tracker.test.ts +287 -0
- package/src/__tests__/version-manager.test.ts +213 -0
- package/src/__tests__/workflow-observation-skill-outcome.test.ts +352 -0
- package/src/__tests__/workflow-observation.test.ts +433 -0
- package/src/__tests__/write-verifier.test.ts +122 -0
- package/src/access-tracker.ts +90 -34
- package/src/activity-counter.ts +46 -18
- package/src/adaptive-retrieval.ts +11 -0
- package/src/aliases.ts +133 -0
- package/src/api-server.ts +36 -9
- package/src/audit-log.ts +4 -2
- package/src/auto-consolidation.ts +8 -3
- package/src/auto-gc.ts +85 -21
- package/src/auto-recall.ts +61 -1
- package/src/belief-history.ts +164 -0
- package/src/capture-engine.ts +142 -32
- package/src/cli.ts +268 -41
- package/src/confidence-tracker.ts +36 -18
- package/src/conflict-engine.ts +24 -3
- package/src/consolidation-engine.ts +255 -23
- package/src/context-composer-stable-selection.ts +7 -1
- package/src/context-composer.ts +8 -1
- package/src/data-checkup.ts +96 -7
- package/src/decay-engine.ts +59 -7
- package/src/distill-lock.ts +194 -15
- package/src/doctor.ts +13 -4
- package/src/dream-pipeline.ts +473 -41
- package/src/env-config.ts +118 -0
- package/src/error-signature.ts +153 -0
- package/src/error-taxonomy.ts +115 -0
- package/src/eval.ts +402 -14
- package/src/freshness.ts +192 -0
- package/src/http-request-policy.ts +148 -0
- package/src/idempotency.ts +8 -0
- package/src/ingest.ts +308 -12
- package/src/kg-extractor.ts +13 -3
- package/src/kg-store.ts +236 -44
- package/src/llm-client.ts +106 -11
- package/src/llm-consolidation.ts +2 -1
- package/src/mcp-server.ts +87 -1800
- package/src/mcp-tool-deps.ts +48 -0
- package/src/mcp-tools-advanced.ts +954 -0
- package/src/mcp-tools-core.ts +637 -0
- package/src/mcp-tools-governance.ts +475 -0
- package/src/memory-boundaries.ts +26 -3
- package/src/memory-evolution.ts +73 -1
- package/src/memory-health-rebalance.ts +4 -0
- package/src/memory-lint.ts +183 -10
- package/src/memory-output.ts +129 -4
- package/src/memory-promotion.ts +246 -0
- package/src/memory-schema.ts +25 -2
- package/src/memory-store-port.ts +46 -0
- package/src/multi-vector.ts +2 -1
- package/src/narrative-schema.ts +2 -1
- package/src/noise-filter.ts +57 -6
- package/src/noise-prototype-bank.ts +408 -0
- package/src/pii-detector.ts +207 -5
- package/src/pivot-apply.ts +399 -0
- package/src/preference-matcher.ts +37 -3
- package/src/query-expander.ts +90 -9
- package/src/recall-gate.ts +305 -0
- package/src/result-synthesizer.ts +2 -1
- package/src/retention-policy.ts +2 -1
- package/src/retrieval-profiles.ts +46 -4
- package/src/retriever.ts +354 -157
- package/src/retroactive-boost.ts +6 -2
- package/src/runtime-config.ts +78 -6
- package/src/scope-suggester.ts +113 -0
- package/src/session-distiller.ts +23 -2
- package/src/session-engine.ts +2 -1
- package/src/session-schema.ts +1 -0
- package/src/session-store.ts +5 -0
- package/src/skill-engine.ts +78 -0
- package/src/skill-promotion.ts +304 -48
- package/src/skill-schema.ts +16 -3
- package/src/skill-verifier.ts +152 -0
- package/src/store.ts +321 -57
- package/src/term-registry.ts +35 -0
- package/src/tool-output-compressor.ts +3 -1
- package/src/ui-server.ts +9 -1
- package/src/usage-tracker.ts +242 -0
- package/src/version-manager.ts +3 -2
- package/src/version.ts +19 -0
- package/src/workflow-observation-engine.ts +2 -1
- package/src/workflow-observation-schema.ts +2 -1
- package/src/workflow-observation-store.ts +6 -2
- package/.env +0 -4
- package/CLAUDE.md +0 -46
- package/config.json +0 -55
- package/daily-recall.sh +0 -110
- package/logs/daily-recall.error.log +0 -0
- package/logs/daily-recall.log +0 -60
- package/logs/ingest-2026-04-11.log +0 -110
- package/logs/ingest-2026-04-12.log +0 -90
- package/logs/ingest-2026-04-13.log +0 -87
- package/logs/launchagent-stderr.log +0 -0
- package/logs/launchagent-stdout.log +0 -0
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NoisePrototypeBank — shadow experiment (mlp #907/#914 borrow, cut down per
|
|
3
|
+
* Codex review 2026-07-17).
|
|
4
|
+
*
|
|
5
|
+
* Hypothesis under test: can embedding similarity catch noise *variants* that
|
|
6
|
+
* the regex noise-filter misses? This bank NEVER rejects anything. It only:
|
|
7
|
+
*
|
|
8
|
+
* - learns: when admission rejects with reason "noise_detected" (and only
|
|
9
|
+
* that reason — "importance_too_low" is a heuristic self-judgment, not a
|
|
10
|
+
* noise label, and learning from it would snowball one misjudgment into a
|
|
11
|
+
* whole semantic neighborhood), the text becomes a *pending candidate*;
|
|
12
|
+
* after MIN_SEEN independent rejections it is embedded and stored as a
|
|
13
|
+
* prototype (cap 32, TTL 90d).
|
|
14
|
+
* - matches: for texts admission ACCEPTED, the write-path embedding that was
|
|
15
|
+
* computed anyway is compared against prototypes; a cosine hit ≥0.93 emits
|
|
16
|
+
* a "suggest_reject" shadow-log line. No extra embedding on this side.
|
|
17
|
+
*
|
|
18
|
+
* Exit condition (per review): after real samples accumulate, offline-evaluate
|
|
19
|
+
* incremental precision/recall from the shadow log; no measurable lift → shut
|
|
20
|
+
* the experiment down (RECALLNEST_NOISE_PROTOTYPE=off) and delete the bank.
|
|
21
|
+
*
|
|
22
|
+
* Permanent protections: content carrying explicit memory verbs, corrections,
|
|
23
|
+
* decisions, identity, or preference cues is never learned from and never
|
|
24
|
+
* flagged, regardless of similarity.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { createHash } from "node:crypto";
|
|
28
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
29
|
+
import { dirname, join } from "node:path";
|
|
30
|
+
import { dataDir } from "./env-config.js";
|
|
31
|
+
|
|
32
|
+
export type NoisePrototypeMode = "shadow" | "off";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* RECALLNEST_NOISE_PROTOTYPE: unset/invalid → "shadow" (log-only), "off"
|
|
36
|
+
* disables. Under NODE_ENV=test an unset flag resolves to "off" so the
|
|
37
|
+
* hundreds of persistMemory tests don't seed the developer's real bank with
|
|
38
|
+
* mock vectors — tests opting in set the env or inject an instance.
|
|
39
|
+
*/
|
|
40
|
+
export function resolveNoisePrototypeMode(env: NodeJS.ProcessEnv = process.env): NoisePrototypeMode {
|
|
41
|
+
const raw = (env.RECALLNEST_NOISE_PROTOTYPE || "").trim().toLowerCase();
|
|
42
|
+
if (raw === "off") return "off";
|
|
43
|
+
if (raw === "shadow") return "shadow";
|
|
44
|
+
return env.NODE_ENV === "test" ? "off" : "shadow";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface NoisePrototype {
|
|
48
|
+
id: string;
|
|
49
|
+
textHash: string;
|
|
50
|
+
/** First 60 chars, kept for human evaluation of the experiment. */
|
|
51
|
+
textPreview: string;
|
|
52
|
+
vector: number[];
|
|
53
|
+
norm: number;
|
|
54
|
+
sourceReason: "noise_detected";
|
|
55
|
+
/** Independent rejections observed before this became a prototype. */
|
|
56
|
+
seenCount: number;
|
|
57
|
+
/** Shadow suggest_reject hits attributed to this prototype. */
|
|
58
|
+
hitCount: number;
|
|
59
|
+
learnedAt: string;
|
|
60
|
+
lastHitAt?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface PendingCandidate {
|
|
64
|
+
count: number;
|
|
65
|
+
firstSeenAt: string;
|
|
66
|
+
preview: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface BankFile {
|
|
70
|
+
version: 1;
|
|
71
|
+
prototypes: NoisePrototype[];
|
|
72
|
+
pending: Record<string, PendingCandidate>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface NoisePrototypeBankConfig {
|
|
76
|
+
/** Cosine similarity threshold for a shadow hit (Codex: start 0.92–0.95, not mlp's 0.82). */
|
|
77
|
+
threshold: number;
|
|
78
|
+
/** Hard cap on learned prototypes. */
|
|
79
|
+
maxPrototypes: number;
|
|
80
|
+
/** Independent rejections required before a candidate is embedded. */
|
|
81
|
+
minSeenCount: number;
|
|
82
|
+
/** Prototype expiry in days. */
|
|
83
|
+
ttlDays: number;
|
|
84
|
+
/** Pending-candidate expiry in days. */
|
|
85
|
+
pendingTtlDays: number;
|
|
86
|
+
/** Bank persistence path. */
|
|
87
|
+
filePath: string;
|
|
88
|
+
/** Shadow log path (JSONL). */
|
|
89
|
+
shadowLogPath: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const DEFAULTS: Omit<NoisePrototypeBankConfig, "filePath" | "shadowLogPath"> = {
|
|
93
|
+
threshold: 0.93,
|
|
94
|
+
maxPrototypes: 32,
|
|
95
|
+
minSeenCount: 2,
|
|
96
|
+
ttlDays: 90,
|
|
97
|
+
pendingTtlDays: 7,
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export type NoisePrototypeEvent =
|
|
101
|
+
| "learned"
|
|
102
|
+
| "candidate_seen"
|
|
103
|
+
| "suggest_reject"
|
|
104
|
+
| "skipped_protected"
|
|
105
|
+
| "skipped_full"
|
|
106
|
+
| "skipped_duplicate"
|
|
107
|
+
| "no_match";
|
|
108
|
+
|
|
109
|
+
// --- permanent protection rules ---
|
|
110
|
+
|
|
111
|
+
const PROTECTED_CUES_ZH = [
|
|
112
|
+
"记住",
|
|
113
|
+
"记一下",
|
|
114
|
+
"记下来",
|
|
115
|
+
"别忘了",
|
|
116
|
+
"帮我记",
|
|
117
|
+
"存一下",
|
|
118
|
+
"我决定",
|
|
119
|
+
"定了",
|
|
120
|
+
"拍板",
|
|
121
|
+
"纠正",
|
|
122
|
+
"不对",
|
|
123
|
+
"错了",
|
|
124
|
+
"我是",
|
|
125
|
+
"我叫",
|
|
126
|
+
"我的名字",
|
|
127
|
+
"我喜欢",
|
|
128
|
+
"我不喜欢",
|
|
129
|
+
"我偏好",
|
|
130
|
+
"偏好",
|
|
131
|
+
"我的生日",
|
|
132
|
+
"我的电话",
|
|
133
|
+
"我的地址",
|
|
134
|
+
];
|
|
135
|
+
|
|
136
|
+
const PROTECTED_CUES_EN = [
|
|
137
|
+
/(?<![\w-])remember this(?![\w-])/,
|
|
138
|
+
/(?<![\w-])note this(?![\w-])/,
|
|
139
|
+
/(?<![\w-])my name(?![\w-])/,
|
|
140
|
+
/(?<![\w-])i am(?![\w-])/,
|
|
141
|
+
/(?<![\w-])i'm(?![\w-])/,
|
|
142
|
+
/(?<![\w-])i prefer(?![\w-])/,
|
|
143
|
+
/(?<![\w-])my preferences?(?![\w-])/,
|
|
144
|
+
/(?<![\w-])i decided(?![\w-])/,
|
|
145
|
+
/(?<![\w-])correction(?![\w-])/,
|
|
146
|
+
];
|
|
147
|
+
|
|
148
|
+
/** Identity/preference/decision/correction content is permanently protected. */
|
|
149
|
+
export function isProtectedContent(text: string): boolean {
|
|
150
|
+
for (const cue of PROTECTED_CUES_ZH) {
|
|
151
|
+
if (text.includes(cue)) return true;
|
|
152
|
+
}
|
|
153
|
+
const lower = text.toLowerCase();
|
|
154
|
+
for (const cue of PROTECTED_CUES_EN) {
|
|
155
|
+
if (cue.test(lower)) return true;
|
|
156
|
+
}
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function normalizeForHash(text: string): string {
|
|
161
|
+
return text
|
|
162
|
+
.toLowerCase()
|
|
163
|
+
.replace(/[\s。,,.!!??~〜…、;;::'"'"()()\-_]/gu, "")
|
|
164
|
+
.trim();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function hashText(text: string): string {
|
|
168
|
+
return createHash("sha256").update(normalizeForHash(text)).digest("hex").slice(0, 16);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function l2norm(vector: number[]): number {
|
|
172
|
+
let sum = 0;
|
|
173
|
+
for (const v of vector) sum += v * v;
|
|
174
|
+
return Math.sqrt(sum);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function cosine(a: number[], normA: number, b: number[], normB: number): number {
|
|
178
|
+
if (normA === 0 || normB === 0 || a.length !== b.length) return 0;
|
|
179
|
+
let dot = 0;
|
|
180
|
+
for (let i = 0; i < a.length; i++) dot += a[i]! * b[i]!;
|
|
181
|
+
return dot / (normA * normB);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export interface ShadowMatch {
|
|
185
|
+
matched: boolean;
|
|
186
|
+
prototypeId?: string;
|
|
187
|
+
similarity?: number;
|
|
188
|
+
protected?: boolean;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export class NoisePrototypeBank {
|
|
192
|
+
private readonly config: NoisePrototypeBankConfig;
|
|
193
|
+
private state: BankFile | null = null;
|
|
194
|
+
|
|
195
|
+
constructor(config: Partial<NoisePrototypeBankConfig> & Pick<NoisePrototypeBankConfig, "filePath" | "shadowLogPath">) {
|
|
196
|
+
this.config = { ...DEFAULTS, ...config };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Learning side. Call when admission rejected with reason "noise_detected".
|
|
201
|
+
* Embeds at most once per unique normalized text, and only after the
|
|
202
|
+
* candidate has been independently rejected minSeenCount times.
|
|
203
|
+
* Best-effort: any failure is swallowed — the experiment must never break
|
|
204
|
+
* the write path it observes.
|
|
205
|
+
*/
|
|
206
|
+
async learnFromRejection(
|
|
207
|
+
text: string,
|
|
208
|
+
embedPassage: (text: string) => Promise<number[]>,
|
|
209
|
+
): Promise<NoisePrototypeEvent> {
|
|
210
|
+
try {
|
|
211
|
+
if (isProtectedContent(text)) {
|
|
212
|
+
this.logShadow({ event: "skipped_protected", side: "learn", msgLen: text.length });
|
|
213
|
+
return "skipped_protected";
|
|
214
|
+
}
|
|
215
|
+
const state = this.load();
|
|
216
|
+
const hash = hashText(text);
|
|
217
|
+
if (state.prototypes.some((p) => p.textHash === hash)) {
|
|
218
|
+
return "skipped_duplicate";
|
|
219
|
+
}
|
|
220
|
+
const pending = state.pending[hash] ?? {
|
|
221
|
+
count: 0,
|
|
222
|
+
firstSeenAt: new Date().toISOString(),
|
|
223
|
+
preview: text.slice(0, 60),
|
|
224
|
+
};
|
|
225
|
+
pending.count += 1;
|
|
226
|
+
state.pending[hash] = pending;
|
|
227
|
+
|
|
228
|
+
if (pending.count < this.config.minSeenCount) {
|
|
229
|
+
this.save();
|
|
230
|
+
this.logShadow({ event: "candidate_seen", side: "learn", msgLen: text.length, seenCount: pending.count });
|
|
231
|
+
return "candidate_seen";
|
|
232
|
+
}
|
|
233
|
+
if (state.prototypes.length >= this.config.maxPrototypes) {
|
|
234
|
+
this.save();
|
|
235
|
+
this.logShadow({ event: "skipped_full", side: "learn", msgLen: text.length });
|
|
236
|
+
return "skipped_full";
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const vector = await embedPassage(text);
|
|
240
|
+
const prototype: NoisePrototype = {
|
|
241
|
+
id: `proto-${hash.slice(0, 8)}`,
|
|
242
|
+
textHash: hash,
|
|
243
|
+
textPreview: pending.preview,
|
|
244
|
+
vector,
|
|
245
|
+
norm: l2norm(vector),
|
|
246
|
+
sourceReason: "noise_detected",
|
|
247
|
+
seenCount: pending.count,
|
|
248
|
+
hitCount: 0,
|
|
249
|
+
learnedAt: new Date().toISOString(),
|
|
250
|
+
};
|
|
251
|
+
state.prototypes.push(prototype);
|
|
252
|
+
delete state.pending[hash];
|
|
253
|
+
this.save();
|
|
254
|
+
this.logShadow({ event: "learned", side: "learn", msgLen: text.length, prototypeId: prototype.id, seenCount: prototype.seenCount });
|
|
255
|
+
return "learned";
|
|
256
|
+
} catch {
|
|
257
|
+
return "no_match";
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Matching side. Call with the write-path embedding that was computed
|
|
263
|
+
* anyway for an ACCEPTED text — this side never spends an extra embedding.
|
|
264
|
+
* Only ever logs; the caller must not reject based on the result while the
|
|
265
|
+
* experiment is in shadow.
|
|
266
|
+
*/
|
|
267
|
+
matchShadow(text: string, vector: number[], memoryId?: string): ShadowMatch {
|
|
268
|
+
try {
|
|
269
|
+
const state = this.load();
|
|
270
|
+
if (state.prototypes.length === 0) return { matched: false };
|
|
271
|
+
if (isProtectedContent(text)) {
|
|
272
|
+
return { matched: false, protected: true };
|
|
273
|
+
}
|
|
274
|
+
const norm = l2norm(vector);
|
|
275
|
+
let best: { prototype: NoisePrototype; similarity: number } | undefined;
|
|
276
|
+
for (const prototype of state.prototypes) {
|
|
277
|
+
const similarity = cosine(vector, norm, prototype.vector, prototype.norm);
|
|
278
|
+
if (!best || similarity > best.similarity) best = { prototype, similarity };
|
|
279
|
+
}
|
|
280
|
+
if (!best || best.similarity < this.config.threshold) {
|
|
281
|
+
return { matched: false };
|
|
282
|
+
}
|
|
283
|
+
best.prototype.hitCount += 1;
|
|
284
|
+
best.prototype.lastHitAt = new Date().toISOString();
|
|
285
|
+
this.save();
|
|
286
|
+
this.logShadow({
|
|
287
|
+
event: "suggest_reject",
|
|
288
|
+
side: "match",
|
|
289
|
+
msgLen: text.length,
|
|
290
|
+
prototypeId: best.prototype.id,
|
|
291
|
+
similarity: Number(best.similarity.toFixed(4)),
|
|
292
|
+
memoryId,
|
|
293
|
+
});
|
|
294
|
+
return { matched: true, prototypeId: best.prototype.id, similarity: best.similarity };
|
|
295
|
+
} catch {
|
|
296
|
+
return { matched: false };
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Correction API — single-prototype removal (a false positive must be surgically removable). */
|
|
301
|
+
removePrototype(id: string): boolean {
|
|
302
|
+
const state = this.load();
|
|
303
|
+
const before = state.prototypes.length;
|
|
304
|
+
state.prototypes = state.prototypes.filter((p) => p.id !== id);
|
|
305
|
+
const removed = state.prototypes.length < before;
|
|
306
|
+
if (removed) this.save();
|
|
307
|
+
return removed;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Correction API — full reset. */
|
|
311
|
+
clear(): void {
|
|
312
|
+
this.state = { version: 1, prototypes: [], pending: {} };
|
|
313
|
+
this.save();
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
list(): NoisePrototype[] {
|
|
317
|
+
return [...this.load().prototypes];
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
pendingCount(): number {
|
|
321
|
+
return Object.keys(this.load().pending).length;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// --- persistence ---
|
|
325
|
+
|
|
326
|
+
private load(): BankFile {
|
|
327
|
+
if (this.state) return this.state;
|
|
328
|
+
let parsed: BankFile | null = null;
|
|
329
|
+
try {
|
|
330
|
+
if (existsSync(this.config.filePath)) {
|
|
331
|
+
parsed = JSON.parse(readFileSync(this.config.filePath, "utf8")) as BankFile;
|
|
332
|
+
}
|
|
333
|
+
} catch {
|
|
334
|
+
parsed = null;
|
|
335
|
+
}
|
|
336
|
+
const state: BankFile = parsed && parsed.version === 1
|
|
337
|
+
? parsed
|
|
338
|
+
: { version: 1, prototypes: [], pending: {} };
|
|
339
|
+
|
|
340
|
+
// TTL sweep on load.
|
|
341
|
+
const now = Date.now();
|
|
342
|
+
const protoCutoff = now - this.config.ttlDays * 86_400_000;
|
|
343
|
+
state.prototypes = state.prototypes.filter((p) => Date.parse(p.learnedAt) >= protoCutoff);
|
|
344
|
+
for (const [hash, candidate] of Object.entries(state.pending)) {
|
|
345
|
+
if (Date.parse(candidate.firstSeenAt) < now - this.config.pendingTtlDays * 86_400_000) {
|
|
346
|
+
delete state.pending[hash];
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
this.state = state;
|
|
350
|
+
return state;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
private save(): void {
|
|
354
|
+
try {
|
|
355
|
+
const dir = dirname(this.config.filePath);
|
|
356
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
357
|
+
writeFileSync(this.config.filePath, JSON.stringify(this.load(), null, 2), "utf8");
|
|
358
|
+
} catch {
|
|
359
|
+
// Persistence is best-effort; in-memory state still serves this process.
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
private logShadow(entry: Record<string, unknown>): void {
|
|
364
|
+
try {
|
|
365
|
+
const dir = dirname(this.config.shadowLogPath);
|
|
366
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
367
|
+
appendFileSync(
|
|
368
|
+
this.config.shadowLogPath,
|
|
369
|
+
`${JSON.stringify({ ts: new Date().toISOString(), ...entry })}\n`,
|
|
370
|
+
"utf8",
|
|
371
|
+
);
|
|
372
|
+
} catch {
|
|
373
|
+
// Shadow logging is best-effort by design.
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// --- shared instance (env-governed, injectable via PersistMemoryDeps) ---
|
|
379
|
+
|
|
380
|
+
export const NOISE_PROTOTYPE_BANK_FILE = "noise-prototype-bank.json";
|
|
381
|
+
export const NOISE_PROTOTYPE_SHADOW_FILE = "noise-prototype-shadow.jsonl";
|
|
382
|
+
|
|
383
|
+
let sharedBank: NoisePrototypeBank | null | undefined;
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Resolve the process-wide bank: explicit deps win (null disables, instance
|
|
387
|
+
* overrides); otherwise lazily construct from env (shadow default, off → null).
|
|
388
|
+
*/
|
|
389
|
+
export function resolveNoisePrototypeBank(
|
|
390
|
+
explicit: NoisePrototypeBank | null | undefined,
|
|
391
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
392
|
+
): NoisePrototypeBank | null {
|
|
393
|
+
if (explicit !== undefined) return explicit;
|
|
394
|
+
if (sharedBank === undefined) {
|
|
395
|
+
sharedBank = resolveNoisePrototypeMode(env) === "off"
|
|
396
|
+
? null
|
|
397
|
+
: new NoisePrototypeBank({
|
|
398
|
+
filePath: join(dataDir(), NOISE_PROTOTYPE_BANK_FILE),
|
|
399
|
+
shadowLogPath: join(dataDir(), NOISE_PROTOTYPE_SHADOW_FILE),
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
return sharedBank;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Test hook: drop the memoized shared bank so env changes take effect. */
|
|
406
|
+
export function resetSharedNoisePrototypeBank(): void {
|
|
407
|
+
sharedBank = undefined;
|
|
408
|
+
}
|
package/src/pii-detector.ts
CHANGED
|
@@ -2,12 +2,21 @@
|
|
|
2
2
|
* F-3: Lightweight PII detection.
|
|
3
3
|
* Scans text for common sensitive patterns (passwords, tokens, IDs).
|
|
4
4
|
* Returns warnings, does not block writes.
|
|
5
|
+
*
|
|
6
|
+
* F-3b (2026-07-10): Deterministic secret redaction on the same rule set.
|
|
7
|
+
* `redactSecrets` scrubs redactable matches BEFORE text reaches any external
|
|
8
|
+
* surface (LLM prompts, embedding API, stderr logs, audit trail). Detection
|
|
9
|
+
* and redaction share one rule table so the two can never drift apart.
|
|
10
|
+
* Deliberately NOT redacted: phone / email (legitimate memory content, warn
|
|
11
|
+
* only) and bare long-hex (would false-positive on git commit hashes, which
|
|
12
|
+
* are high-value memory content; prefixed forms like `secret=<hex>` are
|
|
13
|
+
* caught by the assignment rules).
|
|
5
14
|
*/
|
|
6
15
|
|
|
7
16
|
export type PIISeverity = "high" | "medium" | "low";
|
|
8
17
|
|
|
9
18
|
export interface PIIDetection {
|
|
10
|
-
type: string; // "api_key" | "password" | "id_number" | "email" | "phone" | "credit_card"
|
|
19
|
+
type: string; // "api_key" | "password" | "id_number" | "email" | "phone" | "credit_card" | ...
|
|
11
20
|
severity: PIISeverity;
|
|
12
21
|
match: string; // the matched text (partially masked)
|
|
13
22
|
position: number; // char offset
|
|
@@ -19,42 +28,196 @@ export interface PIIScanResult {
|
|
|
19
28
|
summary: string; // human-readable summary
|
|
20
29
|
}
|
|
21
30
|
|
|
31
|
+
export interface RedactResult {
|
|
32
|
+
text: string; // scrubbed text
|
|
33
|
+
redacted: number; // number of replacements made
|
|
34
|
+
}
|
|
35
|
+
|
|
22
36
|
interface PIIRule {
|
|
23
37
|
type: string;
|
|
24
38
|
severity: PIISeverity;
|
|
25
39
|
pattern: RegExp;
|
|
40
|
+
/** Scrub matches via redactSecrets (detection-only rules leave text intact) */
|
|
41
|
+
redact: boolean;
|
|
42
|
+
/** For key=value style rules: keep the key name, scrub only the value */
|
|
43
|
+
keepPrefix?: RegExp;
|
|
44
|
+
/** Custom scrubber for structured credentials where preserving a safe
|
|
45
|
+
* prefix/suffix is clearer than replacing the whole match. */
|
|
46
|
+
replacement?: (match: string) => string;
|
|
47
|
+
/** Checksum gate for broad numeric patterns: redact ONLY when this returns
|
|
48
|
+
* true (scan still warns regardless). Keeps snowflake IDs / order numbers
|
|
49
|
+
* from being destructively rewritten. */
|
|
50
|
+
validate?: (match: string) => boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** GB 11643 checksum (ISO 7064 MOD 11-2) for 18-digit Chinese national IDs. */
|
|
54
|
+
function isValidChineseId(id: string): boolean {
|
|
55
|
+
if (!/^\d{17}[\dXx]$/.test(id)) return false;
|
|
56
|
+
const weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2];
|
|
57
|
+
const checkChars = "10X98765432";
|
|
58
|
+
let sum = 0;
|
|
59
|
+
for (let i = 0; i < 17; i++) sum += Number(id[i]) * weights[i];
|
|
60
|
+
return checkChars[sum % 11] === id[17].toUpperCase();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Luhn checksum for card-number candidates (separators stripped first). */
|
|
64
|
+
function isValidLuhn(candidate: string): boolean {
|
|
65
|
+
const digits = candidate.replace(/[\s-]/g, "");
|
|
66
|
+
if (!/^\d{16}$/.test(digits)) return false;
|
|
67
|
+
let sum = 0;
|
|
68
|
+
for (let i = 0; i < 16; i++) {
|
|
69
|
+
let d = Number(digits[15 - i]);
|
|
70
|
+
if (i % 2 === 1) {
|
|
71
|
+
d *= 2;
|
|
72
|
+
if (d > 9) d -= 9;
|
|
73
|
+
}
|
|
74
|
+
sum += d;
|
|
75
|
+
}
|
|
76
|
+
return sum % 10 === 0;
|
|
26
77
|
}
|
|
27
78
|
|
|
28
79
|
const PII_RULES: PIIRule[] = [
|
|
80
|
+
// --- Structured credentials commonly found in shell/config transcripts ---
|
|
81
|
+
{
|
|
82
|
+
type: "uri_credentials",
|
|
83
|
+
severity: "high",
|
|
84
|
+
pattern: /\b[a-z][a-z0-9+.-]{1,20}:\/\/[^\s\/:@]+:[^\s\/@]+@/gi,
|
|
85
|
+
redact: true,
|
|
86
|
+
replacement: (match) => {
|
|
87
|
+
const schemeEnd = match.indexOf("://") + 3;
|
|
88
|
+
return `${match.slice(0, schemeEnd)}[REDACTED:uri_credentials]@`;
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
type: "basic_auth",
|
|
93
|
+
severity: "high",
|
|
94
|
+
pattern: /\b(?:Authorization\s*:\s*)?Basic\s+[A-Za-z0-9+/]{8,}={0,2}/gi,
|
|
95
|
+
redact: true,
|
|
96
|
+
replacement: (match) => {
|
|
97
|
+
const prefix = match.match(/^(?:Authorization\s*:\s*)?Basic\s+/i)?.[0] ?? "Basic ";
|
|
98
|
+
return `${prefix}[REDACTED:basic_auth]`;
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
type: "cookie_header",
|
|
103
|
+
severity: "high",
|
|
104
|
+
pattern: /\b(?:Cookie|Set-Cookie)\s*:\s*[^\r\n]+/gi,
|
|
105
|
+
redact: true,
|
|
106
|
+
replacement: (match) => `${match.slice(0, match.indexOf(":") + 1)} [REDACTED:cookie_header]`,
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
type: "sensitive_assignment",
|
|
110
|
+
severity: "high",
|
|
111
|
+
pattern: /["']?(?:database[_-]?url|aws[_-]?secret[_-]?access[_-]?key|client[_-]?secret|access[_-]?token|refresh[_-]?token|auth[_-]?token|session[_-]?(?:id|token)|cookie|private[_-]?key)["']?\s*[=:]\s*["']?[^\s"',;]{8,}/gi,
|
|
112
|
+
redact: true,
|
|
113
|
+
keepPrefix: /^["']?(?:database[_-]?url|aws[_-]?secret[_-]?access[_-]?key|client[_-]?secret|access[_-]?token|refresh[_-]?token|auth[_-]?token|session[_-]?(?:id|token)|cookie|private[_-]?key)["']?\s*[=:]\s*["']?/i,
|
|
114
|
+
},
|
|
115
|
+
// --- Vendor-prefixed token literals (high confidence, full scrub) ---
|
|
116
|
+
{
|
|
117
|
+
type: "anthropic_key",
|
|
118
|
+
severity: "high",
|
|
119
|
+
// Negative lookbehind: real keys start their own token; without it the
|
|
120
|
+
// "sk" tail of ordinary words (task-, risk-) plus a hyphenated slug
|
|
121
|
+
// matches (e.g. "task-ant..." would scrub from "sk-ant...").
|
|
122
|
+
pattern: /(?<![A-Za-z0-9])sk-ant-[A-Za-z0-9_\-]{10,}/g,
|
|
123
|
+
redact: true,
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
type: "github_token",
|
|
127
|
+
severity: "high",
|
|
128
|
+
pattern: /gh[pousr]_[A-Za-z0-9]{20,}/g,
|
|
129
|
+
redact: true,
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
type: "slack_token",
|
|
133
|
+
severity: "high",
|
|
134
|
+
pattern: /xox[baprs]-[A-Za-z0-9\-]{10,}/g,
|
|
135
|
+
redact: true,
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
type: "aws_access_key",
|
|
139
|
+
severity: "high",
|
|
140
|
+
pattern: /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g,
|
|
141
|
+
redact: true,
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
type: "google_api_key",
|
|
145
|
+
severity: "high",
|
|
146
|
+
pattern: /\bAIza[A-Za-z0-9_\-]{35}\b/g,
|
|
147
|
+
redact: true,
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
type: "jwt",
|
|
151
|
+
severity: "high",
|
|
152
|
+
pattern: /\beyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\b/g,
|
|
153
|
+
redact: true,
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
type: "segmented_token",
|
|
157
|
+
severity: "high",
|
|
158
|
+
pattern: /\b[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g,
|
|
159
|
+
redact: true,
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
type: "bearer_token",
|
|
163
|
+
severity: "high",
|
|
164
|
+
pattern: /\bBearer\s+[A-Za-z0-9._~+\/\-]{16,}=*/g,
|
|
165
|
+
redact: true,
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
type: "pem_private_key",
|
|
169
|
+
severity: "high",
|
|
170
|
+
pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
|
|
171
|
+
redact: true,
|
|
172
|
+
},
|
|
173
|
+
// --- Generic assignment / key styles (keep key name, scrub value) ---
|
|
174
|
+
// Quoted keys and whitespace around the separator are allowed so JSON/YAML/
|
|
175
|
+
// config forms ({"api_key": "..."}, password = ...) are caught too.
|
|
29
176
|
{
|
|
30
177
|
type: "api_key",
|
|
31
178
|
severity: "high",
|
|
32
|
-
|
|
179
|
+
// (?<![A-Za-z0-9]) keeps the "sk" tail of hyphenated slugs (task-assignment,
|
|
180
|
+
// risk-gated) from matching as an sk- key; real keys start their own token.
|
|
181
|
+
pattern: /(?:(?<![A-Za-z0-9])sk-[A-Za-z0-9_\-]{20,}|["']?(?:api[_-]?key|token|secret)["']?\s*[=:]\s*["']?[A-Za-z0-9_\-]{20,})/gi,
|
|
182
|
+
redact: true,
|
|
183
|
+
keepPrefix: /^["']?(?:api[_-]?key|token|secret)["']?\s*[=:]\s*["']?/i,
|
|
33
184
|
},
|
|
34
185
|
{
|
|
35
186
|
type: "password",
|
|
36
187
|
severity: "high",
|
|
37
|
-
pattern: /(?:password|passwd|pwd)[
|
|
188
|
+
pattern: /["']?(?:password|passwd|pwd)["']?\s*[=:]\s*["']?[^\s"',;]{8,}/gi,
|
|
189
|
+
redact: true,
|
|
190
|
+
keepPrefix: /^["']?(?:password|passwd|pwd)["']?\s*[=:]\s*["']?/i,
|
|
38
191
|
},
|
|
192
|
+
// --- High-risk personal identifiers ---
|
|
193
|
+
// Broad numeric patterns scan-warn on shape but only REDACT when the
|
|
194
|
+
// checksum verifies — otherwise snowflake IDs / order numbers get rewritten.
|
|
39
195
|
{
|
|
40
196
|
type: "id_number",
|
|
41
197
|
severity: "high",
|
|
42
|
-
pattern:
|
|
198
|
+
pattern: /(?<!\d)\d{17}[\dXx](?!\d)/g,
|
|
199
|
+
redact: true,
|
|
200
|
+
validate: isValidChineseId,
|
|
43
201
|
},
|
|
44
202
|
{
|
|
45
203
|
type: "credit_card",
|
|
46
204
|
severity: "high",
|
|
47
|
-
pattern:
|
|
205
|
+
pattern: /(?<!\d)\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}(?![\d])/g,
|
|
206
|
+
redact: true,
|
|
207
|
+
validate: isValidLuhn,
|
|
48
208
|
},
|
|
209
|
+
// --- Detection-only: legitimate memory content, warn but keep ---
|
|
49
210
|
{
|
|
50
211
|
type: "phone",
|
|
51
212
|
severity: "medium",
|
|
52
213
|
pattern: /1[3-9]\d{9}/g,
|
|
214
|
+
redact: false,
|
|
53
215
|
},
|
|
54
216
|
{
|
|
55
217
|
type: "email",
|
|
56
218
|
severity: "low",
|
|
57
219
|
pattern: /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g,
|
|
220
|
+
redact: false,
|
|
58
221
|
},
|
|
59
222
|
];
|
|
60
223
|
|
|
@@ -96,3 +259,42 @@ export function scanForPII(text: string): PIIScanResult {
|
|
|
96
259
|
summary,
|
|
97
260
|
};
|
|
98
261
|
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Deterministically scrub redactable secrets. Pure regex, no LLM call.
|
|
265
|
+
* Replacement is `[REDACTED:<type>]`; assignment-style rules keep the key
|
|
266
|
+
* name (`password=[REDACTED:password]`) so the memory stays readable.
|
|
267
|
+
*/
|
|
268
|
+
export function redactSecrets(text: string): RedactResult {
|
|
269
|
+
let out = text;
|
|
270
|
+
let redacted = 0;
|
|
271
|
+
|
|
272
|
+
for (const rule of PII_RULES) {
|
|
273
|
+
if (!rule.redact) continue;
|
|
274
|
+
rule.pattern.lastIndex = 0;
|
|
275
|
+
out = out.replace(rule.pattern, (match) => {
|
|
276
|
+
if (rule.validate && !rule.validate(match)) {
|
|
277
|
+
return match; // checksum failed → leave intact (detection-only)
|
|
278
|
+
}
|
|
279
|
+
redacted++;
|
|
280
|
+
if (rule.replacement) return rule.replacement(match);
|
|
281
|
+
if (rule.keepPrefix) {
|
|
282
|
+
const prefixMatch = match.match(rule.keepPrefix);
|
|
283
|
+
if (prefixMatch) {
|
|
284
|
+
return `${prefixMatch[0]}[REDACTED:${rule.type}]`;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return `[REDACTED:${rule.type}]`;
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return { text: out, redacted };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Redact + truncate for log lines. Truncation happens AFTER redaction so a
|
|
296
|
+
* secret can never straddle the cut and leak its prefix.
|
|
297
|
+
*/
|
|
298
|
+
export function redactForLog(text: string, max = 60): string {
|
|
299
|
+
return redactSecrets(text).text.slice(0, max);
|
|
300
|
+
}
|