@matt82198/aesop 0.1.0 → 0.3.1

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 (130) hide show
  1. package/CHANGELOG.md +117 -2
  2. package/README.md +59 -218
  3. package/bin/cli.js +168 -41
  4. package/daemons/run-watchdog.sh +16 -4
  5. package/daemons/selfheal.sh +231 -0
  6. package/docs/ANY-REPO.md +427 -0
  7. package/docs/CONTRIBUTING.md +72 -0
  8. package/docs/DEMO.md +334 -0
  9. package/docs/HOOK-INSTALL.md +15 -56
  10. package/docs/INSTALL.md +74 -4
  11. package/docs/PORTING.md +166 -0
  12. package/docs/README.md +33 -3
  13. package/docs/TEAM-STATE.md +372 -0
  14. package/docs/reproduce.md +33 -3
  15. package/driver/CLAUDE.md +150 -0
  16. package/driver/README.md +383 -0
  17. package/driver/aesop.config.example.json +80 -0
  18. package/driver/agent_driver.py +355 -0
  19. package/driver/backend_config.py +253 -0
  20. package/driver/claude_code_driver.py +198 -0
  21. package/driver/codex_driver.py +673 -0
  22. package/driver/openai_compatible_driver.py +249 -0
  23. package/driver/openai_transport.py +179 -0
  24. package/driver/verification_policy.py +75 -0
  25. package/driver/wave_bridge.py +254 -0
  26. package/driver/wave_loop.py +1408 -0
  27. package/driver/wave_scheduler.py +890 -0
  28. package/hooks/pre-push-policy.sh +131 -33
  29. package/mcp/server.mjs +320 -4
  30. package/monitor/collect-signals.mjs +69 -0
  31. package/package.json +22 -14
  32. package/skills/CLAUDE.md +132 -2
  33. package/skills/buildsystem/SKILL.md +330 -0
  34. package/skills/buildsystem/wave-flat-dispatch.template.mjs +658 -0
  35. package/skills/fleet/SKILL.md +113 -0
  36. package/skills/power/SKILL.md +246 -131
  37. package/state_store/__init__.py +4 -2
  38. package/state_store/api.py +19 -4
  39. package/state_store/coordination.py +209 -0
  40. package/state_store/identity.py +51 -0
  41. package/state_store/projections.py +63 -0
  42. package/state_store/read_api.py +156 -0
  43. package/state_store/store.py +185 -73
  44. package/state_store/write_api.py +462 -0
  45. package/templates/wave-presets/data.json +65 -0
  46. package/templates/wave-presets/library.json +65 -0
  47. package/templates/wave-presets/saas.json +64 -0
  48. package/tools/audit_report.py +388 -0
  49. package/tools/bench_runner.py +100 -3
  50. package/tools/ci_merge_wait.py +256 -35
  51. package/tools/ci_workflow_lint.py +430 -0
  52. package/tools/claudemd_drift.py +394 -0
  53. package/tools/claudemd_lint.py +359 -0
  54. package/tools/common.py +39 -3
  55. package/tools/cost_ceiling.py +166 -43
  56. package/tools/cost_econ.py +480 -0
  57. package/tools/cost_projection.py +559 -0
  58. package/tools/crossos_drift.py +394 -0
  59. package/tools/defect_escape.py +252 -0
  60. package/tools/doctor.js +1 -1
  61. package/tools/eod_sweep.py +188 -26
  62. package/tools/fleet.js +260 -0
  63. package/tools/fleet_ledger.py +209 -7
  64. package/tools/git_identity_check.py +315 -0
  65. package/tools/health-score.js +40 -0
  66. package/tools/health_score.py +361 -0
  67. package/tools/metrics_gate.py +13 -4
  68. package/tools/mutation_test.py +523 -0
  69. package/tools/portability_check.py +206 -0
  70. package/tools/proposals.mjs +47 -2
  71. package/tools/reconcile.py +7 -4
  72. package/tools/reproduce.js +405 -0
  73. package/tools/secret_scan.py +207 -65
  74. package/tools/self_stats.py +20 -0
  75. package/tools/stall_check.py +247 -16
  76. package/tools/stateapi_lint.py +325 -0
  77. package/tools/test_battery.py +173 -0
  78. package/tools/transcript_digest.py +380 -0
  79. package/tools/verify_activity_filter.py +437 -0
  80. package/tools/verify_agent_inspector.py +2 -0
  81. package/tools/verify_cost_panel.py +345 -0
  82. package/tools/verify_dash.py +2 -0
  83. package/tools/verify_dispatch_panel.py +301 -0
  84. package/tools/verify_failure_drilldown.py +188 -0
  85. package/tools/verify_prboard.py +2 -0
  86. package/tools/verify_scorecards.py +281 -0
  87. package/tools/verify_submit_encoding.py +2 -0
  88. package/tools/verify_ui_trio.py +409 -0
  89. package/tools/verify_wave_telemetry.py +268 -0
  90. package/tools/wave_backlog_analyzer.py +490 -0
  91. package/tools/wave_ledger_hook.py +150 -0
  92. package/tools/wave_preflight.py +779 -0
  93. package/tools/wave_resume.py +215 -0
  94. package/tools/wave_templates.py +340 -0
  95. package/ui/agents.py +68 -14
  96. package/ui/collectors.py +81 -55
  97. package/ui/config.py +7 -2
  98. package/ui/cost.py +231 -12
  99. package/ui/handler.py +383 -55
  100. package/ui/quality_scorecard.py +232 -0
  101. package/ui/sse.py +3 -3
  102. package/ui/wave_audit_tail.py +213 -0
  103. package/ui/wave_dispatch.py +280 -0
  104. package/ui/wave_failure.py +288 -0
  105. package/ui/wave_gantt.py +152 -0
  106. package/ui/wave_reasoning_tail.py +176 -0
  107. package/ui/wave_telemetry.py +383 -0
  108. package/ui/web/dist/assets/index-CNQxaiOW.css +1 -0
  109. package/ui/web/dist/assets/index-CP68RIh3.js +9 -0
  110. package/ui/web/dist/index.html +2 -2
  111. package/bin/CLAUDE.md +0 -76
  112. package/daemons/CLAUDE.md +0 -36
  113. package/dash/CLAUDE.md +0 -32
  114. package/docs/archive/README.md +0 -3
  115. package/docs/archive/spikes/tiered-cognition/ACTIVATION.md +0 -125
  116. package/docs/archive/spikes/tiered-cognition/DESIGN.md +0 -287
  117. package/docs/archive/spikes/tiered-cognition/FINDINGS.md +0 -113
  118. package/docs/archive/spikes/tiered-cognition/README.md +0 -27
  119. package/docs/archive/spikes/tiered-cognition/aesop-cognition.example.md +0 -32
  120. package/docs/archive/spikes/tiered-cognition/force-model-policy.merged.mjs +0 -673
  121. package/docs/archive/spikes/tiered-cognition/strip-tools-hook.mjs +0 -434
  122. package/hooks/CLAUDE.md +0 -89
  123. package/mcp/CLAUDE.md +0 -213
  124. package/monitor/CLAUDE.md +0 -40
  125. package/scan/CLAUDE.md +0 -30
  126. package/state_store/CLAUDE.md +0 -39
  127. package/tools/CLAUDE.md +0 -79
  128. package/ui/CLAUDE.md +0 -127
  129. package/ui/web/dist/assets/index-0qQYnvMC.js +0 -9
  130. package/ui/web/dist/assets/index-BdIlFieV.css +0 -1
@@ -8,7 +8,7 @@ Modes:
8
8
  secret_scan.py --history [--repo PATH] Scan all blobs in git history
9
9
  secret_scan.py PATH [PATH...] Scan files/dirs directly (recurse dirs)
10
10
 
11
- Exit codes: 0=clean, 1=findings, 2=usage error
11
+ Exit codes: 0=clean, 1=findings, 2=error (file unreadable/git failure/scan error)
12
12
  Output: one line per finding or summary (never prints full secrets)
13
13
 
14
14
  Pragma escape hatch (STRICTLY SCOPED to doc-shaped rules):
@@ -123,12 +123,41 @@ FATAL_RULES = {
123
123
  "connection_string",
124
124
  }
125
125
 
126
+ # USER-APPROVED EXEMPTION (2026-07-22): redaction-pattern source files.
127
+ # A regex whose PURPOSE is redacting connection strings necessarily contains a
128
+ # connection-string-SHAPED pattern in its source text. connection_string
129
+ # findings in these exact repo-relative paths are downgraded to non-fatal
130
+ # ALLOWED-REDACTION-SOURCE (still reported, never silent). Every other rule
131
+ # stays fully fatal in these files; connection_string stays fatal everywhere
132
+ # else, including pragma'd files. Additions to this set are a security-gate
133
+ # change and require explicit user approval.
134
+ # Residual risk (accepted): matching is by trailing path components, so a
135
+ # nested copy at */bench/sample_transcripts_judgment.py inherits the
136
+ # exemption for this ONE rule; all other rules still apply there.
137
+ REDACTION_SOURCE_FILES = {"bench/sample_transcripts_judgment.py"}
138
+
139
+
140
+ def _is_redaction_source(scan_path):
141
+ """True when scan_path's trailing components match an exempted file."""
142
+ if not scan_path:
143
+ return False
144
+ parts = str(scan_path).replace("\\", "/").split("/")
145
+ for entry in REDACTION_SOURCE_FILES:
146
+ entry_parts = entry.split("/")
147
+ if parts[-len(entry_parts):] == entry_parts:
148
+ return True
149
+ return False
150
+
126
151
 
127
- def _classify_finding(rule_name, has_file_pragma):
152
+ def _classify_finding(rule_name, has_file_pragma, scan_path=None):
128
153
  """Shared fatal/softened decision, used by every scan_* variant (disk-file,
129
154
  blob, large-file-chunked) so the pragma contract can't drift between them.
130
- FATAL_RULES are always fatal. SOFTENED_BY_PRAGMA rules are fatal unless the
131
- pragma is present. Everything else (e.g. env_assignment) is always fatal."""
155
+ FATAL_RULES are always fatal except the single user-approved
156
+ connection_string downgrade for REDACTION_SOURCE_FILES (see above).
157
+ SOFTENED_BY_PRAGMA rules are fatal unless the pragma is present.
158
+ Everything else (e.g. env_assignment) is always fatal."""
159
+ if rule_name == "connection_string" and _is_redaction_source(scan_path):
160
+ return False
132
161
  if rule_name in FATAL_RULES:
133
162
  return True
134
163
  if rule_name in SOFTENED_BY_PRAGMA:
@@ -145,6 +174,12 @@ class GitScanError(Exception):
145
174
  printed CLEAN and exited 0 on an unresolvable range)."""
146
175
 
147
176
 
177
+ class ScanError(Exception):
178
+ """Raised when a file cannot be scanned due to read/permission/OS errors.
179
+ Callers MUST treat this as fail-CLOSED (block / exit 2) -- a file the
180
+ scanner cannot read must block the push, not sail through as CLEAN."""
181
+
182
+
148
183
  def has_pragma(filepath):
149
184
  """Check if file has 'secretscan: allow-pattern-docs' pragma in first 10 lines."""
150
185
  try:
@@ -230,6 +265,9 @@ def scan_file(filepath):
230
265
  Large files (>1MB) and binary files are scanned for FATAL_RULES patterns:
231
266
  - Large files: scan first 1MB; emit SKIPPED-LARGE to stderr if file is larger
232
267
  - Binary files: decode as latin-1; emit SKIPPED-BINARY to stderr if not fully scanned
268
+
269
+ Raises ScanError if file cannot be read (permission denied, vanished, etc.)
270
+ to fail CLOSED rather than reporting the file clean.
233
271
  """
234
272
  SIZE_THRESHOLD = 1024 * 1024 # 1MB
235
273
  MAX_READ_SIZE = 2 * 1024 * 1024 # 2MB max to read
@@ -240,6 +278,7 @@ def scan_file(filepath):
240
278
  return findings
241
279
 
242
280
  # Check for pragma (applies only to specific rule-based findings, not filename findings)
281
+ # has_pragma() already swallows its own exceptions, so safe to call
243
282
  has_file_pragma = has_pragma(filepath)
244
283
 
245
284
  # Check if filename matches credential patterns (always fatal, pragma does NOT apply)
@@ -251,13 +290,21 @@ def scan_file(filepath):
251
290
  )
252
291
  break
253
292
 
293
+ # File stat and type detection — not wrapped in try/except so errors propagate
254
294
  try:
255
- # Check file size and binary status
256
295
  stat = filepath.stat()
257
296
  file_size = stat.st_size
297
+ except (OSError, PermissionError, FileNotFoundError) as e:
298
+ raise ScanError(f"Cannot stat {filepath}: {e}")
299
+
300
+ try:
258
301
  is_binary = is_binary_file(filepath)
259
- is_large = file_size > SIZE_THRESHOLD
302
+ except (OSError, PermissionError, FileNotFoundError) as e:
303
+ raise ScanError(f"Cannot check if binary {filepath}: {e}")
304
+
305
+ is_large = file_size > SIZE_THRESHOLD
260
306
 
307
+ try:
261
308
  if is_binary:
262
309
  # Binary file (any size): scan as-is for FATAL_RULES only
263
310
  with open(filepath, "rb") as f:
@@ -289,31 +336,26 @@ def scan_file(filepath):
289
336
  # Large text file: scan entire file in chunks for all rules
290
337
  print(f"SKIPPED-LARGE {filepath} (scanned in chunks)", file=sys.stderr)
291
338
  line_num = 0
292
- try:
293
- with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
294
- # Read in 1MB chunks to avoid loading entire large file into memory
295
- for chunk in iter(lambda: f.read(1024 * 1024), ""):
296
- for chunk_line in chunk.split("\n"):
297
- line_num += 1
298
- for rule_name, (pattern, flags) in PATTERNS.items():
299
- # Skip env_assignment rule if not an .env-like file
300
- if rule_name == "env_assignment" and not is_env_file(filepath):
301
- continue
339
+ with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
340
+ # Read in 1MB chunks to avoid loading entire large file into memory
341
+ for chunk in iter(lambda: f.read(1024 * 1024), ""):
342
+ for chunk_line in chunk.split("\n"):
343
+ line_num += 1
344
+ for rule_name, (pattern, flags) in PATTERNS.items():
345
+ # Skip env_assignment rule if not an .env-like file
346
+ if rule_name == "env_assignment" and not is_env_file(filepath):
347
+ continue
302
348
 
303
- matches = re.finditer(pattern, chunk_line, flags)
304
- for match in matches:
305
- match_str = match.group(0)
349
+ matches = re.finditer(pattern, chunk_line, flags)
350
+ for match in matches:
351
+ match_str = match.group(0)
306
352
 
307
- # Skip if it's a placeholder
308
- if is_placeholder(match_str):
309
- continue
353
+ # Skip if it's a placeholder
354
+ if is_placeholder(match_str):
355
+ continue
310
356
 
311
- is_fatal = _classify_finding(rule_name, has_file_pragma)
312
- findings.append((line_num, rule_name, match_str, is_fatal))
313
- except (IOError, OSError) as e:
314
- # FAIL CLOSED: if we cannot fully scan a large text file, exit with error
315
- print(f"FATAL: Cannot fully scan large text file {filepath}: {e}", file=sys.stderr)
316
- sys.exit(1)
357
+ is_fatal = _classify_finding(rule_name, has_file_pragma, filepath)
358
+ findings.append((line_num, rule_name, match_str, is_fatal))
317
359
 
318
360
  else:
319
361
  # Normal small text file: scan all rules
@@ -332,11 +374,12 @@ def scan_file(filepath):
332
374
  if is_placeholder(match_str):
333
375
  continue
334
376
 
335
- is_fatal = _classify_finding(rule_name, has_file_pragma)
377
+ is_fatal = _classify_finding(rule_name, has_file_pragma, filepath)
336
378
  findings.append((line_num, rule_name, match_str, is_fatal))
337
379
 
338
- except Exception:
339
- pass
380
+ except (OSError, PermissionError, FileNotFoundError, IOError) as e:
381
+ # FAIL CLOSED: if file cannot be read, raise error (not return clean)
382
+ raise ScanError(f"Cannot scan file {filepath}: {e}")
340
383
 
341
384
  return findings
342
385
 
@@ -424,7 +467,7 @@ def scan_blob(label, content_bytes):
424
467
  match_str = match.group(0)
425
468
  if is_placeholder(match_str):
426
469
  continue
427
- is_fatal = _classify_finding(rule_name, has_file_pragma)
470
+ is_fatal = _classify_finding(rule_name, has_file_pragma, label)
428
471
  findings.append((line_num, rule_name, match_str, is_fatal))
429
472
 
430
473
  return findings
@@ -486,6 +529,7 @@ def get_staged_files(repo_path):
486
529
  cwd=repo_path,
487
530
  capture_output=True,
488
531
  text=True,
532
+ encoding='utf-8',
489
533
  timeout=10,
490
534
  )
491
535
  except Exception as e:
@@ -513,6 +557,7 @@ def get_range_files(repo_path, commit_range):
513
557
  cwd=repo_path,
514
558
  capture_output=True,
515
559
  text=True,
560
+ encoding='utf-8',
516
561
  timeout=10,
517
562
  )
518
563
  except Exception as e:
@@ -528,7 +573,11 @@ def get_range_files(repo_path, commit_range):
528
573
 
529
574
 
530
575
  def get_history_files(repo_path):
531
- """Get all file contents from git history via git log -p."""
576
+ """Get all file contents from git history via git log -p.
577
+
578
+ Raises GitScanError if git log fails (corrupt repo, git crash, etc.).
579
+ This mirrors get_range_files() and get_staged_files() which already
580
+ fail closed on git errors."""
532
581
  files_content = []
533
582
  try:
534
583
  # Use git log -p to get full diff history
@@ -537,45 +586,108 @@ def get_history_files(repo_path):
537
586
  cwd=repo_path,
538
587
  capture_output=True,
539
588
  text=True,
589
+ encoding='utf-8',
590
+ errors='replace',
540
591
  timeout=60,
541
592
  )
542
- if result.returncode == 0:
543
- # Parse the git log output: each file appears as +++ b/path/to/file followed by its content
544
- current_file = None
545
- current_content = []
546
- for line in result.stdout.split("\n"):
547
- if line.startswith("+++ b/"):
548
- if current_file and current_content:
549
- files_content.append((current_file, "\n".join(current_content)))
550
- current_file = line[6:] # Remove "+++ b/"
551
- current_content = []
552
- elif current_file and line.startswith("+") and not line.startswith("+++"):
553
- # Content line (added), strip the leading +
554
- current_content.append(line[1:])
555
- elif current_file and line.startswith(" "):
556
- # Context line, keep it as-is (strip leading space)
557
- current_content.append(line[1:])
593
+ except Exception as e:
594
+ raise GitScanError(f"git log raised {e!r}")
595
+
596
+ if result.returncode != 0:
597
+ raise GitScanError(
598
+ f"git log failed (rc={result.returncode}): {result.stderr.strip()}"
599
+ )
600
+
601
+ # Parse the git log output: each file appears as +++ b/path/to/file followed by its content
602
+ current_file = None
603
+ current_content = []
604
+ for line in result.stdout.split("\n"):
605
+ if line.startswith("+++ b/"):
558
606
  if current_file and current_content:
559
607
  files_content.append((current_file, "\n".join(current_content)))
560
- except Exception:
561
- pass
608
+ current_file = line[6:] # Remove "+++ b/"
609
+ current_content = []
610
+ elif current_file and line.startswith("+") and not line.startswith("+++"):
611
+ # Content line (added), strip the leading +
612
+ current_content.append(line[1:])
613
+ elif current_file and line.startswith(" "):
614
+ # Context line, keep it as-is (strip leading space)
615
+ current_content.append(line[1:])
616
+ if current_file and current_content:
617
+ files_content.append((current_file, "\n".join(current_content)))
618
+
562
619
  return files_content
563
620
 
564
621
 
622
+ def _git_committable_set(dir_path):
623
+ """Return the set of resolved absolute paths that git tracks or would
624
+ consider committable (tracked + untracked-but-not-ignored) for the repo
625
+ containing `dir_path`, or None if dir_path is not inside a git work tree
626
+ or git is unavailable/errors.
627
+
628
+ Built from `git ls-files --cached --others --exclude-standard`, which lists
629
+ exactly the tracked files plus untracked files that are NOT git-ignored.
630
+ A tracked file is ALWAYS included (even a force-added one that matches an
631
+ ignore rule), so filtering by this set never skips a committable file --
632
+ only ephemeral, git-ignored runtime files (which can never be pushed and
633
+ therefore can't leak a secret through git) are excluded.
634
+ """
635
+ try:
636
+ top = subprocess.run(
637
+ ["git", "rev-parse", "--show-toplevel"],
638
+ cwd=str(dir_path), capture_output=True, text=True, encoding='utf-8', timeout=10,
639
+ )
640
+ if top.returncode != 0:
641
+ return None
642
+ toplevel = Path(top.stdout.strip())
643
+ listing = subprocess.run(
644
+ ["git", "ls-files", "--cached", "--others", "--exclude-standard", "-z"],
645
+ cwd=str(toplevel), capture_output=True, text=True, encoding='utf-8', timeout=60,
646
+ )
647
+ if listing.returncode != 0:
648
+ return None
649
+ except Exception:
650
+ return None
651
+
652
+ committable = set()
653
+ for rel in listing.stdout.split("\0"):
654
+ if rel:
655
+ committable.add((toplevel / rel).resolve())
656
+ return committable
657
+
658
+
565
659
  def scan_paths(paths):
566
- """Recursively scan paths (files and directories)."""
660
+ """Recursively scan paths (files and directories).
661
+
662
+ For a DIRECTORY argument inside a git work tree, git-ignored files are
663
+ skipped: a full-tree scan (e.g. CI's `secret_scan.py .`) must not flag
664
+ ephemeral, git-ignored runtime files -- such as state/.ui-session-token --
665
+ that can never be committed or pushed and therefore cannot leak a secret
666
+ through git. Filtering uses the tracked+untracked-not-ignored set from
667
+ `git ls-files`, so a committable file is never skipped. When a directory is
668
+ not inside a git repo (or git errors), NO filtering is applied and every
669
+ file is walked (fail OPEN toward more scanning, never less). Explicitly
670
+ named FILE arguments are always scanned, even if git-ignored.
671
+ """
567
672
  files_to_scan = []
568
673
  for path_str in paths:
569
674
  path = Path(path_str).resolve()
570
675
  if path.is_file():
571
676
  files_to_scan.append(path)
572
677
  elif path.is_dir():
573
- files_to_scan.extend(path.rglob("*"))
678
+ candidates = [p for p in path.rglob("*") if p.is_file()]
679
+ committable = _git_committable_set(path)
680
+ if committable is not None:
681
+ candidates = [p for p in candidates if p.resolve() in committable]
682
+ files_to_scan.extend(candidates)
574
683
  return [p for p in files_to_scan if p.is_file()]
575
684
 
576
685
 
577
- def scan_content(content):
578
- """Scan raw content for secrets (used by history scanning)."""
686
+ def scan_content(content, scan_path=None):
687
+ """Scan raw content for secrets (used by history scanning). scan_path
688
+ (the blob's repo-relative path, when known) feeds the REDACTION_SOURCE_FILES
689
+ exemption so committed redaction-regex source does not turn every future
690
+ --history / prepublish scan permanently red."""
579
691
  findings = []
580
692
  for line_num, line in enumerate(content.split("\n"), start=1):
581
693
  for rule_name, (pattern, flags) in PATTERNS.items():
@@ -584,7 +696,8 @@ def scan_content(content):
584
696
  match_str = match.group(0)
585
697
  if is_placeholder(match_str):
586
698
  continue
587
- findings.append((line_num, rule_name, match_str, True))
699
+ is_fatal = _classify_finding(rule_name, False, scan_path)
700
+ findings.append((line_num, rule_name, match_str, is_fatal))
588
701
  return findings
589
702
 
590
703
 
@@ -627,6 +740,7 @@ def main():
627
740
  all_findings = []
628
741
  fatal_findings = []
629
742
  allowed_doc_count = 0
743
+ allowed_redaction_count = 0
630
744
  file_count = 0
631
745
 
632
746
  if args.staged:
@@ -655,7 +769,10 @@ def main():
655
769
  if is_fatal:
656
770
  fatal_findings.append((label, line_num, rule, match_str))
657
771
  else:
658
- allowed_doc_count += 1
772
+ if rule == "connection_string":
773
+ allowed_redaction_count += 1
774
+ else:
775
+ allowed_doc_count += 1
659
776
 
660
777
  elif args.range:
661
778
  # Scan the COMMITTED blob at the TIP of the range for each changed
@@ -685,15 +802,24 @@ def main():
685
802
  if is_fatal:
686
803
  fatal_findings.append((label, line_num, rule, match_str))
687
804
  else:
688
- allowed_doc_count += 1
805
+ if rule == "connection_string":
806
+ allowed_redaction_count += 1
807
+ else:
808
+ allowed_doc_count += 1
689
809
 
690
810
  elif args.history:
691
811
  # History scanning mode: unaffected by the blob-scan fix above, since
692
812
  # it already walks committed diff content via `git log -p`.
693
- history_files = get_history_files(args.repo)
813
+ try:
814
+ history_files = get_history_files(args.repo)
815
+ except GitScanError as e:
816
+ print(f"FATAL: could not scan git history: {e}", file=sys.stderr)
817
+ print("Failing CLOSED: refusing to report CLEAN when git history cannot be read.", file=sys.stderr)
818
+ sys.exit(2)
819
+
694
820
  file_count = len(set(f for f, _ in history_files))
695
821
  for filepath, content in history_files:
696
- findings = scan_content(content)
822
+ findings = scan_content(content, scan_path=filepath)
697
823
  for line_num, rule, match_str, is_fatal in findings:
698
824
  all_findings.append((filepath, line_num, rule, match_str, is_fatal))
699
825
  if is_fatal:
@@ -703,13 +829,21 @@ def main():
703
829
  files = scan_paths(args.paths)
704
830
  file_count = len(files)
705
831
  for filepath in files:
706
- findings = scan_file(filepath)
832
+ try:
833
+ findings = scan_file(filepath)
834
+ except ScanError as e:
835
+ print(f"FATAL: cannot scan file {filepath}: {e}", file=sys.stderr)
836
+ print("Failing CLOSED: refusing to report CLEAN when a file cannot be read.", file=sys.stderr)
837
+ sys.exit(2)
707
838
  for line_num, rule, match_str, is_fatal in findings:
708
839
  all_findings.append((filepath, line_num, rule, match_str, is_fatal))
709
840
  if is_fatal:
710
841
  fatal_findings.append((filepath, line_num, rule, match_str))
711
842
  else:
712
- allowed_doc_count += 1
843
+ if rule == "connection_string":
844
+ allowed_redaction_count += 1
845
+ else:
846
+ allowed_doc_count += 1
713
847
 
714
848
  # Output findings
715
849
  for filepath, line_num, rule, match_str, is_fatal in all_findings:
@@ -717,18 +851,26 @@ def main():
717
851
  if is_fatal:
718
852
  print(f"HIGH {filepath}:{line_num} {rule} ({masked})")
719
853
  else:
720
- print(f"ALLOWED-DOC {filepath}:{line_num} {rule} ({masked})")
854
+ if rule == "connection_string":
855
+ print(f"ALLOWED-REDACTION-SOURCE {filepath}:{line_num} {rule} ({masked})")
856
+ else:
857
+ print(f"ALLOWED-DOC {filepath}:{line_num} {rule} ({masked})")
721
858
 
722
859
  # Summary and exit
723
860
  if len(fatal_findings) == 0:
724
- if allowed_doc_count == 0:
861
+ if allowed_doc_count == 0 and allowed_redaction_count == 0:
725
862
  if args.history:
726
863
  print(f"CLEAN: git history scanned")
727
864
  else:
728
865
  print(f"CLEAN: {file_count} files scanned")
729
866
  else:
730
867
  pragma_file_count = len(set(f for f, _, _, _, is_fatal in all_findings if not is_fatal))
731
- print(f"CLEAN: scanned ({allowed_doc_count} allowed-doc findings in {pragma_file_count} pragma files)")
868
+ parts = []
869
+ if allowed_doc_count:
870
+ parts.append(f"{allowed_doc_count} allowed-doc findings in {pragma_file_count} pragma files")
871
+ if allowed_redaction_count:
872
+ parts.append(f"{allowed_redaction_count} allowed-redaction-source findings")
873
+ print(f"CLEAN: scanned ({'; '.join(parts)})")
732
874
  sys.exit(0)
733
875
  else:
734
876
  print(f"FOUND: {len(fatal_findings)} secret(s)")
@@ -502,6 +502,26 @@ class StatsCounter:
502
502
  data = json.loads(self.json())
503
503
  data["generated_at"] = datetime.now(timezone.utc).isoformat()
504
504
  data["loc"] = self.git.lines_of_code
505
+
506
+ # Add cost economics metrics (requires cost_econ module)
507
+ try:
508
+ from cost_econ import calculate_economics, get_metric_honesty_caveats
509
+ # Infer state directory from repo root or config
510
+ repo_path = Path(self.git.repo_root)
511
+ state_dir = repo_path / "state"
512
+
513
+ economics = calculate_economics(
514
+ repo_root=str(repo_path),
515
+ state_dir=str(state_dir) if state_dir.exists() else None,
516
+ config_file=None
517
+ )
518
+
519
+ data["economics"] = economics
520
+ data["economics_caveats"] = get_metric_honesty_caveats()
521
+ except Exception:
522
+ # Graceful fallback: if cost_econ unavailable, skip economics metrics
523
+ pass
524
+
505
525
  return data
506
526
 
507
527
  def save_stats(self, output_file: str = "stats.json") -> None: