secure-code-agent 0.11.0__tar.gz → 0.12.0__tar.gz

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 (52) hide show
  1. {secure_code_agent-0.11.0/src/secure_code_agent.egg-info → secure_code_agent-0.12.0}/PKG-INFO +41 -2
  2. {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/README.md +38 -1
  3. {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/pyproject.toml +8 -0
  4. {secure_code_agent-0.11.0 → secure_code_agent-0.12.0/src/secure_code_agent.egg-info}/PKG-INFO +41 -2
  5. {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_agent.egg-info/SOURCES.txt +1 -0
  6. {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_agent.egg-info/entry_points.txt +1 -0
  7. {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_agent.egg-info/requires.txt +3 -0
  8. {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/__init__.py +1 -1
  9. {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/cli.py +131 -12
  10. {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/demo.py +5 -5
  11. {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/git_tools.py +89 -0
  12. secure_code_agent-0.12.0/src/secure_code_audit/mcp_server.py +186 -0
  13. {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/renderers.py +9 -1
  14. {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/scoring.py +42 -4
  15. {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/verify.py +116 -0
  16. {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/LICENSE +0 -0
  17. {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/setup.cfg +0 -0
  18. {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_agent.egg-info/dependency_links.txt +0 -0
  19. {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_agent.egg-info/top_level.txt +0 -0
  20. {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/baseline.py +0 -0
  21. {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/config.py +0 -0
  22. {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/data/semgrep-offline.yaml +0 -0
  23. {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/findings.py +0 -0
  24. {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/history.py +0 -0
  25. {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/instructions.py +0 -0
  26. {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/pillar.py +0 -0
  27. {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/practice.py +0 -0
  28. {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/remediation.py +0 -0
  29. {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/ruleset.py +0 -0
  30. {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/sarif.py +0 -0
  31. {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/scanner_status.py +0 -0
  32. {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/scanners/__init__.py +0 -0
  33. {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/scanners/bandit_scanner.py +0 -0
  34. {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/scanners/base.py +0 -0
  35. {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/scanners/builtin_rules.py +0 -0
  36. {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/scanners/checkov_scanner.py +0 -0
  37. {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/scanners/floor.py +0 -0
  38. {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/scanners/gitleaks_scanner.py +0 -0
  39. {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/scanners/gosec_scanner.py +0 -0
  40. {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/scanners/hadolint_scanner.py +0 -0
  41. {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/scanners/njsscan_scanner.py +0 -0
  42. {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/scanners/npm_audit_scanner.py +0 -0
  43. {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/scanners/osv_scanner.py +0 -0
  44. {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/scanners/pip_audit_scanner.py +0 -0
  45. {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/scanners/rubocop_scanner.py +0 -0
  46. {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/scanners/scorecard_scanner.py +0 -0
  47. {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/scanners/semgrep_scanner.py +0 -0
  48. {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/scanners/trivy_scanner.py +0 -0
  49. {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/scanners/trufflehog_scanner.py +0 -0
  50. {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/standards.py +0 -0
  51. {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/suppressions.py +0 -0
  52. {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/triage.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: secure-code-agent
3
- Version: 0.11.0
3
+ Version: 0.12.0
4
4
  Summary: Deterministic security gate + bounded AI remediation prompt generator. NIST SSDF / OWASP ASVS / CWE Top 25 anchored.
5
5
  Author: Marshall Guillory
6
6
  License: MIT
@@ -29,6 +29,8 @@ Requires-Python: >=3.10
29
29
  Description-Content-Type: text/markdown
30
30
  License-File: LICENSE
31
31
  Requires-Dist: PyYAML<7,>=6.0.2
32
+ Provides-Extra: mcp
33
+ Requires-Dist: mcp<2,>=1.0; extra == "mcp"
32
34
  Provides-Extra: required-scanners
33
35
  Requires-Dist: bandit==1.9.4; extra == "required-scanners"
34
36
  Requires-Dist: pip-audit==2.10.1; extra == "required-scanners"
@@ -90,6 +92,43 @@ A pure-Python repository requires six of the ten floor scanners; the Ruby,
90
92
  JavaScript and container tools are *not applicable* rather than missing, so a
91
93
  single-language project is never permanently incomplete.
92
94
 
95
+ ## In your editor or an agent, not just a terminal
96
+
97
+ ```bash
98
+ pip install 'secure-code-agent[mcp]'
99
+ secure-code-agent-mcp
100
+ ```
101
+
102
+ Exposes `audit_repository`, `preflight` and `agent_info`. It never audits
103
+ unasked — `audit_repository` without `action="run"` returns the question — and
104
+ it hands back the **work order first**, because the score is second class.
105
+
106
+ ## Auditing a pull request
107
+
108
+ ```bash
109
+ secure-code-agent . --changed-only origin/main
110
+ ```
111
+
112
+ The whole tree is still scanned; the *report* is scoped to what changed. **No
113
+ grade is issued** for a scoped run, because a run that looks at less must not
114
+ score better.
115
+
116
+ ## Proving the agent stayed inside the order
117
+
118
+ ```bash
119
+ secure-code-agent . --verify-against before.json
120
+ ```
121
+
122
+ Reports what was fixed, what is still open, what was **silenced rather than
123
+ fixed** — and the blast radius: which changed files the work order never
124
+ cited.
125
+
126
+ ```
127
+ work order verification · improved: 1 fixed
128
+ fixed sca.python.subprocess.shell_true bad.py:3
129
+ scope: EXCEEDED — 1 of 2 changed file(s) were never cited: src/other.py
130
+ ```
131
+
93
132
  ## Why this exists
94
133
 
95
134
  AI coding agents ship code at human-review-saturating speed. Point them at a security finding and the documented anti-patterns are:
@@ -470,7 +509,7 @@ For agents that support invokable skills, this repo ships a portable skill under
470
509
  ## GitHub Action
471
510
 
472
511
  ```yaml
473
- - uses: marshallguillory86/secure-code-agent@v0.11.0
512
+ - uses: marshallguillory86/secure-code-agent@v0.12.0
474
513
  with:
475
514
  config: secure-code-agent.json
476
515
  fail-on-gate: true
@@ -44,6 +44,43 @@ A pure-Python repository requires six of the ten floor scanners; the Ruby,
44
44
  JavaScript and container tools are *not applicable* rather than missing, so a
45
45
  single-language project is never permanently incomplete.
46
46
 
47
+ ## In your editor or an agent, not just a terminal
48
+
49
+ ```bash
50
+ pip install 'secure-code-agent[mcp]'
51
+ secure-code-agent-mcp
52
+ ```
53
+
54
+ Exposes `audit_repository`, `preflight` and `agent_info`. It never audits
55
+ unasked — `audit_repository` without `action="run"` returns the question — and
56
+ it hands back the **work order first**, because the score is second class.
57
+
58
+ ## Auditing a pull request
59
+
60
+ ```bash
61
+ secure-code-agent . --changed-only origin/main
62
+ ```
63
+
64
+ The whole tree is still scanned; the *report* is scoped to what changed. **No
65
+ grade is issued** for a scoped run, because a run that looks at less must not
66
+ score better.
67
+
68
+ ## Proving the agent stayed inside the order
69
+
70
+ ```bash
71
+ secure-code-agent . --verify-against before.json
72
+ ```
73
+
74
+ Reports what was fixed, what is still open, what was **silenced rather than
75
+ fixed** — and the blast radius: which changed files the work order never
76
+ cited.
77
+
78
+ ```
79
+ work order verification · improved: 1 fixed
80
+ fixed sca.python.subprocess.shell_true bad.py:3
81
+ scope: EXCEEDED — 1 of 2 changed file(s) were never cited: src/other.py
82
+ ```
83
+
47
84
  ## Why this exists
48
85
 
49
86
  AI coding agents ship code at human-review-saturating speed. Point them at a security finding and the documented anti-patterns are:
@@ -424,7 +461,7 @@ For agents that support invokable skills, this repo ships a portable skill under
424
461
  ## GitHub Action
425
462
 
426
463
  ```yaml
427
- - uses: marshallguillory86/secure-code-agent@v0.11.0
464
+ - uses: marshallguillory86/secure-code-agent@v0.12.0
428
465
  with:
429
466
  config: secure-code-agent.json
430
467
  fail-on-gate: true
@@ -49,6 +49,13 @@ dependencies = [
49
49
  ]
50
50
 
51
51
  [project.optional-dependencies]
52
+ # The chat door. Optional because the CLI is the tested surface and the MCP
53
+ # server drives it by subprocess rather than reimplementing it — so nobody
54
+ # who only wants the gate pays for an MCP dependency.
55
+ mcp = [
56
+ "mcp>=1.0,<2",
57
+ ]
58
+
52
59
  # Scanners the default configuration lists in gates.require_scanners. Pinned
53
60
  # exactly: a gate asserting "bandit completed" should mean a known Bandit
54
61
  # completed, not whatever Bandit resolved that day.
@@ -86,6 +93,7 @@ dev = [
86
93
  [project.scripts]
87
94
  secure-code-agent = "secure_code_audit.cli:main"
88
95
  secure-code-audit = "secure_code_audit.cli:main"
96
+ secure-code-agent-mcp = "secure_code_audit.mcp_server:main"
89
97
 
90
98
  [project.urls]
91
99
  Homepage = "https://github.com/marshallguillory86/secure-code-agent"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: secure-code-agent
3
- Version: 0.11.0
3
+ Version: 0.12.0
4
4
  Summary: Deterministic security gate + bounded AI remediation prompt generator. NIST SSDF / OWASP ASVS / CWE Top 25 anchored.
5
5
  Author: Marshall Guillory
6
6
  License: MIT
@@ -29,6 +29,8 @@ Requires-Python: >=3.10
29
29
  Description-Content-Type: text/markdown
30
30
  License-File: LICENSE
31
31
  Requires-Dist: PyYAML<7,>=6.0.2
32
+ Provides-Extra: mcp
33
+ Requires-Dist: mcp<2,>=1.0; extra == "mcp"
32
34
  Provides-Extra: required-scanners
33
35
  Requires-Dist: bandit==1.9.4; extra == "required-scanners"
34
36
  Requires-Dist: pip-audit==2.10.1; extra == "required-scanners"
@@ -90,6 +92,43 @@ A pure-Python repository requires six of the ten floor scanners; the Ruby,
90
92
  JavaScript and container tools are *not applicable* rather than missing, so a
91
93
  single-language project is never permanently incomplete.
92
94
 
95
+ ## In your editor or an agent, not just a terminal
96
+
97
+ ```bash
98
+ pip install 'secure-code-agent[mcp]'
99
+ secure-code-agent-mcp
100
+ ```
101
+
102
+ Exposes `audit_repository`, `preflight` and `agent_info`. It never audits
103
+ unasked — `audit_repository` without `action="run"` returns the question — and
104
+ it hands back the **work order first**, because the score is second class.
105
+
106
+ ## Auditing a pull request
107
+
108
+ ```bash
109
+ secure-code-agent . --changed-only origin/main
110
+ ```
111
+
112
+ The whole tree is still scanned; the *report* is scoped to what changed. **No
113
+ grade is issued** for a scoped run, because a run that looks at less must not
114
+ score better.
115
+
116
+ ## Proving the agent stayed inside the order
117
+
118
+ ```bash
119
+ secure-code-agent . --verify-against before.json
120
+ ```
121
+
122
+ Reports what was fixed, what is still open, what was **silenced rather than
123
+ fixed** — and the blast radius: which changed files the work order never
124
+ cited.
125
+
126
+ ```
127
+ work order verification · improved: 1 fixed
128
+ fixed sca.python.subprocess.shell_true bad.py:3
129
+ scope: EXCEEDED — 1 of 2 changed file(s) were never cited: src/other.py
130
+ ```
131
+
93
132
  ## Why this exists
94
133
 
95
134
  AI coding agents ship code at human-review-saturating speed. Point them at a security finding and the documented anti-patterns are:
@@ -470,7 +509,7 @@ For agents that support invokable skills, this repo ships a portable skill under
470
509
  ## GitHub Action
471
510
 
472
511
  ```yaml
473
- - uses: marshallguillory86/secure-code-agent@v0.11.0
512
+ - uses: marshallguillory86/secure-code-agent@v0.12.0
474
513
  with:
475
514
  config: secure-code-agent.json
476
515
  fail-on-gate: true
@@ -16,6 +16,7 @@ src/secure_code_audit/findings.py
16
16
  src/secure_code_audit/git_tools.py
17
17
  src/secure_code_audit/history.py
18
18
  src/secure_code_audit/instructions.py
19
+ src/secure_code_audit/mcp_server.py
19
20
  src/secure_code_audit/pillar.py
20
21
  src/secure_code_audit/practice.py
21
22
  src/secure_code_audit/remediation.py
@@ -1,3 +1,4 @@
1
1
  [console_scripts]
2
2
  secure-code-agent = secure_code_audit.cli:main
3
+ secure-code-agent-mcp = secure_code_audit.mcp_server:main
3
4
  secure-code-audit = secure_code_audit.cli:main
@@ -6,6 +6,9 @@ pytest>=8.0
6
6
  pytest-cov>=4.0
7
7
  ruff==0.16.2
8
8
 
9
+ [mcp]
10
+ mcp<2,>=1.0
11
+
9
12
  [python-scanners]
10
13
  secure-code-agent[required-scanners]
11
14
  semgrep<2,>=1.172
@@ -12,4 +12,4 @@
12
12
  #:
13
13
  #: PyPI is immutable, so 0.4.0 stays wrong. 0.5.0 is the first build whose
14
14
  #: artifacts name their own producer correctly.
15
- __version__ = "0.11.0"
15
+ __version__ = "0.12.0"
@@ -22,6 +22,7 @@ from secure_code_audit import (
22
22
  sarif,
23
23
  scanners,
24
24
  suppressions,
25
+ triage,
25
26
  )
26
27
  from secure_code_audit import baseline as baseline_mod
27
28
  from secure_code_audit import config as config_mod
@@ -111,7 +112,16 @@ def _parser() -> argparse.ArgumentParser:
111
112
  "--fail-on-new", action="store_true", help="Exit nonzero on findings not in baseline."
112
113
  )
113
114
 
114
- p.add_argument("--changed-only", help="Audit only files changed since REF (e.g. main...HEAD).")
115
+ p.add_argument(
116
+ "--changed-only",
117
+ metavar="REF",
118
+ help=(
119
+ "Report only findings in files changed since REF (e.g. main...HEAD). "
120
+ "The whole tree is still scanned — scanners read trees, not diffs — "
121
+ "and the grade is WITHHELD, because a run that looks at less must "
122
+ "not score better."
123
+ ),
124
+ )
115
125
  p.add_argument("--skip-scanners", help="Comma-separated scanner names to skip.")
116
126
  p.add_argument("--only-scanners", help="Comma-separated scanner names — only these run.")
117
127
  p.add_argument(
@@ -400,6 +410,19 @@ def _do_audit(args: argparse.Namespace) -> int:
400
410
  root_for_tests = target if target.is_dir() else target.parent
401
411
 
402
412
  def _classify(finding: Finding) -> str:
413
+ # A dependency advisory is about the dependency, not about the file
414
+ # that happened to declare it. Classified by category before path,
415
+ # because the path routing gets it wrong: `requirements.txt` matches
416
+ # the documentation pattern `**/*.txt`, so every CVE in a pip
417
+ # manifest was filed under **documentation** — eighteen of them on a
418
+ # six-file demo tree. A dependency finding is not documentation
419
+ # whatever the manifest is called.
420
+ #
421
+ # Returning "primary" hands it to `split_side_axes` below, which is
422
+ # what moves it onto the dependencies axis. Neither axis is scored,
423
+ # so this changes where a finding is *reported*, not the grade.
424
+ if finding.category is Category.DEPENDENCIES:
425
+ return "primary"
403
426
  if is_test_path(finding.file_path, root_for_tests, cfg.test_patterns):
404
427
  return "test tree"
405
428
  if is_test_path(finding.file_path, root_for_tests, cfg.docs_patterns):
@@ -418,6 +441,11 @@ def _do_audit(args: argparse.Namespace) -> int:
418
441
  # * the baseline. `baseline.write` recorded only the primary set, so
419
442
  # every side-axis finding was absent from it and `fail_on_new`
420
443
  # re-flagged the same test-tree secret as new on every run, forever.
444
+ if args.changed_only:
445
+ all_findings, changed_note = _restrict_to_changed(all_findings, root, args.changed_only)
446
+ else:
447
+ changed_note = None
448
+
421
449
  primary_findings, path_axes = partition_by_path(all_findings, _classify)
422
450
  test_findings = path_axes.get("test tree", [])
423
451
  docs_findings = path_axes.get("documentation", [])
@@ -480,7 +508,27 @@ def _do_audit(args: argparse.Namespace) -> int:
480
508
  gate = evaluate_gates(
481
509
  gated, score, {**cfg.gates, "_baseline_state": baseline_state.value}, coverage
482
510
  )
483
- verdict = build_verdict(score, cfg.gates, coverage)
511
+ # A first run has triaged nothing: no baseline exists, so no finding has
512
+ # been accepted, deferred or dismissed by anyone. Counted from the
513
+ # actionable set (§FIX and §REVIEW), not the raw total — §ACCEPT is the
514
+ # test tree and documentation, which the work order explicitly says not
515
+ # to patch, and counting those would make every repository look untriaged
516
+ # forever.
517
+ untriaged = 0
518
+ if baseline_state is baseline_mod.State.ABSENT:
519
+ tiers = triage.partition(all_findings, lambda f: renderers.axis_of(f, axes))
520
+ untriaged = len(tiers[triage.Tier.FIX]) + len(tiers[triage.Tier.REVIEW])
521
+ verdict = build_verdict(score, cfg.gates, coverage, untriaged)
522
+ if changed_note is not None:
523
+ # A scoped run has no denominator it can defend: the LOC is the whole
524
+ # tree and the findings are a slice of it, so any number would be
525
+ # flattering by construction. Withhold the grade and say why. The
526
+ # findings, the work order and the gate are all still real.
527
+ verdict = replace(
528
+ verdict,
529
+ verified_grade=None,
530
+ reasons=(*verdict.reasons, changed_note),
531
+ )
484
532
 
485
533
  # ----- write outputs -----
486
534
  # The pillar artifact is what maintainability-agent ingests (D3). Built
@@ -528,13 +576,14 @@ def _do_audit(args: argparse.Namespace) -> int:
528
576
 
529
577
  # ----- did the work order actually improve anything? -----
530
578
  if args.verify_against:
531
- return _do_verify(args, all_findings, root, axes)
579
+ return _do_verify(args, all_findings, root, axes, own_artifacts)
532
580
 
533
581
  # ----- terminal output -----
534
582
  if args.json:
535
583
  sys.stdout.write(
536
584
  json.dumps(
537
- renderers.to_json(all_findings, score, gate, coverage, verdict, axes), indent=2
585
+ renderers.to_json(all_findings, score, gate, coverage, verdict, axes, root),
586
+ indent=2,
538
587
  )
539
588
  )
540
589
  sys.stdout.write("\n")
@@ -548,10 +597,11 @@ def _prepare_audit(
548
597
  args: argparse.Namespace,
549
598
  ) -> tuple[config_mod.Config, Path, Path]:
550
599
  """Load config and resolve the single scan root, or refuse."""
551
- if args.changed_only:
552
- raise ValueError(
553
- "--changed-only is not implemented safely; refusing to claim a scoped audit"
554
- )
600
+ # `--changed-only` used to refuse outright, because a scoped audit that
601
+ # produced a score would let looking at less score better — the
602
+ # absence-as-value defect, arriving through a convenience flag. It is
603
+ # implemented now, and the refusal is replaced by the property that made
604
+ # it unsafe: a scoped run reports findings and **never a grade**.
555
605
  if len(args.paths) > 1:
556
606
  raise ValueError("multiple scan roots are not supported; provide one repository root")
557
607
  # The target is resolved first because the default config belongs to it.
@@ -856,7 +906,7 @@ def _write_outputs(
856
906
  findings, score, gate, paths.markdown, ran, unavailable, coverage, verdict, axes
857
907
  )
858
908
  if paths.json_out is not None:
859
- renderers.write_json(findings, score, gate, paths.json_out, coverage, verdict, axes)
909
+ renderers.write_json(findings, score, gate, paths.json_out, coverage, verdict, axes, root)
860
910
  if paths.sarif is not None:
861
911
  sarif.write(findings, paths.sarif, coverage, lambda f: renderers.axis_of(f, axes))
862
912
  if paths.comment is not None:
@@ -1056,7 +1106,7 @@ def _print_summary(
1056
1106
  status = "PASS"
1057
1107
  else:
1058
1108
  status = "NOT CONFIGURED"
1059
- print(f"secure-code-agent · score {verdict.headline()} · gate {status}")
1109
+ print(f"secure-code-agent · {verdict.headline()} · gate {status}")
1060
1110
  for reason in verdict.reasons:
1061
1111
  print(f" ! grade withheld: {reason}")
1062
1112
  print(f" scanned LOC: {score.loc_scanned:,}")
@@ -1158,15 +1208,34 @@ def _findings_from_report(path: Path) -> list[Finding]:
1158
1208
  return restored
1159
1209
 
1160
1210
 
1161
- def _do_verify(args: argparse.Namespace, after: list[Finding], root: Path, axes=()) -> int:
1211
+ def _do_verify(
1212
+ args: argparse.Namespace,
1213
+ after: list[Finding],
1214
+ root: Path,
1215
+ axes=(),
1216
+ own_artifacts: frozenset[Path] = frozenset(),
1217
+ ) -> int:
1162
1218
  """Compare this run against the one that produced the work order.
1163
1219
 
1164
1220
  Exits nonzero unless the run passes, because a verification step that
1165
1221
  always passes verifies nothing. "Improved" is deliberately
1166
1222
  strict: something fixed, nothing introduced, nothing merely silenced.
1167
1223
  """
1168
- before = _findings_from_report(_under_root(root, args.verify_against))
1224
+ report_path = _under_root(root, args.verify_against)
1225
+ before = _findings_from_report(report_path)
1169
1226
  result = verify_mod.compare(before, after, root, lambda f: renderers.axis_of(f, axes))
1227
+ # Blast radius: what changed that the order never cited. Reads the commit
1228
+ # the before-report was taken at; unknown rather than conformant when the
1229
+ # report predates that field or the tree is not a git repository.
1230
+ result = replace(
1231
+ result,
1232
+ scope=verify_mod.measure_scope(
1233
+ before,
1234
+ root,
1235
+ _commit_of_report(report_path),
1236
+ ours={*own_artifacts, report_path},
1237
+ ),
1238
+ )
1170
1239
 
1171
1240
  if args.json:
1172
1241
  sys.stdout.write(json.dumps(verify_mod.to_dict(result), indent=2) + "\n")
@@ -1194,11 +1263,61 @@ def _do_verify(args: argparse.Namespace, after: list[Finding], root: Path, axes=
1194
1263
  f" ({len(result.deferred)} finding(s) in the test tree and documentation "
1195
1264
  f"are reported but not required — see §ACCEPT)"
1196
1265
  )
1266
+ print(f" {result.scope.headline()}")
1267
+ for path in sorted(result.scope.collateral)[:10]:
1268
+ print(f" collateral {path}")
1197
1269
  for note in result.notes:
1198
1270
  print(f" ! {note}")
1199
1271
 
1200
1272
  return 0 if result.passed else 1
1201
1273
 
1202
1274
 
1275
+ def _restrict_to_changed(
1276
+ findings: list[Finding], root: Path, ref: str
1277
+ ) -> tuple[list[Finding], str]:
1278
+ """Keep only findings in files changed since `ref`.
1279
+
1280
+ The whole tree is still scanned. Scanners read trees rather than diffs,
1281
+ and asking one to look at a subset changes what it can see — Semgrep's
1282
+ cross-file dataflow being the obvious case. So the *scan* is complete and
1283
+ the *report* is scoped, which is the only ordering that does not quietly
1284
+ trade coverage for speed.
1285
+
1286
+ A ref git cannot resolve is an error rather than an empty diff. "Nothing
1287
+ changed" and "your ref is wrong" produce the same finding count and only
1288
+ one of them should exit 0.
1289
+ """
1290
+ from secure_code_audit.git_tools import changed_files
1291
+
1292
+ paths, reason = changed_files(root, ref)
1293
+ if paths is None:
1294
+ raise ValueError(f"--changed-only {ref}: {reason}")
1295
+
1296
+ absolute = {(root / path).resolve() for path in paths}
1297
+
1298
+ def touched(finding: Finding) -> bool:
1299
+ try:
1300
+ return finding.file_path.resolve() in absolute
1301
+ except OSError:
1302
+ return False
1303
+
1304
+ kept = [f for f in findings if f.severity is Severity.INFORMATIONAL or touched(f)]
1305
+ note = (
1306
+ f"scoped to files changed since {ref} — a grade needs the whole tree, "
1307
+ f"and this run reports a slice of it"
1308
+ )
1309
+ return kept, note
1310
+
1311
+
1312
+ def _commit_of_report(path: Path) -> str | None:
1313
+ """The commit a saved report was taken at, if it recorded one."""
1314
+ try:
1315
+ payload = json.loads(path.read_text(encoding="utf-8"))
1316
+ except (OSError, json.JSONDecodeError):
1317
+ return None
1318
+ commit = payload.get("commit")
1319
+ return commit if isinstance(commit, str) and commit else None
1320
+
1321
+
1203
1322
  if __name__ == "__main__":
1204
1323
  sys.exit(main())
@@ -101,11 +101,11 @@ def build(destination: Path | None = None) -> Path:
101
101
  (root / "README.md").write_text(README, encoding="utf-8")
102
102
  # No dependency manifest. A pinned-vulnerable `requirements.txt` was here
103
103
  # and it made the demo worse, not better: eighteen pip-audit CVEs drowned
104
- # the seven code defects the demo exists to show. It also surfaced a
105
- # separate defect worth its own fix — those CVEs were filed on the
106
- # **documentation** axis, because the manifest is a `.txt` file and the
107
- # axis split reads the extension. A dependency finding is not
108
- # documentation whatever the manifest is called.
104
+ # the seven code defects the demo exists to show.
105
+ #
106
+ # It also surfaced a real defect, since fixed: those CVEs were filed on
107
+ # the **documentation** axis, because the manifest is a `.txt` file and
108
+ # the axis split read the extension before the category.
109
109
  return root
110
110
 
111
111
 
@@ -216,3 +216,92 @@ def loc_under(
216
216
  else:
217
217
  primary += lines
218
218
  return primary, test, docs
219
+
220
+
221
+ def _git() -> str | None:
222
+ """Resolve `git` to an absolute path, once.
223
+
224
+ Invoking a bare `git` leaves the choice of binary to `PATH`, which is the
225
+ same class of exposure this project refuses for scanner commands — and
226
+ Bandit says so (`B607`, partial executable path). Resolving it is cheaper
227
+ than suppressing it, and consistent with how every scanner adapter here
228
+ already resolves its tool.
229
+ """
230
+ import shutil # noqa: PLC0415
231
+
232
+ return shutil.which("git")
233
+
234
+
235
+ def head_commit(root: Path) -> str | None:
236
+ """The commit an audit was taken at, or None outside a git repository.
237
+
238
+ Recorded in the JSON report so a later `--verify-against` can ask *what
239
+ actually changed* rather than inferring it from two finding sets. Two
240
+ reports tell you which findings moved; they cannot tell you that an agent
241
+ also rewrote three unrelated modules, and that is the question the work
242
+ order's constraints exist to answer.
243
+ """
244
+ import subprocess # noqa: PLC0415 — only needed on this path
245
+
246
+ git = _git()
247
+ if git is None:
248
+ return None
249
+ try:
250
+ completed = subprocess.run(
251
+ [git, "-C", str(root), "rev-parse", "HEAD"],
252
+ capture_output=True,
253
+ text=True,
254
+ timeout=10,
255
+ check=False,
256
+ )
257
+ except (OSError, subprocess.SubprocessError):
258
+ return None
259
+ if completed.returncode != 0:
260
+ return None
261
+ sha = completed.stdout.strip()
262
+ return sha or None
263
+
264
+
265
+ def changed_files(root: Path, since: str) -> tuple[frozenset[str], str | None] | tuple[None, str]:
266
+ """Repository-relative paths changed since `since`, or a reason it is unknown.
267
+
268
+ Includes uncommitted work, because an agent handed a work order usually
269
+ has not committed. `git diff --name-only <sha>` covers tracked
270
+ modifications against the working tree; untracked files are asked for
271
+ separately, since a newly added module is exactly the kind of collateral
272
+ worth seeing.
273
+
274
+ Returns `(paths, None)` on success and `(None, reason)` when the answer is
275
+ unavailable — never an empty set standing in for "could not tell", which
276
+ would read as "nothing changed" and turn a failed measurement into a
277
+ clean bill of health.
278
+ """
279
+ import subprocess # noqa: PLC0415
280
+
281
+ git = _git()
282
+ if git is None:
283
+ return None, "git is not on PATH"
284
+
285
+ def _run(args: list[str]) -> tuple[str, str | None]:
286
+ try:
287
+ completed = subprocess.run(
288
+ [git, "-C", str(root), *args],
289
+ capture_output=True,
290
+ text=True,
291
+ timeout=30,
292
+ check=False,
293
+ )
294
+ except (OSError, subprocess.SubprocessError) as exc:
295
+ return "", f"git failed: {type(exc).__name__}"
296
+ if completed.returncode != 0:
297
+ return "", (completed.stderr.strip().splitlines() or ["git failed"])[0]
298
+ return completed.stdout, None
299
+
300
+ tracked, reason = _run(["diff", "--name-only", since])
301
+ if reason is not None:
302
+ return None, reason
303
+ untracked, reason = _run(["ls-files", "--others", "--exclude-standard"])
304
+ if reason is not None:
305
+ return None, reason
306
+ paths = {line.strip() for line in (tracked + untracked).splitlines() if line.strip()}
307
+ return frozenset(paths), None
@@ -0,0 +1,186 @@
1
+ """An MCP door, because almost nobody lives in the CLI.
2
+
3
+ A reviewer's objection: *"It is named agent and there is no chat door. No
4
+ MCP. Skills are files you copy. MA already learned almost nobody lives in the
5
+ CLI. This still does."* That is fair, and `maintainability-agent` learned it
6
+ first — its server is the reference for the shape used here.
7
+
8
+ **The server never audits unasked.** `audit_repository` without an explicit
9
+ `action` returns a question, not a result. Scanning someone's repository is
10
+ not a thing to do because a model inferred it might be useful, and a tool
11
+ that audits on mention trains people to stop reading what it did.
12
+
13
+ **It returns the work order, not only the score.** The score is second class
14
+ here by design; the work order is the product, and a chat surface that hands
15
+ back a letter grade would invert that.
16
+
17
+ Optional: `pip install 'secure-code-agent[mcp]'`. Nothing in the package
18
+ imports this module unless the server is started, so the dependency stays
19
+ off the default install.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import subprocess
26
+ import sys
27
+ import tempfile
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ from secure_code_audit import __version__
32
+
33
+
34
+ def _run_cli(args: list[str]) -> tuple[int, str, str]:
35
+ """Drive the CLI rather than reimport its internals.
36
+
37
+ The CLI is the tested surface: gates, containment, the axis split and
38
+ coverage integrity all live behind it. A second entry point that
39
+ assembled the same pieces differently would be a second set of bugs, and
40
+ the one thing this project cannot afford is two answers to "what did you
41
+ find".
42
+ """
43
+ completed = subprocess.run(
44
+ [sys.executable, "-m", "secure_code_audit.cli", *args],
45
+ capture_output=True,
46
+ text=True,
47
+ timeout=1800,
48
+ check=False,
49
+ )
50
+ return completed.returncode, completed.stdout, completed.stderr
51
+
52
+
53
+ def _audit(path: str, extra: list[str] | None = None) -> dict[str, Any]:
54
+ target = Path(path).expanduser().resolve()
55
+ if not target.exists():
56
+ return {"error": f"path does not exist: {target}", "audit_ran": False}
57
+
58
+ with tempfile.TemporaryDirectory(prefix="sca-mcp-") as tmp:
59
+ report = Path(tmp) / "report.json"
60
+ prompt = Path(tmp) / "work-order.md"
61
+ code, out, err = _run_cli(
62
+ [
63
+ str(target),
64
+ "--json-output",
65
+ str(report),
66
+ "--prompt-output",
67
+ str(prompt),
68
+ # No report, work order, SARIF or baseline is written into
69
+ # the operator's tree from a chat surface — they land in a
70
+ # temp directory and are read back.
71
+ #
72
+ # One thing IS written: `.secure-code/history.jsonl`, this
73
+ # tool's own state directory, because that is how `trend`
74
+ # works across runs and a trend is the score's one genuine
75
+ # use. Saying "writes nothing" would have been the tidier
76
+ # sentence and a false one; a test asserts the tree is
77
+ # otherwise untouched.
78
+ "--output",
79
+ str(Path(tmp) / "report.md"),
80
+ *(extra or []),
81
+ ]
82
+ )
83
+ if not report.is_file():
84
+ return {
85
+ "error": "the audit did not produce a report",
86
+ "exit_code": code,
87
+ "stderr": err[-2000:],
88
+ "audit_ran": False,
89
+ }
90
+ payload = json.loads(report.read_text(encoding="utf-8"))
91
+ work_order = prompt.read_text(encoding="utf-8") if prompt.is_file() else ""
92
+
93
+ score = payload.get("score", {})
94
+ coverage = payload.get("coverage", {})
95
+ return {
96
+ "audit_ran": True,
97
+ "exit_code": code,
98
+ "summary": out.strip().splitlines()[:1],
99
+ # The work order first, deliberately. It is the output that changes
100
+ # the code; the score is the one that describes it.
101
+ "work_order": work_order,
102
+ "verified_grade": score.get("verified_grade"),
103
+ "score": score.get("overall"),
104
+ "evidence_status": score.get("evidence_status"),
105
+ "evidence_reasons": score.get("evidence_reasons", []),
106
+ "coverage_status": coverage.get("status"),
107
+ "scanners_missing": [
108
+ s.get("name") for s in coverage.get("scanners", []) if s.get("outcome") != "completed"
109
+ ],
110
+ "gate": payload.get("gate", {}),
111
+ "producer": {"tool": "secure-code-agent", "version": __version__},
112
+ }
113
+
114
+
115
+ def build_server(): # pragma: no cover - exercised by the smoke test below
116
+ from mcp.server.fastmcp import FastMCP
117
+
118
+ server = FastMCP("secure-code-agent")
119
+
120
+ @server.tool()
121
+ def audit_repository(path: str, action: str | None = None) -> dict[str, Any]:
122
+ """Audit a repository and return its bounded work order.
123
+
124
+ `action` must be `"run"` before anything is scanned. Called without
125
+ it, this returns the question instead — auditing someone's code
126
+ because it came up in conversation is not a thing to do quietly.
127
+ """
128
+ if action != "run":
129
+ return {
130
+ "audit_ran": False,
131
+ "choice_needed": (
132
+ f"Audit {path}? This runs the scanner floor over the tree. "
133
+ f"No report is written into it; only this tool's own "
134
+ f"`.secure-code/history.jsonl` trend log is appended. "
135
+ f"Call again with action='run' to proceed."
136
+ ),
137
+ "options": ["run", "preflight"],
138
+ }
139
+ return _audit(path)
140
+
141
+ @server.tool()
142
+ def preflight(path: str) -> dict[str, Any]:
143
+ """Which scanners resolve here, and how to install the ones that do not.
144
+
145
+ Read-only and safe to call unasked: it resolves tool paths and runs
146
+ nothing against the code.
147
+ """
148
+ code, out, err = _run_cli([path, "--preflight"])
149
+ return {"audit_ran": False, "exit_code": code, "report": out.strip() or err.strip()}
150
+
151
+ @server.tool()
152
+ def agent_info() -> dict[str, Any]:
153
+ """What this tool is for, so a model does not have to guess."""
154
+ return {
155
+ "tool": "secure-code-agent",
156
+ "version": __version__,
157
+ "produces": [
158
+ "a bounded work order an agent can act on (the product)",
159
+ "a coverage report stating what was examined (never inferred)",
160
+ "a score, which is second class to both",
161
+ ],
162
+ "never": [
163
+ "installs a scanner",
164
+ "audits without an explicit action='run'",
165
+ "grades what it could not examine",
166
+ ],
167
+ }
168
+
169
+ return server
170
+
171
+
172
+ def main() -> int:
173
+ try:
174
+ server = build_server()
175
+ except ImportError:
176
+ sys.stderr.write(
177
+ "ERROR: the MCP server needs the `mcp` package.\n"
178
+ " pip install 'secure-code-agent[mcp]'\n"
179
+ )
180
+ return 2
181
+ server.run()
182
+ return 0
183
+
184
+
185
+ if __name__ == "__main__": # pragma: no cover
186
+ raise SystemExit(main())
@@ -10,6 +10,7 @@ from pathlib import Path
10
10
 
11
11
  from secure_code_audit import __version__
12
12
  from secure_code_audit.findings import Finding, Severity
13
+ from secure_code_audit.git_tools import head_commit
13
14
  from secure_code_audit.scanner_status import CoverageReport
14
15
  from secure_code_audit.scanners import floor
15
16
  from secure_code_audit.scoring import AxisReport, GateResult, ScoreReport, Verdict
@@ -27,11 +28,17 @@ def to_json(
27
28
  coverage: CoverageReport | None = None,
28
29
  verdict: Verdict | None = None,
29
30
  axes: Iterable[AxisReport] = (),
31
+ root: Path | None = None,
30
32
  ) -> dict:
31
33
  findings = list(findings)
32
34
  return {
33
35
  "version": __version__,
34
36
  "generated": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
37
+ # The commit this was taken at, so a later `--verify-against` can ask
38
+ # what actually changed rather than inferring it from two finding
39
+ # sets. Null outside a git repository, which is honest: the scope
40
+ # measurement then reports itself unknown rather than conformant.
41
+ "commit": head_commit(root) if root is not None else None,
35
42
  "score": {
36
43
  "overall": score.overall,
37
44
  "letter": score.letter,
@@ -207,9 +214,10 @@ def write_json(
207
214
  coverage: CoverageReport | None = None,
208
215
  verdict: Verdict | None = None,
209
216
  axes: Iterable[AxisReport] = (),
217
+ root: Path | None = None,
210
218
  ) -> None:
211
219
  path.write_text(
212
- json.dumps(to_json(findings, score, gate, coverage, verdict, axes), indent=2),
220
+ json.dumps(to_json(findings, score, gate, coverage, verdict, axes, root), indent=2),
213
221
  encoding="utf-8",
214
222
  )
215
223
 
@@ -151,6 +151,9 @@ class Verdict:
151
151
  estimated_letter: str | None
152
152
  verified_grade: str | None
153
153
  reasons: tuple[str, ...]
154
+ #: Findings exist and none has been triaged — no baseline, nothing
155
+ #: accepted, nobody has looked. See `headline`.
156
+ untriaged: int = 0
154
157
 
155
158
  @property
156
159
  def is_verified(self) -> bool:
@@ -161,17 +164,51 @@ class Verdict:
161
164
  return "complete" if self.is_verified else "incomplete"
162
165
 
163
166
  def headline(self) -> str:
164
- """One line, used by every renderer that shows a score."""
167
+ """One line, used by every renderer that shows a score.
168
+
169
+ **An untriaged run leads with the work, not a letter.** A reviewer put
170
+ the failure precisely: *"a staff engineer who sees Django at F turns
171
+ the gate off."* They are right to, and the F is not wrong — it is a
172
+ density of findings nobody has looked at yet, which is a starting
173
+ position rather than a verdict on the code. Leading with the letter
174
+ invites the reader to treat it as one, and the letter they see on
175
+ first contact decides whether the tool survives the afternoon.
176
+
177
+ So on a first run with findings and no baseline, the headline states
178
+ the work. The number is still in the report, in the JSON, and in the
179
+ trend; it is simply not the first thing said about a repository
180
+ nobody has triaged.
181
+ """
165
182
  if self.estimate is None:
166
183
  return "no score — nothing measurable was scanned"
184
+ if self.untriaged:
185
+ return f"{self.untriaged} finding(s), none triaged — a starting position, not a grade"
167
186
  if self.is_verified:
168
- return f"{self.estimate:.2f} ({self.verified_grade})"
169
- return f"{self.estimate:.2f} — grade withheld ({self.estimated_letter} unverified)"
187
+ return f"score {self.estimate:.2f} ({self.verified_grade})"
188
+ return f"score {self.estimate:.2f} — grade withheld ({self.estimated_letter} unverified)"
170
189
 
171
190
 
172
- def verdict(report: ScoreReport, gate_config: dict, coverage: CoverageReport | None) -> Verdict:
191
+ def verdict(
192
+ report: ScoreReport,
193
+ gate_config: dict,
194
+ coverage: CoverageReport | None,
195
+ untriaged: int = 0,
196
+ ) -> Verdict:
173
197
  """Decide the letter, or withhold it, once for the whole run."""
174
198
  reasons = list(evidence_reasons(gate_config, coverage))
199
+ # `untriaged` deliberately does NOT join `reasons`.
200
+ #
201
+ # It did, briefly, and that conflated two different axes. `reasons`
202
+ # answers *did we look* — coverage, required scanners, evidence — and
203
+ # withholding the grade is its consequence. Triage answers *did you
204
+ # review what we found*, which is the operator's work rather than the
205
+ # scanners'. Folding it in made a verified grade unreachable on first
206
+ # contact for any repository with a single finding, and
207
+ # `test_a_grade_is_issued_only_when_a_declared_scanner_set_actually_ran`
208
+ # caught it: the positive half of P7 stopped being reachable.
209
+ #
210
+ # So an untriaged run changes what the headline *leads with* and nothing
211
+ # else. The grade is still computed, still reported, still in the trend.
175
212
  if report.overall is None:
176
213
  # Nothing measurable ran. There is no estimate to qualify, so the
177
214
  # reason is stated rather than a letter being caveated — a caveated
@@ -182,6 +219,7 @@ def verdict(report: ScoreReport, gate_config: dict, coverage: CoverageReport | N
182
219
  estimated_letter=report.letter,
183
220
  verified_grade=None if reasons else report.letter,
184
221
  reasons=tuple(reasons),
222
+ untriaged=untriaged,
185
223
  )
186
224
 
187
225
 
@@ -44,6 +44,108 @@ class Outcome(enum.Enum):
44
44
  INTRODUCED = "introduced"
45
45
 
46
46
 
47
+ @dataclass(frozen=True)
48
+ class Scope:
49
+ """Did the work stay inside the order?
50
+
51
+ The product's claim is that a bounded work order keeps an agent from
52
+ doing crypto roulette, auth rewrites and 600-line "while I was in there"
53
+ patches. Until now only *silencing* and *regressions* were mechanical —
54
+ a reviewer put it exactly: "ten hard constraints are a leash I can still
55
+ ignore ... not for 'did you rewrite the session model'."
56
+
57
+ This makes that question mechanical. The work order cites files and
58
+ lines; the repository knows what actually changed. Everything changed
59
+ outside the cited files is collateral, and collateral is the measurable
60
+ shadow of the constraint the prompt cannot enforce.
61
+
62
+ **`known` is False when the answer is unavailable** — no commit in the
63
+ before-report, or not a git repository. An unknown scope is reported as
64
+ unknown and never as conformant: a measurement that failed must not read
65
+ as a clean result, which is the same rule the coverage axis applies to
66
+ scanners.
67
+ """
68
+
69
+ known: bool = False
70
+ reason: str | None = None
71
+ #: Files the work order named.
72
+ cited: tuple[str, ...] = ()
73
+ #: Files that actually changed, including untracked additions.
74
+ changed: tuple[str, ...] = ()
75
+ #: Changed, and never cited. The blast radius.
76
+ collateral: tuple[str, ...] = ()
77
+
78
+ @property
79
+ def conformant(self) -> bool:
80
+ """Only ever True when the question was actually answered."""
81
+ return self.known and not self.collateral
82
+
83
+ def headline(self) -> str:
84
+ if not self.known:
85
+ return f"scope: unknown ({self.reason or 'no baseline commit recorded'})"
86
+ if not self.changed:
87
+ return "scope: nothing changed"
88
+ if not self.collateral:
89
+ return f"scope: conformant — {len(self.changed)} file(s), all cited in the order"
90
+ return (
91
+ f"scope: EXCEEDED — {len(self.collateral)} of {len(self.changed)} changed "
92
+ f"file(s) were never cited: {', '.join(sorted(self.collateral)[:5])}"
93
+ + (" …" if len(self.collateral) > 5 else "")
94
+ )
95
+
96
+
97
+ def measure_scope(
98
+ before: Iterable[Finding],
99
+ root: Path | None,
100
+ since: str | None,
101
+ ours: Iterable[Path] = (),
102
+ ) -> Scope:
103
+ """Compare what the order cited against what the repository changed."""
104
+ if root is None:
105
+ return Scope(reason="no repository root")
106
+ if not since:
107
+ return Scope(reason="the before-report records no commit")
108
+
109
+ from secure_code_audit.git_tools import changed_files
110
+
111
+ paths, reason = changed_files(root, since)
112
+ if paths is None:
113
+ return Scope(reason=reason)
114
+
115
+ cited: set[str] = set()
116
+ for finding in before:
117
+ try:
118
+ cited.add(finding.file_path.resolve().relative_to(root.resolve()).as_posix())
119
+ except (ValueError, OSError):
120
+ cited.add(finding.file_path.as_posix())
121
+
122
+ # This tool's own artifacts are written *by* the run doing the verifying.
123
+ # An agent that fixed something did not "also change
124
+ # secure-code-report.md"; we did, a second ago. Counting them as
125
+ # collateral would make every verification exceed its scope.
126
+ #
127
+ # The set comes from the caller — `cli._own_artifacts`, the same function
128
+ # that keeps a run from scanning its own report — rather than a list of
129
+ # default basenames here. A report written to `--json-output
130
+ # before.json` is ours too, and a hard-coded list of defaults said it was
131
+ # the agent's.
132
+ ignored: set[str] = set()
133
+ for artifact in ours:
134
+ try:
135
+ ignored.add(artifact.resolve().relative_to(root.resolve()).as_posix())
136
+ except (ValueError, OSError):
137
+ continue
138
+ changed = {
139
+ path for path in paths if path not in ignored and not path.startswith(".secure-code/")
140
+ }
141
+ return Scope(
142
+ known=True,
143
+ cited=tuple(sorted(cited)),
144
+ changed=tuple(sorted(changed)),
145
+ collateral=tuple(sorted(changed - cited)),
146
+ )
147
+
148
+
47
149
  @dataclass(frozen=True)
48
150
  class Verification:
49
151
  """What changed between two audits of the same repository."""
@@ -60,6 +162,8 @@ class Verification:
60
162
  before_count: int = 0
61
163
  after_count: int = 0
62
164
  notes: tuple[str, ...] = field(default_factory=tuple)
165
+ #: Blast radius, when it could be measured.
166
+ scope: Scope = field(default_factory=Scope)
63
167
 
64
168
  @property
65
169
  def regressed(self) -> bool:
@@ -262,6 +366,17 @@ def compare(
262
366
  )
263
367
 
264
368
 
369
+ def _scope_to_dict(scope: Scope) -> dict:
370
+ return {
371
+ "known": scope.known,
372
+ "reason": scope.reason,
373
+ "conformant": scope.conformant,
374
+ "cited": list(scope.cited),
375
+ "changed": list(scope.changed),
376
+ "collateral": list(scope.collateral),
377
+ }
378
+
379
+
265
380
  def to_dict(result: Verification) -> dict:
266
381
  """The machine-readable form, for CI and for maintainability-agent."""
267
382
 
@@ -291,5 +406,6 @@ def to_dict(result: Verification) -> dict:
291
406
  "deferred": _rows(result.deferred),
292
407
  "suppressed": _rows(result.suppressed),
293
408
  "introduced": _rows(result.introduced),
409
+ "scope": _scope_to_dict(result.scope),
294
410
  "notes": list(result.notes),
295
411
  }