@clear-capabilities/agentic-security-scanner 0.124.1 → 0.128.1

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 (40) hide show
  1. package/CHANGELOG.md +206 -0
  2. package/bin/agentic-security.js +75 -2
  3. package/dist/11.index.js +353 -0
  4. package/dist/113.index.js +525 -0
  5. package/dist/178.index.js +1 -1
  6. package/dist/220.index.js +193 -0
  7. package/dist/384.index.js +1 -1
  8. package/dist/435.index.js +2406 -0
  9. package/dist/449.index.js +135 -0
  10. package/dist/637.index.js +1 -1
  11. package/dist/752.index.js +7 -4
  12. package/dist/801.index.js +87 -0
  13. package/dist/826.index.js +4 -1
  14. package/dist/838.index.js +1 -1
  15. package/dist/agentic-security.mjs +1 -2
  16. package/dist/agentic-security.mjs.sha256 +1 -1
  17. package/package.json +6 -6
  18. package/src/engine.js +31 -1
  19. package/src/integrations/tickets.js +9 -3
  20. package/src/ir/CLAUDE.md +22 -17
  21. package/src/llm-validator/index.js +47 -12
  22. package/src/mcp/tools.js +108 -3
  23. package/src/posture/CLAUDE.md +10 -1
  24. package/src/posture/cache-economics.js +7 -4
  25. package/src/posture/deterministic-fix.js +65 -0
  26. package/src/posture/entrypoint-inventory.js +248 -0
  27. package/src/posture/falsification.js +121 -0
  28. package/src/posture/fix-honesty-gate.js +175 -0
  29. package/src/posture/fix-verify.js +18 -3
  30. package/src/posture/model-routing.js +126 -0
  31. package/src/posture/mttr.js +25 -0
  32. package/src/posture/provider-catalog.js +108 -0
  33. package/src/posture/root-cause-sweep.js +262 -0
  34. package/src/posture/secret-live-check.js +71 -0
  35. package/src/pr-comment.js +3 -1
  36. package/src/sast/CLAUDE.md +1 -1
  37. package/src/sast/api-authz.js +36 -0
  38. package/src/sast/file-upload.js +118 -0
  39. package/src/sast/llm-cost-advisor.js +88 -0
  40. package/src/util/untrusted.js +148 -0
@@ -0,0 +1,248 @@
1
+ // Attack-surface completeness inventory (addition #2).
2
+ //
3
+ // Enumerates every attacker-reachable entry point across a codebase and
4
+ // assigns each a disposition, producing an auditable coverage ledger. The
5
+ // point is completeness: rather than only reporting where a finding fired,
6
+ // this lists every surface an attacker can *reach* (HTTP routes, queue
7
+ // consumers, cron jobs, CLI arg parsing, environment reads, file uploads,
8
+ // webhooks) and states, for each, whether it was traced clean, has an open
9
+ // finding, or otherwise. A reviewer can then audit the coverage rather than
10
+ // trust that "no finding == safe."
11
+ //
12
+ // Entry-point types (7):
13
+ // http — inbound HTTP routes (from opts.routes; one entry per route)
14
+ // queue — message-queue consumers (Kafka / SQS / RabbitMQ / pub-sub)
15
+ // cron — scheduled jobs (@Scheduled, cron.schedule, setInterval, celery)
16
+ // cli — command-line argument parsing (argv / argparse / commander / …)
17
+ // env — environment-variable reads (process.env / getenv)
18
+ // upload — file-upload sinks (multer / req.files / MultipartFile)
19
+ // webhook — inbound webhook handlers (route path or /webhook literal)
20
+ //
21
+ // Granularity: HTTP/webhook routes are enumerated per route. The regex-
22
+ // discovered surfaces are enumerated per (type, file) — a source file that
23
+ // exposes a surface is counted once for that surface, recording the first
24
+ // matching line. This avoids double-counting an import line plus its use
25
+ // site (e.g. `import multer` + `multer(...)`) while still distinguishing
26
+ // distinct surface types that share a file.
27
+ //
28
+ // No throwing: every public entry degrades to an empty/zeroed ledger.
29
+
30
+ // ── Entry-point type order (fixed — drives byType key order) ────────────────
31
+ const ENTRY_TYPES = ['http', 'queue', 'cron', 'cli', 'env', 'upload', 'webhook'];
32
+
33
+ // ── Regex-discovered surface patterns ──────────────────────────────────────
34
+ // Non-global regexes (no /g) so repeated .test()/.exec() calls are stateless.
35
+ // Ordered roughly most-specific → most-generic within each type.
36
+ const SURFACE_PATTERNS = {
37
+ queue: [
38
+ /@KafkaListener\b/,
39
+ /@SqsListener\b/,
40
+ /@RabbitListener\b/,
41
+ /\b(?:sqs|kafka|rabbit)\w*\s*\.\s*(?:consume|subscribe|receiveMessage|poll|on)\b/i,
42
+ /new\s+(?:Kafka)?Consumer\s*\(/,
43
+ /\.consume\s*\(/,
44
+ /\.subscribe\s*\(/,
45
+ ],
46
+ cron: [
47
+ /@Scheduled\b/,
48
+ /cron\.schedule\s*\(/,
49
+ /\bnode-cron\b/,
50
+ /@shared_task\b/,
51
+ /@periodic_task\b/,
52
+ /@(?:app\.)?task\b/, // celery
53
+ /setInterval\s*\(/, // interval used as a recurring job
54
+ ],
55
+ cli: [
56
+ /process\.argv\b/,
57
+ /\bargparse\b/,
58
+ /\bcommander\b/,
59
+ /\byargs\b/,
60
+ /\bcobra\.Command\b/,
61
+ /\bflag\.Parse\s*\(/,
62
+ ],
63
+ env: [
64
+ /process\.env\.\w+/,
65
+ /os\.getenv\s*\(/,
66
+ /System\.getenv\s*\(/,
67
+ ],
68
+ upload: [
69
+ /\bmulter\b/,
70
+ /\breq\.files?\b/,
71
+ /\brequest\.files?\b/,
72
+ /multipart\/form-data/i,
73
+ /\bMultipartFile\b/,
74
+ ],
75
+ webhook: [
76
+ /['"`][^'"`]*\/webhook[^'"`]*['"`]/i, // a "/webhook…" string literal
77
+ ],
78
+ };
79
+
80
+ // Auth tokens — presence near an entry point in the same file promotes its
81
+ // trust boundary from unauthenticated → authenticated. `\bauth\b` matches the
82
+ // bare token only (not "author" / "oauth", which retain their word chars).
83
+ const AUTH_TOKEN = /(?:@PreAuthorize|requireAuth|require_auth|login_required|isAuthenticated|authenticate\w*|authorize\w*|authMiddleware|authGuard|\bauth\b)/i;
84
+
85
+ const AUTH_WINDOW = 8; // lines above/below the entry point to scan for auth
86
+
87
+ // ── Input normalization ─────────────────────────────────────────────────────
88
+ // Accept either a Map<filepath,string> or a plain object {path: source}.
89
+ function _entries(fileContents) {
90
+ if (!fileContents) return [];
91
+ if (fileContents instanceof Map) {
92
+ return [...fileContents.entries()].filter(([, v]) => typeof v === 'string');
93
+ }
94
+ if (typeof fileContents === 'object') {
95
+ return Object.entries(fileContents).filter(([, v]) => typeof v === 'string');
96
+ }
97
+ return [];
98
+ }
99
+
100
+ function _emptyCoverage() {
101
+ const byType = {};
102
+ for (const t of ENTRY_TYPES) byType[t] = 0;
103
+ return { total: 0, byType, tracedSafe: 0, finding: 0, notReachable: 0, noInput: 0 };
104
+ }
105
+
106
+ function _authNear(lines, line) {
107
+ const lo = Math.max(0, line - 1 - AUTH_WINDOW);
108
+ const hi = Math.min(lines.length, line + AUTH_WINDOW);
109
+ return AUTH_TOKEN.test(lines.slice(lo, hi).join('\n'));
110
+ }
111
+
112
+ function _routeHasInput(route) {
113
+ if (Array.isArray(route.params) && route.params.length) return true;
114
+ const p = typeof route.path === 'string' ? route.path : '';
115
+ // :id (Express/Koa), {id} (FastAPI/Spring), <id> (Flask/Django) → has input.
116
+ return /[:{<]\w/.test(p);
117
+ }
118
+
119
+ function _clip(s, n = 80) {
120
+ const str = String(s == null ? '' : s).trim();
121
+ return str.length > n ? str.slice(0, n) : str;
122
+ }
123
+
124
+ // ── Core ─────────────────────────────────────────────────────────────────────
125
+
126
+ export function buildEntrypointInventory(fileContents, opts = {}) {
127
+ const coverage = _emptyCoverage();
128
+ const entrypoints = [];
129
+ try {
130
+ const routes = Array.isArray(opts && opts.routes) ? opts.routes : [];
131
+ const findings = Array.isArray(opts && opts.findings) ? opts.findings : [];
132
+ const findingFiles = new Set(
133
+ findings.map(f => (f && typeof f.file === 'string' ? f.file : null)).filter(Boolean),
134
+ );
135
+
136
+ // 1) HTTP / webhook entry points — one per route.
137
+ for (const route of routes) {
138
+ if (!route || typeof route !== 'object') continue;
139
+ const file = typeof route.file === 'string' && route.file ? route.file : '(unknown)';
140
+ const line = Number.isInteger(route.line) ? route.line : 0;
141
+ const method = typeof route.method === 'string' ? route.method : 'GET';
142
+ const path = typeof route.path === 'string' ? route.path : '';
143
+ const isWebhook = /webhook/i.test(path) || /webhook/i.test(String(route.handler || ''));
144
+ const type = isWebhook ? 'webhook' : 'http';
145
+ const name = _clip(route.handler || `${method} ${path}`.trim() || type);
146
+
147
+ // Trust: an authenticated route (hasAuth) or a nearby auth token wins.
148
+ let trust = route.hasAuth === true ? 'authenticated' : 'unauthenticated';
149
+ if (trust === 'unauthenticated' && fileContents) {
150
+ const src = _srcOf(fileContents, file);
151
+ if (src && _authNear(src.split(/\r?\n/), line)) trust = 'authenticated';
152
+ }
153
+
154
+ let disposition;
155
+ if (findingFiles.has(file)) disposition = 'finding';
156
+ else if (type === 'http' && !_routeHasInput(route)) disposition = 'no-input';
157
+ else disposition = 'traced-safe';
158
+
159
+ entrypoints.push({ type, file, line, name, trust, disposition });
160
+ }
161
+
162
+ // 2) Regex-discovered surfaces — one per (type, file), first matching line.
163
+ for (const [file, source] of _entries(fileContents)) {
164
+ const lines = source.split(/\r?\n/);
165
+ for (const type of ENTRY_TYPES) {
166
+ const pats = SURFACE_PATTERNS[type];
167
+ if (!pats) continue; // http has no file-scan pattern (routes-only)
168
+ let hitLine = -1;
169
+ let hitText = '';
170
+ outer:
171
+ for (let i = 0; i < lines.length; i++) {
172
+ for (const re of pats) {
173
+ const m = re.exec(lines[i]);
174
+ if (m) { hitLine = i + 1; hitText = _clip(m[0] || type); break outer; }
175
+ }
176
+ }
177
+ if (hitLine < 0) continue;
178
+
179
+ const trust = _authNear(lines, hitLine) ? 'authenticated' : 'unauthenticated';
180
+ // Regex surfaces inherently carry attacker-controlled input (a queue
181
+ // message, an env value, a CLI arg, an upload). Only HTTP routes get
182
+ // the optional 'no-input' disposition.
183
+ const disposition = findingFiles.has(file) ? 'finding' : 'traced-safe';
184
+ entrypoints.push({ type, file, line: hitLine, name: hitText || type, trust, disposition });
185
+ }
186
+ }
187
+
188
+ // 3) Roll up the coverage ledger.
189
+ coverage.total = entrypoints.length;
190
+ for (const e of entrypoints) {
191
+ if (coverage.byType[e.type] != null) coverage.byType[e.type]++;
192
+ if (e.disposition === 'finding') coverage.finding++;
193
+ else if (e.disposition === 'not-reachable') coverage.notReachable++;
194
+ else if (e.disposition === 'no-input') coverage.noInput++;
195
+ else coverage.tracedSafe++;
196
+ }
197
+ } catch (_) {
198
+ // Degrade to whatever we accumulated before the error (or empty).
199
+ return { entrypoints, coverage: _recount(entrypoints) };
200
+ }
201
+ return { entrypoints, coverage };
202
+ }
203
+
204
+ // Defensive re-count used only on the error path.
205
+ function _recount(entrypoints) {
206
+ const coverage = _emptyCoverage();
207
+ coverage.total = entrypoints.length;
208
+ for (const e of entrypoints) {
209
+ if (coverage.byType[e.type] != null) coverage.byType[e.type]++;
210
+ if (e.disposition === 'finding') coverage.finding++;
211
+ else if (e.disposition === 'not-reachable') coverage.notReachable++;
212
+ else if (e.disposition === 'no-input') coverage.noInput++;
213
+ else coverage.tracedSafe++;
214
+ }
215
+ return coverage;
216
+ }
217
+
218
+ function _srcOf(fileContents, file) {
219
+ if (!fileContents) return null;
220
+ if (fileContents instanceof Map) {
221
+ const v = fileContents.get(file);
222
+ return typeof v === 'string' ? v : null;
223
+ }
224
+ const v = fileContents[file];
225
+ return typeof v === 'string' ? v : null;
226
+ }
227
+
228
+ // ── Engine wiring helper ─────────────────────────────────────────────────────
229
+ // Annotates a scan object in place with scan.entrypointInventory. Never throws;
230
+ // absent inputs yield an empty ledger.
231
+ export function annotateEntrypointCoverage(scan) {
232
+ if (!scan || typeof scan !== 'object') return scan;
233
+ try {
234
+ const fileContents = scan.fileContents || scan._fileContents || null;
235
+ const routes = Array.isArray(scan.routes) ? scan.routes : [];
236
+ const findings = Array.isArray(scan.findings) ? scan.findings : [];
237
+ if (!fileContents && routes.length === 0) {
238
+ scan.entrypointInventory = { entrypoints: [], coverage: _emptyCoverage() };
239
+ return scan;
240
+ }
241
+ scan.entrypointInventory = buildEntrypointInventory(fileContents || {}, { routes, findings });
242
+ } catch (_) {
243
+ scan.entrypointInventory = { entrypoints: [], coverage: _emptyCoverage() };
244
+ }
245
+ return scan;
246
+ }
247
+
248
+ export const _internals = { SURFACE_PATTERNS, ENTRY_TYPES, AUTH_TOKEN, _entries };
@@ -0,0 +1,121 @@
1
+ // Addition #1 — Default falsification pass ("prove it can't be blocked, or demote").
2
+ //
3
+ // For each taint-style finding we actively try to DISPROVE it: locate a
4
+ // context-matched control (a sanitizer whose shape actually neutralizes THIS
5
+ // CWE family) on the path between source and sink. A finding that is blocked by
6
+ // such a control is "falsified" — demoted and quarantined. A finding with no
7
+ // blocking control "survives" and stands.
8
+ //
9
+ // This is recall-preserving, exactly like `dataflow/proof-gate.js`: a falsified
10
+ // finding is DEMOTED (confidence + tiers) and flagged `quarantined`, never
11
+ // removed and never severity-touched. Genuine vulnerabilities have no valid
12
+ // control on the path, so they survive — the corpus `pre:TP` fixtures stay TP.
13
+ //
14
+ // An OPTIONAL LLM tier (`opts.llmReview`, wired only when an LLM endpoint is
15
+ // configured) argues the opposing case over survivors; it is never required and
16
+ // the deterministic core runs fully offline.
17
+
18
+ import { isValidSanitizerFor } from '../dataflow/sanitizer-proof.js';
19
+
20
+ const DEMOTE_FACTOR = 0.4; // mirror proof-gate.js
21
+ const TIERS = ['low', 'medium', 'high']; // confidence / exploitability tier order
22
+
23
+ function _dropTier(tier) {
24
+ const i = TIERS.indexOf(tier);
25
+ if (i <= 0) return tier; // unknown or already lowest → unchanged
26
+ return TIERS[i - 1];
27
+ }
28
+
29
+ function _fileText(fileContents, file) {
30
+ if (!fileContents || !file) return '';
31
+ if (fileContents instanceof Map) return fileContents.get(file) || '';
32
+ return fileContents[file] || '';
33
+ }
34
+
35
+ // Reconstruct the path window: the source line, the sink line, and the lines
36
+ // between/around the sink, plus whatever snippets the finding already carries.
37
+ function _pathWindow(finding, fileContents) {
38
+ const parts = [];
39
+ if (finding.source?.snippet) parts.push(String(finding.source.snippet));
40
+ if (finding.sink?.snippet) parts.push(String(finding.sink.snippet));
41
+ const text = _fileText(fileContents, finding.file);
42
+ if (text) {
43
+ const lines = text.split('\n');
44
+ const sinkLine = Number(finding.sink?.line) || 0;
45
+ const srcLine = Number(finding.source?.line) || 0;
46
+ const lo = Math.max(0, Math.min(sinkLine, srcLine) - 3);
47
+ const hi = Math.min(lines.length, Math.max(sinkLine, srcLine) + 3);
48
+ for (let i = lo; i < hi; i++) parts.push(lines[i]);
49
+ }
50
+ return parts.join('\n');
51
+ }
52
+
53
+ /**
54
+ * Pure classifier. Returns `{ verdict, reasons }` with verdict ∈
55
+ * 'blocked' — a context-matched control for this CWE family sits on the path
56
+ * 'survived' — no blocking control found; the finding stands
57
+ * 'unproven' — not enough context to attempt falsification
58
+ */
59
+ export function classifyFinding(finding, fileContents) {
60
+ if (!finding || !finding.cwe || !finding.source || !finding.sink) {
61
+ return { verdict: 'unproven', reasons: ['not a taint-style finding'] };
62
+ }
63
+ // A sanitizer that doesn't match the sink context does NOT block the flow —
64
+ // the finding survives (this is a real bug, not a mitigation).
65
+ if (finding.sanitizerMismatch === true) {
66
+ return { verdict: 'survived', reasons: ['wrong-context sanitizer does not neutralize this sink'] };
67
+ }
68
+ const window = _pathWindow(finding, fileContents);
69
+ if (!window || !window.trim()) {
70
+ return { verdict: 'unproven', reasons: ['no source context available to attempt falsification'] };
71
+ }
72
+ const v = isValidSanitizerFor(window, finding.cwe);
73
+ if (v.trusted) {
74
+ return { verdict: 'blocked', reasons: [`context-matched control on path — ${v.reason}`] };
75
+ }
76
+ return { verdict: 'survived', reasons: ['no context-matched control found between source and sink'] };
77
+ }
78
+
79
+ /**
80
+ * Default-on annotator. Adds `finding.falsification = { verdict, reasons }` to
81
+ * every taint-style finding; demotes + quarantines the ones falsified as blocked.
82
+ * NEVER removes a finding and NEVER mutates severity (recall-preserving).
83
+ *
84
+ * @param opts.llmReview optional (survivor) => { verdict, reason } — the LLM tier.
85
+ * Wired only when an LLM endpoint is configured; run over
86
+ * survivors, and its result is attached at .falsification.llm.
87
+ */
88
+ export function annotateFalsification(findings, fileContents, opts = {}) {
89
+ if (!Array.isArray(findings)) return findings;
90
+ const survivors = [];
91
+ for (const f of findings) {
92
+ if (!f || !f.source || !f.sink || !f.cwe) continue; // only taint-style findings
93
+ let res;
94
+ try { res = classifyFinding(f, fileContents); }
95
+ catch { res = { verdict: 'unproven', reasons: ['classification error'] }; }
96
+ f.falsification = { verdict: res.verdict, reasons: res.reasons };
97
+
98
+ if (res.verdict === 'blocked') {
99
+ f.quarantined = true;
100
+ if (typeof f.confidence === 'number') {
101
+ f.confidence = Math.max(0, Math.round(f.confidence * DEMOTE_FACTOR * 1000) / 1000);
102
+ }
103
+ if (f.confidenceTier) f.confidenceTier = _dropTier(f.confidenceTier);
104
+ if (f.exploitabilityTier) f.exploitabilityTier = _dropTier(f.exploitabilityTier);
105
+ // severity intentionally untouched.
106
+ } else if (res.verdict === 'survived') {
107
+ survivors.push(f);
108
+ }
109
+ }
110
+
111
+ // Optional LLM tier — only over survivors, only when a reviewer is supplied.
112
+ if (typeof opts.llmReview === 'function') {
113
+ for (const f of survivors) {
114
+ try {
115
+ const llm = opts.llmReview(f);
116
+ if (llm) f.falsification.llm = llm;
117
+ } catch { /* the LLM tier is advisory; never let it break the scan */ }
118
+ }
119
+ }
120
+ return findings;
121
+ }
@@ -0,0 +1,175 @@
1
+ // Deterministic honesty gates on fix / finding output (#7).
2
+ //
3
+ // The project's verification discipline (scanner/CLAUDE.md) exists because
4
+ // several releases shipped broken or false because work was reported as done
5
+ // without confirming the artifact changed. Two of those failure modes are
6
+ // *textual* — they live in the prose an agent emits alongside a fix — and can
7
+ // be caught deterministically, with no LLM and no network:
8
+ //
9
+ // 1. Hand-wave residual-risk prose. "The input is adequately handled",
10
+ // "future work", "tbd", "later" — vague assurances that claim safety
11
+ // without naming a concrete remaining vector. A residual you can't name
12
+ // is a residual you're guessing about; reject the guess.
13
+ //
14
+ // 2. An unbacked "this is a false positive / provably safe" verdict. Marking
15
+ // a finding safe is a coverage *reduction* — it must cite a `file:line`
16
+ // that shows why, exactly like the rules-override gate refuses to silently
17
+ // shrink coverage.
18
+ //
19
+ // Plus a conservative fix-tier classifier so a partial remediation can never be
20
+ // labelled FULL: any workaround-only signal (rate-limit, docs, log-without-
21
+ // reject) is WORKAROUND; anything short of (sink signature changed + all callers
22
+ // routed + a discriminating test) is at most MITIGATION; only the full set with
23
+ // no partial-sanitization caveat earns FULL.
24
+ //
25
+ // Pure functions, no side effects, no throwing — safe to call from a command,
26
+ // a hook, or the MCP verify_fix path.
27
+
28
+ // Vague-assurance phrases that a real residual must never hide behind. Matched
29
+ // case-insensitively with word boundaries so "later" doesn't trip on
30
+ // "collateral" and "tbd" doesn't trip on a longer token.
31
+ const BANNED_RESIDUAL_PHRASES = Object.freeze([
32
+ 'adequately handled',
33
+ 'adequately handles',
34
+ 'properly validated',
35
+ 'properly handled',
36
+ 'handled properly',
37
+ 'handled safely',
38
+ 'future work',
39
+ 'more work needed',
40
+ 'to be done',
41
+ 'tbd',
42
+ 'later',
43
+ ]);
44
+
45
+ // A citation shaped like `file:line` — one or more non-space, non-colon chars,
46
+ // a colon, then digits. Unanchored: it need only appear somewhere in the item.
47
+ const CITATION_RE = /[^\s:]+:\d+/;
48
+
49
+ // Verdicts that assert the finding is not real and therefore demand a citation.
50
+ // Compared after normalizing separators (`_`/space → `-`) and lowercasing, so
51
+ // FALSE_POSITIVE, false-positive, and "provably safe" all land here.
52
+ const FP_VERDICTS = Object.freeze(new Set(['false-positive', 'provably-safe', 'safe']));
53
+
54
+ function _escapeRe(s) {
55
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
56
+ }
57
+
58
+ /**
59
+ * Reject vague-assurance / hand-wave residual-risk prose.
60
+ *
61
+ * An empty or whitespace-only residual is ok — there is no residual to lie
62
+ * about. A non-empty residual is rejected when it contains any banned phrase;
63
+ * each match yields one violation naming the offending phrase.
64
+ *
65
+ * @param {string} residualText
66
+ * @returns {{ ok: boolean, violations: string[] }}
67
+ */
68
+ export function checkResidualHonesty(residualText) {
69
+ const text = typeof residualText === 'string' ? residualText : '';
70
+ if (text.trim() === '') return { ok: true, violations: [] };
71
+
72
+ const violations = [];
73
+ for (const phrase of BANNED_RESIDUAL_PHRASES) {
74
+ const re = new RegExp(`\\b${_escapeRe(phrase)}\\b`, 'i');
75
+ if (re.test(text)) {
76
+ violations.push(`vague-assurance phrase: "${phrase}"`);
77
+ }
78
+ }
79
+ return { ok: violations.length === 0, violations };
80
+ }
81
+
82
+ function _isCitation(item) {
83
+ if (typeof item === 'string') return CITATION_RE.test(item);
84
+ if (item && typeof item === 'object' && typeof item.location === 'string') {
85
+ return CITATION_RE.test(item.location);
86
+ }
87
+ return false;
88
+ }
89
+
90
+ function _normalizeVerdict(verdict) {
91
+ return String(verdict).trim().toLowerCase().replace(/[_\s]+/g, '-');
92
+ }
93
+
94
+ /**
95
+ * Require a file:line citation behind a "this is not real" verdict.
96
+ *
97
+ * For a false-positive / provably-safe / safe verdict (case-insensitive; also
98
+ * accepts FALSE_POSITIVE), at least one evidence item must be a `file:line`
99
+ * citation — either a string matching /[^\s:]+:\d+/ or an object
100
+ * `{ location: "file:line" }`. Any other verdict passes unconditionally.
101
+ *
102
+ * @param {string} verdict
103
+ * @param {Array|string|object} evidence
104
+ * @returns {{ ok: boolean, violations: string[] }}
105
+ */
106
+ export function requireCitedEvidence(verdict, evidence) {
107
+ if (typeof verdict !== 'string' || !FP_VERDICTS.has(_normalizeVerdict(verdict))) {
108
+ return { ok: true, violations: [] };
109
+ }
110
+ const items = Array.isArray(evidence)
111
+ ? evidence
112
+ : evidence == null
113
+ ? []
114
+ : [evidence];
115
+ if (items.some(_isCitation)) return { ok: true, violations: [] };
116
+ return {
117
+ ok: false,
118
+ violations: ['false-positive/safe verdict requires a file:line citation'],
119
+ };
120
+ }
121
+
122
+ /**
123
+ * Classify a fix into FULL | MITIGATION | WORKAROUND, conservative-first.
124
+ *
125
+ * @param {object} signals
126
+ * @param {boolean} signals.sinkSignatureChanged
127
+ * @param {boolean} signals.allCallersRouted
128
+ * @param {boolean} signals.testDiscriminates - a test that fails pre-fix, passes post-fix
129
+ * @param {boolean} [signals.rateLimitOnly]
130
+ * @param {boolean} [signals.docsOnly]
131
+ * @param {boolean} [signals.logOnlyNoReject]
132
+ * @param {boolean} [signals.partialSanitization]
133
+ * @returns {'FULL'|'MITIGATION'|'WORKAROUND'}
134
+ */
135
+ export function computeFixTier(signals) {
136
+ const s = signals && typeof signals === 'object' ? signals : {};
137
+ if (s.rateLimitOnly || s.docsOnly || s.logOnlyNoReject) return 'WORKAROUND';
138
+ const complete = s.sinkSignatureChanged && s.allCallersRouted && s.testDiscriminates;
139
+ if (s.partialSanitization || !complete) return 'MITIGATION';
140
+ return 'FULL';
141
+ }
142
+
143
+ /**
144
+ * Compose the three gates for a single fix's output.
145
+ *
146
+ * ok = residual-honesty ok AND evidence-citation ok, further constrained by the
147
+ * tier/residual consistency invariant:
148
+ * - a FULL tier must NOT carry a residual (a full fix has nothing left);
149
+ * - a non-FULL tier MUST document a residual (say what's still open).
150
+ *
151
+ * @param {{ residual?: string, verdict?: string, evidence?: any, signals?: object }} input
152
+ * @returns {{ ok: boolean, tier: string, violations: string[] }}
153
+ */
154
+ export function gateFixOutput({ residual, verdict, evidence, signals } = {}) {
155
+ const tier = computeFixTier(signals);
156
+ const residualCheck = checkResidualHonesty(residual);
157
+ const evidenceCheck = requireCitedEvidence(verdict, evidence);
158
+
159
+ const violations = [...residualCheck.violations, ...evidenceCheck.violations];
160
+ let ok = residualCheck.ok && evidenceCheck.ok;
161
+
162
+ const residualEmpty = typeof residual !== 'string' || residual.trim() === '';
163
+ if (tier === 'FULL' && !residualEmpty) {
164
+ violations.push('FULL tier cannot carry a residual');
165
+ ok = false;
166
+ }
167
+ if (tier !== 'FULL' && residualEmpty) {
168
+ violations.push('non-FULL tier must document a residual');
169
+ ok = false;
170
+ }
171
+
172
+ return { ok, tier, violations };
173
+ }
174
+
175
+ export const _internals = Object.freeze({ BANNED_RESIDUAL_PHRASES, CITATION_RE, FP_VERDICTS });
@@ -15,6 +15,7 @@ import { spawnSync } from 'node:child_process';
15
15
  import * as fs from 'node:fs';
16
16
  import * as path from 'node:path';
17
17
  import { runFullScan } from '../engine.js';
18
+ import { gateFixOutput } from './fix-honesty-gate.js';
18
19
 
19
20
  const SEVERITY_RANK = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
20
21
 
@@ -110,21 +111,35 @@ function runLinter(cwd, cmd, args) {
110
111
 
111
112
  // Top-level verify: re-scan + lint. Returns the combined verdict + a
112
113
  // human-readable summary string suitable for surfacing to the user.
114
+ // Addition #7 — deterministic honesty gates on fix output. When the caller
115
+ // supplies `fixMeta` ({ residual, verdict, evidence, signals }) — e.g. the
116
+ // security-fixer agent's residual-risk text + completeness signals — the fix's
117
+ // claims are checked mechanically (no hand-wave residual prose, a cited
118
+ // file:line for any FP/safe verdict, and a FULL/MITIGATION/WORKAROUND tier). A
119
+ // dishonest or over-claiming fix fails the gate. When `fixMeta` is absent
120
+ // (the deterministic MCP write path, which has no claims to check) the honesty
121
+ // gate is skipped and behavior is unchanged.
113
122
  export async function verifyFix({
114
123
  scanRoot,
115
124
  originalFindingStableId,
116
125
  files,
117
126
  depFileContents,
127
+ fixMeta,
118
128
  } = {}) {
119
129
  const rescan = await verifyPatch({ scanRoot, originalFindingStableId, files, depFileContents });
120
130
  const lint = runProjectLinter(scanRoot, Object.keys(files || {}));
121
- const ok = rescan.ok && (lint.ok || lint.skipped);
131
+ let honesty = null;
132
+ if (fixMeta && typeof fixMeta === 'object') {
133
+ try { honesty = gateFixOutput(fixMeta); } catch { honesty = null; }
134
+ }
135
+ const ok = rescan.ok && (lint.ok || lint.skipped) && (honesty ? honesty.ok : true);
122
136
  const summary = [
123
137
  `re-scan: ${rescan.ok ? 'PASS' : 'FAIL — ' + rescan.reason}`,
124
138
  `linter: ${lint.runner === 'none' ? 'skipped (no linter config)'
125
139
  : lint.skipped ? `${lint.runner} not installed`
126
140
  : lint.ok ? `${lint.runner} PASS`
127
141
  : `${lint.runner} FAIL (exit ${lint.exitCode})`}`,
128
- ].join('\n');
129
- return { ok, rescan, lint, summary };
142
+ honesty ? `honesty: ${honesty.ok ? `PASS (${honesty.tier})` : 'FAIL — ' + honesty.violations.join('; ')}` : null,
143
+ ].filter(Boolean).join('\n');
144
+ return { ok, rescan, lint, honesty, summary };
130
145
  }