agent-bios 0.4.0 → 0.7.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.
@@ -0,0 +1,423 @@
1
+ #!/usr/bin/env python3
2
+ """Promote -> migrate: clear the personal copy of promoted learnings (Phase 4).
3
+
4
+ After a push lands, a learning that was promoted into the shared corpus is now
5
+ loaded from the corpus — its personal copy (written by `learn!`,
6
+ scripts/collect-learning.py) would double-load. This user-side tool removes the
7
+ absorbed personal copy, and ONLY it (never the user's hand-written entry
8
+ CLAUDE.md `## Personal` section).
9
+
10
+ THE SAFETY RULE (design/corpus-domain-packaging.md:161-168): remove a personal
11
+ copy ONLY after verifying the promoted item is actually in THIS user's assembled
12
+ bundle. Per-domain opt-in means a promotion into a package the user did NOT
13
+ install must NOT trigger removal — that would silently lose the learning. When
14
+ unsure, KEEP (a kept duplicate is redundant; a wrong removal is data loss).
15
+
16
+ Bundle membership (mirrors scripts/assemble.py `kept`): full install (original
17
+ single-zone / no selection) -> everything installed; universal tier (core/infra)
18
+ -> always installed; a domain key -> installed iff in the user's selection;
19
+ anything else (env-personal / unclassified / unknown) -> KEEP.
20
+
21
+ Run per host (mirrors collect-learning: --host + --config-dir); install.sh calls
22
+ it after the corpus deploy. Manifest absent/empty -> no-op.
23
+ """
24
+ import argparse
25
+ import importlib.util
26
+ import json
27
+ import os
28
+ import pathlib
29
+ import re
30
+ import shutil
31
+ import sys
32
+ import time
33
+
34
+ REPO = pathlib.Path(__file__).resolve().parent.parent
35
+ MANIFEST = REPO / "config" / "promotions.json"
36
+ DOMAINS = REPO / "config" / "domains.json"
37
+ UNIVERSAL_TIERS = frozenset({"core", "infra"}) # == assemble.audience UNIVERSAL
38
+ # learning_id inside a personal bullet's TRAILING comment (collect-learning
39
+ # prose_bullet: "... <!-- learning_id: <uuid> created: <ts> -->"). Anchored to
40
+ # end-of-line so a lesson body that quotes a `<!-- learning_id: … -->` string
41
+ # can never shadow the bullet's own id (this corpus is about agent tooling).
42
+ BULLET_LID_RE = re.compile(r"<!--\s*learning_id:\s*([0-9a-fA-F-]+)[^>]*-->\s*$")
43
+
44
+
45
+ def die(msg, code=1):
46
+ print(f"migrate-learnings: {msg}", file=sys.stderr)
47
+ sys.exit(code)
48
+
49
+
50
+ def _load(name, filename):
51
+ path = REPO / "scripts" / filename
52
+ spec = importlib.util.spec_from_file_location(name, path)
53
+ module = importlib.util.module_from_spec(spec)
54
+ spec.loader.exec_module(module)
55
+ return module
56
+
57
+
58
+ def load_collect():
59
+ """Reuse collect-learning.py's host/home/marker constants (single source)."""
60
+ return _load("collect_learning", "collect-learning.py")
61
+
62
+
63
+ def load_manifest(path=MANIFEST):
64
+ if not path.is_file():
65
+ return []
66
+ data = json.loads(path.read_text(encoding="utf-8"))
67
+ return [p for p in data.get("promotions", [])
68
+ if isinstance(p, dict) and isinstance(p.get("learning_id"), str)]
69
+
70
+
71
+ def load_domain_keys(path=DOMAINS):
72
+ return set(json.loads(path.read_text(encoding="utf-8")).get("domains", {}))
73
+
74
+
75
+ def make_in_bundle(full, selection, domain_keys):
76
+ """A promotion's placement domain -> is it in THIS user's bundle? Biased to
77
+ KEEP on anything not provably installed."""
78
+ selection = set(selection or ())
79
+
80
+ def in_bundle(domain):
81
+ if full:
82
+ return True
83
+ if domain in UNIVERSAL_TIERS:
84
+ return True
85
+ if domain in domain_keys:
86
+ return domain in selection
87
+ return False # env-personal / unclassified / unknown -> keep
88
+
89
+ return in_bundle
90
+
91
+
92
+ def backup(path):
93
+ shutil.copy2(path, path.with_suffix(path.suffix + f".bak-migrate-{time.strftime('%Y%m%d-%H%M%S')}"))
94
+
95
+
96
+ def atomic_write(path, text):
97
+ """Back up, then replace via a temp file + os.replace so a crash mid-write
98
+ can never truncate the durable personal file (reuses collect-learning's
99
+ write discipline)."""
100
+ backup(path)
101
+ tmp = path.with_suffix(path.suffix + ".tmp")
102
+ tmp.write_text(text, encoding="utf-8")
103
+ os.replace(tmp, path)
104
+
105
+
106
+ def prune_jsonl(jsonl, remove_ids, dry):
107
+ """Drop records whose learning_id is being removed. Returns count removed."""
108
+ if not jsonl.is_file():
109
+ return 0
110
+ kept, removed = [], 0
111
+ for line in jsonl.read_text(encoding="utf-8").splitlines():
112
+ s = line.strip()
113
+ if s:
114
+ try:
115
+ rec = json.loads(s)
116
+ if isinstance(rec, dict) and rec.get("learning_id") in remove_ids:
117
+ removed += 1
118
+ continue
119
+ except json.JSONDecodeError:
120
+ pass # keep malformed lines untouched
121
+ kept.append(line)
122
+ if removed and not dry:
123
+ atomic_write(jsonl, "\n".join(kept) + ("\n" if kept else ""))
124
+ return removed
125
+
126
+
127
+ def prune_bullets(text, remove_ids):
128
+ """Drop bullet lines whose comment learning_id is being removed. Returns
129
+ (new_text, count). Non-bullet lines (header/comments) are preserved."""
130
+ out, removed = [], 0
131
+ for line in text.splitlines():
132
+ m = BULLET_LID_RE.search(line)
133
+ if m and m.group(1) in remove_ids:
134
+ removed += 1
135
+ continue
136
+ out.append(line)
137
+ new = "\n".join(out) + ("\n" if text.endswith("\n") and out else "")
138
+ return new, removed
139
+
140
+
141
+ def prune_claude_prose(home, remove_ids, dry):
142
+ md = home / "personal" / "learnings.md"
143
+ if not md.is_file():
144
+ return 0
145
+ new, removed = prune_bullets(md.read_text(encoding="utf-8"), remove_ids)
146
+ if removed and not dry:
147
+ atomic_write(md, new)
148
+ return removed
149
+
150
+
151
+ def prune_codex_prose(home, remove_ids, collect, dry):
152
+ agents = home / "AGENTS.md"
153
+ if not agents.is_file():
154
+ return 0
155
+ body = agents.read_text(encoding="utf-8")
156
+ start, end = collect.PERSONAL_START, collect.PERSONAL_END
157
+ if start not in body:
158
+ return 0
159
+ pre, rest = body.split(start, 1)
160
+ if end not in rest: # malformed (missing, or END before START) → touch nothing
161
+ return 0
162
+ region, post = rest.split(end, 1)
163
+ new_region, removed = prune_bullets(region, remove_ids)
164
+ if removed and not dry:
165
+ atomic_write(agents, f"{pre}{start}{new_region}{end}{post}")
166
+ return removed
167
+
168
+
169
+ def claude_corpus_loaded(home, full):
170
+ """Is the shared corpus actually LOADED for this claude home? Removing a
171
+ personal copy while the corpus copy is not loaded would make the rule vanish
172
+ (Review F2). Non-packaged (`full`): the corpus IS the entry monolith the
173
+ deploy wrote -> loaded. Packaged: loads only via the entry's
174
+ `@central/bundle.md` import; a user-owned entry that lacks it (assemble exit
175
+ 2) is NOT loading central -> keep the personal copy."""
176
+ if full:
177
+ return True
178
+ entry = home / "CLAUDE.md"
179
+ return entry.is_file() and collect_central_import() in entry.read_text(encoding="utf-8")
180
+
181
+
182
+ def collect_central_import():
183
+ return "@central/bundle.md"
184
+
185
+
186
+ def migrate(home, host, promotions, in_bundle, collect, dry=False, corpus_loaded=True):
187
+ """Remove personal copies of promoted+in-bundle learnings present locally.
188
+ corpus_loaded=False (the corpus is not actually loaded for this host) -> keep
189
+ everything (never orphan a personal copy)."""
190
+ if not corpus_loaded:
191
+ return {"removed": 0, "jsonl_removed": 0, "prose_removed": 0,
192
+ "kept_not_in_bundle": 0, "skipped": "corpus-not-loaded"}
193
+ jsonl = home / "personal" / "learnings.jsonl"
194
+ local_ids = set()
195
+ if jsonl.is_file():
196
+ for line in jsonl.read_text(encoding="utf-8").splitlines():
197
+ s = line.strip()
198
+ if not s:
199
+ continue
200
+ try:
201
+ rec = json.loads(s)
202
+ except json.JSONDecodeError:
203
+ continue
204
+ lid = rec.get("learning_id") if isinstance(rec, dict) else None
205
+ if isinstance(lid, str):
206
+ local_ids.add(lid)
207
+
208
+ remove_ids, kept_not_in_bundle = set(), 0
209
+ for p in promotions:
210
+ lid = p["learning_id"]
211
+ if lid not in local_ids:
212
+ continue # not held locally (never captured here, or already migrated)
213
+ if in_bundle(p.get("domain")):
214
+ remove_ids.add(lid)
215
+ else:
216
+ kept_not_in_bundle += 1 # promoted but not in THIS user's bundle -> keep
217
+
218
+ # Prune PROSE first, jsonl second: the gate keys off ids still in the jsonl
219
+ # (line ~"if lid not in local_ids: continue"), so if a prose write fails, the
220
+ # id survives in jsonl and a re-run re-enters and converges. The reverse order
221
+ # would strand the prose bullet forever after a jsonl-only success (Review F4).
222
+ if host == "codex":
223
+ prose_removed = prune_codex_prose(home, remove_ids, collect, dry)
224
+ else:
225
+ prose_removed = prune_claude_prose(home, remove_ids, dry)
226
+ jsonl_removed = prune_jsonl(jsonl, remove_ids, dry)
227
+
228
+ return {"removed": len(remove_ids), "jsonl_removed": jsonl_removed,
229
+ "prose_removed": prose_removed, "kept_not_in_bundle": kept_not_in_bundle}
230
+
231
+
232
+ def main():
233
+ ap = argparse.ArgumentParser(description="Clear personal copies of promoted learnings.")
234
+ # Not argparse-required, so --self-test runs standalone; validated below.
235
+ ap.add_argument("--host", choices=("claude", "codex"))
236
+ ap.add_argument("--config-dir", default=None,
237
+ help="config home (default: $CLAUDE_CONFIG_DIR / $CODEX_HOME by host)")
238
+ ap.add_argument("--full", action="store_true",
239
+ help="non-packaged install: the full corpus is installed")
240
+ ap.add_argument("--selection-file",
241
+ help="packaged install: selection.json ({\"domains\":[...]})")
242
+ ap.add_argument("--domains", help="packaged: comma-separated selection (overrides --selection-file)")
243
+ ap.add_argument("--manifest", default=None,
244
+ help="promotion manifest (default: config/promotions.json)")
245
+ ap.add_argument("--dry-run", action="store_true")
246
+ ap.add_argument("--self-test", action="store_true")
247
+ args = ap.parse_args()
248
+
249
+ if args.self_test:
250
+ _self_test()
251
+ return
252
+
253
+ if not args.host:
254
+ die("--host claude|codex is required")
255
+ if not args.full and args.selection_file is None and args.domains is None:
256
+ die("pass --full (non-packaged) or --selection-file/--domains (packaged)")
257
+ if args.full and (args.selection_file is not None or args.domains is not None):
258
+ die("--full is mutually exclusive with --selection-file/--domains")
259
+
260
+ promotions = load_manifest(pathlib.Path(args.manifest) if args.manifest else MANIFEST)
261
+ if not promotions:
262
+ print("migrate-learnings: no promotions in the manifest — nothing to migrate")
263
+ return
264
+
265
+ collect = load_collect()
266
+ home = collect.resolve_home(args.host, args.config_dir)
267
+
268
+ # F2 gate: only prune where the shared corpus is actually loaded. Codex always
269
+ # loads AGENTS.md's central region; claude loads central only via the entry
270
+ # import (or inline in a full install).
271
+ corpus_loaded = True if args.host == "codex" else claude_corpus_loaded(home, args.full)
272
+
273
+ if args.full:
274
+ in_bundle = make_in_bundle(True, (), set())
275
+ else:
276
+ if args.domains is not None:
277
+ selection = [d for d in args.domains.split(",") if d]
278
+ else:
279
+ sel_path = pathlib.Path(args.selection_file)
280
+ if not sel_path.is_file():
281
+ die(f"selection file not found: {sel_path} (pass --full for a non-packaged install)")
282
+ selection = json.loads(sel_path.read_text(encoding="utf-8")).get("domains", [])
283
+ in_bundle = make_in_bundle(False, selection, load_domain_keys())
284
+
285
+ s = migrate(home, args.host, promotions, in_bundle, collect,
286
+ dry=args.dry_run, corpus_loaded=corpus_loaded)
287
+ tag = "[dry] " if args.dry_run else ""
288
+ if s.get("skipped"):
289
+ print(f"migrate-learnings: {tag}host={args.host} skipped ({s['skipped']}) — "
290
+ "kept every personal copy")
291
+ return
292
+ print(f"migrate-learnings: {tag}host={args.host} removed={s['removed']} "
293
+ f"(jsonl={s['jsonl_removed']} prose={s['prose_removed']}) "
294
+ f"kept_not_in_bundle={s['kept_not_in_bundle']}")
295
+
296
+
297
+ def _self_test():
298
+ """Temp-home verification: the safety gate (keep a promotion not in the
299
+ user's bundle), removal of an in-bundle promotion from BOTH prose + jsonl,
300
+ idempotency, and that the user's own '## Personal' text is untouched. Uses
301
+ collect-learning's own writers so the seeded format is authoritative."""
302
+ import tempfile
303
+
304
+ collect = load_collect()
305
+
306
+ def rec(lid, domain):
307
+ return {"lesson": f"lesson for {domain} " + "x" * 10, "domain": domain,
308
+ "supporting_sessions": ["claude:abcd1234"],
309
+ "learning_id": lid, "schema_version": 1, "created": "2026-07-21T00:00:00Z"}
310
+
311
+ L = {"core": "0f8c1c2a-4d1e-4abc-9def-0000000000a1",
312
+ "bb": "0f8c1c2a-4d1e-4abc-9def-0000000000b2",
313
+ "off": "0f8c1c2a-4d1e-4abc-9def-0000000000c3"}
314
+ promos = [{"learning_id": L["core"], "domain": "core"},
315
+ {"learning_id": L["bb"], "domain": "builder-base"},
316
+ {"learning_id": L["off"], "domain": "office-work"}]
317
+
318
+ def seed_claude():
319
+ home = pathlib.Path(tempfile.mkdtemp(prefix="migrate-selftest-"))
320
+ (home / "personal").mkdir(parents=True)
321
+ jsonl = home / "personal" / "learnings.jsonl"
322
+ with open(jsonl, "w", encoding="utf-8") as f:
323
+ for k, dom in (("core", "core"), ("bb", "builder-base"), ("off", "office-work")):
324
+ r = rec(L[k], dom)
325
+ f.write(json.dumps(r, ensure_ascii=False) + "\n")
326
+ collect.apply_claude(home, collect.prose_bullet(r), dry=False)
327
+ # a user-owned '## Personal' line that migrate must never touch
328
+ entry = home / "CLAUDE.md"
329
+ entry.write_text(entry.read_text(encoding="utf-8") + "\n## Personal\n- my own rule\n", encoding="utf-8")
330
+ return home
331
+
332
+ def local_ids(home):
333
+ return {json.loads(l)["learning_id"]
334
+ for l in (home / "personal" / "learnings.jsonl").read_text(encoding="utf-8").splitlines() if l.strip()}
335
+
336
+ def md_has(home, lid):
337
+ return lid in (home / "personal" / "learnings.md").read_text(encoding="utf-8")
338
+
339
+ checks = []
340
+
341
+ # 1) Packaged, selection={builder-base}: core (universal) + builder-base
342
+ # removed; office-work KEPT (the silent-loss guard).
343
+ home = seed_claude()
344
+ in_bundle = make_in_bundle(False, ["builder-base"], {"builder-base", "office-work", "llm-pipeline-dev"})
345
+ s = migrate(home, "claude", promos, in_bundle, collect)
346
+ ids = local_ids(home)
347
+ checks.append(("packaged: removed core+builder-base", s["removed"] == 2 and s["kept_not_in_bundle"] == 1))
348
+ checks.append(("packaged: office-work KEPT (not selected)", L["off"] in ids and md_has(home, L["off"])))
349
+ checks.append(("packaged: core+bb gone from jsonl", L["core"] not in ids and L["bb"] not in ids))
350
+ checks.append(("packaged: core+bb gone from prose", not md_has(home, L["core"]) and not md_has(home, L["bb"])))
351
+ checks.append(("user '## Personal' untouched", "my own rule" in (home / "CLAUDE.md").read_text(encoding="utf-8")))
352
+ # idempotent re-run removes nothing more
353
+ s2 = migrate(home, "claude", promos, in_bundle, collect)
354
+ checks.append(("idempotent re-run", s2["removed"] == 0))
355
+
356
+ # 2) Full (non-packaged) install: everything in bundle -> all removed.
357
+ home = seed_claude()
358
+ s = migrate(home, "claude", promos, make_in_bundle(True, (), set()), collect)
359
+ checks.append(("full install: all 3 removed", s["removed"] == 3 and not local_ids(home)))
360
+
361
+ # 3) dry-run changes nothing on disk.
362
+ home = seed_claude()
363
+ before = (home / "personal" / "learnings.jsonl").read_text(encoding="utf-8")
364
+ migrate(home, "claude", promos, make_in_bundle(True, (), set()), collect, dry=True)
365
+ checks.append(("dry-run writes nothing",
366
+ (home / "personal" / "learnings.jsonl").read_text(encoding="utf-8") == before))
367
+
368
+ # 4) codex host: seed the AGENTS.md region, migrate prunes it there.
369
+ chome = pathlib.Path(tempfile.mkdtemp(prefix="migrate-codex-"))
370
+ (chome / "personal").mkdir(parents=True)
371
+ with open(chome / "personal" / "learnings.jsonl", "w", encoding="utf-8") as f:
372
+ r = rec(L["bb"], "builder-base")
373
+ f.write(json.dumps(r, ensure_ascii=False) + "\n")
374
+ collect.apply_codex(chome, collect.prose_bullet(r), dry=False)
375
+ s = migrate(chome, "codex", promos, make_in_bundle(True, (), set()), collect)
376
+ agents_txt = (chome / "AGENTS.md").read_text(encoding="utf-8")
377
+ checks.append(("codex: bullet removed from AGENTS.md region",
378
+ s["prose_removed"] == 1 and L["bb"] not in agents_txt))
379
+
380
+ # 5) F2 gate: a packaged claude entry lacking @central/bundle.md means the
381
+ # corpus is NOT loaded -> keep everything (never orphan a personal copy).
382
+ home = seed_claude() # apply_claude writes an entry WITHOUT @central/bundle.md
383
+ checks.append(("F2: unwired packaged entry -> corpus not loaded",
384
+ claude_corpus_loaded(home, full=False) is False))
385
+ s = migrate(home, "claude", promos, make_in_bundle(False, ["builder-base"], {"builder-base"}),
386
+ collect, corpus_loaded=False)
387
+ checks.append(("F2: corpus-not-loaded keeps ALL",
388
+ s.get("skipped") == "corpus-not-loaded"
389
+ and local_ids(home) == {L["core"], L["bb"], L["off"]}))
390
+ entry = home / "CLAUDE.md"
391
+ entry.write_text(entry.read_text(encoding="utf-8") + "\n@central/bundle.md\n", encoding="utf-8")
392
+ checks.append(("F2: wired entry -> corpus loaded",
393
+ claude_corpus_loaded(home, full=False) is True))
394
+ checks.append(("F2: full install always loaded", claude_corpus_loaded(home, full=True) is True))
395
+
396
+ # 6) F5: the TRAILING comment's id wins; a learning_id quoted in the lesson
397
+ # body must never shadow it (else a jsonl/prose desync).
398
+ twin = ("- [core] cf <!-- learning_id: aaaa0000-0000-0000-0000-000000000000 "
399
+ "created: x --> here <!-- learning_id: 11112222-3333-4444-5555-666677778888 "
400
+ "created: y -->\n")
401
+ _, r_real = prune_bullets(twin, {"11112222-3333-4444-5555-666677778888"})
402
+ _, r_quoted = prune_bullets(twin, {"aaaa0000-0000-0000-0000-000000000000"})
403
+ checks.append(("F5: trailing id removed, quoted id ignored", r_real == 1 and r_quoted == 0))
404
+
405
+ # 7) malformed codex markers (END before START) -> no-op, no corruption.
406
+ bad = pathlib.Path(tempfile.mkdtemp(prefix="migrate-badcodex-"))
407
+ agents = bad / "AGENTS.md"
408
+ agents.write_text(f"x {collect.PERSONAL_END} y {collect.PERSONAL_START} z", encoding="utf-8")
409
+ before = agents.read_text(encoding="utf-8")
410
+ rem = prune_codex_prose(bad, {"anything"}, collect, dry=False)
411
+ checks.append(("malformed codex markers -> no-op",
412
+ rem == 0 and agents.read_text(encoding="utf-8") == before))
413
+
414
+ failed = [n for n, ok in checks if not ok]
415
+ if failed:
416
+ for n in failed:
417
+ print(f"migrate-learnings --self-test: FAIL: {n}", file=sys.stderr)
418
+ sys.exit(1)
419
+ print(f"migrate-learnings --self-test: OK ({len(checks)} migrate checks)")
420
+
421
+
422
+ if __name__ == "__main__":
423
+ main()
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env python3
2
+ """Secret-redaction floor — the ONE canonical secret scrubber for the repo.
3
+
4
+ Both flows that egress session-derived text redact through here, so the floor
5
+ is single-sourced (concept economy) rather than duplicated per call site:
6
+ * heavy flow — scripts/session-distill/digest.py (per-session digests);
7
+ * light flow — scripts/collect-learning.py (`learn!` prose + upload + the
8
+ curator export downstream, since the server stores the payload verbatim).
9
+
10
+ `design/corpus-domain-packaging.md` declares this floor as an inherited
11
+ constraint ("the digest pipeline's secret-redaction carries over to harvest").
12
+ The patterns are deliberately conservative: kill obvious secrets/identifiers in
13
+ the `key: value` / `key=value` form and well-known token shapes, keep prose. A
14
+ `token`/`secret`/`bearer` word standing alone (no `:`/`=` + value) is NOT
15
+ touched, so lessons ABOUT tokens (e.g. "trust the X-Hook-Token header") survive.
16
+
17
+ This module has no side effects on import and is safe to load by path.
18
+ scripts/redact.py is shipped in the npm package (package.json `files`) because
19
+ the shipped scripts/collect-learning.py imports it at runtime.
20
+ """
21
+ import re
22
+ import sys
23
+
24
+ # Conservative: kill obvious secrets/identifiers, keep prose.
25
+ SECRET_RE = [
26
+ re.compile(r'(?i)(api[_-]?key|secret|token|password|authorization|bearer)\s*[:=]\s*\S+'),
27
+ re.compile(r'sk-[A-Za-z0-9_-]{16,}'),
28
+ re.compile(r'gh[pousr]_[A-Za-z0-9]{20,}'),
29
+ re.compile(r'eyJ[A-Za-z0-9_-]{20,}\.'), # JWT-ish
30
+ re.compile(r'AKIA[0-9A-Z]{12,}'), # AWS key id
31
+ re.compile(r'[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}'), # email
32
+ ]
33
+ PLACEHOLDER = '<REDACTED>'
34
+
35
+
36
+ def redact(s):
37
+ """Return `s` with obvious secrets/identifiers replaced by <REDACTED>.
38
+ Falsy/non-str input is returned unchanged (callers may pass None)."""
39
+ if not s or not isinstance(s, str):
40
+ return s
41
+ for r in SECRET_RE:
42
+ s = r.sub(PLACEHOLDER, s)
43
+ return s
44
+
45
+
46
+ def _self_test():
47
+ """Prove each pattern fires and that clean prose survives; exits non-zero on
48
+ any miss so the umbrella gate catches a regression in the floor."""
49
+ must_redact = [
50
+ ("api_key value", "my api_key=sk_live_ABCDEF0123456789 leaked", "sk_live_"),
51
+ # key[:=]value form. NB the "Authorization: Bearer <token>" space/scheme
52
+ # form is only partially caught here (up to the scheme word) unless the
53
+ # token itself matches a shape below (JWT/sk-/ghp_/…) — a known floor
54
+ # boundary carried over from the heavy flow, not tightened in Phase 3.
55
+ ("authorization value", "set authorization=topsecretvalue123 here", "topsecretvalue123"),
56
+ ("openai key", "used sk-abcdefghij0123456789 here", "sk-abcdefghij"),
57
+ ("github token", "token ghp_abcdefghij0123456789klmn committed", "ghp_"),
58
+ ("aws key id", "key AKIA0123456789ABCD in env", "AKIA0123456789"),
59
+ ("jwt", "cookie eyJhbGciOiJIUzI1NiIsInR5cCI6.rest here", "eyJ"),
60
+ ("email", "ping alice.dev@example.com about it", "alice.dev@example.com"),
61
+ ]
62
+ must_keep = [
63
+ # a lesson ABOUT tokens/secrets, no `key: value` → untouched.
64
+ "trust the X-Hook-Token header; the server derives identity from it",
65
+ "the secret sauce is verifying the real dispatch path",
66
+ "password rotation policy matters but state no value",
67
+ ]
68
+ checks = []
69
+ for name, src, needle in must_redact:
70
+ out = redact(src)
71
+ checks.append((f"redacts {name}", PLACEHOLDER in out and needle not in out))
72
+ for src in must_keep:
73
+ out = redact(src)
74
+ checks.append((f"keeps: {src[:32]}…", out == src))
75
+ checks.append(("None passthrough", redact(None) is None))
76
+ checks.append(("empty passthrough", redact("") == ""))
77
+
78
+ failed = [name for name, ok in checks if not ok]
79
+ if failed:
80
+ for name in failed:
81
+ print(f"redact --self-test: FAIL: {name}", file=sys.stderr)
82
+ sys.exit(1)
83
+ print(f"redact --self-test: OK ({len(checks)} redaction-floor checks)")
84
+
85
+
86
+ if __name__ == "__main__":
87
+ if len(sys.argv) > 1 and sys.argv[1] == "--self-test":
88
+ _self_test()
89
+ else:
90
+ # Filter mode: redact stdin → stdout (handy for ad-hoc use).
91
+ sys.stdout.write(redact(sys.stdin.read()))