@clear-capabilities/agentic-security-scanner 0.134.0 → 0.136.2

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 (93) hide show
  1. package/CHANGELOG.md +196 -0
  2. package/bin/agentic-security-audit.js +2 -1
  3. package/bin/agentic-security-consistency.js +2 -1
  4. package/bin/agentic-security.js +269 -44
  5. package/dist/113.index.js +6 -6
  6. package/dist/117.index.js +3 -1
  7. package/dist/178.index.js +1 -1
  8. package/dist/220.index.js +5 -3
  9. package/dist/238.index.js +4 -4
  10. package/dist/317.index.js +270 -0
  11. package/dist/384.index.js +1 -1
  12. package/dist/435.index.js +13 -7
  13. package/dist/513.index.js +7 -3
  14. package/dist/526.index.js +6 -6
  15. package/dist/637.index.js +1 -1
  16. package/dist/675.index.js +7 -5
  17. package/dist/839.index.js +4 -3
  18. package/dist/905.index.js +1173 -0
  19. package/dist/agentic-security.mjs +14 -14
  20. package/dist/agentic-security.mjs.sha256 +1 -1
  21. package/dist/compliance-frameworks/ccpa.json +32 -0
  22. package/dist/compliance-frameworks/eu-ai-act.json +51 -0
  23. package/dist/compliance-frameworks/gdpr.json +45 -0
  24. package/dist/compliance-frameworks/hipaa-security-rule.json +56 -0
  25. package/dist/compliance-frameworks/nist-ai-600-1.json +51 -0
  26. package/dist/compliance-frameworks/nist-csf-2.json +73 -0
  27. package/dist/compliance-frameworks/nist-privacy-1-1.json +846 -0
  28. package/dist/compliance-frameworks/owasp-asvs-5.json +79 -0
  29. package/dist/compliance-frameworks/owasp-llm-top-10.json +69 -0
  30. package/package.json +9 -5
  31. package/src/badge.js +2 -1
  32. package/src/dataflow/cross-service-taint.js +2 -1
  33. package/src/dataflow/ifds-precise.js +6 -4
  34. package/src/dataflow/incremental.js +7 -5
  35. package/src/discovery/CLAUDE.md +10 -0
  36. package/src/discovery/index.js +175 -3
  37. package/src/discovery/llm-invoke.js +90 -1
  38. package/src/discovery/memory.js +163 -0
  39. package/src/engine.js +45 -8
  40. package/src/integrations/tickets.js +7 -6
  41. package/src/ir/ir-stats.js +1 -1
  42. package/src/ir/parser-cpp.js +1 -1
  43. package/src/leaderboard.js +3 -2
  44. package/src/llm-validator/consistency.js +6 -2
  45. package/src/llm-validator/index.js +1 -2
  46. package/src/mcp/audit.js +2 -1
  47. package/src/mcp/tools.js +4 -3
  48. package/src/posture/CLAUDE.md +26 -1
  49. package/src/posture/accuracy-scorecard.js +59 -1
  50. package/src/posture/agents-memory.js +5 -3
  51. package/src/posture/auditor-walkthrough.js +9 -7
  52. package/src/posture/auth-posture-import.js +5 -4
  53. package/src/posture/compliance-frameworks/nist-privacy-1-1.json +846 -0
  54. package/src/posture/compliance-policy.js +7 -9
  55. package/src/posture/custom-rules.js +7 -5
  56. package/src/posture/cve-alert-daemon.js +6 -5
  57. package/src/posture/dep-add-guard.js +2 -1
  58. package/src/posture/deterministic.js +3 -2
  59. package/src/posture/evidence-bundle.js +246 -0
  60. package/src/posture/exploitability-probability.js +2 -1
  61. package/src/posture/feature-flags.js +3 -2
  62. package/src/posture/findings-memory.js +3 -3
  63. package/src/posture/fix-history.js +5 -2
  64. package/src/posture/fix-metrics.js +5 -5
  65. package/src/posture/fix-plan.js +2 -1
  66. package/src/posture/grader-calibration.js +3 -4
  67. package/src/posture/intent-context.js +2 -1
  68. package/src/posture/learning.js +4 -3
  69. package/src/posture/license-attributions.js +5 -7
  70. package/src/posture/license-graph.js +2 -1
  71. package/src/posture/license-policy.js +2 -1
  72. package/src/posture/model-rescan.js +4 -3
  73. package/src/posture/network-policy-import.js +3 -2
  74. package/src/posture/pqc-migration-plan.js +7 -5
  75. package/src/posture/pr-augment.js +8 -5
  76. package/src/posture/privacy-framework.js +262 -0
  77. package/src/posture/risk-dollars.js +2 -2
  78. package/src/posture/router.js +5 -4
  79. package/src/posture/ruleset-version.js +2 -2
  80. package/src/posture/runtime-correlation.js +2 -1
  81. package/src/posture/sbom-diff.js +12 -3
  82. package/src/posture/sca-policy.js +7 -4
  83. package/src/posture/scan-checkpoint.js +15 -0
  84. package/src/posture/state-dir.js +34 -0
  85. package/src/posture/telemetry-ingest.js +4 -3
  86. package/src/posture/threat-model-auto.js +4 -1
  87. package/src/posture/threat-model-grounding.js +11 -1
  88. package/src/posture/time-to-fix.js +3 -2
  89. package/src/posture/triage-memory.js +3 -2
  90. package/src/posture/waf-ingest.js +6 -5
  91. package/src/posture/watch-mode.js +4 -3
  92. package/src/sast/code-injection-multilang.js +29 -0
  93. package/src/sca/dep-confusion.js +2 -1
@@ -0,0 +1,79 @@
1
+ {
2
+ "id": "owasp-asvs-5",
3
+ "name": "OWASP Application Security Verification Standard 5.0",
4
+ "publisher": "OWASP Foundation",
5
+ "license": "Creative Commons Attribution-ShareAlike 4.0",
6
+ "url": "https://owasp.org/www-project-application-security-verification-standard/",
7
+ "controls": [
8
+ {
9
+ "id": "V2.1",
10
+ "category": "Authentication",
11
+ "summary": "Verify that authentication is performed for protected functions.",
12
+ "evidence": ["Zero open critical findings in family auth-missing on the current scan."],
13
+ "mapsTo": ["family:auth-missing"]
14
+ },
15
+ {
16
+ "id": "V2.7",
17
+ "category": "Authentication",
18
+ "summary": "Verify that MFA / secondary authentication is used for high-value operations.",
19
+ "evidence": ["aws-no-mfa-condition zero findings (if AWS in scope)."],
20
+ "mapsTo": ["family:aws-no-mfa"]
21
+ },
22
+ {
23
+ "id": "V4.1",
24
+ "category": "Access Control",
25
+ "summary": "Verify that authorization is enforced for every resource access.",
26
+ "evidence": ["Zero idor / authz findings."],
27
+ "mapsTo": ["family:idor", "family:authz"]
28
+ },
29
+ {
30
+ "id": "V5.1",
31
+ "category": "Input Validation",
32
+ "summary": "Verify input validation for type, length, and content.",
33
+ "evidence": ["Zero open critical findings in sqli/xss/command-injection/ldap-injection/xpath-injection."],
34
+ "mapsTo": ["family:sqli", "family:xss", "family:command-injection", "family:ldap-injection", "family:xpath-injection", "family:nosql-injection"]
35
+ },
36
+ {
37
+ "id": "V6.1",
38
+ "category": "Cryptography",
39
+ "summary": "Verify use of strong cryptography per a documented policy.",
40
+ "evidence": ["Zero open findings in crypto-weak-cipher / crypto-weak-hash / crypto-ecb / crypto-static-iv."],
41
+ "mapsTo": ["family:crypto-weak-cipher", "family:crypto-weak-hash", "family:crypto-ecb", "family:crypto-static-iv", "family:crypto-kdf-weak"]
42
+ },
43
+ {
44
+ "id": "V6.2",
45
+ "category": "Cryptography",
46
+ "summary": "Verify all transit data uses TLS ≥ 1.2.",
47
+ "evidence": ["Zero findings in crypto-tls-version / crypto-tls-no-verify."],
48
+ "mapsTo": ["family:crypto-tls-version", "family:crypto-tls-no-verify"]
49
+ },
50
+ {
51
+ "id": "V7.1",
52
+ "category": "Error Handling & Logging",
53
+ "summary": "Verify that error handling does not disclose sensitive information.",
54
+ "evidence": ["No findings in family data-exposure."],
55
+ "mapsTo": ["family:data-exposure"]
56
+ },
57
+ {
58
+ "id": "V8.1",
59
+ "category": "Data Protection",
60
+ "summary": "Verify that personal data is protected at rest and in transit.",
61
+ "evidence": ["DPIA artifact at .agentic-security/dpia.md.", "Zero pii-exposure findings."],
62
+ "mapsTo": ["family:pii-exposure", "family:training-data-pii"]
63
+ },
64
+ {
65
+ "id": "V10.1",
66
+ "category": "Malicious Code",
67
+ "summary": "Verify the application does not include known-malicious or compromised dependencies.",
68
+ "evidence": [".agentic-security/sbom-history snapshots clean.", "No dependency-confusion or dependency-drift findings."],
69
+ "mapsTo": ["family:vulnerable-dependency", "family:dependency-confusion", "family:dependency-drift"]
70
+ },
71
+ {
72
+ "id": "V14.1",
73
+ "category": "Configuration",
74
+ "summary": "Verify that build, deployment, and configuration are secure.",
75
+ "evidence": ["No findings in family iam-overpermissive / k8s-rbac-cluster-admin / k8s-pod-security-privileged."],
76
+ "mapsTo": ["family:iam-overpermissive", "family:k8s-rbac-cluster-admin", "family:k8s-pod-security-privileged"]
77
+ }
78
+ ]
79
+ }
@@ -0,0 +1,69 @@
1
+ {
2
+ "id": "owasp-llm-top-10",
3
+ "name": "OWASP Top 10 for LLM Applications 2025",
4
+ "publisher": "OWASP Foundation",
5
+ "license": "Creative Commons Attribution-ShareAlike 4.0",
6
+ "url": "https://owasp.org/www-project-top-10-for-large-language-model-applications/",
7
+ "controls": [
8
+ {
9
+ "id": "LLM01",
10
+ "summary": "Prompt Injection — controls preventing injected instructions from overriding the system prompt.",
11
+ "evidence": ["Zero open findings in family prompt-injection / llm-app-security.", "Prompt-template integrity check passing."],
12
+ "mapsTo": ["family:prompt-injection", "family:llm-app-security", "family:prompt-integrity"]
13
+ },
14
+ {
15
+ "id": "LLM02",
16
+ "summary": "Insecure Output Handling — LLM output is not directly piped to dangerous sinks (eval/shell/SQL/HTML).",
17
+ "evidence": ["Zero open findings in subfamily llm-output-untrusted-sink."],
18
+ "mapsTo": ["family:llm-app-security:llm-output-untrusted-sink"]
19
+ },
20
+ {
21
+ "id": "LLM03",
22
+ "summary": "Training Data Poisoning — provenance and integrity controls on training/fine-tuning data.",
23
+ "evidence": ["No streaming-dataset-url findings.", "datasets.load_dataset uses pinned revisions."],
24
+ "mapsTo": ["family:streaming-dataset-url", "family:hf-datasets-rce"]
25
+ },
26
+ {
27
+ "id": "LLM04",
28
+ "summary": "Model Denial of Service — rate-limit / token-cap controls.",
29
+ "evidence": ["LLM API calls in source carry an explicit max_tokens cap (bodyguard rule no-max-tokens)."],
30
+ "mapsTo": ["rule:no-max-tokens"]
31
+ },
32
+ {
33
+ "id": "LLM05",
34
+ "summary": "Supply Chain — model & dataset supply-chain integrity.",
35
+ "evidence": ["No mlflow-untrusted-uri / model-format / hf-endpoint-override findings.", "Sigstore provenance verification configured if opt-in."],
36
+ "mapsTo": ["family:mlflow-untrusted-uri", "family:model-format", "family:hf-endpoint-override"]
37
+ },
38
+ {
39
+ "id": "LLM06",
40
+ "summary": "Sensitive Information Disclosure — output filters prevent leakage.",
41
+ "evidence": ["Zero findings in subfamily llm-credential-in-prompt.", "Redact module covers prompt + response."],
42
+ "mapsTo": ["family:llm-app-security:llm-credential-in-prompt"]
43
+ },
44
+ {
45
+ "id": "LLM07",
46
+ "summary": "Insecure Plugin / Tool Design — tools the LLM can invoke are narrowly scoped.",
47
+ "evidence": ["Zero llm-app-security:llm-tool-exec findings.", "Agent tool definitions reviewed for excessive agency."],
48
+ "mapsTo": ["family:agent-tool-exec", "family:llm-app-security:llm-tool-exec"]
49
+ },
50
+ {
51
+ "id": "LLM08",
52
+ "summary": "Excessive Agency — agent autonomy is bounded by explicit guards.",
53
+ "evidence": ["MCP server config limits write-tools to confirm:true + reserved-write-path refusal.", "Apply-fix path requires HMAC-verified scan."],
54
+ "mapsTo": ["module:mcp-tools", "module:apply-fix"]
55
+ },
56
+ {
57
+ "id": "LLM09",
58
+ "summary": "Overreliance — human review checkpoints exist.",
59
+ "evidence": ["security-fixer agent has Stop-on-verify-fail loop.", "PreToolUse bodyguard exists."],
60
+ "mapsTo": ["module:security-fixer", "module:pre-edit-bodyguard"]
61
+ },
62
+ {
63
+ "id": "LLM10",
64
+ "summary": "Model Theft — model access controls.",
65
+ "evidence": ["No private-key-in-frontend or rpc-key-inline findings in client code."],
66
+ "mapsTo": ["family:private-key-in-frontend", "family:rpc-key-inline"]
67
+ }
68
+ ]
69
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clear-capabilities/agentic-security-scanner",
3
- "version": "0.134.0",
3
+ "version": "0.136.2",
4
4
  "description": "Scanner engine for the agentic-security Claude Code plugin — SAST, SCA (function-level reachability + CISA KEV), secrets, IaC, prompt-injection, MCP/agent-tool audit, auth/authZ deep analysis, attack chains, PoC generation, business logic, toxic-combinations scoring, SBOM, SARIF ingest, pipeline integrity, compliance attestation, and more.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -55,19 +55,19 @@
55
55
  "lodash-es": "^4.18.1"
56
56
  },
57
57
  "scripts": {
58
- "build": "ncc build bin/agentic-security.js -o dist --minify -e web-tree-sitter -e tree-sitter-wasms && mv dist/index.js dist/agentic-security.mjs && rm -f dist/package.json && chmod +x dist/agentic-security.mjs && node -e \"const fs=require('fs');const p='dist/agentic-security.mjs';const c=fs.readFileSync(p,'utf8');if(!c.startsWith('#!'))fs.writeFileSync(p,'#!/usr/bin/env node\\n'+c);\" && node -e \"const fs=require('fs');const c=require('crypto');const h=c.createHash('sha256').update(fs.readFileSync('dist/agentic-security.mjs')).digest('hex');fs.writeFileSync('dist/agentic-security.mjs.sha256',h+' agentic-security.mjs\\n');\"",
58
+ "build": "ncc build bin/agentic-security.js -o dist --minify -e web-tree-sitter -e tree-sitter-wasms && rm -rf dist/compliance-frameworks && mkdir -p dist/compliance-frameworks && cp src/posture/compliance-frameworks/*.json dist/compliance-frameworks/ && mv dist/index.js dist/agentic-security.mjs && rm -f dist/package.json && chmod +x dist/agentic-security.mjs && node -e \"const fs=require('fs');const p='dist/agentic-security.mjs';const c=fs.readFileSync(p,'utf8');if(!c.startsWith('#!'))fs.writeFileSync(p,'#!/usr/bin/env node\\n'+c);\" && node -e \"const fs=require('fs');const c=require('crypto');const h=c.createHash('sha256').update(fs.readFileSync('dist/agentic-security.mjs')).digest('hex');fs.writeFileSync('dist/agentic-security.mjs.sha256',h+' agentic-security.mjs\\n');\"",
59
59
  "prepare": "node ../scripts/pre-push-gate.mjs --install-hook",
60
60
  "prepublishOnly": "npm run build && node ../scripts/sync-scanner-changelog.mjs && node ../scripts/release-check.mjs",
61
61
  "test": "npm run test:smoke && npm run test:glob && npm run test:sast && npm run test:posture && npm run test:dataflow && npm run test:mcp && npm run test:report && npm run test:bench-modules && npm run test:lifecycle && npm run test:eval && npm run test:discovery && AGENTIC_SECURITY_CPP_DATAFLOW=1 node --test test/cpp-dataflow.test.js",
62
62
  "test:smoke": "node --test test/smoke.test.js",
63
63
  "test:glob": "node --test test/glob-compat.test.js",
64
64
  "test:sast": "node --test test/crypto-specialist.test.js test/llm.test.js test/llm-cost-advisor.test.js test/llm-owasp.test.js test/logic.test.js test/authz.test.js test/model-load.test.js test/prompt-template.test.js test/business-logic.test.js test/python-sinks.test.js test/phase1-detectors.test.js test/phase2-detectors.test.js test/phase3-v3.test.js test/phase7-extensions.test.js test/phase8-extensions.test.js test/new-cwe-detectors.test.js test/file-upload.test.js test/llmsecops-detectors.test.js test/db-taint.test.js test/dart-swift.test.js test/redos-nfa.test.js test/weak-randomness.test.js test/csharp-pipeline.test.js test/post-quantum-crypto.test.js test/web3-advanced.test.js test/cloud-iam-k8s.test.js test/crypto-protocol.test.js test/ml-supply-chain.test.js test/wrong-context-sanitizer.test.js test/sanitizer-context.test.js test/frontend-hygiene.test.js test/csv-injection.test.js test/stored-taint.test.js test/tree-sitter-sinks.test.js test/kotlin-structural.test.js test/ruby-php-structural.test.js test/java-csharp-structural.test.js test/guard-recognition.test.js test/js-python-framework-structural.test.js test/go-structural.test.js test/secret-concat.test.js test/xss-reflected-multilang.test.js test/code-injection-multilang.test.js test/xxe-multilang.test.js test/xpath-injection-multilang.test.js test/gapfill-batch13.test.js test/agent-untrusted-flow.test.js test/api-authz.test.js test/event-entrypoint.test.js test/iac-terraform.test.js test/cross-service.test.js test/rbac-consistency.test.js",
65
- "test:posture": "node --test test/material-change.test.js test/drift.test.js test/scorecard.test.js test/accuracy-scorecard.test.js test/scorecard-gate.test.js test/release-check.test.js test/pre-push-gate.test.js test/dependency-currency.test.js test/package-contents-check.test.js test/gate-verdict-cache.test.js test/mttr.test.js test/license-policy.test.js test/aibom.test.js test/sbom.test.js test/api-inventory.test.js test/iam-policy.test.js test/container.test.js test/container-runtime.test.js test/image-packages.test.js test/kev.test.js test/dep-confusion.test.js test/sca-deprecated.test.js test/sca-batch.test.js test/composite-risk.test.js test/sca-coverage.test.js test/gradle-deps.test.js test/sca-route-reachable.test.js test/sca-policy.test.js test/sca-verdict.test.js test/install-script.test.js test/sca-linked-findings.test.js test/packs.test.js test/flow-narration.test.js test/regression-test-gen.test.js test/deterministic-fix.test.js test/falsification.test.js test/verification-separation.test.js test/logic-claims.test.js test/attestation.test.js test/comparison.test.js test/determinism-cross-machine.test.js test/fix-honesty-gate.test.js test/model-routing.test.js test/root-cause-sweep.test.js test/entrypoint-inventory.test.js test/relevance.test.js test/untrusted.test.js test/agent-hardening.test.js test/rule-synthesis.test.js test/policy-gate.test.js test/agents-memory.test.js test/cve-lookup.test.js test/cve-alert-daemon.test.js test/fix-verify-loop.test.js test/fix-verify-tests.test.js test/fix-acceptance.test.js test/exploitability-probability.test.js test/history-scan.test.js test/viral-features.test.js test/viral-v074.test.js test/state-dir.test.js test/ignore-pragma.test.js test/license-graph.test.js test/secret-live-check.test.js test/attack-taxonomy.test.js test/triage-memory.test.js test/pr-augment.test.js test/chat-batch2.test.js test/chat-batch3.test.js test/chat-batch4.test.js test/chat-batch5.test.js test/chat-batch6.test.js test/router.test.js test/legacy-alias-redirect.test.js test/cache-economics.test.js test/coverage-report.test.js test/corpus-status.test.js test/provenance.test.js test/secret-history.test.js test/execution-proof.test.js test/fix-metrics.test.js test/corpus-enroll.test.js test/prove-findings.test.js test/corpus-match.test.js test/corpus-provenance.test.js test/learning-quorum.test.js test/llm-cache-integrity.test.js test/proof-artifact.test.js test/providers.test.js test/fleet.test.js test/autopilot.test.js test/autopilot-cli.test.js test/verifier-independence.test.js test/integrity-legacy-key.test.js test/suppression-visibility.test.js test/cost-ceiling.test.js test/local-endpoint.test.js test/model-trust.test.js test/vuln-archaeology.test.js test/scan-checkpoint.test.js test/llm-redact.test.js",
65
+ "test:posture": "node --test test/privacy-framework.test.js test/material-change.test.js test/drift.test.js test/scorecard.test.js test/accuracy-scorecard.test.js test/scorecard-gate.test.js test/release-check.test.js test/pre-push-gate.test.js test/dependency-currency.test.js test/package-contents-check.test.js test/gate-verdict-cache.test.js test/independent-population.test.js test/evidence-bundle.test.js test/discovery-memory.test.js test/mttr.test.js test/license-policy.test.js test/aibom.test.js test/sbom.test.js test/api-inventory.test.js test/iam-policy.test.js test/container.test.js test/container-runtime.test.js test/image-packages.test.js test/kev.test.js test/dep-confusion.test.js test/sca-deprecated.test.js test/sca-batch.test.js test/composite-risk.test.js test/sca-coverage.test.js test/gradle-deps.test.js test/sca-route-reachable.test.js test/sca-policy.test.js test/sca-verdict.test.js test/install-script.test.js test/sca-linked-findings.test.js test/packs.test.js test/flow-narration.test.js test/regression-test-gen.test.js test/deterministic-fix.test.js test/falsification.test.js test/verification-separation.test.js test/logic-claims.test.js test/attestation.test.js test/comparison.test.js test/determinism-cross-machine.test.js test/fix-honesty-gate.test.js test/model-routing.test.js test/root-cause-sweep.test.js test/entrypoint-inventory.test.js test/relevance.test.js test/untrusted.test.js test/agent-hardening.test.js test/rule-synthesis.test.js test/policy-gate.test.js test/agents-memory.test.js test/cve-lookup.test.js test/cve-alert-daemon.test.js test/fix-verify-loop.test.js test/fix-verify-tests.test.js test/fix-acceptance.test.js test/exploitability-probability.test.js test/history-scan.test.js test/viral-features.test.js test/viral-v074.test.js test/state-dir.test.js test/ignore-pragma.test.js test/license-graph.test.js test/secret-live-check.test.js test/attack-taxonomy.test.js test/triage-memory.test.js test/pr-augment.test.js test/chat-batch2.test.js test/chat-batch3.test.js test/chat-batch4.test.js test/chat-batch5.test.js test/chat-batch6.test.js test/router.test.js test/legacy-alias-redirect.test.js test/cache-economics.test.js test/coverage-report.test.js test/corpus-status.test.js test/provenance.test.js test/secret-history.test.js test/execution-proof.test.js test/fix-metrics.test.js test/corpus-enroll.test.js test/prove-findings.test.js test/corpus-match.test.js test/corpus-provenance.test.js test/learning-quorum.test.js test/llm-cache-integrity.test.js test/proof-artifact.test.js test/providers.test.js test/fleet.test.js test/autopilot.test.js test/autopilot-cli.test.js test/verifier-independence.test.js test/integrity-legacy-key.test.js test/suppression-visibility.test.js test/cost-ceiling.test.js test/local-endpoint.test.js test/model-trust.test.js test/vuln-archaeology.test.js test/scan-checkpoint.test.js test/llm-redact.test.js",
66
66
  "test:dataflow": "node --test test/fn-reach.test.js test/deep-taint.test.js test/calibration.test.js test/holdout-eval.test.js test/cross-lang-meta.test.js test/cross-lang-queues.test.js test/phase5-xlang.test.js test/phase5-coverage.test.js test/phase6-taint.test.js test/llm-validator-consistency.test.js test/llm-validator-default-on.test.js test/llm-validator-preset.test.js test/parser-py-cst.test.js test/parser-cs-kt.test.js test/parser-go.test.js test/parser-php-rb.test.js test/interproc-k2.test.js test/proven-clean.test.js test/backward-default.test.js test/incremental-cache.test.js test/string-regex-lattice.test.js test/closure-capture.test.js test/points-to.test.js test/type-stubs.test.js test/soft-taint.test.js test/ifds.test.js test/symbolic-exec-proof.test.js test/ifds-summary-edges.test.js test/stub-aware-filter.test.js test/cross-repo.test.js test/proof-gate.test.js test/proof-safe.test.js test/collection-taint.test.js test/kcfa-context.test.js test/kcfa-callstring.test.js test/flow-parity.test.js test/callgraph-resolve.test.js test/import-reachability.test.js test/ir-stats.test.js test/parser-cpp.test.js test/parser-js-decorators.test.js test/cpp-integration.test.js test/engine-reconnect.test.js test/phase2-scoping.test.js test/engine-recall.test.js",
67
67
  "test:mcp": "node --test test/mcp.test.js test/mcp-audit.test.js test/audit-cli.test.js test/mcp-scratchpad.test.js test/mcp-offload.test.js test/sca-upgrade.test.js",
68
68
  "test:report": "node --test test/sarif-ingest.test.js test/junit.test.js test/ci.test.js test/poc-generator.test.js test/verifier.test.js test/verifier-target.test.js test/annotator-errors.test.js test/grader-calibration.test.js test/pr-delta-gate.test.js test/vex.test.js test/report-render.test.js",
69
69
  "test:bench-modules": "node --test test/phase4-harness.test.js test/pipeline.test.js test/proof-corpus-lib.test.js test/proof-corpus-runner.test.js",
70
- "test:lifecycle": "node --test test/dead-code.test.js test/no-dead-modules.test.js test/stop-hook.test.js test/plugin-self-check.test.js test/skills-registry.test.js test/bodyguard.test.js test/cache-invalidator-guard.test.js test/dispatch-pre-tool.test.js test/dispatch-user-prompt.test.js test/session-start-model-capture.test.js test/sandbox.test.js test/sandbox-escape.test.js && node ../scripts/lint-command-descriptions.mjs",
70
+ "test:lifecycle": "node --test test/dead-code.test.js test/no-dead-modules.test.js test/no-stray-state.test.js test/tree-integrity.test.js test/stop-hook.test.js test/plugin-self-check.test.js test/skills-registry.test.js test/bodyguard.test.js test/cache-invalidator-guard.test.js test/dispatch-pre-tool.test.js test/dispatch-user-prompt.test.js test/session-start-model-capture.test.js test/sandbox.test.js test/sandbox-escape.test.js && node ../scripts/lint-command-descriptions.mjs",
71
71
  "test:eval": "node --test test/independent-eval.test.js test/realworld-recall.test.js",
72
72
  "test:discovery": "node --test test/discovery-partition.test.js test/discovery-lenses.test.js test/discovery-hunter.test.js test/discovery-confirm.test.js test/discovery-disprove.test.js test/discovery-judge.test.js test/discovery-run.test.js test/discovery-wiring.test.js test/discovery-llm-invoke.test.js",
73
73
  "eval:independent": "node ../bench/independent-eval/runner.mjs",
@@ -108,7 +108,11 @@
108
108
  "release:check": "node ../scripts/release-check.mjs",
109
109
  "release:check:fast": "node ../scripts/release-check.mjs --fast",
110
110
  "gate:prepush": "node ../scripts/pre-push-gate.mjs",
111
- "gate:prepush:install": "node ../scripts/pre-push-gate.mjs --install-hook"
111
+ "gate:prepush:install": "node ../scripts/pre-push-gate.mjs --install-hook",
112
+ "bench:independent": "node ../bench/independent/runner.mjs",
113
+ "bench:independent:fetch": "node ../bench/independent/fetch.mjs",
114
+ "bench:independent:mine": "node ../bench/independent/mine.mjs",
115
+ "bench:independent:materialise": "node ../bench/independent/materialise-cli.mjs"
112
116
  },
113
117
  "author": "Ross Young <ross@clearcapabilities.com>",
114
118
  "license": "PolyForm-Internal-Use-1.0.0"
package/src/badge.js CHANGED
@@ -28,6 +28,7 @@
28
28
  import * as fs from 'node:fs';
29
29
  import * as path from 'node:path';
30
30
 
31
+ import { statePath } from './posture/state-dir.js';
31
32
  const COLORS = {
32
33
  critical: '#e05d44', // red
33
34
  high: '#fe7d37', // orange
@@ -42,7 +43,7 @@ const SEVERITIES = ['critical', 'high', 'medium', 'low', 'info'];
42
43
 
43
44
  function _readLastScan(scanRoot) {
44
45
  if (!scanRoot) return null;
45
- const fp = path.join(scanRoot, '.agentic-security', 'last-scan.json');
46
+ const fp = statePath(scanRoot, 'last-scan.json');
46
47
  if (!fs.existsSync(fp)) return null;
47
48
  try { return JSON.parse(fs.readFileSync(fp, 'utf8')); }
48
49
  catch { return null; }
@@ -44,12 +44,13 @@ import * as fs from 'node:fs';
44
44
  import * as path from 'node:path';
45
45
  import * as yaml from '../util/yaml.js';
46
46
 
47
+ import { statePath } from '../posture/state-dir.js';
47
48
  const SERVICES_FILE_NAMES = ['services.yml', 'services.yaml'];
48
49
 
49
50
  export function loadServiceGraph(scanRoot) {
50
51
  if (!scanRoot) return null;
51
52
  for (const name of SERVICES_FILE_NAMES) {
52
- const fp = path.join(scanRoot, '.agentic-security', name);
53
+ const fp = statePath(scanRoot, name);
53
54
  if (!fs.existsSync(fp)) continue;
54
55
  try {
55
56
  const raw = fs.readFileSync(fp, 'utf8');
@@ -24,6 +24,7 @@ import * as fs from 'node:fs';
24
24
  import * as path from 'node:path';
25
25
  import * as crypto from 'node:crypto';
26
26
 
27
+ import { statePath, safeWriteState, stateWritesEnabled } from '../posture/state-dir.js';
27
28
  // ── Per-call-site refined summaries ────────────────────────────────────────
28
29
 
29
30
  /**
@@ -160,7 +161,7 @@ export function backwardSlice(callGraph, finding, opts = {}) {
160
161
  // ── Persistent cross-scan summary cache ────────────────────────────────────
161
162
 
162
163
  function _cachePath(scanRoot) {
163
- return path.join(scanRoot, '.agentic-security', 'ifds-summaries.json');
164
+ return statePath(scanRoot, 'ifds-summaries.json');
164
165
  }
165
166
 
166
167
  function _fileHash(content) {
@@ -190,8 +191,9 @@ export function loadPersistedCache(scanRoot) {
190
191
  * skip re-analysis of functions whose file hash hasn't changed.
191
192
  */
192
193
  export function persistCache(scanRoot, cache, perFileIR) {
193
- const dir = path.join(scanRoot, '.agentic-security');
194
- try { fs.mkdirSync(dir, { recursive: true }); } catch {}
194
+ // A read-only scan still ANALYSES; it just may not persist the cache. The
195
+ // next scan simply recomputes, which is slower and identical. (PRD M1)
196
+ if (!stateWritesEnabled()) return;
195
197
  const fileHashes = {};
196
198
  for (const [filePath, ir] of (perFileIR || new Map())) {
197
199
  if (ir && typeof ir._content === 'string') fileHashes[filePath] = _fileHash(ir._content);
@@ -203,7 +205,7 @@ export function persistCache(scanRoot, cache, perFileIR) {
203
205
  if (sum.has('∅')) summaries[qid] = sum.get('∅');
204
206
  }
205
207
  const out = { scanTs: new Date().toISOString(), summaries, fileHashes };
206
- try { fs.writeFileSync(_cachePath(scanRoot), JSON.stringify(out, null, 2)); }
208
+ try { safeWriteState(_cachePath(scanRoot), JSON.stringify(out, null, 2)); }
207
209
  catch { /* best-effort */ }
208
210
  }
209
211
 
@@ -31,7 +31,8 @@ import * as fs from 'node:fs';
31
31
  import * as path from 'node:path';
32
32
  import * as crypto from 'node:crypto';
33
33
 
34
- const STATE_DIR = '.agentic-security/incremental';
34
+ import { statePath, stateWritesEnabled } from '../posture/state-dir.js';
35
+
35
36
  const FILES_PATH = 'files.json';
36
37
  const SUMMARIES_PATH = 'summaries.json';
37
38
  const VERSION_PATH = 'version.json';
@@ -44,7 +45,7 @@ export function hashFileContent(stripped) {
44
45
 
45
46
  /** Read the persisted state. Returns a fresh empty state on any error. */
46
47
  export function readIncrementalState(projectRoot) {
47
- const dir = path.join(projectRoot, STATE_DIR);
48
+ const dir = statePath(projectRoot, 'incremental');
48
49
  try {
49
50
  const versionFp = path.join(dir, VERSION_PATH);
50
51
  if (!fs.existsSync(versionFp)) return _emptyState();
@@ -197,9 +198,10 @@ export function serializeSummaries(summaryCache) {
197
198
  */
198
199
  export function commitIncrementalState(projectRoot, state, currentVersion) {
199
200
  if (!projectRoot) return false;
200
- const dir = path.join(projectRoot, STATE_DIR);
201
+ const dir = statePath(projectRoot, 'incremental');
201
202
  try {
202
- fs.mkdirSync(dir, { recursive: true });
203
+ if (!stateWritesEnabled()) return;
204
+ fs.mkdirSync(dir, { recursive: true });
203
205
  fs.writeFileSync(path.join(dir, VERSION_PATH), JSON.stringify(currentVersion, null, 2));
204
206
  fs.writeFileSync(path.join(dir, FILES_PATH), JSON.stringify(state.files || {}, null, 2));
205
207
  const payload = {
@@ -215,7 +217,7 @@ export function commitIncrementalState(projectRoot, state, currentVersion) {
215
217
 
216
218
  /** Drop persisted state — used when a version mismatch is detected. */
217
219
  export function dropIncrementalState(projectRoot) {
218
- const dir = path.join(projectRoot, STATE_DIR);
220
+ const dir = statePath(projectRoot, 'incremental');
219
221
  try {
220
222
  if (!fs.existsSync(dir)) return true;
221
223
  for (const fn of [VERSION_PATH, FILES_PATH, SUMMARIES_PATH]) {
@@ -34,5 +34,15 @@ panel · `judge.js` shapes findings and dedupes against the prior scan ·
34
34
  This layer never emits `critical`.
35
35
  - **Ids are content digests.** No clock, no randomness, anywhere in an id, a
36
36
  digest, or a sort key.
37
+ - **Bounded by default.** The pipeline is multiplicative — areas × lenses
38
+ hunter calls, then three refutation votes per surviving candidate. Six
39
+ files produced 168 LLM calls before C3. `makeBudget` in `index.js` wraps
40
+ `llmInvoke` so every call is counted against a ceiling, and `maxCandidates`
41
+ caps what reaches the panel. Never remove a bound without replacing it:
42
+ an unbounded run's cost is a function of repository size.
43
+ - **An exhausted budget means INCOMPLETE, not clean.** Exhaustion arrives
44
+ through the same degradation path as a dead endpoint and lands in
45
+ `coverage.reasons`. A capped candidate is neither a finding nor cleared —
46
+ it was not examined, and the report says so.
37
47
  - **Coverage is reported.** Degraded runs and their reasons appear in every
38
48
  report. A half-failed pass must never read as a clean one.
@@ -13,6 +13,7 @@ import { runHunter } from './hunter.js';
13
13
  import { confirmAll } from './confirm.js';
14
14
  import { disprovePanel } from './disprove.js';
15
15
  import { judgeCandidates } from './judge.js';
16
+ import { loadMemory, saveMemory, rememberRun, previouslyRefuted, nextWavePlan } from './memory.js';
16
17
 
17
18
  // Bridge a candidate to the deterministic layer. A taint finding at or within
18
19
  // two lines of the candidate corroborates it; a modelled sink on the line
@@ -54,10 +55,91 @@ async function runDeepAnalysisSafe(perFileIR, callGraph) {
54
55
  // where { perFileIR, callGraph } come from buildProjectIR(fileContents),
55
56
  // which returns { perFile, callGraph } — callers must pass perFile as
56
57
  // perFileIR (see scanner/src/ir/index.js).
58
+ /**
59
+ * PRD Phase 0 / C3 — the run budget.
60
+ *
61
+ * This pipeline is multiplicative and was, until now, unbounded. Eight focus
62
+ * areas × seven lenses is 56 hunter calls before a single candidate exists, and
63
+ * every surviving candidate then costs three more calls in the refutation
64
+ * panel. Nothing capped any of it, so the cost of a run was a function of how
65
+ * large the repository happened to be — which is not a property you want to
66
+ * discover from an invoice.
67
+ *
68
+ * ENFORCED AT THE ONE SEAM EVERY CALL PASSES THROUGH. Rather than thread checks
69
+ * through the hunter and the panel, the budget wraps `llmInvoke` itself. When
70
+ * it is spent the wrapper throws, and both callers already treat a throwing
71
+ * llmInvoke as ordinary degradation with a stated reason. So exhaustion arrives
72
+ * through the same path as a rate limit or a dead endpoint, and lands in
73
+ * `coverage.reasons` like any other coverage gap. No new failure mode.
74
+ *
75
+ * CALLS AND WALL CLOCK, NOT TOKENS. `llmInvoke` is an injected callback that
76
+ * returns a string; it carries no usage metadata, so counting tokens here would
77
+ * mean inventing a number. Calls are exactly countable and wall clock is
78
+ * exactly observable. A caller who knows their per-call cost can pass
79
+ * `costPerCallUsd` and get a `maxCostUsd` ceiling expressed in calls, which is
80
+ * honest about being an estimate derived from their figure rather than ours.
81
+ */
82
+ // Internal, not exported: the dead-module guard treats an export with no
83
+ // external call site as shipped dead code, and these are read only by
84
+ // makeBudget below. A consumer sets a ceiling by passing opts, not by importing
85
+ // a constant.
86
+ const DEFAULT_MAX_LLM_CALLS = 200;
87
+ const DEFAULT_MAX_WALL_MS = 15 * 60 * 1000;
88
+ const DEFAULT_MAX_CANDIDATES = 50;
89
+
90
+ export function makeBudget(opts = {}, now = Date.now) {
91
+ const startedAt = now();
92
+ let maxCalls = Number.isInteger(opts.maxLlmCalls) && opts.maxLlmCalls >= 0
93
+ ? opts.maxLlmCalls : DEFAULT_MAX_LLM_CALLS;
94
+ // A dollar ceiling is only meaningful with a caller-supplied per-call cost.
95
+ // Converting it to a call count keeps one enforcement mechanism rather than
96
+ // two that can disagree.
97
+ if (Number.isFinite(opts.maxCostUsd) && Number.isFinite(opts.costPerCallUsd) && opts.costPerCallUsd > 0) {
98
+ maxCalls = Math.min(maxCalls, Math.floor(opts.maxCostUsd / opts.costPerCallUsd));
99
+ }
100
+ const maxWallMs = Number.isInteger(opts.maxWallMs) && opts.maxWallMs > 0
101
+ ? opts.maxWallMs : DEFAULT_MAX_WALL_MS;
102
+
103
+ let calls = 0;
104
+ let exhaustedReason = null;
105
+
106
+ const check = () => {
107
+ if (exhaustedReason) return exhaustedReason;
108
+ if (calls >= maxCalls) return (exhaustedReason = `LLM call budget spent (${calls}/${maxCalls} calls)`);
109
+ if (now() - startedAt >= maxWallMs) {
110
+ return (exhaustedReason = `wall-clock budget spent (${Math.round(maxWallMs / 1000)}s)`);
111
+ }
112
+ return null;
113
+ };
114
+
115
+ return {
116
+ get calls() { return calls; },
117
+ get maxCalls() { return maxCalls; },
118
+ get exhaustedReason() { return exhaustedReason; },
119
+ spent: () => check() !== null,
120
+ /** Wrap an llmInvoke so every call is counted and the ceiling is enforced. */
121
+ wrap(llmInvoke) {
122
+ if (typeof llmInvoke !== 'function') return llmInvoke;
123
+ return async (prompt) => {
124
+ const stop = check();
125
+ if (stop) throw new Error(`discovery budget exhausted: ${stop}`);
126
+ calls += 1;
127
+ return llmInvoke(prompt);
128
+ };
129
+ },
130
+ };
131
+ }
132
+
57
133
  export async function runDiscovery(ctx = {}, opts = {}) {
58
134
  const areas = partitionCallGraph(ctx.callGraph, { maxAreas: opts.maxAreas ?? 8 });
59
135
 
60
136
  const reasons = [];
137
+ const budget = makeBudget(opts);
138
+ // PRD C4 — what previous runs already judged. scanRoot absent => no memory,
139
+ // which is the correct default for a library call with nowhere to persist.
140
+ const memory = opts.scanRoot ? loadMemory(opts.scanRoot) : null;
141
+ // Every LLM call in this pipeline goes through this one wrapped callback.
142
+ const llmInvoke = budget.wrap(opts.llmInvoke);
61
143
 
62
144
  // An explicit array (including an empty one) is honoured exactly — a caller
63
145
  // narrowing a run to no lenses must get no lenses, not a silent fallback to
@@ -87,7 +169,7 @@ export async function runDiscovery(ctx = {}, opts = {}) {
87
169
  for (const area of areas) {
88
170
  let areaDegradedCount = 0;
89
171
  for (const lens of lenses) {
90
- const run = await runHunter(area, lens, { fileContents: ctx.fileContents || {} }, { llmInvoke: opts.llmInvoke });
172
+ const run = await runHunter(area, lens, { fileContents: ctx.fileContents || {} }, { llmInvoke });
91
173
  runs.push({ focusAreaId: run.focusAreaId, lens: run.lens, degraded: run.degraded, reason: run.reason, candidateCount: run.candidates.length });
92
174
  if (run.degraded && run.reason) reasons.push(`${area.label} × ${lens.key}: ${run.reason}`);
93
175
  if (run.degraded) areaDegradedCount += 1;
@@ -97,11 +179,69 @@ export async function runDiscovery(ctx = {}, opts = {}) {
97
179
  if (lenses.length > 0 && areaDegradedCount === 0) fullyHunted.add(area.id);
98
180
  }
99
181
 
100
- const taintProbe = makeTaintProbe(ctx.perFileIR, ctx.callGraph);
182
+ // PRD Phase 0 / C3.2 — the candidate cap.
183
+ //
184
+ // Every candidate that reaches the panel costs three more LLM calls, so an
185
+ // unusually productive hunt multiplies straight into spend. Cap it, and
186
+ // REPORT the cap rather than applying it silently: a run that quietly
187
+ // examined the first N candidates and said nothing would look identical to a
188
+ // run that found only N. Same precedent as prove-findings.js's `capped`.
189
+ // PRD C4 — drop what a previous run already refuted, BEFORE spending the
190
+ // panel's three calls per candidate on it again. Only refutals suppress: a
191
+ // candidate previously judged fresh is re-reported, because it was never
192
+ // fixed, which is the same asymmetry judge.js applies to tp/fp triage.
193
+ let rememberedRefutals = 0;
194
+ if (memory) {
195
+ const before = candidates.length;
196
+ candidates = candidates.filter(c => !previouslyRefuted(memory, c));
197
+ rememberedRefutals = before - candidates.length;
198
+ if (rememberedRefutals > 0) {
199
+ reasons.push(`${rememberedRefutals} candidate(s) were refuted by an earlier run and not re-examined ` +
200
+ '(clear with --forget-refuted if a model, ruleset or the code has changed since)');
201
+ }
202
+ }
203
+
204
+ const maxCandidates = Number.isInteger(opts.maxCandidates) && opts.maxCandidates >= 0
205
+ ? opts.maxCandidates : DEFAULT_MAX_CANDIDATES;
206
+ let candidatesCapped = 0;
207
+ if (candidates.length > maxCandidates) {
208
+ candidatesCapped = candidates.length - maxCandidates;
209
+ // Deterministic: candidates arrive in a stable (area, lens) order, so the
210
+ // cap keeps the same prefix on every run over the same inputs.
211
+ candidates = candidates.slice(0, maxCandidates);
212
+ reasons.push(`candidate cap: ${candidatesCapped} candidate(s) were NOT confirmed or refuted ` +
213
+ `(cap ${maxCandidates}); they are neither findings nor cleared — they were not examined`);
214
+ }
215
+
216
+ // PRD D3 — the hybrid-loop uplift measurement.
217
+ //
218
+ // `confirm: false` runs the pipeline with the deterministic gate switched OFF,
219
+ // so every candidate reaches the panel as `unconfirmed`. Running a population
220
+ // both ways and diffing the result isolates what the taint engine contributes
221
+ // on top of the model — a number no surveyed competitor can compute, because
222
+ // none of them has a deterministic layer to switch off.
223
+ //
224
+ // It exists ONLY to be measured against. It is not a performance switch, and
225
+ // a run with it disabled is strictly weaker: severity collapses to `low` for
226
+ // everything, since the confirmation tier is what sets it.
227
+ const confirmationEnabled = opts.confirm !== false;
228
+ const taintProbe = confirmationEnabled ? makeTaintProbe(ctx.perFileIR, ctx.callGraph) : null;
229
+ if (!confirmationEnabled) {
230
+ reasons.push('deterministic confirmation was DISABLED for this run (uplift measurement); ' +
231
+ 'every candidate is reported unconfirmed and severity is not evidence-derived');
232
+ }
101
233
  const confirmed = await confirmAll(candidates, { taintProbe });
102
- const { survivors, refuted } = await disprovePanel(confirmed, { llmInvoke: opts.llmInvoke });
234
+ const { survivors, refuted } = await disprovePanel(confirmed, { llmInvoke });
103
235
  const { fresh, duplicates, suppressed } = judgeCandidates(survivors, ctx.priorScan, ctx.triageFeedback);
104
236
 
237
+ // A spent budget is a coverage gap, stated once at the top level rather than
238
+ // left to be inferred from N identical per-run degradation reasons.
239
+ if (budget.exhaustedReason) {
240
+ reasons.push(`RUN INCOMPLETE — ${budget.exhaustedReason}. Work remained when the budget ran ` +
241
+ 'out, so absence of a finding below is not evidence of absence. Raise maxLlmCalls / ' +
242
+ 'maxWallMs, or narrow the scope with --root or --lens, and re-run.');
243
+ }
244
+
105
245
  // Coverage must not stop at the hunter stage. `confirm.js` correctly never
106
246
  // lowers a candidate below `unconfirmed`, and `disprove.js` correctly lets
107
247
  // a candidate survive when no voter votes — each rule is right on its own,
@@ -124,6 +264,16 @@ export async function runDiscovery(ctx = {}, opts = {}) {
124
264
  reasons.push(`refutation panel returned no votes for any of ${panelsRun} candidate(s) — every finding below survived unrefuted, not because it withstood scrutiny`);
125
265
  }
126
266
 
267
+ // PRD C4 — fold this run into the memory so the next one can be additive
268
+ // rather than a repeat. Persistence failure is non-fatal: the report is still
269
+ // valid, it just will not inform the next run.
270
+ if (memory && opts.scanRoot) {
271
+ saveMemory(opts.scanRoot, rememberRun(memory, {
272
+ fresh, refutedCandidates: refuted,
273
+ areas: areas.map(a => ({ id: a.id, label: a.label, files: a.files.length, hunted: hunted.has(a.id) })),
274
+ }));
275
+ }
276
+
127
277
  return {
128
278
  schema: 'agentic-security/discovery@1',
129
279
  focusAreas: areas.map(a => ({ id: a.id, label: a.label, files: a.files.length, size: a.size })),
@@ -153,6 +303,28 @@ export async function runDiscovery(ctx = {}, opts = {}) {
153
303
  // of those came back with no votes at all (undecided, not refuted).
154
304
  panelsRun,
155
305
  undecidedPanels,
306
+ // PRD Phase 0 / C3 — what the run cost and whether the budget stopped it.
307
+ // `budgetExhausted` true means the report is INCOMPLETE by construction:
308
+ // work remained and was not done. Reading it as a clean result is the
309
+ // exact misreading the coverage block exists to prevent.
310
+ // PRD C4 — what history contributed, and what to hunt next. A coverage
311
+ // report says what happened; `nextWave` says what to do about it.
312
+ rememberedRefutals,
313
+ priorRuns: memory ? memory.runs : null,
314
+ nextWave: memory ? nextWavePlan(memory, areas.map(a => ({ id: a.id, label: a.label }))) : null,
315
+ llmCalls: budget.calls,
316
+ maxLlmCalls: budget.maxCalls,
317
+ // PRD N4 — the standing cost metric. C3 bounded the worst case and C4
318
+ // moved the typical case by 4x, so cost is a property that drifts across
319
+ // several workstreams rather than one that a phase finishes. A number
320
+ // that only appears when somebody goes looking regresses silently, so it
321
+ // is reported every run and carries its denominator like every other rate
322
+ // in this engine. `null` when nothing was found — dividing by zero
323
+ // findings would print Infinity and read as a catastrophe rather than as
324
+ // "there is nothing to divide".
325
+ callsPerFinding: fresh.length > 0 ? Number((budget.calls / fresh.length).toFixed(1)) : null,
326
+ budgetExhausted: Boolean(budget.exhaustedReason),
327
+ candidatesCapped,
156
328
  reasons,
157
329
  },
158
330
  };