secure-code-agent 0.9.0__tar.gz → 0.11.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.9.0/src/secure_code_agent.egg-info → secure_code_agent-0.11.0}/PKG-INFO +31 -3
  2. {secure_code_agent-0.9.0 → secure_code_agent-0.11.0}/README.md +30 -2
  3. {secure_code_agent-0.9.0 → secure_code_agent-0.11.0/src/secure_code_agent.egg-info}/PKG-INFO +31 -3
  4. {secure_code_agent-0.9.0 → secure_code_agent-0.11.0}/src/secure_code_agent.egg-info/SOURCES.txt +1 -0
  5. {secure_code_agent-0.9.0 → secure_code_agent-0.11.0}/src/secure_code_audit/__init__.py +1 -1
  6. {secure_code_agent-0.9.0 → secure_code_agent-0.11.0}/src/secure_code_audit/cli.py +147 -4
  7. {secure_code_agent-0.9.0 → secure_code_agent-0.11.0}/src/secure_code_audit/config.py +128 -3
  8. secure_code_agent-0.11.0/src/secure_code_audit/demo.py +118 -0
  9. {secure_code_agent-0.9.0 → secure_code_agent-0.11.0}/src/secure_code_audit/git_tools.py +65 -1
  10. {secure_code_agent-0.9.0 → secure_code_agent-0.11.0}/src/secure_code_audit/pillar.py +53 -2
  11. secure_code_agent-0.11.0/src/secure_code_audit/remediation.py +332 -0
  12. {secure_code_agent-0.9.0 → secure_code_agent-0.11.0}/src/secure_code_audit/scanners/base.py +28 -9
  13. {secure_code_agent-0.9.0 → secure_code_agent-0.11.0}/src/secure_code_audit/scoring.py +141 -8
  14. {secure_code_agent-0.9.0 → secure_code_agent-0.11.0}/src/secure_code_audit/standards.py +60 -3
  15. {secure_code_agent-0.9.0 → secure_code_agent-0.11.0}/src/secure_code_audit/triage.py +129 -1
  16. secure_code_agent-0.9.0/src/secure_code_audit/remediation.py +0 -303
  17. {secure_code_agent-0.9.0 → secure_code_agent-0.11.0}/LICENSE +0 -0
  18. {secure_code_agent-0.9.0 → secure_code_agent-0.11.0}/pyproject.toml +0 -0
  19. {secure_code_agent-0.9.0 → secure_code_agent-0.11.0}/setup.cfg +0 -0
  20. {secure_code_agent-0.9.0 → secure_code_agent-0.11.0}/src/secure_code_agent.egg-info/dependency_links.txt +0 -0
  21. {secure_code_agent-0.9.0 → secure_code_agent-0.11.0}/src/secure_code_agent.egg-info/entry_points.txt +0 -0
  22. {secure_code_agent-0.9.0 → secure_code_agent-0.11.0}/src/secure_code_agent.egg-info/requires.txt +0 -0
  23. {secure_code_agent-0.9.0 → secure_code_agent-0.11.0}/src/secure_code_agent.egg-info/top_level.txt +0 -0
  24. {secure_code_agent-0.9.0 → secure_code_agent-0.11.0}/src/secure_code_audit/baseline.py +0 -0
  25. {secure_code_agent-0.9.0 → secure_code_agent-0.11.0}/src/secure_code_audit/data/semgrep-offline.yaml +0 -0
  26. {secure_code_agent-0.9.0 → secure_code_agent-0.11.0}/src/secure_code_audit/findings.py +0 -0
  27. {secure_code_agent-0.9.0 → secure_code_agent-0.11.0}/src/secure_code_audit/history.py +0 -0
  28. {secure_code_agent-0.9.0 → secure_code_agent-0.11.0}/src/secure_code_audit/instructions.py +0 -0
  29. {secure_code_agent-0.9.0 → secure_code_agent-0.11.0}/src/secure_code_audit/practice.py +0 -0
  30. {secure_code_agent-0.9.0 → secure_code_agent-0.11.0}/src/secure_code_audit/renderers.py +0 -0
  31. {secure_code_agent-0.9.0 → secure_code_agent-0.11.0}/src/secure_code_audit/ruleset.py +0 -0
  32. {secure_code_agent-0.9.0 → secure_code_agent-0.11.0}/src/secure_code_audit/sarif.py +0 -0
  33. {secure_code_agent-0.9.0 → secure_code_agent-0.11.0}/src/secure_code_audit/scanner_status.py +0 -0
  34. {secure_code_agent-0.9.0 → secure_code_agent-0.11.0}/src/secure_code_audit/scanners/__init__.py +0 -0
  35. {secure_code_agent-0.9.0 → secure_code_agent-0.11.0}/src/secure_code_audit/scanners/bandit_scanner.py +0 -0
  36. {secure_code_agent-0.9.0 → secure_code_agent-0.11.0}/src/secure_code_audit/scanners/builtin_rules.py +0 -0
  37. {secure_code_agent-0.9.0 → secure_code_agent-0.11.0}/src/secure_code_audit/scanners/checkov_scanner.py +0 -0
  38. {secure_code_agent-0.9.0 → secure_code_agent-0.11.0}/src/secure_code_audit/scanners/floor.py +0 -0
  39. {secure_code_agent-0.9.0 → secure_code_agent-0.11.0}/src/secure_code_audit/scanners/gitleaks_scanner.py +0 -0
  40. {secure_code_agent-0.9.0 → secure_code_agent-0.11.0}/src/secure_code_audit/scanners/gosec_scanner.py +0 -0
  41. {secure_code_agent-0.9.0 → secure_code_agent-0.11.0}/src/secure_code_audit/scanners/hadolint_scanner.py +0 -0
  42. {secure_code_agent-0.9.0 → secure_code_agent-0.11.0}/src/secure_code_audit/scanners/njsscan_scanner.py +0 -0
  43. {secure_code_agent-0.9.0 → secure_code_agent-0.11.0}/src/secure_code_audit/scanners/npm_audit_scanner.py +0 -0
  44. {secure_code_agent-0.9.0 → secure_code_agent-0.11.0}/src/secure_code_audit/scanners/osv_scanner.py +0 -0
  45. {secure_code_agent-0.9.0 → secure_code_agent-0.11.0}/src/secure_code_audit/scanners/pip_audit_scanner.py +0 -0
  46. {secure_code_agent-0.9.0 → secure_code_agent-0.11.0}/src/secure_code_audit/scanners/rubocop_scanner.py +0 -0
  47. {secure_code_agent-0.9.0 → secure_code_agent-0.11.0}/src/secure_code_audit/scanners/scorecard_scanner.py +0 -0
  48. {secure_code_agent-0.9.0 → secure_code_agent-0.11.0}/src/secure_code_audit/scanners/semgrep_scanner.py +0 -0
  49. {secure_code_agent-0.9.0 → secure_code_agent-0.11.0}/src/secure_code_audit/scanners/trivy_scanner.py +0 -0
  50. {secure_code_agent-0.9.0 → secure_code_agent-0.11.0}/src/secure_code_audit/scanners/trufflehog_scanner.py +0 -0
  51. {secure_code_agent-0.9.0 → secure_code_agent-0.11.0}/src/secure_code_audit/suppressions.py +0 -0
  52. {secure_code_agent-0.9.0 → secure_code_agent-0.11.0}/src/secure_code_audit/verify.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: secure-code-agent
3
- Version: 0.9.0
3
+ Version: 0.11.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
@@ -62,6 +62,34 @@ The sibling of [`maintainability-agent`](https://github.com/marshallguillory86/m
62
62
 
63
63
  ---
64
64
 
65
+ ## Try it in one command
66
+
67
+ ```bash
68
+ pip install 'secure-code-agent[python-scanners]'
69
+ secure-code-agent --demo
70
+ ```
71
+
72
+ `--demo` generates a small application carrying seven real defects — SQL
73
+ injection, command injection, unsafe deserialization, a weak hash, dynamic
74
+ evaluation — audits it, and writes the work order you would hand an agent. The
75
+ tree is generated at runtime into a temp directory, so installing this tool
76
+ never puts vulnerable source on your disk.
77
+
78
+ To audit something real:
79
+
80
+ ```bash
81
+ secure-code-agent /path/to/repo
82
+ ```
83
+
84
+ Any scanner that is missing is named, with the command to install it. This
85
+ tool never installs anything itself — naming a command is not running one —
86
+ and it will not grade what it could not examine. `--preflight` checks the
87
+ whole floor before a run.
88
+
89
+ A pure-Python repository requires six of the ten floor scanners; the Ruby,
90
+ JavaScript and container tools are *not applicable* rather than missing, so a
91
+ single-language project is never permanently incomplete.
92
+
65
93
  ## Why this exists
66
94
 
67
95
  AI coding agents ship code at human-review-saturating speed. Point them at a security finding and the documented anti-patterns are:
@@ -442,7 +470,7 @@ For agents that support invokable skills, this repo ships a portable skill under
442
470
  ## GitHub Action
443
471
 
444
472
  ```yaml
445
- - uses: marshallguillory86/secure-code-agent@v0.3.0
473
+ - uses: marshallguillory86/secure-code-agent@v0.11.0
446
474
  with:
447
475
  config: secure-code-agent.json
448
476
  fail-on-gate: true
@@ -508,7 +536,7 @@ Full design philosophy in [`docs/design.md`](docs/design.md).
508
536
  - [`docs/calibration.md`](docs/calibration.md) — The calibration study, its corpus, and what it found
509
537
  - [`docs/design.md`](docs/design.md) — Architecture + non-goals + scanner protocol
510
538
  - [`docs/architecture.md`](docs/architecture.md) — Audit of the system as built + remediation sequence
511
- - [`docs/release-blockers.md`](docs/release-blockers.md) — Open v0.3.0 release blockers (do not tag until closed)
539
+ - [`docs/release-blockers.md`](docs/release-blockers.md) — the v0.3.0 release blockers, all closed (historical)
512
540
  - [`docs/standards.md`](docs/standards.md) — NIST SSDF / OWASP / CWE / Scorecard / SARIF citations
513
541
  - [`docs/scoring.md`](docs/scoring.md) — Weighting model + worked examples
514
542
  - [`docs/scanners.md`](docs/scanners.md) — Per-scanner integrations + caveats
@@ -16,6 +16,34 @@ The sibling of [`maintainability-agent`](https://github.com/marshallguillory86/m
16
16
 
17
17
  ---
18
18
 
19
+ ## Try it in one command
20
+
21
+ ```bash
22
+ pip install 'secure-code-agent[python-scanners]'
23
+ secure-code-agent --demo
24
+ ```
25
+
26
+ `--demo` generates a small application carrying seven real defects — SQL
27
+ injection, command injection, unsafe deserialization, a weak hash, dynamic
28
+ evaluation — audits it, and writes the work order you would hand an agent. The
29
+ tree is generated at runtime into a temp directory, so installing this tool
30
+ never puts vulnerable source on your disk.
31
+
32
+ To audit something real:
33
+
34
+ ```bash
35
+ secure-code-agent /path/to/repo
36
+ ```
37
+
38
+ Any scanner that is missing is named, with the command to install it. This
39
+ tool never installs anything itself — naming a command is not running one —
40
+ and it will not grade what it could not examine. `--preflight` checks the
41
+ whole floor before a run.
42
+
43
+ A pure-Python repository requires six of the ten floor scanners; the Ruby,
44
+ JavaScript and container tools are *not applicable* rather than missing, so a
45
+ single-language project is never permanently incomplete.
46
+
19
47
  ## Why this exists
20
48
 
21
49
  AI coding agents ship code at human-review-saturating speed. Point them at a security finding and the documented anti-patterns are:
@@ -396,7 +424,7 @@ For agents that support invokable skills, this repo ships a portable skill under
396
424
  ## GitHub Action
397
425
 
398
426
  ```yaml
399
- - uses: marshallguillory86/secure-code-agent@v0.3.0
427
+ - uses: marshallguillory86/secure-code-agent@v0.11.0
400
428
  with:
401
429
  config: secure-code-agent.json
402
430
  fail-on-gate: true
@@ -462,7 +490,7 @@ Full design philosophy in [`docs/design.md`](docs/design.md).
462
490
  - [`docs/calibration.md`](docs/calibration.md) — The calibration study, its corpus, and what it found
463
491
  - [`docs/design.md`](docs/design.md) — Architecture + non-goals + scanner protocol
464
492
  - [`docs/architecture.md`](docs/architecture.md) — Audit of the system as built + remediation sequence
465
- - [`docs/release-blockers.md`](docs/release-blockers.md) — Open v0.3.0 release blockers (do not tag until closed)
493
+ - [`docs/release-blockers.md`](docs/release-blockers.md) — the v0.3.0 release blockers, all closed (historical)
466
494
  - [`docs/standards.md`](docs/standards.md) — NIST SSDF / OWASP / CWE / Scorecard / SARIF citations
467
495
  - [`docs/scoring.md`](docs/scoring.md) — Weighting model + worked examples
468
496
  - [`docs/scanners.md`](docs/scanners.md) — Per-scanner integrations + caveats
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: secure-code-agent
3
- Version: 0.9.0
3
+ Version: 0.11.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
@@ -62,6 +62,34 @@ The sibling of [`maintainability-agent`](https://github.com/marshallguillory86/m
62
62
 
63
63
  ---
64
64
 
65
+ ## Try it in one command
66
+
67
+ ```bash
68
+ pip install 'secure-code-agent[python-scanners]'
69
+ secure-code-agent --demo
70
+ ```
71
+
72
+ `--demo` generates a small application carrying seven real defects — SQL
73
+ injection, command injection, unsafe deserialization, a weak hash, dynamic
74
+ evaluation — audits it, and writes the work order you would hand an agent. The
75
+ tree is generated at runtime into a temp directory, so installing this tool
76
+ never puts vulnerable source on your disk.
77
+
78
+ To audit something real:
79
+
80
+ ```bash
81
+ secure-code-agent /path/to/repo
82
+ ```
83
+
84
+ Any scanner that is missing is named, with the command to install it. This
85
+ tool never installs anything itself — naming a command is not running one —
86
+ and it will not grade what it could not examine. `--preflight` checks the
87
+ whole floor before a run.
88
+
89
+ A pure-Python repository requires six of the ten floor scanners; the Ruby,
90
+ JavaScript and container tools are *not applicable* rather than missing, so a
91
+ single-language project is never permanently incomplete.
92
+
65
93
  ## Why this exists
66
94
 
67
95
  AI coding agents ship code at human-review-saturating speed. Point them at a security finding and the documented anti-patterns are:
@@ -442,7 +470,7 @@ For agents that support invokable skills, this repo ships a portable skill under
442
470
  ## GitHub Action
443
471
 
444
472
  ```yaml
445
- - uses: marshallguillory86/secure-code-agent@v0.3.0
473
+ - uses: marshallguillory86/secure-code-agent@v0.11.0
446
474
  with:
447
475
  config: secure-code-agent.json
448
476
  fail-on-gate: true
@@ -508,7 +536,7 @@ Full design philosophy in [`docs/design.md`](docs/design.md).
508
536
  - [`docs/calibration.md`](docs/calibration.md) — The calibration study, its corpus, and what it found
509
537
  - [`docs/design.md`](docs/design.md) — Architecture + non-goals + scanner protocol
510
538
  - [`docs/architecture.md`](docs/architecture.md) — Audit of the system as built + remediation sequence
511
- - [`docs/release-blockers.md`](docs/release-blockers.md) — Open v0.3.0 release blockers (do not tag until closed)
539
+ - [`docs/release-blockers.md`](docs/release-blockers.md) — the v0.3.0 release blockers, all closed (historical)
512
540
  - [`docs/standards.md`](docs/standards.md) — NIST SSDF / OWASP / CWE / Scorecard / SARIF citations
513
541
  - [`docs/scoring.md`](docs/scoring.md) — Weighting model + worked examples
514
542
  - [`docs/scanners.md`](docs/scanners.md) — Per-scanner integrations + caveats
@@ -11,6 +11,7 @@ src/secure_code_audit/__init__.py
11
11
  src/secure_code_audit/baseline.py
12
12
  src/secure_code_audit/cli.py
13
13
  src/secure_code_audit/config.py
14
+ src/secure_code_audit/demo.py
14
15
  src/secure_code_audit/findings.py
15
16
  src/secure_code_audit/git_tools.py
16
17
  src/secure_code_audit/history.py
@@ -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.9.0"
15
+ __version__ = "0.11.0"
@@ -15,6 +15,7 @@ from pathlib import Path
15
15
 
16
16
  from secure_code_audit import (
17
17
  __version__,
18
+ demo,
18
19
  instructions,
19
20
  remediation,
20
21
  renderers,
@@ -136,9 +137,22 @@ def _parser() -> argparse.ArgumentParser:
136
137
  "--trust-target-config",
137
138
  action="store_true",
138
139
  help=(
139
- "Allow a config inside the audited tree to name executables from "
140
- "that tree. Only for repositories you own. CLI-only by design: a "
141
- "config file cannot grant itself this."
140
+ "Treat a config inside the audited tree as if you wrote it. This "
141
+ "grants it TWO things: (1) it may name executables from that tree, "
142
+ "which this host will then run, and (2) it may direct this tool's "
143
+ "outputs, baseline and history to paths outside the tree. Only for "
144
+ "repositories you own. CLI-only by design: a config file cannot "
145
+ "grant itself this."
146
+ ),
147
+ )
148
+
149
+ p.add_argument(
150
+ "--demo",
151
+ action="store_true",
152
+ help=(
153
+ "Audit a generated example application instead of a repository, so "
154
+ "a first run produces a real work order without a scanner scavenger "
155
+ "hunt. The tree is written to a temp directory and is never shipped."
142
156
  ),
143
157
  )
144
158
 
@@ -160,7 +174,11 @@ def _parser() -> argparse.ArgumentParser:
160
174
  "--target",
161
175
  action="append",
162
176
  default=[],
163
- help="Target for --init-agent-standards (codex, claude-code, cursor, copilot, windsurf, generic).",
177
+ help=(
178
+ "Agent name for --init-agent-standards (codex, claude-code, cursor, "
179
+ "copilot, windsurf, generic). NOT the repository to audit — pass that "
180
+ "as a positional path."
181
+ ),
164
182
  )
165
183
  p.add_argument(
166
184
  "--instructions-output-dir",
@@ -183,6 +201,32 @@ def main(argv: list[str] | None = None) -> int:
183
201
  if args.init_agent_standards:
184
202
  return _do_init_standards(args)
185
203
 
204
+ if getattr(args, "demo", False):
205
+ # `paths` defaults to ["."], so "was a path given" is a comparison
206
+ # against the default rather than a truth test.
207
+ if args.paths != ["."]:
208
+ sys.stderr.write("ERROR: --demo audits its own generated tree; drop the path.\n")
209
+ return 2
210
+ root = demo.build()
211
+ print(demo.describe(root))
212
+ args.paths = [str(root)]
213
+
214
+ if args.target:
215
+ # `--target` names an *agent* for --init-agent-standards, and it reads
216
+ # exactly like the flag for "the repository to audit". It was accepted
217
+ # and silently discarded on an audit run, so `--target /some/repo`
218
+ # audited the current directory instead and reported a clean result
219
+ # for a repository nobody had looked at. Found by using the tool: a
220
+ # 136k-line control was audited from inside itself and appeared to
221
+ # honour the flag, which is the kind of coincidence that keeps a bug.
222
+ sys.stderr.write(
223
+ "ERROR: --target names an agent for --init-agent-standards, not a "
224
+ "repository to audit.\n"
225
+ f" To audit a repository, pass it as a path: "
226
+ f"secure-code-agent {args.target[0]}\n"
227
+ )
228
+ return 2
229
+
186
230
  try:
187
231
  return _do_preflight(args) if args.preflight else _do_audit(args)
188
232
  except ValueError as exc:
@@ -279,6 +323,8 @@ def _print_preflight(rows: list[dict], unselected: list[str], blocking: list[str
279
323
  def _do_audit(args: argparse.Namespace) -> int:
280
324
  cfg, target, root = _prepare_audit(args)
281
325
  _require_configured_gates(args, cfg)
326
+ # Before anything is resolved or written, and before any scanner runs.
327
+ _assert_config_writes_are_contained(cfg, root, target)
282
328
  # Resolved before the scan so the run can recognise its own artifacts.
283
329
  paths = _resolve_outputs(args, cfg, root)
284
330
 
@@ -902,6 +948,102 @@ def _under_root(root: Path, value: str) -> Path:
902
948
  return path.resolve() if path.is_absolute() else (root / path).resolve()
903
949
 
904
950
 
951
+ #: Config keys naming a file this tool writes.
952
+ _WRITE_KEYS = (
953
+ "markdown_path",
954
+ "json_path",
955
+ "sarif_path",
956
+ "comment_path",
957
+ "prompt_path",
958
+ "security_pillar_path",
959
+ "baseline_path",
960
+ "history_path",
961
+ )
962
+
963
+
964
+ def _assert_config_writes_are_contained(cfg: config_mod.Config, root: Path, target: Path) -> None:
965
+ """A tree may not choose where the host writes.
966
+
967
+ D1 draws the line at *executing* what the tree supplies, and that was the
968
+ only boundary implemented. The tree could still choose file *paths*: the
969
+ default configuration is loaded from the audit target, and its
970
+ `outputs.*_path`, baseline and history values were resolved against the
971
+ root with no containment check at all. A repository shipping
972
+
973
+ {"outputs": {"markdown_path": "../../../../.bashrc"}}
974
+
975
+ had an ordinary audit overwrite that file, with the report's own content.
976
+ Arbitrary write is not a lesser thing than arbitrary execute; it is
977
+ usually a slower route to the same place.
978
+
979
+ Three escapes, all closed here and all in the red contract: `..`
980
+ traversal, an absolute path, and a **symlink inside the tree** pointing
981
+ out of it. The third is why this resolves before comparing rather than
982
+ checking the string — `escaped/report.md`, where `escaped` is a symlink
983
+ to the parent, is textually innocent.
984
+
985
+ **Only for a config the tree supplied.** An operator whose config lives
986
+ outside the audited tree keeps full authority over where things go, and
987
+ so does anyone passing `--trust-target-config`. Explicit CLI output flags
988
+ are never touched by this: `--output /tmp/report.md` is the operator
989
+ speaking, and they are checked nowhere in this function.
990
+
991
+ Raises ValueError, so the CLI exits 2 before any scanner runs and before
992
+ a single byte is written.
993
+ """
994
+ if not config_mod.target_config_is_untrusted(cfg, target):
995
+ return
996
+ for key in _WRITE_KEYS:
997
+ configured = cfg.outputs.get(key)
998
+ if not configured:
999
+ continue
1000
+ destination = _under_root(root, configured)
1001
+ if config_mod.is_within(destination, root):
1002
+ continue
1003
+ raise ValueError(
1004
+ f"outputs.{key} in {cfg.source_path} resolves to {destination}, which is "
1005
+ f"outside the audit root {root}. Configuration supplied by the audited "
1006
+ f"tree may not choose where this tool writes. Pass the path on the "
1007
+ f"command line instead, move the configuration outside the tree, or "
1008
+ f"re-run with --trust-target-config if you wrote this file yourself."
1009
+ )
1010
+
1011
+
1012
+ def _print_install_guidance(unavailable: list[str]) -> None:
1013
+ """Say how to resolve a missing scanner, at the moment it is missing.
1014
+
1015
+ `--preflight` already produced exactly this — one install command per
1016
+ scanner — and the failure path printed only the names. So the first run
1017
+ of a fresh install reported four missing tools and left the operator to
1018
+ find `--preflight` on their own, which is a scavenger hunt wearing a
1019
+ coverage report.
1020
+
1021
+ The guidance is the adapter's own `install_hint`, so it cannot drift from
1022
+ what `--preflight` says. The tool still installs nothing itself; naming
1023
+ the command is not running it.
1024
+ """
1025
+ from secure_code_audit.scanners import SCANNERS
1026
+
1027
+ def hint_for(name: str) -> str:
1028
+ """Empty rather than raising. Advice must never break a run, and an
1029
+ imported SARIF can name a scanner this build does not ship."""
1030
+ cls = SCANNERS.get(name)
1031
+ if cls is None:
1032
+ return ""
1033
+ try:
1034
+ return cls().unavailable_fix_hint() or ""
1035
+ except Exception: # noqa: BLE001 — see above
1036
+ return ""
1037
+
1038
+ shown = [(name, hint) for name in unavailable if (hint := hint_for(name))]
1039
+ if not shown:
1040
+ return
1041
+ print(" to resolve:")
1042
+ for name, hint in shown:
1043
+ print(f" {name}: {hint}")
1044
+ print(" (or --preflight to check the whole floor before a run)")
1045
+
1046
+
905
1047
  def _print_summary(
906
1048
  verdict, score, gate, ran, unavailable, coverage, paths, axes=(), trend=None
907
1049
  ) -> None:
@@ -934,6 +1076,7 @@ def _print_summary(
934
1076
  print(coverage_line)
935
1077
  if unavailable:
936
1078
  print(f" unavailable: {', '.join(unavailable)}")
1079
+ _print_install_guidance(unavailable)
937
1080
  if not gate.passed:
938
1081
  for reason in gate.reasons:
939
1082
  print(f" ✗ {reason}")
@@ -8,7 +8,7 @@ from __future__ import annotations
8
8
 
9
9
  import json
10
10
  from dataclasses import dataclass, field
11
- from pathlib import Path
11
+ from pathlib import Path, PurePosixPath
12
12
  from typing import Any
13
13
 
14
14
  DEFAULT_CONFIG_PATH = Path("secure-code-agent.json")
@@ -37,6 +37,42 @@ DEFAULT_EXCLUDES: tuple[str, ...] = (
37
37
  "**/npm-shrinkwrap.json",
38
38
  "**/pnpm-lock.yaml",
39
39
  "**/bun.lockb",
40
+ # --- stored analysis output -------------------------------------------
41
+ #
42
+ # This tool's own output is never its input, *wherever* it is stored.
43
+ #
44
+ # `cli._own_artifacts` already removes the paths the current run is about
45
+ # to write, which is what stopped an audit scoring the report it had just
46
+ # produced. It cannot see a *copy* kept somewhere else, and two
47
+ # independent reports of that landed on the same day:
48
+ #
49
+ # - this repository scanned `calibration/.corpus` — fourteen cloned
50
+ # third-party projects, 556,808 LOC and 550 findings, all about code
51
+ # that is not ours;
52
+ # - `maintainability-agent` scanned `tools/validation/reports/` —
53
+ # 957,219 LOC of stored audit output *about other repositories*,
54
+ # 4,929 findings, which diluted five genuine criticals to an A-.
55
+ #
56
+ # A stored report is the worst possible input: it quotes findings
57
+ # verbatim, including the code snippets and the redacted secrets that
58
+ # produced them, so it manufactures findings about findings and inflates
59
+ # the denominator at the same time.
60
+ #
61
+ # Filename patterns rather than directory names, because the directory is
62
+ # whatever the operator chose and the filenames are ours. Derived from
63
+ # `DEFAULT_OUTPUTS` below so the two cannot drift — see
64
+ # `_own_output_globs`.
65
+ #
66
+ # Deliberately NOT here: `vendor/`, `third_party/` and their kin. Vendored
67
+ # code is deployed code, and excluding it by default would hide real
68
+ # vulnerabilities in exactly the place nobody is reading. Stored analysis
69
+ # output is not code at all; that is the whole difference.
70
+ ".secure-code/",
71
+ "**/.secure-code/",
72
+ # maintainability-agent's state and output directory. Same argument: its
73
+ # reports quote findings, and its history is append-only JSONL.
74
+ ".maintainability/",
75
+ "**/.maintainability/",
40
76
  )
41
77
 
42
78
  #: Conventional test-tree locations across the languages the floor reads.
@@ -105,8 +141,36 @@ DEFAULT_OUTPUTS: dict[str, str] = {
105
141
  "baseline_path": "secure-code-baseline.json",
106
142
  # Append-only trend. The score's one genuine use is movement over time.
107
143
  "history_path": ".secure-code/history.jsonl",
144
+ # The artifact maintainability-agent ingests (D3). Off unless declared,
145
+ # like sarif/json/comment — the default only supplies the name.
146
+ #
147
+ # It was missing here while `_resolve_outputs` read it, so
148
+ # `outputs.security_pillar_path` was a **dead key**: the validation loop
149
+ # below iterates this mapping, so a configured value never reached
150
+ # `cfg.outputs` and the resolver read `None` from it every time. That is
151
+ # the seventh instance of the defect `_resolve_outputs` already
152
+ # describes — "four of the six keys were dead the same way".
153
+ "security_pillar_path": "security-pillar.json",
108
154
  }
109
155
 
156
+
157
+ def _own_output_globs() -> tuple[str, ...]:
158
+ """Match this tool's default output filenames anywhere in a tree.
159
+
160
+ Kept as a function over `DEFAULT_OUTPUTS` rather than a hand-written list
161
+ so that adding an output cannot leave a file this tool writes readable by
162
+ the next run. `history_path` is already covered by the `.secure-code/`
163
+ directory entries; matching its basename anywhere would be wrong, since
164
+ `history.jsonl` is not a name this project owns.
165
+ """
166
+ names = {
167
+ PurePosixPath(path).name for key, path in DEFAULT_OUTPUTS.items() if key != "history_path"
168
+ }
169
+ return tuple(sorted(f"**/{name}" for name in names))
170
+
171
+
172
+ DEFAULT_EXCLUDES = DEFAULT_EXCLUDES + _own_output_globs()
173
+
110
174
  _SEVERITIES = {"critical", "high", "medium", "low", "informational"}
111
175
  _CATEGORIES = {
112
176
  "secrets",
@@ -251,6 +315,51 @@ def is_within(path: Path, root: Path) -> bool:
251
315
  return resolved == root_resolved or root_resolved in resolved.parents
252
316
 
253
317
 
318
+ def containment_root(target: Path) -> Path:
319
+ """The directory every containment decision is made against.
320
+
321
+ **A file target's trust boundary is its parent, not the file.** Auditing
322
+ `app.py` meant `is_within(<anything>, app.py)` was false for everything,
323
+ because nothing lives beneath a regular file — so every containment test
324
+ inverted to "allowed" at once. A `secure-code-agent.json` beside the file
325
+ read as *outside* the tree, and `--only-scanners bandit` with an in-tree
326
+ `command` executed a script from the audited repository without
327
+ `--trust-target-config`. The single-file shape silently opted out of the
328
+ guard that D1 exists to hold.
329
+
330
+ Directories are returned unchanged, so the ordinary repository audit is
331
+ unaffected.
332
+ """
333
+ resolved = target.resolve()
334
+ return resolved if resolved.is_dir() else resolved.parent
335
+
336
+
337
+ def target_config_is_untrusted(config: Config, target: Path) -> bool:
338
+ """Was this configuration supplied by the audited tree?
339
+
340
+ Location, not provenance of the flag: a config *inside* the target is
341
+ repository content whether it was found by default discovery or named
342
+ explicitly with `--config`. An operator who means to trust it says so with
343
+ `--trust-target-config`.
344
+
345
+ **This is not the negation of `target_executables_allowed`, and writing it
346
+ as one was a live regression.** They disagree when there is no config file
347
+ at all. For execution, "no config" must still *deny* tree-local
348
+ executables — nothing has authorised them, and an absent file is not
349
+ permission. For writes, "no config" is simply nothing to distrust: the
350
+ built-in defaults are relative names that cannot escape. Collapsing the
351
+ two flipped the execution guard open for every run with no config, which
352
+ `test_defaults_never_allow_executables_from_the_tree` caught immediately.
353
+
354
+ Only `containment_root` is shared.
355
+ """
356
+ if config.trust_target_config:
357
+ return False
358
+ if config.source_path is None:
359
+ return False # built-in defaults are relative names; nothing can escape
360
+ return is_within(config.source_path, containment_root(target))
361
+
362
+
254
363
  def target_executables_allowed(config: Config, target: Path) -> bool:
255
364
  """May this configuration name an executable inside the audited tree?
256
365
 
@@ -264,12 +373,15 @@ def target_executables_allowed(config: Config, target: Path) -> bool:
264
373
  repositories wholesale, so a config the operator keeps outside the tree
265
374
  still gets the documented tree-local interpreter workflow. Inside the tree,
266
375
  it takes an explicit `--trust-target-config`.
376
+
377
+ See `target_config_is_untrusted` for why this is *not* expressed as its
378
+ negation: an absent config denies here and is harmless there.
267
379
  """
268
380
  if config.trust_target_config:
269
381
  return True
270
382
  if config.source_path is None:
271
- return False # built-in defaults name no commands anyway
272
- return not is_within(config.source_path, target)
383
+ return False # nothing authorised a tree-local command
384
+ return not is_within(config.source_path, containment_root(target))
273
385
 
274
386
 
275
387
  #: Top-level keys the loader understands. Mirrors `properties` in
@@ -378,6 +490,19 @@ def _from_dict(raw: dict[str, Any]) -> Config:
378
490
  outputs = raw.get("outputs", {})
379
491
  if not isinstance(outputs, dict):
380
492
  raise ValueError("outputs must be a JSON object")
493
+ # D2 applies inside `outputs`, and it did not. The loop below iterates
494
+ # the *known* keys, so anything else was read by nobody and reported by
495
+ # nobody: `outputs.markdwon_path` was accepted in silence while the
496
+ # report went to the default path. That is precisely the failure D2 cites
497
+ # for top-level keys — "a typo'd gate name disabled a gate with no
498
+ # diagnostic" — surviving one level down because the check was never
499
+ # applied recursively.
500
+ unknown = sorted(set(outputs) - set(DEFAULT_OUTPUTS))
501
+ if unknown:
502
+ raise ValueError(
503
+ f"unknown outputs key(s): {', '.join(unknown)}. "
504
+ f"Known keys: {', '.join(sorted(DEFAULT_OUTPUTS))}"
505
+ )
381
506
  for k, default_v in DEFAULT_OUTPUTS.items():
382
507
  value = outputs.get(k, default_v)
383
508
  # `null` turns an output off. The markdown report and the work order