@clear-capabilities/agentic-security-scanner 0.133.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 (111) hide show
  1. package/CHANGELOG.md +299 -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 +348 -40
  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/499.index.js +86 -0
  14. package/dist/513.index.js +7 -3
  15. package/dist/526.index.js +6 -6
  16. package/dist/609.index.js +741 -0
  17. package/dist/637.index.js +1 -1
  18. package/dist/675.index.js +7 -5
  19. package/dist/839.index.js +4 -3
  20. package/dist/905.index.js +1173 -0
  21. package/dist/agentic-security.mjs +56 -56
  22. package/dist/agentic-security.mjs.sha256 +1 -1
  23. package/dist/compliance-frameworks/ccpa.json +32 -0
  24. package/dist/compliance-frameworks/eu-ai-act.json +51 -0
  25. package/dist/compliance-frameworks/gdpr.json +45 -0
  26. package/dist/compliance-frameworks/hipaa-security-rule.json +56 -0
  27. package/dist/compliance-frameworks/nist-ai-600-1.json +51 -0
  28. package/dist/compliance-frameworks/nist-csf-2.json +73 -0
  29. package/dist/compliance-frameworks/nist-privacy-1-1.json +846 -0
  30. package/dist/compliance-frameworks/owasp-asvs-5.json +79 -0
  31. package/dist/compliance-frameworks/owasp-llm-top-10.json +69 -0
  32. package/package.json +14 -6
  33. package/src/badge.js +2 -1
  34. package/src/dataflow/cross-service-taint.js +2 -1
  35. package/src/dataflow/ifds-precise.js +6 -4
  36. package/src/dataflow/incremental.js +7 -5
  37. package/src/discovery/CLAUDE.md +48 -0
  38. package/src/discovery/confirm.js +47 -0
  39. package/src/discovery/disprove.js +79 -0
  40. package/src/discovery/hunter.js +116 -0
  41. package/src/discovery/index.js +331 -0
  42. package/src/discovery/judge.js +97 -0
  43. package/src/discovery/lenses.js +69 -0
  44. package/src/discovery/llm-invoke.js +120 -0
  45. package/src/discovery/memory.js +163 -0
  46. package/src/discovery/partition.js +92 -0
  47. package/src/engine.js +163 -7
  48. package/src/integrations/tickets.js +7 -6
  49. package/src/ir/ir-stats.js +1 -1
  50. package/src/ir/parser-cpp.js +1 -1
  51. package/src/leaderboard.js +3 -2
  52. package/src/llm-validator/consistency.js +6 -2
  53. package/src/llm-validator/index.js +30 -41
  54. package/src/llm-validator/providers.js +227 -0
  55. package/src/mcp/audit.js +2 -1
  56. package/src/mcp/tools.js +4 -3
  57. package/src/posture/CLAUDE.md +102 -1
  58. package/src/posture/accuracy-scorecard.js +59 -1
  59. package/src/posture/agents-memory.js +5 -3
  60. package/src/posture/auditor-walkthrough.js +9 -7
  61. package/src/posture/auth-posture-import.js +5 -4
  62. package/src/posture/autopilot.js +225 -0
  63. package/src/posture/comparison.js +181 -0
  64. package/src/posture/compliance-frameworks/nist-privacy-1-1.json +846 -0
  65. package/src/posture/compliance-policy.js +7 -9
  66. package/src/posture/custom-rules.js +7 -5
  67. package/src/posture/cve-alert-daemon.js +6 -5
  68. package/src/posture/dep-add-guard.js +2 -1
  69. package/src/posture/deterministic.js +3 -2
  70. package/src/posture/evidence-bundle.js +246 -0
  71. package/src/posture/execution-proof.js +25 -1
  72. package/src/posture/exploitability-probability.js +2 -1
  73. package/src/posture/feature-flags.js +3 -2
  74. package/src/posture/findings-memory.js +3 -3
  75. package/src/posture/fix-history.js +5 -2
  76. package/src/posture/fix-metrics.js +5 -5
  77. package/src/posture/fix-plan.js +2 -1
  78. package/src/posture/fleet.js +0 -0
  79. package/src/posture/grader-calibration.js +3 -4
  80. package/src/posture/intent-context.js +2 -1
  81. package/src/posture/learning.js +4 -3
  82. package/src/posture/license-attributions.js +5 -7
  83. package/src/posture/license-graph.js +2 -1
  84. package/src/posture/license-policy.js +2 -1
  85. package/src/posture/logic-claims.js +266 -0
  86. package/src/posture/model-rescan.js +4 -3
  87. package/src/posture/network-policy-import.js +3 -2
  88. package/src/posture/poc-inprocess.js +404 -2
  89. package/src/posture/pqc-migration-plan.js +7 -5
  90. package/src/posture/pr-augment.js +8 -5
  91. package/src/posture/privacy-framework.js +262 -0
  92. package/src/posture/proof-artifact.js +101 -0
  93. package/src/posture/prove-findings.js +28 -4
  94. package/src/posture/risk-dollars.js +2 -2
  95. package/src/posture/router.js +5 -4
  96. package/src/posture/ruleset-version.js +2 -2
  97. package/src/posture/runtime-correlation.js +2 -1
  98. package/src/posture/sbom-diff.js +12 -3
  99. package/src/posture/sca-policy.js +7 -4
  100. package/src/posture/scan-checkpoint.js +15 -0
  101. package/src/posture/state-dir.js +34 -0
  102. package/src/posture/telemetry-ingest.js +4 -3
  103. package/src/posture/threat-model-auto.js +4 -1
  104. package/src/posture/threat-model-grounding.js +11 -1
  105. package/src/posture/time-to-fix.js +3 -2
  106. package/src/posture/triage-memory.js +3 -2
  107. package/src/posture/waf-ingest.js +6 -5
  108. package/src/posture/watch-mode.js +4 -3
  109. package/src/report/index.js +9 -0
  110. package/src/sast/code-injection-multilang.js +29 -0
  111. package/src/sca/dep-confusion.js +2 -1
@@ -64,7 +64,7 @@
64
64
  import * as fs from 'node:fs';
65
65
  import * as path from 'node:path';
66
66
  import * as crypto from 'node:crypto';
67
- import { statePath, ensureStateDir, safeWriteState } from '../posture/state-dir.js';
67
+ import { ensureStateDir, safeWriteState, statePath } from '../posture/state-dir.js';
68
68
  import { redactSecrets } from './redact.js';
69
69
  import { signLastScan } from '../posture/integrity.js';
70
70
 
@@ -73,6 +73,7 @@ import { signLastScan } from '../posture/integrity.js';
73
73
  // doesn't have to reach through the `_internal` underscore-prefixed export.
74
74
  import { createCostLedger, parseCapUsd, renderCostCeiling } from './cost-ceiling.js';
75
75
  import { localEndpointConfig } from './local-endpoint.js';
76
+ import { resolveProvider, buildProviderRequest, providerMatrix } from './providers.js';
76
77
 
77
78
  // The output cap we request. Shared with the cost estimate so the ceiling
78
79
  // charges exactly what we permit the model to produce.
@@ -83,7 +84,6 @@ const MAX_OUTPUT_TOKENS = 512;
83
84
  let _localPresetRefusal = null;
84
85
 
85
86
  export const PROMPT_VERSION = 'v2.0-hardened';
86
- const CACHE_DIR = '.agentic-security/llm-cache';
87
87
 
88
88
  // System preamble — embeds a per-request challenge token the model MUST
89
89
  // echo, and a strict instruction-priority frame. {{challenge}} and {{nonce}}
@@ -124,49 +124,36 @@ Snippet (single line, trusted from scanner output): {{snippet}}
124
124
  Reply now with the JSON object on the last line of your response. Nothing else after it.
125
125
  `;
126
126
 
127
+ // Delegates to the provider seam (PRD Epic 3). Kept as a thin adapter rather
128
+ // than deleted: every call site, test and cost-ceiling path already speaks this
129
+ // shape, and changing a seam and all its consumers at once is how a refactor
130
+ // becomes a regression. `_localPresetRefusal` still carries a REFUSAL
131
+ // distinctly from "nothing configured" — the local preset declining a remote
132
+ // endpoint must not read as an absent config.
127
133
  function endpointConfig() {
128
- // R11 the local path is checked FIRST, ahead of the BYO endpoint, because
129
- // it is the only mode that makes a promise about where data goes. If the
130
- // operator asked for `local`, a stray AGENTIC_SECURITY_LLM_ENDPOINT pointing
131
- // at a remote host must not quietly win: it is refused, and the tier stays
132
- // off. Silently honouring it would break the one guarantee the preset exists
133
- // to provide.
134
- if ((process.env.AGENTIC_SECURITY_LLM_PRESET || '').toLowerCase() === 'local') {
135
- const r = localEndpointConfig();
136
- if (!r.ok) {
137
- _localPresetRefusal = r.reason;
138
- return null;
139
- }
140
- _localPresetRefusal = null;
141
- return r.config;
142
- }
143
- // Explicit BYO endpoint always wins (unchanged behaviour).
144
- const endpoint = process.env.AGENTIC_SECURITY_LLM_ENDPOINT;
145
- if (endpoint) {
146
- return { endpoint, apiKey: process.env.AGENTIC_SECURITY_LLM_API_KEY, model: process.env.AGENTIC_SECURITY_LLM_MODEL || 'unknown', preset: null };
147
- }
148
- // #18 — first-class Anthropic preset. Opt-in via AGENTIC_SECURITY_LLM_PRESET=anthropic
149
- // + a key (AGENTIC_SECURITY_LLM_API_KEY or ANTHROPIC_API_KEY): makes the FP-suppression
150
- // validator reachable with just a key — no BYO endpoint URL or request-shape wrangling.
151
- // Offline-degrading: no key → null (validator no-ops; no runtime cloud call by default).
152
- if ((process.env.AGENTIC_SECURITY_LLM_PRESET || '').toLowerCase() === 'anthropic') {
153
- const apiKey = process.env.AGENTIC_SECURITY_LLM_API_KEY || process.env.ANTHROPIC_API_KEY;
154
- if (!apiKey) return null;
155
- return {
156
- endpoint: 'https://api.anthropic.com/v1/messages',
157
- apiKey,
158
- model: process.env.AGENTIC_SECURITY_LLM_MODEL || 'claude-haiku-4-5',
159
- preset: 'anthropic',
160
- };
161
- }
162
- return null;
134
+ const r = resolveProvider({ role: 'validate' });
135
+ if (!r.ok) { _localPresetRefusal = r.reason || null; return null; }
136
+ _localPresetRefusal = null;
137
+ const c = r.config;
138
+ return {
139
+ endpoint: c.endpoint,
140
+ apiKey: c.apiKey,
141
+ model: c.model,
142
+ preset: c.provider === 'anthropic' ? 'anthropic' : (c.provider === 'local' ? 'local' : null),
143
+ provider: c.provider,
144
+ egress: c.egress,
145
+ _shape: c.shape,
146
+ };
163
147
  }
164
148
 
165
149
  // Shape the request for the target: the Anthropic Messages API needs an
166
150
  // x-api-key header (added by the caller), an anthropic-version header, and a
167
151
  // {model, max_tokens, messages:[…]} body with the reply in content[].text. The
168
152
  // generic path posts {prompt, model} with a Bearer header. Pure — no I/O.
169
- function buildRequest(model, prompt, preset) {
153
+ function buildRequest(model, prompt, preset, shape) {
154
+ // A resolved provider carries its own wire shape; use it. The hand-written
155
+ // branches below remain for callers that pass only a preset string.
156
+ if (shape) return buildProviderRequest({ shape, model, apiKey: null }, prompt, MAX_OUTPUT_TOKENS);
170
157
  if (preset === 'anthropic') {
171
158
  return {
172
159
  headers: { 'Content-Type': 'application/json', 'anthropic-version': '2023-06-01' },
@@ -347,8 +334,8 @@ function renderPrompt(finding, fileContents, challenge, nonce) {
347
334
  .replace('{{context}}', sterileContext || '(no surrounding code available)');
348
335
  }
349
336
 
350
- async function callEndpoint(endpoint, apiKey, model, prompt, preset = null) {
351
- const { headers, body, extractText, extractUsage } = buildRequest(model, prompt, preset);
337
+ async function callEndpoint(endpoint, apiKey, model, prompt, preset = null, shape = null) {
338
+ const { headers, body, extractText, extractUsage } = buildRequest(model, prompt, preset, shape);
352
339
  if (apiKey) {
353
340
  if (preset === 'anthropic') headers['x-api-key'] = apiKey;
354
341
  else headers['Authorization'] = `Bearer ${apiKey}`;
@@ -520,7 +507,7 @@ export async function validateOne(finding, fileContents, scanRoot, ledger = null
520
507
  }
521
508
  }
522
509
 
523
- const resp = await callEndpoint(cfg.endpoint, cfg.apiKey, cfg.model, prompt, cfg.preset);
510
+ const resp = await callEndpoint(cfg.endpoint, cfg.apiKey, cfg.model, prompt, cfg.preset, cfg._shape);
524
511
  // Record actual usage when the endpoint reports it, else the estimate. An
525
512
  // unreported call is never free — but the two are recorded DISTINCTLY, so
526
513
  // the reported spend can say which it is. Presenting an upper bound as a
@@ -629,6 +616,8 @@ export async function validateMany(findings, { fileContents, scanRoot, concurren
629
616
  findings.costCeiling = ledger.state();
630
617
  findings.costCeilingSummary = renderCostCeiling(ledger.state());
631
618
  }
619
+ // Which provider each role would use. No keys, ever — this is reported.
620
+ findings.providerMatrix = providerMatrix();
632
621
  const _cs = cacheStats();
633
622
  findings.validatorCache = _cs;
634
623
  if (_cs.unverified > 0) {
@@ -0,0 +1,227 @@
1
+ // PRD Epic 3 — the model-neutral seam.
2
+ //
3
+ // The deterministic engine has never depended on a model. The only coupling was
4
+ // here, in how an AI call is shaped: one hardcoded pair of request builders and
5
+ // one `endpointConfig` that knew about exactly two presets. This module makes
6
+ // the provider a parameter instead, so an OpenAI or Gemini shop — or a
7
+ // regulated org that cannot send code to any vendor — is a configuration
8
+ // change rather than a fork.
9
+ //
10
+ // WHAT A PROVIDER IS. Endpoint, auth header shape, request body, and how to
11
+ // read text and token usage back out. Nothing else. Prompt construction,
12
+ // redaction, response validation, the challenge/nonce cross-check and the cost
13
+ // ceiling all stay upstream and apply identically to every provider — those are
14
+ // the security properties, and they must not become per-vendor.
15
+ //
16
+ // PER-ROLE PINNING. The strong pipelines route cheap models at triage and
17
+ // frontier models at synthesis. `resolveProvider({ role })` reads a per-role
18
+ // override before the global one, so `AGENTIC_SECURITY_LLM_MODEL_VERIFY` can
19
+ // point at something cheap while `..._FIX` points at something capable. Roles
20
+ // are a closed set: an unknown role would silently fall back to the global
21
+ // model, which is how a "cheap verify" quietly becomes an expensive one.
22
+ //
23
+ // GRACEFUL DEGRADATION IS THE DEFAULT. No provider configured means no AI
24
+ // stages — the engine still returns its deterministic findings. That is the
25
+ // existing behaviour and it is load-bearing: adding providers must not make the
26
+ // scanner require one.
27
+ //
28
+ // THE LOCAL PATH KEEPS ITS GUARANTEE. `local` still routes through
29
+ // `local-endpoint.js`, which enforces loopback on the host literal. A provider
30
+ // abstraction must not become a way to smuggle a remote endpoint into the mode
31
+ // whose entire promise is that nothing leaves the machine.
32
+
33
+ import { localEndpointConfig } from './local-endpoint.js';
34
+
35
+ // Roles the pipeline dispatches under. Closed set on purpose — see above.
36
+ export const ROLES = Object.freeze([
37
+ 'validate', // the FP-suppression validator (the original caller)
38
+ 'verify', // adversarial verification (Epic 2) — cheap tier by default
39
+ 'explain', // human-facing explanation
40
+ 'fix', // patch synthesis
41
+ 'poc', // proof-of-concept synthesis (Epic 1)
42
+ 'logic', // cross-file business-logic reasoning (Epic 6)
43
+ ]);
44
+
45
+ const ANTHROPIC_VERSION = '2023-06-01';
46
+
47
+ // ── Request/response shapes, one per wire protocol ──────────────────────────
48
+ //
49
+ // Kept as data rather than subclasses: each is four small functions, and a flat
50
+ // table makes it obvious what a new provider must supply.
51
+ const SHAPES = {
52
+ anthropic: {
53
+ headers: () => ({ 'Content-Type': 'application/json', 'anthropic-version': ANTHROPIC_VERSION }),
54
+ auth: (h, key) => { if (key) h['x-api-key'] = key; },
55
+ body: (model, prompt, maxTokens) => ({ model, max_tokens: maxTokens, messages: [{ role: 'user', content: prompt }] }),
56
+ text: (j) => (Array.isArray(j?.content) ? j.content.filter(b => b?.type === 'text').map(b => b.text || '').join('') : ''),
57
+ usage: (j) => (j?.usage && Number.isFinite(j.usage.input_tokens)
58
+ ? { inputTokens: j.usage.input_tokens, outputTokens: j.usage.output_tokens || 0 } : null),
59
+ },
60
+ // OpenAI-compatible: also what most local servers speak, which is why the
61
+ // `local` provider reuses it rather than inventing a third shape.
62
+ openai: {
63
+ headers: () => ({ 'Content-Type': 'application/json' }),
64
+ auth: (h, key) => { if (key) h.Authorization = `Bearer ${key}`; },
65
+ body: (model, prompt, maxTokens) => ({ model, max_tokens: maxTokens, messages: [{ role: 'user', content: prompt }] }),
66
+ text: (j) => j?.choices?.[0]?.message?.content || j?.choices?.[0]?.text || '',
67
+ usage: (j) => {
68
+ const u = j?.usage; if (!u) return null;
69
+ const inputTokens = u.prompt_tokens ?? u.input_tokens;
70
+ const outputTokens = u.completion_tokens ?? u.output_tokens ?? 0;
71
+ return Number.isFinite(inputTokens) ? { inputTokens, outputTokens } : null;
72
+ },
73
+ },
74
+ // The LEGACY generic shape: `{prompt, model}` with a permissive extractor.
75
+ // This is what BYO endpoints and local servers have always been sent, and
76
+ // existing deployments speak it. Assuming OpenAI-compatibility here would
77
+ // silently break every one of them, so the OpenAI shape is used ONLY when the
78
+ // operator explicitly asks for the openai preset.
79
+ generic: {
80
+ headers: () => ({ 'Content-Type': 'application/json' }),
81
+ auth: (h, key) => { if (key) h.Authorization = `Bearer ${key}`; },
82
+ body: (model, prompt) => ({ prompt, model }),
83
+ text: (j) => (j && (j.response || j.text || j.content || j.output
84
+ || j.choices?.[0]?.message?.content || j.message?.content)) || '',
85
+ usage: (j) => {
86
+ const u = j?.usage; if (!u) return null;
87
+ const inputTokens = u.prompt_tokens ?? u.input_tokens;
88
+ const outputTokens = u.completion_tokens ?? u.output_tokens ?? 0;
89
+ return Number.isFinite(inputTokens) ? { inputTokens, outputTokens } : null;
90
+ },
91
+ },
92
+ gemini: {
93
+ headers: () => ({ 'Content-Type': 'application/json' }),
94
+ // Gemini takes the key on the query string; the caller appends it, so the
95
+ // header set stays empty rather than carrying a bearer token that the API
96
+ // would ignore.
97
+ auth: () => {},
98
+ body: (model, prompt, maxTokens) => ({
99
+ contents: [{ role: 'user', parts: [{ text: prompt }] }],
100
+ generationConfig: { maxOutputTokens: maxTokens },
101
+ }),
102
+ text: (j) => (j?.candidates?.[0]?.content?.parts || []).map(p => p?.text || '').join(''),
103
+ usage: (j) => {
104
+ const u = j?.usageMetadata; if (!u) return null;
105
+ return Number.isFinite(u.promptTokenCount)
106
+ ? { inputTokens: u.promptTokenCount, outputTokens: u.candidatesTokenCount || 0 } : null;
107
+ },
108
+ },
109
+ };
110
+
111
+ const DEFAULT_MODEL = {
112
+ anthropic: 'claude-haiku-4-5',
113
+ openai: 'gpt-4o-mini',
114
+ gemini: 'gemini-2.0-flash',
115
+ local: 'local-model',
116
+ };
117
+
118
+ function _envKey(role, suffix) {
119
+ return `AGENTIC_SECURITY_LLM_${suffix}_${String(role).toUpperCase()}`;
120
+ }
121
+
122
+ /** Per-role override, falling back to the global setting. */
123
+ function _forRole(env, role, suffix) {
124
+ if (role && ROLES.includes(role)) {
125
+ const v = env[_envKey(role, suffix)];
126
+ if (v) return v;
127
+ }
128
+ return env[`AGENTIC_SECURITY_LLM_${suffix}`];
129
+ }
130
+
131
+ /**
132
+ * Resolve the provider for a role.
133
+ *
134
+ * @returns {{ok:true, config:object} | {ok:false, reason:string|null}}
135
+ * `ok:false` with `reason:null` means "no AI configured", which is the normal
136
+ * default and NOT an error. A non-null reason means a configuration was
137
+ * supplied and refused — those must be surfaced, not silently treated the
138
+ * same as "off".
139
+ */
140
+ export function resolveProvider({ role = 'validate', env = process.env } = {}) {
141
+ const explicit = (_forRole(env, role, 'PRESET') || '').toLowerCase();
142
+ const model = _forRole(env, role, 'MODEL');
143
+
144
+ // 1. Local — checked first because it is the only mode that makes a promise
145
+ // about where data goes, and a stray endpoint must not override it.
146
+ if (explicit === 'local') {
147
+ const r = localEndpointConfig(env);
148
+ if (!r.ok) return { ok: false, reason: r.reason };
149
+ return {
150
+ ok: true,
151
+ config: {
152
+ provider: 'local', shape: SHAPES.generic, endpoint: r.config.endpoint,
153
+ apiKey: r.config.apiKey, model: model || r.config.model,
154
+ egress: 'loopback-only', role,
155
+ },
156
+ };
157
+ }
158
+
159
+ // 2. Explicit BYO endpoint — checked BEFORE the vendor presets because that
160
+ // is the documented precedence: an operator who names an endpoint means
161
+ // that endpoint, even with a preset also set. Reversing it would silently
162
+ // redirect traffic to a vendor.
163
+ const byoEndpoint = _forRole(env, role, 'ENDPOINT');
164
+ if (byoEndpoint) {
165
+ return {
166
+ ok: true,
167
+ config: {
168
+ provider: 'byo', shape: SHAPES.generic, endpoint: byoEndpoint,
169
+ apiKey: _forRole(env, role, 'API_KEY') || null,
170
+ model: model || 'unknown', egress: 'remote', role,
171
+ },
172
+ };
173
+ }
174
+
175
+ // 3. Explicit vendor presets.
176
+ if (explicit === 'anthropic' || explicit === 'openai' || explicit === 'gemini') {
177
+ const apiKey = _forRole(env, role, 'API_KEY')
178
+ || (explicit === 'anthropic' ? env.ANTHROPIC_API_KEY
179
+ : explicit === 'openai' ? env.OPENAI_API_KEY
180
+ : env.GEMINI_API_KEY || env.GOOGLE_API_KEY);
181
+ // No key -> the tier is off, not broken. Same offline-degrading rule the
182
+ // anthropic preset has always had.
183
+ if (!apiKey) return { ok: false, reason: null };
184
+ const m = model || DEFAULT_MODEL[explicit];
185
+ const endpoint = (
186
+ explicit === 'anthropic' ? 'https://api.anthropic.com/v1/messages'
187
+ : explicit === 'openai' ? 'https://api.openai.com/v1/chat/completions'
188
+ : `https://generativelanguage.googleapis.com/v1beta/models/${m}:generateContent?key=${encodeURIComponent(apiKey)}`
189
+ );
190
+ return {
191
+ ok: true,
192
+ config: {
193
+ provider: explicit, shape: SHAPES[explicit], endpoint,
194
+ apiKey: explicit === 'gemini' ? null : apiKey, model: m, egress: 'remote', role,
195
+ },
196
+ };
197
+ }
198
+
199
+ // 4. Nothing configured — deterministic engine only.
200
+ return { ok: false, reason: null };
201
+ }
202
+
203
+ /** Build the HTTP call for a resolved provider. Pure. */
204
+ export function buildProviderRequest(config, prompt, maxTokens) {
205
+ const headers = config.shape.headers();
206
+ config.shape.auth(headers, config.apiKey);
207
+ return {
208
+ headers,
209
+ body: config.shape.body(config.model, prompt, maxTokens),
210
+ extractText: config.shape.text,
211
+ extractUsage: config.shape.usage,
212
+ };
213
+ }
214
+
215
+ /** Which provider each role would use, for reporting. Never includes keys. */
216
+ export function providerMatrix(env = process.env) {
217
+ const out = {};
218
+ for (const role of ROLES) {
219
+ const r = resolveProvider({ role, env });
220
+ out[role] = r.ok
221
+ ? { provider: r.config.provider, model: r.config.model, egress: r.config.egress }
222
+ : { provider: null, reason: r.reason || 'not configured' };
223
+ }
224
+ return out;
225
+ }
226
+
227
+ export const _internals = { SHAPES, DEFAULT_MODEL, _forRole };
package/src/mcp/audit.js CHANGED
@@ -27,6 +27,7 @@ import * as path from 'node:path';
27
27
  import * as crypto from 'node:crypto';
28
28
  import { redactArgsBlob } from './redact.js';
29
29
 
30
+ import { stateDir } from '../posture/state-dir.js';
30
31
  const MAX_ARG_BYTES = 1024;
31
32
  const GENESIS = 'GENESIS';
32
33
  const REMOTE_TIMEOUT_MS = 1500;
@@ -87,7 +88,7 @@ export function auditCall({ sessionRoot, tool, args, outcome, reason }) {
87
88
  let hasMarker = false;
88
89
  for (const m of MARKERS) { try { if (fs.existsSync(path.join(sessionRoot, m))) { hasMarker = true; break; } } catch {} }
89
90
  if (!hasMarker) return;
90
- const dir = path.join(sessionRoot, '.agentic-security');
91
+ const dir = stateDir(sessionRoot);
91
92
  fs.mkdirSync(dir, { recursive: true });
92
93
  const logFile = path.join(dir, 'mcp-audit.log');
93
94
  const entry = {
package/src/mcp/tools.js CHANGED
@@ -157,7 +157,7 @@ function _scratchpadAbs(sessionRoot, relPath) {
157
157
  }
158
158
 
159
159
  function _scratchpadTotalBytes(sessionRoot) {
160
- const base = path.join(sessionRoot, '.agentic-security', 'agent-scratchpad');
160
+ const base = statePath(sessionRoot, 'agent-scratchpad');
161
161
  if (!fs.existsSync(base)) return 0;
162
162
  let total = 0;
163
163
  const walk = (dir) => {
@@ -222,8 +222,8 @@ function _confine(sessionRoot, candidate, label) {
222
222
  }
223
223
 
224
224
  function _readLastScanVerified(sessionRoot, { allowUnsigned = false } = {}) {
225
- const stateDir = path.join(sessionRoot, '.agentic-security');
226
- const scanFile = path.join(stateDir, 'last-scan.json');
225
+ const stateDirPath = stateDir(sessionRoot);
226
+ const scanFile = path.join(stateDirPath, 'last-scan.json');
227
227
  const sigFile = scanFile + '.sig';
228
228
  if (!fs.existsSync(scanFile)) return { scan: null, status: 'missing' };
229
229
  const body = fs.readFileSync(scanFile, 'utf8');
@@ -1013,6 +1013,7 @@ export const read_scratchpad = {
1013
1013
  import { appendAgentsMemory as _appendAgentsMemory, readAgentsMemory as _readAgentsMemory } from '../posture/agents-memory.js';
1014
1014
  import { lookupCve as _lookupCve } from '../posture/cve-lookup.js';
1015
1015
 
1016
+ import { stateDir, statePath } from '../posture/state-dir.js';
1016
1017
  export const append_agents_memory = {
1017
1018
  name: 'append_agents_memory',
1018
1019
  description: 'Append a short narrative entry to AGENTS.md — agent-authored continual-learning notes. Use at session end to record "what worked / what didn\'t / what I\'d try differently next time" so the next agent can pick up the lesson. Bounded: 2 KB per entry, 20 KB total before rotation to AGENTS.md.archive. Use sparingly — narrative, not structured data.',
@@ -44,7 +44,7 @@ rather than creating a stray state dir outside a project.
44
44
 
45
45
  **Agentic verification** — `verifier.js`, `verifier-target.js`, `verifier-ephemeral.js`, `harness-discovery.js`, `adversary-agent.js`, `defender-agent.js`, `auditor-agent.js`, `three-agent-pipeline.js`.
46
46
 
47
- **Methodology additions (`docs/AGENTIC_METHODOLOGY_PRD.md`)** — default-on annotators/artifacts that layer the agentic-hunter methodology on the deterministic engine:
47
+ **Methodology additions (Agentic Methodology PRD, removed post-implementation)** — default-on annotators/artifacts that layer the agentic-hunter methodology on the deterministic engine:
48
48
  - `falsification.js` — default falsification pass. For each taint-style finding, tries to DISPROVE it (locate a context-matched control on the path, reusing `dataflow/sanitizer-proof.js`'s shape rules read-only); a blocked finding is demoted + `quarantined`, never removed and never severity-touched (recall-preserving, like `proof-gate`). Wired after `annotateProofGate`. Opt out: `AGENTIC_SECURITY_NO_FALSIFICATION=1`. Optional LLM tier over survivors when an endpoint is configured.
49
49
  - `entrypoint-inventory.js` — attack-surface completeness ledger. Enumerates every entry point (HTTP/queue/cron/CLI/env/upload/webhook) with a disposition each; on `scan.entrypointInventory`.
50
50
  - `root-cause-sweep.js` — from confirmed findings, finds sibling instances detectors missed with total-count accounting (`found === candidates + mitigated`); on `scan.rootCauseSweep`. Searches the corpus **once per distinct sink pattern**, not once per finding — findings deriving the same pattern share one walk and one set of (read-only) match records. The counts are always exact; the materialised `instances` list is a bounded sample (`INSTANCE_SAMPLE_LIMIT`, 100) and says so via `instancesTruncated`. Both properties are load-bearing on large corpora: the per-finding walk was O(findings × corpus-bytes) and the instance records were O(findings × matches), which together exhausted a 6 GB heap on a 40k-file suite. If you touch this module, keep the own-site exclusion **per pattern group** — resolving it globally makes a group subtract an exclusion it never matched and drives counts negative.
@@ -80,6 +80,31 @@ Wired in `bin/agentic-security.js` after every filter and after `makeDeterminist
80
80
 
81
81
  **Rule lifecycle** — `custom-rules.js` (YAML pattern DSL), `rule-overrides.js` (`disable:` gated on signature), `rule-packs.js`, `rule-synthesis.js` (proposes suppressions from triage feedback), `ruleset-version.js`.
82
82
 
83
+ **NIST Privacy Framework 1.1 (`privacy-framework.js`)** — assessment + remediation
84
+ over the bundled `compliance-frameworks/nist-privacy-1-1.json` (all 104 controls).
85
+ Sits on top of `auditor-walkthrough.js`'s evaluator and adds the half a narrative
86
+ cannot give you: a gap becomes a FINDING (`family: privacy-compliance`,
87
+ `CWE-359`) carrying an actionable remediation, so it flows through triage and
88
+ `/fix`.
89
+
90
+ Four buckets, and the bucket is always stated: `gap` (mapped signal failing —
91
+ the ONLY bucket that emits a finding), `engine-gap` (NIST rates it code-testable
92
+ but this engine has no signal — disclosed by name, never a pass), `manual` (NIST
93
+ rates it not code-testable), `satisfied`. NIST's own `codeTestable` rating is
94
+ carried per control and is what separates "nobody checked" from "we checked and
95
+ it is fine" — 48 of 104 are governance controls no scanner can assess, and
96
+ reporting those as passed is the failure mode the module exists to prevent.
97
+
98
+ Two guards are load-bearing. **Findings are opt-in**
99
+ (`AGENTIC_SECURITY_PRIVACY_FRAMEWORK=1`); the assessment always lands on
100
+ `scan.privacyFramework` and at `.agentic-security/privacy-framework.{json,md}`,
101
+ but appending to `scan.findings` by default would change every severity count
102
+ and gate verdict downstream. And the **vacuous-satisfaction guard**: a
103
+ `family:`-mapped control clears when no findings of that family are open, which
104
+ is also true of a scan that read zero files — so when nothing was examined every
105
+ mapped control degrades to `engine-gap` instead of reporting as satisfied. That
106
+ one was caught by the module's own test, not in review.
107
+
83
108
  **Posture artifacts** — `sbom.js`, `aibom.js`, `api-inventory.js`, `threat-model.js`, `trust-boundary-diagram.js`, `stack-playbook.js`, `deploy-platform.js`, `license-policy.js`, `material-change.js`, `mttr.js`, `streak.js`, `scorecard.js`, `security-trend.js`.
84
109
 
85
110
  **Why this fired** — `why-fired.js`. Runs LAST so it reflects every annotation. Customer-facing provenance.
@@ -98,6 +123,31 @@ Wired in `bin/agentic-security.js` after every filter and after `makeDeterminist
98
123
  credibility, orthogonal to `confidence`/`exploitability`: whether the bug was
99
124
  *run*, not just reasoned about.
100
125
 
126
+ **The five proof classes** (`poc-inprocess.js`), and what each observes:
127
+
128
+ | Family | Evidence |
129
+ |---|---|
130
+ | `command-injection`, `code-injection` | the injected payload itself writes the marker |
131
+ | `webhook-missing-signature-verification` | the handler is observed *accepting* an unsigned request |
132
+ | `sql-injection` | the payload reaches a stubbed driver inside the **SQL text** rather than as a bound parameter |
133
+ | `path-traversal` | a sentinel planted outside the served directory comes back out of the handler |
134
+
135
+ The last two need no running application, which is the point: the SQL question
136
+ ("text or bound parameter?") is settled where the query crosses into the driver,
137
+ and the traversal question is settled by what the handler hands back. A
138
+ parameterised query and a `basename`-guarded read both reach `proof-failed` by
139
+ **execution**, not by a source pattern.
140
+
141
+ Classes deliberately absent, with reasons, are listed in the module header —
142
+ IDOR (needs two identities and a populated store; a PoC built on invented state
143
+ proves something about the invention), SSRF (the sandbox denies egress, so a
144
+ failed fetch is confinement talking), XSS (a marker file cannot observe a DOM).
145
+
146
+ `extraFiles` on a PoC carries support files that are **not** the vulnerable
147
+ source (the SQL driver stub). `mergePocFiles` merges it under `requires`, which
148
+ always wins — otherwise a template could replace the code it is supposed to
149
+ exploit and prove a fact about itself.
150
+
101
151
  **The four tiers** (`PROOF_TIERS`, most-proven first):
102
152
 
103
153
  - `execution-proven` — a generated PoC ran inside the sandbox and the sandbox
@@ -236,6 +286,57 @@ State lives at `<scanRoot>/.agentic-security/scan-checkpoint.jsonl`; like every
236
286
  other module here, nothing throws — a failure to open, read or append degrades to
237
287
  "no checkpoint", i.e. a normal full scan.
238
288
 
289
+ ## The autonomous loop, the fleet, and the two things that judge them
290
+
291
+ **`autopilot.js`** is the chain — scan → prove → validate → fix → **re-verify** —
292
+ and nothing else. Every stage is injected, so the orchestration is testable
293
+ without an engine or a model; `scripts/autopilot.mjs` is where the real stages
294
+ get wired (a real scan, a real sandboxed exploit, a deterministic-then-model fix,
295
+ and the real gate).
296
+
297
+ The rule that makes it safe to automate: a fix is applied **only** if the PoC
298
+ that proved the bug no longer fires **and** the test suite still passes. Anything
299
+ else is `NEEDS_REVIEW` and is not written. A re-scan proves the *detector* went
300
+ quiet; only re-running the exploit proves the hole is shut. Gates are ON by
301
+ default — `apply` is an explicit opt-in — and the outcome set (`OUTCOMES`) is
302
+ closed, because the report groups on it and an undeclared value would vanish
303
+ from every count. `maxFindings` is reported as `capped`, never applied silently.
304
+
305
+ The CLI refuses to start with no confinement backend (the gate's verdict
306
+ requires executing something) and refuses a dirty git tree by default (the test
307
+ leg writes the candidate patch to disk and restores it in a `finally`, so a
308
+ clean tree is what makes a crash recoverable). A VERIFIED_FIXED reached with no
309
+ test runner detected is counted and reported separately — the exploit stopped
310
+ firing, but nothing checked the application still works.
311
+
312
+ **`fleet.js`** rolls many repositories into one offline page. `renderFleetHtml`
313
+ emits no scripts and no external references, and a repo that FAILED to scan
314
+ always forces a non-zero exit: an unscanned repo is unknown, not clean.
315
+
316
+ **`logic-claims.js` (PRD Epic 6)** is the business-logic tier's other half. The
317
+ deterministic side already existed (`sast/logic.js`, `posture/business-logic.js`);
318
+ what did not was any way to be *wrong* about a claim from the reviewing agent,
319
+ which is prose and was the only tier nothing could disagree with. Three offline
320
+ lenses can refute one: `citation` (the file exists and the line is inside it),
321
+ `quotation` (the quoted snippet is at the cited line ±3), and `corroboration`
322
+ (for kinds that assert something checkable — "this route has no authentication"
323
+ against a handler that plainly authenticates). Verdicts go through
324
+ `verification-separation.js`, so a lens can never vote on a claim it produced.
325
+ Recall-preserving: a refuted claim is `quarantined`, never deleted, never
326
+ severity-touched. Wired in `engine.js`, which reads
327
+ `.agentic-security/logic-claims.json` from the scan root and lands the results
328
+ on `scan.logicVulns` with a summary at `scan.logicClaims`.
329
+
330
+ **`comparison.js` (PRD Epic 7.2)** scores this engine head-to-head against
331
+ participants **the operator supplies** — the repository ships the harness and the
332
+ answer key, never a participant, and a test asserts no tool name appears in
333
+ either file. Two properties are the whole module: every rate is computed over the
334
+ **intersection** of entries *all* participants completed (two tools scored over
335
+ different subsets are not comparable, and the difference is invisible in the
336
+ output), and an entry a participant could not run is **unscored**, never counted
337
+ as a miss. Matching is CWE-only so nobody is scored on this engine's vocabulary.
338
+ Driver: `scripts/comparison.mjs`, over the CVE-replay corpus.
339
+
239
340
  ## Gotchas
240
341
 
241
342
  - The seed `calibration-seed.json` is small (n < 30 for several families). Don't treat it as a held-out set — that's `holdout-eval.js`'s job, against an externally-supplied JSONL.
@@ -121,7 +121,14 @@ export function buildScorecard(inputs) {
121
121
  notGeneralRecall: true,
122
122
  notGeneralFalsePositiveRate: true,
123
123
  f1Emitted: false,
124
- f1OmissionReason: 'no labelled real-world population is available to measure precision over, so any F1 would combine denominators that do not describe the same population',
124
+ // Was: "no labelled real-world population is available". That stopped
125
+ // being true when bench/independent/ was built, and a stale justification
126
+ // is worse than none — it argues against a measurement that now exists.
127
+ // No F1 is emitted FOR THE CURATED CORPUS, and the reason is narrower:
128
+ // its `post/` fixtures are authored here to be silent, so its precision
129
+ // denominator describes fixture design rather than engine behaviour.
130
+ // F1 over the independent population IS reported, in its own section.
131
+ f1OmissionReason: 'the curated corpus authors both its vulnerable and its fixed fixtures, so an F1 over it would measure fixture design, not accuracy; F1 over the labelled third-party population is reported separately from bench/independent/RESULT.json',
125
132
  },
126
133
  corpus: {
127
134
  measuredThisRun: true,
@@ -138,6 +145,17 @@ export function buildScorecard(inputs) {
138
145
  corpusBaseline: committed.corpusBaseline
139
146
  ? { source: 'bench/cve-replay/corpus-baseline.json', generatedAt: committed.corpusBaseline.generatedAt, total: committed.corpusBaseline.total, passing: committed.corpusBaseline.passing }
140
147
  : null,
148
+ independent: committed.independent
149
+ ? {
150
+ source: 'bench/independent/RESULT.json',
151
+ measuredAt: committed.independent.measuredAt || null,
152
+ engineVersion: committed.independent.engineVersion || null,
153
+ population: committed.independent.population || null,
154
+ overall: committed.independent.overall || null,
155
+ wide: committed.independent.wide || null,
156
+ byLanguage: committed.independent.byLanguage || null,
157
+ }
158
+ : null,
141
159
  proofCorpus: proof
142
160
  ? {
143
161
  source: 'bench/proof-corpus/results/summary.json',
@@ -307,6 +325,46 @@ export function renderScorecardMarkdown(m) {
307
325
  }
308
326
  L.push('Per-file counts are in `docs/scorecard.json`.');
309
327
  L.push('');
328
+ const ind = m.committedInputs.independent;
329
+ if (ind && ind.overall) {
330
+ L.push('## Independent evaluation population — the number that matters');
331
+ L.push('');
332
+ L.push('Everything above is a **regression net**: its fixtures and its labels are both');
333
+ L.push('written here, which is why its detection rate sits at the ceiling by');
334
+ L.push('construction. `bench/independent/` is the other instrument — real upstream code');
335
+ L.push('at the commit where a vulnerability really existed, with the CWE assigned by a');
336
+ L.push('public advisory database rather than by this project.');
337
+ L.push('');
338
+ L.push(`**Measured ${ind.measuredAt} on engine ${ind.engineVersion}, ` +
339
+ `n=${ind.population?.scoredEntries}, ${ind.population?.unscored} unscored** ` +
340
+ '(*committed artifact*, `' + ind.source + '` — read, not re-run: scoring takes ~32 minutes).');
341
+ L.push('');
342
+ L.push('| | Advisory-local (**the claim**) | Wide (diagnostic) |');
343
+ L.push('| --- | --- | --- |');
344
+ L.push(`| Precision | **${formatRate(ind.overall.precision?.n, ind.overall.precision?.d)}** | ${formatRate(ind.wide?.precision?.n, ind.wide?.precision?.d)} |`);
345
+ L.push(`| Recall | **${formatRate(ind.overall.recall?.n, ind.overall.recall?.d)}** | ${formatRate(ind.wide?.recall?.n, ind.wide?.recall?.d)} |`);
346
+ L.push(`| F1 | **${ind.overall.f1 === null || ind.overall.f1 === undefined ? 'n/a' : ind.overall.f1.toFixed(3)}** | ${ind.wide?.f1 === null || ind.wide?.f1 === undefined ? 'n/a' : ind.wide.f1.toFixed(3)} |`);
347
+ L.push('');
348
+ if (ind.byLanguage) {
349
+ L.push('| Language | n | Recall | Precision |');
350
+ L.push('| --- | --- | --- | --- |');
351
+ for (const [k, v] of Object.entries(ind.byLanguage)) {
352
+ L.push(`| ${k} | ${v.entries} | ${formatRate(v.recall?.n, v.recall?.d)} | ${formatRate(v.precision?.n, v.precision?.d)} |`);
353
+ }
354
+ L.push('');
355
+ }
356
+ L.push('**Quote the advisory-local column.** "Wide" scores the same scans without');
357
+ L.push('restricting findings to the files the advisory\'s fix commit touched — it asks');
358
+ L.push('only whether the CWE appeared *anywhere* in the package. Over scopes holding up');
359
+ L.push('to 1740 findings that is close to asking whether the codebase contains the bug');
360
+ L.push('class at all, a question with a much easier yes. It is kept because it is the');
361
+ L.push('only way to tell whether a change moved the engine or moved the benchmark.');
362
+ L.push('');
363
+ L.push('Against ~100% on the curated corpus above. **That gap is the most useful number');
364
+ L.push('in this document**, and publishing it is the point of the exercise. The figure');
365
+ L.push('went DOWN when the benchmark was corrected, and is published that way.');
366
+ L.push('');
367
+ }
310
368
  L.push('## Committed artifacts referenced (not re-run by this command)');
311
369
  L.push('');
312
370
  const cb = m.committedInputs.corpusBaseline;
@@ -29,6 +29,7 @@
29
29
  import * as fs from 'node:fs';
30
30
  import * as path from 'node:path';
31
31
 
32
+ import { statePath, stateWritesEnabled } from './state-dir.js';
32
33
  const MEMORY_FILE = '.agentic-security/AGENTS.md';
33
34
  const ARCHIVE_FILE = '.agentic-security/AGENTS.md.archive';
34
35
  const MAX_BYTES = 20 * 1024;
@@ -36,8 +37,8 @@ const MAX_ENTRY_BYTES = 2 * 1024;
36
37
  const ARCHIVE_MAX_BYTES = 200 * 1024;
37
38
  const HEADER = '# AGENTS.md\n\nAgent-authored continual-learning notes. Each entry: timestamp + agent name + one short paragraph. New entries appended at the bottom; oldest entries rotate to AGENTS.md.archive when this file exceeds 20 KB.\n\n';
38
39
 
39
- function _resolve(scanRoot) { return path.join(scanRoot, MEMORY_FILE); }
40
- function _archivePath(scanRoot) { return path.join(scanRoot, ARCHIVE_FILE); }
40
+ function _resolve(scanRoot) { return statePath(scanRoot, 'AGENTS.md'); }
41
+ function _archivePath(scanRoot) { return statePath(scanRoot, 'AGENTS.md.archive'); }
41
42
 
42
43
  export function readAgentsMemory(scanRoot) {
43
44
  const fp = _resolve(scanRoot);
@@ -65,7 +66,8 @@ export function appendAgentsMemory(scanRoot, { agent, body }) {
65
66
  const entry = `\n## ${ts} agent: ${agent}\n\n${snippet}\n`;
66
67
  try {
67
68
  const fp = _resolve(scanRoot);
68
- fs.mkdirSync(path.dirname(fp), { recursive: true });
69
+ if (!stateWritesEnabled()) return;
70
+ fs.mkdirSync(path.dirname(fp), { recursive: true });
69
71
  if (!fs.existsSync(fp)) fs.writeFileSync(fp, HEADER);
70
72
  fs.appendFileSync(fp, entry);
71
73
  _maybeRotate(scanRoot);