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

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 (50) hide show
  1. package/CHANGELOG.md +228 -0
  2. package/bin/agentic-security.js +103 -1
  3. package/dist/113.index.js +3 -3
  4. package/dist/178.index.js +1 -1
  5. package/dist/384.index.js +1 -1
  6. package/dist/499.index.js +86 -0
  7. package/dist/526.index.js +3 -3
  8. package/dist/609.index.js +741 -0
  9. package/dist/637.index.js +1 -1
  10. package/dist/agentic-security.mjs +56 -56
  11. package/dist/agentic-security.mjs.sha256 +1 -1
  12. package/package.json +9 -4
  13. package/src/discovery/CLAUDE.md +38 -0
  14. package/src/discovery/confirm.js +47 -0
  15. package/src/discovery/disprove.js +79 -0
  16. package/src/discovery/hunter.js +116 -0
  17. package/src/discovery/index.js +159 -0
  18. package/src/discovery/judge.js +97 -0
  19. package/src/discovery/lenses.js +69 -0
  20. package/src/discovery/llm-invoke.js +31 -0
  21. package/src/discovery/partition.js +92 -0
  22. package/src/engine.js +151 -1
  23. package/src/llm-validator/cost-ceiling.js +199 -0
  24. package/src/llm-validator/index.js +254 -35
  25. package/src/llm-validator/local-endpoint.js +90 -0
  26. package/src/llm-validator/providers.js +227 -0
  27. package/src/posture/CLAUDE.md +76 -0
  28. package/src/posture/accuracy-scorecard.js +37 -6
  29. package/src/posture/autopilot.js +225 -0
  30. package/src/posture/comparison.js +181 -0
  31. package/src/posture/corpus-match.js +29 -14
  32. package/src/posture/execution-proof.js +25 -1
  33. package/src/posture/fleet.js +0 -0
  34. package/src/posture/integrity.js +42 -9
  35. package/src/posture/learning.js +8 -1
  36. package/src/posture/logic-claims.js +266 -0
  37. package/src/posture/model-routing.js +26 -0
  38. package/src/posture/model-trust.js +174 -0
  39. package/src/posture/poc-inprocess.js +567 -0
  40. package/src/posture/proof-artifact.js +101 -0
  41. package/src/posture/prove-findings.js +172 -0
  42. package/src/posture/rule-overrides.js +64 -3
  43. package/src/posture/state-dir.js +25 -0
  44. package/src/posture/vuln-archaeology.js +231 -0
  45. package/src/report/index.js +16 -0
  46. package/src/sandbox/CLAUDE.md +27 -5
  47. package/src/sandbox/backend-namespace.js +39 -11
  48. package/src/sandbox/backend-userspace.js +4 -0
  49. package/src/sast/CLAUDE.md +4 -0
  50. package/src/sast/crypto-specialist.js +247 -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 };
@@ -98,6 +98,31 @@ Wired in `bin/agentic-security.js` after every filter and after `makeDeterminist
98
98
  credibility, orthogonal to `confidence`/`exploitability`: whether the bug was
99
99
  *run*, not just reasoned about.
100
100
 
101
+ **The five proof classes** (`poc-inprocess.js`), and what each observes:
102
+
103
+ | Family | Evidence |
104
+ |---|---|
105
+ | `command-injection`, `code-injection` | the injected payload itself writes the marker |
106
+ | `webhook-missing-signature-verification` | the handler is observed *accepting* an unsigned request |
107
+ | `sql-injection` | the payload reaches a stubbed driver inside the **SQL text** rather than as a bound parameter |
108
+ | `path-traversal` | a sentinel planted outside the served directory comes back out of the handler |
109
+
110
+ The last two need no running application, which is the point: the SQL question
111
+ ("text or bound parameter?") is settled where the query crosses into the driver,
112
+ and the traversal question is settled by what the handler hands back. A
113
+ parameterised query and a `basename`-guarded read both reach `proof-failed` by
114
+ **execution**, not by a source pattern.
115
+
116
+ Classes deliberately absent, with reasons, are listed in the module header —
117
+ IDOR (needs two identities and a populated store; a PoC built on invented state
118
+ proves something about the invention), SSRF (the sandbox denies egress, so a
119
+ failed fetch is confinement talking), XSS (a marker file cannot observe a DOM).
120
+
121
+ `extraFiles` on a PoC carries support files that are **not** the vulnerable
122
+ source (the SQL driver stub). `mergePocFiles` merges it under `requires`, which
123
+ always wins — otherwise a template could replace the code it is supposed to
124
+ exploit and prove a fact about itself.
125
+
101
126
  **The four tiers** (`PROOF_TIERS`, most-proven first):
102
127
 
103
128
  - `execution-proven` — a generated PoC ran inside the sandbox and the sandbox
@@ -236,6 +261,57 @@ State lives at `<scanRoot>/.agentic-security/scan-checkpoint.jsonl`; like every
236
261
  other module here, nothing throws — a failure to open, read or append degrades to
237
262
  "no checkpoint", i.e. a normal full scan.
238
263
 
264
+ ## The autonomous loop, the fleet, and the two things that judge them
265
+
266
+ **`autopilot.js`** is the chain — scan → prove → validate → fix → **re-verify** —
267
+ and nothing else. Every stage is injected, so the orchestration is testable
268
+ without an engine or a model; `scripts/autopilot.mjs` is where the real stages
269
+ get wired (a real scan, a real sandboxed exploit, a deterministic-then-model fix,
270
+ and the real gate).
271
+
272
+ The rule that makes it safe to automate: a fix is applied **only** if the PoC
273
+ that proved the bug no longer fires **and** the test suite still passes. Anything
274
+ else is `NEEDS_REVIEW` and is not written. A re-scan proves the *detector* went
275
+ quiet; only re-running the exploit proves the hole is shut. Gates are ON by
276
+ default — `apply` is an explicit opt-in — and the outcome set (`OUTCOMES`) is
277
+ closed, because the report groups on it and an undeclared value would vanish
278
+ from every count. `maxFindings` is reported as `capped`, never applied silently.
279
+
280
+ The CLI refuses to start with no confinement backend (the gate's verdict
281
+ requires executing something) and refuses a dirty git tree by default (the test
282
+ leg writes the candidate patch to disk and restores it in a `finally`, so a
283
+ clean tree is what makes a crash recoverable). A VERIFIED_FIXED reached with no
284
+ test runner detected is counted and reported separately — the exploit stopped
285
+ firing, but nothing checked the application still works.
286
+
287
+ **`fleet.js`** rolls many repositories into one offline page. `renderFleetHtml`
288
+ emits no scripts and no external references, and a repo that FAILED to scan
289
+ always forces a non-zero exit: an unscanned repo is unknown, not clean.
290
+
291
+ **`logic-claims.js` (PRD Epic 6)** is the business-logic tier's other half. The
292
+ deterministic side already existed (`sast/logic.js`, `posture/business-logic.js`);
293
+ what did not was any way to be *wrong* about a claim from the reviewing agent,
294
+ which is prose and was the only tier nothing could disagree with. Three offline
295
+ lenses can refute one: `citation` (the file exists and the line is inside it),
296
+ `quotation` (the quoted snippet is at the cited line ±3), and `corroboration`
297
+ (for kinds that assert something checkable — "this route has no authentication"
298
+ against a handler that plainly authenticates). Verdicts go through
299
+ `verification-separation.js`, so a lens can never vote on a claim it produced.
300
+ Recall-preserving: a refuted claim is `quarantined`, never deleted, never
301
+ severity-touched. Wired in `engine.js`, which reads
302
+ `.agentic-security/logic-claims.json` from the scan root and lands the results
303
+ on `scan.logicVulns` with a summary at `scan.logicClaims`.
304
+
305
+ **`comparison.js` (PRD Epic 7.2)** scores this engine head-to-head against
306
+ participants **the operator supplies** — the repository ships the harness and the
307
+ answer key, never a participant, and a test asserts no tool name appears in
308
+ either file. Two properties are the whole module: every rate is computed over the
309
+ **intersection** of entries *all* participants completed (two tools scored over
310
+ different subsets are not comparable, and the difference is invisible in the
311
+ output), and an entry a participant could not run is **unscored**, never counted
312
+ as a miss. Matching is CWE-only so nobody is scored on this engine's vocabulary.
313
+ Driver: `scripts/comparison.mjs`, over the CVE-replay corpus.
314
+
239
315
  ## Gotchas
240
316
 
241
317
  - 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.
@@ -263,17 +263,48 @@ export function renderScorecardMarkdown(m) {
263
263
  L.push('');
264
264
  L.push('## Precision-side signal: self-scan (measured this run)');
265
265
  L.push('');
266
- L.push('The engine scanned this repository\'s own hand-reviewed source. These are');
267
- L.push('absolute finding counts, not a rate — there is no labelled ground truth');
268
- L.push('over this code, so no precision figure is derived from it. What it');
269
- L.push('supports is a movement claim: any change in these counts between');
270
- L.push('releases is a real change in what the engine reports on unchanged code.');
266
+ L.push('The engine scanned its own repository. These are absolute finding');
267
+ L.push('counts, not a rate — there is no labelled ground truth over this code,');
268
+ L.push('so no precision figure is derived from it. What it supports is a');
269
+ L.push('movement claim: any change in these counts between releases is a real');
270
+ L.push('change in what the engine reports on unchanged code.');
271
+ L.push('');
272
+ L.push('**The two halves are not the same kind of evidence, so they are not');
273
+ L.push('reported together.** `hooks/` and `scripts/` were reviewed by hand,');
274
+ L.push('finding by finding. `scanner/src` was not: it is the engine itself, at a');
275
+ L.push('scale no one has read line by line, and a scanner\'s own source contains');
276
+ L.push('sink patterns as DATA — rule tables, catalogs, remediation strings — so a');
277
+ L.push('large share of its findings are self-referential rather than defects.');
278
+ L.push('Treat it as a tripwire, never as a quality figure.');
279
+ L.push('');
280
+ // Split deliberately. Both halves used to sit under a single "hand-reviewed"
281
+ // sentence; when `scanner/src` was added to the gate its 594 findings
282
+ // inherited a review claim that was true of 48 findings and false of these.
283
+ // Widening a gate must not silently upgrade what its numbers assert.
284
+ const REVIEWED = new Set(['hooks', 'scripts']);
285
+ const entries = Object.entries(m.selfScan.targets);
286
+ const reviewed = entries.filter(([k]) => REVIEWED.has(k));
287
+ const unreviewed = entries.filter(([k]) => !REVIEWED.has(k));
288
+
289
+ L.push('### Hand-reviewed targets');
271
290
  L.push('');
272
291
  L.push('| Target | Findings |');
273
292
  L.push('| --- | --- |');
274
- for (const [k, v] of Object.entries(m.selfScan.targets)) L.push(`| \`${k}\` | ${v.total} |`);
293
+ for (const [k, v] of reviewed) L.push(`| \`${k}\` | ${v.total} |`);
275
294
  L.push(`| \`polyglot\` fixture (expected 0) | ${m.selfScan.polyglot.total} |`);
276
295
  L.push('');
296
+ if (unreviewed.length) {
297
+ L.push('### Drift tripwire — NOT hand-reviewed, NOT a precision signal');
298
+ L.push('');
299
+ L.push('| Target | Findings |');
300
+ L.push('| --- | --- |');
301
+ for (const [k, v] of unreviewed) L.push(`| \`${k}\` | ${v.total} |`);
302
+ L.push('');
303
+ L.push('These counts exist so that a rule which starts firing somewhere new is');
304
+ L.push('visible per file. Nobody has adjudicated them, and quoting the total as');
305
+ L.push('a false-positive count would be wrong in both directions.');
306
+ L.push('');
307
+ }
277
308
  L.push('Per-file counts are in `docs/scorecard.json`.');
278
309
  L.push('');
279
310
  L.push('## Committed artifacts referenced (not re-run by this command)');
@@ -0,0 +1,225 @@
1
+ // PRD Epic 4 — the autonomous loop.
2
+ //
3
+ // Every stage already existed as a separate command. What did not exist was the
4
+ // chain: scan → prove → validate → fix → RE-VERIFY, with state between stages
5
+ // and a gate before anything is written. This module is that chain and nothing
6
+ // else — it owns no analysis, and each stage is injected, so the orchestration
7
+ // can be tested without running an engine or calling a model.
8
+ //
9
+ // THE RULE THAT MAKES IT SAFE TO AUTOMATE: a fix is applied only if the PoC
10
+ // that proved the bug no longer fires AND the test suite still passes. That is
11
+ // `VERIFIED_FIXED`. Anything else is `NEEDS_REVIEW` and is NOT written. A loop
12
+ // that applies patches on the strength of "the scanner stopped complaining"
13
+ // automates the failure mode where a cosmetic edit silences a detector while
14
+ // the vulnerability remains — the re-scan proves the DETECTOR went quiet, and
15
+ // only re-running the exploit proves the hole is shut.
16
+ //
17
+ // GATES ARE ON BY DEFAULT. `apply` requires an explicit opt-in. Autonomy plus
18
+ // write access is the combination that turns a bad patch into a bad commit, and
19
+ // the default must be the one that cannot.
20
+ //
21
+ // RESUMABLE, AND HONEST ABOUT WHAT IT SKIPPED. Each stage records its outcome
22
+ // per finding; a resumed run replays completed stages from state rather than
23
+ // re-running them. A stage that was skipped is reported as skipped — never
24
+ // folded into "nothing to do", which is how an interrupted run reads as a clean
25
+ // one.
26
+
27
+ import fs from 'node:fs';
28
+ import path from 'node:path';
29
+
30
+ const SCHEMA = 'agentic-security/autopilot@1';
31
+
32
+ export const STAGES = Object.freeze(['scan', 'prove', 'validate', 'fix', 'reverify']);
33
+
34
+ // Outcomes a finding can end a run in. Closed set: a new outcome must be
35
+ // declared here, because the report groups on it and an unknown value would
36
+ // silently vanish from every count.
37
+ export const OUTCOMES = Object.freeze([
38
+ 'VERIFIED_FIXED', // patch applied (or ready): PoC no longer fires, tests pass
39
+ 'NEEDS_REVIEW', // a fix exists but did not re-verify — NOT applied
40
+ 'NO_FIX', // nothing was synthesised
41
+ 'UNPROVEN', // no PoC fired, so no fix was attempted at this tier
42
+ ]);
43
+
44
+ export function loadAutopilotState(stateFile) {
45
+ try {
46
+ const j = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
47
+ if (j && j.schema === SCHEMA) return j;
48
+ } catch { /* absent -> clean */ }
49
+ return { schema: SCHEMA, stages: {}, findings: {} };
50
+ }
51
+
52
+ function _save(stateFile, state) {
53
+ if (!stateFile) return;
54
+ try {
55
+ fs.mkdirSync(path.dirname(stateFile), { recursive: true });
56
+ fs.writeFileSync(stateFile, JSON.stringify(state, null, 2));
57
+ } catch { /* state is an optimisation; losing it must not fail a run */ }
58
+ }
59
+
60
+ /**
61
+ * Run the loop.
62
+ *
63
+ * Every stage is injected. `apply` defaults to false: the loop produces
64
+ * VERIFIED_FIXED patches and does not write them unless told to.
65
+ *
66
+ * @param {object} stages
67
+ * scan() -> { findings: [] }
68
+ * prove(finding) -> { proofTier, proofEvidence, poc }
69
+ * validate(finding) -> { verdict: 'upheld'|'refuted'|'undecided' }
70
+ * synthesizeFix(finding) -> { patch: {file: content} } | null
71
+ * verifyFix(finding, patch) -> { ok, pocStillFires, testsPass, reason }
72
+ * applyFix(finding, patch) -> void (only called when apply === true)
73
+ */
74
+ export async function runAutopilot({
75
+ stages = {}, stateFile = null, resume = true, apply = false,
76
+ onStage = () => {}, severities = ['critical', 'high'], maxFindings = Infinity,
77
+ } = {}) {
78
+ const required = ['scan'];
79
+ for (const r of required) {
80
+ if (typeof stages[r] !== 'function') return { ok: false, reason: `no ${r} stage supplied` };
81
+ }
82
+ const state = resume && stateFile ? loadAutopilotState(stateFile) : { schema: SCHEMA, stages: {}, findings: {} };
83
+ const skipped = [];
84
+
85
+ // ── scan ────────────────────────────────────────────────────────────────
86
+ let findings;
87
+ if (resume && state.stages.scan) {
88
+ findings = state.stages.scan.findings || [];
89
+ skipped.push('scan');
90
+ } else {
91
+ const r = await stages.scan();
92
+ findings = r?.findings || [];
93
+ state.stages.scan = { findings };
94
+ _save(stateFile, state);
95
+ }
96
+ onStage({ stage: 'scan', count: findings.length });
97
+
98
+ // Only the severities asked for reach the expensive stages. Reported, so a
99
+ // reader can see what was in scope rather than assuming everything was.
100
+ const all = findings.filter(f => severities.includes(String(f.severity || '').toLowerCase()));
101
+ const outOfScope = findings.length - all.length;
102
+ // Every stage costs a sandboxed process and possibly a test-suite run, so the
103
+ // count is bounded. Reported, never silent: findings past the cap were NOT
104
+ // examined, and a run that quietly stopped at N would read as a run that
105
+ // found only N.
106
+ const inScope = all.slice(0, Number.isFinite(maxFindings) ? Math.max(0, maxFindings) : all.length);
107
+ const capped = all.length - inScope.length;
108
+
109
+ const results = [];
110
+ for (const f of inScope) {
111
+ const key = f.stableId || `${f.file}:${f.line}:${f.vuln}`;
112
+ const prior = resume ? state.findings[key] : null;
113
+ if (prior?.outcome) { results.push(prior); continue; }
114
+
115
+ const rec = { key, file: f.file, line: f.line, vuln: f.vuln, severity: f.severity };
116
+
117
+ // ── prove ─────────────────────────────────────────────────────────────
118
+ let proved = null;
119
+ if (typeof stages.prove === 'function') {
120
+ proved = await stages.prove(f).catch(e => ({ error: String(e?.message || e) }));
121
+ rec.proofTier = proved?.proofTier || null;
122
+ }
123
+ const isProven = rec.proofTier === 'execution-proven';
124
+
125
+ // ── validate ──────────────────────────────────────────────────────────
126
+ if (typeof stages.validate === 'function') {
127
+ const v = await stages.validate(f).catch(() => null);
128
+ rec.validation = v?.verdict || null;
129
+ // A refuted finding is NOT dropped — same recall-preserving rule the rest
130
+ // of the engine follows. It is recorded and skipped for fixing.
131
+ if (rec.validation === 'refuted') {
132
+ rec.outcome = 'NEEDS_REVIEW';
133
+ rec.reason = 'an independent verifier refuted this finding; not fixed automatically';
134
+ results.push(rec); state.findings[key] = rec; _save(stateFile, state);
135
+ onStage({ stage: 'validate', key, verdict: 'refuted' });
136
+ continue;
137
+ }
138
+ }
139
+
140
+ // Fixing is gated on proof at this tier. An unproven finding may still be
141
+ // real — it is reported UNPROVEN, not dismissed.
142
+ if (!isProven) {
143
+ rec.outcome = 'UNPROVEN';
144
+ rec.reason = proved?.proofEvidence?.reason || 'no proof-of-concept demonstrated this finding';
145
+ results.push(rec); state.findings[key] = rec; _save(stateFile, state);
146
+ continue;
147
+ }
148
+
149
+ // ── fix ───────────────────────────────────────────────────────────────
150
+ let patch = null;
151
+ if (typeof stages.synthesizeFix === 'function') {
152
+ patch = await stages.synthesizeFix(f).catch(() => null);
153
+ }
154
+ if (!patch || !patch.patch) {
155
+ rec.outcome = 'NO_FIX';
156
+ rec.reason = 'no patch was synthesised';
157
+ results.push(rec); state.findings[key] = rec; _save(stateFile, state);
158
+ continue;
159
+ }
160
+
161
+ // ── re-verify ─────────────────────────────────────────────────────────
162
+ // The gate. Without a verifier we cannot claim VERIFIED_FIXED, so we do
163
+ // not — an unverifiable patch is NEEDS_REVIEW even if it looks right.
164
+ if (typeof stages.verifyFix !== 'function') {
165
+ rec.outcome = 'NEEDS_REVIEW';
166
+ rec.reason = 'no verifyFix stage supplied — a patch that cannot be re-verified is never applied';
167
+ results.push(rec); state.findings[key] = rec; _save(stateFile, state);
168
+ continue;
169
+ }
170
+ const v = await stages.verifyFix(f, patch).catch(e => ({ ok: false, reason: String(e?.message || e) }));
171
+ rec.pocStillFires = v?.pocStillFires === true;
172
+ rec.testsPass = v?.testsPass !== false;
173
+
174
+ if (v?.ok && !rec.pocStillFires && rec.testsPass) {
175
+ rec.outcome = 'VERIFIED_FIXED';
176
+ if (apply && typeof stages.applyFix === 'function') {
177
+ try { await stages.applyFix(f, patch); rec.applied = true; }
178
+ catch (e) { rec.applied = false; rec.outcome = 'NEEDS_REVIEW'; rec.reason = `apply failed: ${e.message}`; }
179
+ } else {
180
+ rec.applied = false;
181
+ rec.reason = apply ? 'no applyFix stage supplied' : 'gates on: patch is ready but was not written';
182
+ }
183
+ } else {
184
+ rec.outcome = 'NEEDS_REVIEW';
185
+ rec.reason = rec.pocStillFires
186
+ ? 'the proof-of-concept still fires against the patch — the vulnerability is not fixed'
187
+ : (!rec.testsPass ? 'the project test suite fails with this patch'
188
+ : (v?.reason || 'the patch did not re-verify'));
189
+ }
190
+ results.push(rec);
191
+ state.findings[key] = rec;
192
+ _save(stateFile, state);
193
+ onStage({ stage: 'reverify', key, outcome: rec.outcome });
194
+ }
195
+
196
+ return { ok: true, results, skipped, outOfScope, capped, summary: summarizeAutopilot(results, outOfScope, capped) };
197
+ }
198
+
199
+ export function summarizeAutopilot(results, outOfScope = 0, capped = 0) {
200
+ const byOutcome = Object.fromEntries(OUTCOMES.map(o => [o, 0]));
201
+ for (const r of results) if (r.outcome in byOutcome) byOutcome[r.outcome]++;
202
+ return {
203
+ considered: results.length,
204
+ outOfScope,
205
+ capped,
206
+ byOutcome,
207
+ applied: results.filter(r => r.applied).length,
208
+ };
209
+ }
210
+
211
+ /** One line. Leads with what was NOT fixed, because that is the actionable part. */
212
+ export function renderAutopilotSummary(s) {
213
+ if (!s) return null;
214
+ const b = s.byOutcome;
215
+ const bits = [`${s.considered} finding(s) in scope`];
216
+ if (b.VERIFIED_FIXED) bits.push(`${b.VERIFIED_FIXED} VERIFIED_FIXED (${s.applied} applied)`);
217
+ if (b.NEEDS_REVIEW) bits.push(`${b.NEEDS_REVIEW} NEEDS_REVIEW — not applied`);
218
+ if (b.NO_FIX) bits.push(`${b.NO_FIX} with no patch`);
219
+ if (b.UNPROVEN) bits.push(`${b.UNPROVEN} unproven (not dismissed — no PoC fired)`);
220
+ if (s.outOfScope) bits.push(`${s.outOfScope} below the severity floor and not considered`);
221
+ if (s.capped) bits.push(`${s.capped} in scope but NOT examined (per-run cap) — unexamined, not clean`);
222
+ return bits.join('; ') + '.';
223
+ }
224
+
225
+ export const _internals = { SCHEMA };