@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.
- package/CHANGELOG.md +117 -2
- package/README.md +59 -218
- package/bin/cli.js +168 -41
- package/daemons/run-watchdog.sh +16 -4
- package/daemons/selfheal.sh +231 -0
- package/docs/ANY-REPO.md +427 -0
- package/docs/CONTRIBUTING.md +72 -0
- package/docs/DEMO.md +334 -0
- package/docs/HOOK-INSTALL.md +15 -56
- package/docs/INSTALL.md +74 -4
- package/docs/PORTING.md +166 -0
- package/docs/README.md +33 -3
- package/docs/TEAM-STATE.md +372 -0
- package/docs/reproduce.md +33 -3
- package/driver/CLAUDE.md +150 -0
- package/driver/README.md +383 -0
- package/driver/aesop.config.example.json +80 -0
- package/driver/agent_driver.py +355 -0
- package/driver/backend_config.py +253 -0
- package/driver/claude_code_driver.py +198 -0
- package/driver/codex_driver.py +673 -0
- package/driver/openai_compatible_driver.py +249 -0
- package/driver/openai_transport.py +179 -0
- package/driver/verification_policy.py +75 -0
- package/driver/wave_bridge.py +254 -0
- package/driver/wave_loop.py +1408 -0
- package/driver/wave_scheduler.py +890 -0
- package/hooks/pre-push-policy.sh +131 -33
- package/mcp/server.mjs +320 -4
- package/monitor/collect-signals.mjs +69 -0
- package/package.json +22 -14
- package/skills/CLAUDE.md +132 -2
- package/skills/buildsystem/SKILL.md +330 -0
- package/skills/buildsystem/wave-flat-dispatch.template.mjs +658 -0
- package/skills/fleet/SKILL.md +113 -0
- package/skills/power/SKILL.md +246 -131
- package/state_store/__init__.py +4 -2
- package/state_store/api.py +19 -4
- package/state_store/coordination.py +209 -0
- package/state_store/identity.py +51 -0
- package/state_store/projections.py +63 -0
- package/state_store/read_api.py +156 -0
- package/state_store/store.py +185 -73
- package/state_store/write_api.py +462 -0
- package/templates/wave-presets/data.json +65 -0
- package/templates/wave-presets/library.json +65 -0
- package/templates/wave-presets/saas.json +64 -0
- package/tools/audit_report.py +388 -0
- package/tools/bench_runner.py +100 -3
- package/tools/ci_merge_wait.py +256 -35
- package/tools/ci_workflow_lint.py +430 -0
- package/tools/claudemd_drift.py +394 -0
- package/tools/claudemd_lint.py +359 -0
- package/tools/common.py +39 -3
- package/tools/cost_ceiling.py +166 -43
- package/tools/cost_econ.py +480 -0
- package/tools/cost_projection.py +559 -0
- package/tools/crossos_drift.py +394 -0
- package/tools/defect_escape.py +252 -0
- package/tools/doctor.js +1 -1
- package/tools/eod_sweep.py +188 -26
- package/tools/fleet.js +260 -0
- package/tools/fleet_ledger.py +209 -7
- package/tools/git_identity_check.py +315 -0
- package/tools/health-score.js +40 -0
- package/tools/health_score.py +361 -0
- package/tools/metrics_gate.py +13 -4
- package/tools/mutation_test.py +523 -0
- package/tools/portability_check.py +206 -0
- package/tools/proposals.mjs +47 -2
- package/tools/reconcile.py +7 -4
- package/tools/reproduce.js +405 -0
- package/tools/secret_scan.py +207 -65
- package/tools/self_stats.py +20 -0
- package/tools/stall_check.py +247 -16
- package/tools/stateapi_lint.py +325 -0
- package/tools/test_battery.py +173 -0
- package/tools/transcript_digest.py +380 -0
- package/tools/verify_activity_filter.py +437 -0
- package/tools/verify_agent_inspector.py +2 -0
- package/tools/verify_cost_panel.py +345 -0
- package/tools/verify_dash.py +2 -0
- package/tools/verify_dispatch_panel.py +301 -0
- package/tools/verify_failure_drilldown.py +188 -0
- package/tools/verify_prboard.py +2 -0
- package/tools/verify_scorecards.py +281 -0
- package/tools/verify_submit_encoding.py +2 -0
- package/tools/verify_ui_trio.py +409 -0
- package/tools/verify_wave_telemetry.py +268 -0
- package/tools/wave_backlog_analyzer.py +490 -0
- package/tools/wave_ledger_hook.py +150 -0
- package/tools/wave_preflight.py +779 -0
- package/tools/wave_resume.py +215 -0
- package/tools/wave_templates.py +340 -0
- package/ui/agents.py +68 -14
- package/ui/collectors.py +81 -55
- package/ui/config.py +7 -2
- package/ui/cost.py +231 -12
- package/ui/handler.py +383 -55
- package/ui/quality_scorecard.py +232 -0
- package/ui/sse.py +3 -3
- package/ui/wave_audit_tail.py +213 -0
- package/ui/wave_dispatch.py +280 -0
- package/ui/wave_failure.py +288 -0
- package/ui/wave_gantt.py +152 -0
- package/ui/wave_reasoning_tail.py +176 -0
- package/ui/wave_telemetry.py +383 -0
- package/ui/web/dist/assets/index-CNQxaiOW.css +1 -0
- package/ui/web/dist/assets/index-CP68RIh3.js +9 -0
- package/ui/web/dist/index.html +2 -2
- package/bin/CLAUDE.md +0 -76
- package/daemons/CLAUDE.md +0 -36
- package/dash/CLAUDE.md +0 -32
- package/docs/archive/README.md +0 -3
- package/docs/archive/spikes/tiered-cognition/ACTIVATION.md +0 -125
- package/docs/archive/spikes/tiered-cognition/DESIGN.md +0 -287
- package/docs/archive/spikes/tiered-cognition/FINDINGS.md +0 -113
- package/docs/archive/spikes/tiered-cognition/README.md +0 -27
- package/docs/archive/spikes/tiered-cognition/aesop-cognition.example.md +0 -32
- package/docs/archive/spikes/tiered-cognition/force-model-policy.merged.mjs +0 -673
- package/docs/archive/spikes/tiered-cognition/strip-tools-hook.mjs +0 -434
- package/hooks/CLAUDE.md +0 -89
- package/mcp/CLAUDE.md +0 -213
- package/monitor/CLAUDE.md +0 -40
- package/scan/CLAUDE.md +0 -30
- package/state_store/CLAUDE.md +0 -39
- package/tools/CLAUDE.md +0 -79
- package/ui/CLAUDE.md +0 -127
- package/ui/web/dist/assets/index-0qQYnvMC.js +0 -9
- package/ui/web/dist/assets/index-BdIlFieV.css +0 -1
package/tools/secret_scan.py
CHANGED
|
@@ -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=
|
|
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
|
|
131
|
-
|
|
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
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
for
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
349
|
+
matches = re.finditer(pattern, chunk_line, flags)
|
|
350
|
+
for match in matches:
|
|
351
|
+
match_str = match.group(0)
|
|
306
352
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
353
|
+
# Skip if it's a placeholder
|
|
354
|
+
if is_placeholder(match_str):
|
|
355
|
+
continue
|
|
310
356
|
|
|
311
|
-
|
|
312
|
-
|
|
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
|
|
339
|
-
|
|
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
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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
|
-
|
|
561
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)")
|
package/tools/self_stats.py
CHANGED
|
@@ -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:
|