@clear-capabilities/agentic-security-scanner 0.143.0 → 0.144.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 (95) hide show
  1. package/CHANGELOG.md +304 -0
  2. package/bin/agentic-security.js +477 -47
  3. package/dist/1.index.js +223 -0
  4. package/dist/113.index.js +108 -17
  5. package/dist/144.index.js +163 -0
  6. package/dist/178.index.js +1 -1
  7. package/dist/238.index.js +3 -2
  8. package/dist/265.index.js +191 -0
  9. package/dist/384.index.js +1 -1
  10. package/dist/435.index.js +165 -52
  11. package/dist/526.index.js +108 -17
  12. package/dist/552.index.js +97 -0
  13. package/dist/637.index.js +1 -1
  14. package/dist/730.index.js +311 -0
  15. package/dist/736.index.js +301 -0
  16. package/dist/824.index.js +7 -0
  17. package/dist/905.index.js +88 -22
  18. package/dist/920.index.js +491 -0
  19. package/dist/970.index.js +109 -0
  20. package/dist/agentic-security.mjs +13 -13
  21. package/dist/agentic-security.mjs.sha256 +1 -1
  22. package/dist/calibration-seed.json +2 -0
  23. package/package.json +19 -11
  24. package/src/dataflow/index.js +18 -0
  25. package/src/dataflow/privacy-catalog.js +290 -0
  26. package/src/dataflow/privacy-deep-walker.js +515 -0
  27. package/src/dataflow/privacy-governance.js +126 -0
  28. package/src/dataflow/privacy-inventory.js +154 -0
  29. package/src/dataflow/privacy-sink-policy.js +125 -0
  30. package/src/dataflow/privacy-taint.js +115 -54
  31. package/src/dataflow/privacy-taxonomy.js +233 -0
  32. package/src/discovery/disprove.js +7 -3
  33. package/src/discovery/hunter.js +9 -5
  34. package/src/discovery/index.js +2 -2
  35. package/src/discovery/llm-invoke.js +69 -13
  36. package/src/egress/audit.js +147 -0
  37. package/src/egress/policy.js +313 -0
  38. package/src/egress/redact.js +180 -0
  39. package/src/engine.js +575 -288
  40. package/src/fix/apply-fix-service.js +403 -0
  41. package/src/fix/approver-registry.js +157 -0
  42. package/src/llm-validator/index.js +86 -9
  43. package/src/llm-validator/model-status.js +66 -0
  44. package/src/mcp/tools.js +157 -50
  45. package/src/pipeline/analyzer-supervisor.js +93 -0
  46. package/src/pipeline/analyzer-worker.js +26 -0
  47. package/src/pipeline/annotator-runner.js +33 -0
  48. package/src/pipeline/assurance-mode.js +91 -0
  49. package/src/pipeline/cascade-worker-pool.js +172 -0
  50. package/src/pipeline/cascade-worker.js +43 -0
  51. package/src/pipeline/coverage-ledger.js +0 -0
  52. package/src/pipeline/detector-runner.js +51 -0
  53. package/src/pipeline/enrichment-completion.js +58 -0
  54. package/src/pipeline/evidence-provenance.js +91 -0
  55. package/src/pipeline/finding-schema.js +101 -0
  56. package/src/pipeline/legacy-compat.js +101 -0
  57. package/src/pipeline/producer-collector.js +48 -0
  58. package/src/pipeline/producer-registry.js +112 -0
  59. package/src/pipeline/scan-health.js +144 -0
  60. package/src/posture/CLAUDE.md +2 -0
  61. package/src/posture/accuracy-scorecard.js +96 -1
  62. package/src/posture/adversary-agent.js +15 -3
  63. package/src/posture/artifact-registry.js +217 -0
  64. package/src/posture/auditor-walkthrough.js +70 -8
  65. package/src/posture/calibration-feedback.js +201 -0
  66. package/src/posture/calibration-seed.json +2 -0
  67. package/src/posture/calibration.js +25 -0
  68. package/src/posture/compliance-evidence-signing.js +131 -0
  69. package/src/posture/compliance-policy.js +314 -17
  70. package/src/posture/custom-rules.js +36 -0
  71. package/src/posture/deterministic.js +8 -1
  72. package/src/posture/encryption-provider.js +205 -0
  73. package/src/posture/evidence-grade-wording.js +71 -0
  74. package/src/posture/fix-history.js +113 -19
  75. package/src/posture/fix-honesty-gate.js +47 -6
  76. package/src/posture/fix-verify.js +56 -7
  77. package/src/posture/fleet.js +0 -0
  78. package/src/posture/flow-narration.js +7 -2
  79. package/src/posture/legal-hold.js +140 -0
  80. package/src/posture/llm-redteam.js +10 -1
  81. package/src/posture/material-change.js +90 -0
  82. package/src/posture/policy-bundle.js +274 -0
  83. package/src/posture/privacy-framework.js +33 -6
  84. package/src/posture/production-feedback.js +179 -0
  85. package/src/posture/retention-policy.js +132 -0
  86. package/src/posture/risk-dollars.js +216 -26
  87. package/src/posture/scan-checkpoint.js +176 -31
  88. package/src/posture/state-dir.js +36 -1
  89. package/src/posture/state-lifecycle-report.js +77 -0
  90. package/src/posture/suppressions.js +59 -3
  91. package/src/privacy/ir-adapter.js +380 -0
  92. package/src/report/index.js +51 -2
  93. package/src/report/oscal.js +7 -2
  94. package/src/sast/cpp.js +3 -14
  95. package/src/sca/llm-function-extract.js +6 -0
@@ -0,0 +1,205 @@
1
+ // FR-705 (assurance-hardening PRD): "Encrypt state classes marked
2
+ // confidential when an encryption provider is configured or required |
3
+ // Required encryption absence fails before sensitive state is written."
4
+ //
5
+ // SCOPE (Phase 1 of a deliberately staged rollout — see this module's own
6
+ // header for what is and is not covered, and D-0039-style honesty about
7
+ // what remains):
8
+ //
9
+ // - Confidentiality is a NEW `confidential: true` field on specific
10
+ // artifact-registry.js entries (see that module for the exact list and
11
+ // the reasoning for each). `last-scan.json`/`findings.json` are
12
+ // DELIBERATELY EXCLUDED from Phase 1: dozens of commands read them
13
+ // directly as plain JSON, so encrypting them is a much larger,
14
+ // separate migration (every reader needs a decrypt-on-read hook) —
15
+ // doing that alongside a handful of narrow, low-traffic audit/evidence
16
+ // artifacts in one pass would repeat exactly the "large, structurally-
17
+ // coupled epic attempted in one unplanned cycle" mistake this session
18
+ // has deliberately avoided elsewhere (see D-0028, D-0039).
19
+ // - ONE provider is implemented: 'local-key' — AES-256-GCM with a
20
+ // per-install key generated the first time encryption is actually
21
+ // used, stored at the same $XDG_CONFIG_HOME/agentic-security/ directory
22
+ // integrity.js already uses for its own signing key (a SEPARATE key
23
+ // file — never reuse one key across different cryptographic purposes).
24
+ // A KMS/envelope-encryption provider is a real future extension point
25
+ // (the provider interface is already shaped to allow one) but is out
26
+ // of scope here: this codebase makes no runtime cloud calls, and a KMS
27
+ // call is unavoidably a network call.
28
+ // - "Configured or required" is a per-project opt-in policy file
29
+ // (.agentic-security/encryption-policy.yml: `provider: local-key`,
30
+ // `required: true|false`). No file means "not configured, not
31
+ // required" — every confidential artifact keeps writing exactly as it
32
+ // always has, so this feature is inert until an operator opts in,
33
+ // matching every other policy surface in this codebase
34
+ // (retention-policy.yml, risk-config.yml, sca-policy.yml, ...).
35
+ // - The FAIL-CLOSED half of the acceptance criterion is
36
+ // `maybeEncryptForWrite`'s own contract: when `required: true` is set
37
+ // for a confidential artifact but no working provider is available,
38
+ // it returns `{ok:false}` — the caller must not write ANYTHING in that
39
+ // case, plaintext or otherwise. This is checked BEFORE any bytes touch
40
+ // disk, per the literal "fails before sensitive state is written"
41
+ // wording.
42
+
43
+ import * as crypto from 'node:crypto';
44
+ import * as fs from 'node:fs';
45
+ import * as os from 'node:os';
46
+ import * as path from 'node:path';
47
+ import * as yaml from '../util/yaml.js';
48
+ import { statePath } from './state-dir.js';
49
+ import { confidentialOf } from './artifact-registry.js';
50
+
51
+ export const ENCRYPTION_POLICY_FILE = 'encryption-policy.yml';
52
+ export const ENCRYPTION_MARKER = '__agentic_security_encrypted_v1__';
53
+ const KEY_FILE_NAME = 'encryption-key';
54
+ const ALGORITHM = 'aes-256-gcm';
55
+ const IV_BYTES = 12;
56
+
57
+ function _keyDir() {
58
+ const xdg = process.env.XDG_CONFIG_HOME;
59
+ const base = xdg && xdg.length ? xdg : path.join(os.homedir(), '.config');
60
+ return path.join(base, 'agentic-security');
61
+ }
62
+ function _keyPath() { return path.join(_keyDir(), KEY_FILE_NAME); }
63
+
64
+ // Same atomic-publish shape as integrity.js's _publishKeyAtomically — a
65
+ // hard-link into place is atomic and fails EEXIST rather than clobbering,
66
+ // closing the same first-writer-wins race a plain exclusive-create alone
67
+ // leaves open on filesystems without hard-link support (documented in
68
+ // integrity.js's own header; reproduced here rather than imported, since
69
+ // this is a genuinely separate key with its own purpose and no reason to
70
+ // couple the two modules' internals together).
71
+ function _publishKeyAtomically(fp, contents) {
72
+ const dir = _keyDir();
73
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
74
+ const tmp = path.join(dir, `${KEY_FILE_NAME}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`);
75
+ try {
76
+ fs.writeFileSync(tmp, contents, { mode: 0o600 });
77
+ try {
78
+ fs.linkSync(tmp, fp);
79
+ return 'created';
80
+ } catch (e) {
81
+ if (e.code === 'EEXIST') return 'exists';
82
+ try {
83
+ fs.writeFileSync(fp, contents, { mode: 0o600, flag: 'wx' });
84
+ return 'created';
85
+ } catch (e2) {
86
+ if (e2.code === 'EEXIST') return 'exists';
87
+ throw e2;
88
+ }
89
+ }
90
+ } finally {
91
+ try { fs.unlinkSync(tmp); } catch { /* best effort */ }
92
+ }
93
+ }
94
+
95
+ function _readOrGenerateKey() {
96
+ const fromEnv = process.env.AGENTIC_SECURITY_ENCRYPTION_KEY;
97
+ if (fromEnv && /^[0-9a-fA-F]{64}$/.test(fromEnv.trim())) return Buffer.from(fromEnv.trim(), 'hex');
98
+ const fp = _keyPath();
99
+ try {
100
+ if (fs.existsSync(fp)) {
101
+ const hex = fs.readFileSync(fp, 'utf8').trim();
102
+ if (/^[0-9a-fA-F]{64}$/.test(hex)) return Buffer.from(hex, 'hex');
103
+ }
104
+ } catch { /* fall through to generate */ }
105
+ const buf = crypto.randomBytes(32);
106
+ try {
107
+ const outcome = _publishKeyAtomically(fp, buf.toString('hex') + '\n');
108
+ if (outcome === 'created') return buf;
109
+ const hex = fs.readFileSync(fp, 'utf8').trim();
110
+ return Buffer.from(hex, 'hex');
111
+ } catch {
112
+ return buf; // ephemeral — a write can proceed this run, but nothing encrypted with it will ever decrypt again. Degraded, not blocked.
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Read `.agentic-security/encryption-policy.yml`. Never throws; a missing
118
+ * or malformed file is "not configured, not required" (the safe,
119
+ * inert-by-default state every other policy file in this codebase uses).
120
+ */
121
+ export function loadEncryptionPolicy(scanRoot) {
122
+ let fp;
123
+ try { fp = statePath(scanRoot, ENCRYPTION_POLICY_FILE); } catch { return null; }
124
+ let raw;
125
+ try { raw = fs.readFileSync(fp, 'utf8'); } catch { return null; }
126
+ try {
127
+ const doc = yaml.load(raw);
128
+ if (!doc || typeof doc !== 'object') return null;
129
+ const provider = doc.provider === 'local-key' ? 'local-key' : null;
130
+ return { provider, required: doc.required === true };
131
+ } catch { return null; }
132
+ }
133
+
134
+ function _encryptBuffer(plaintext, key) {
135
+ const iv = crypto.randomBytes(IV_BYTES);
136
+ const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
137
+ const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
138
+ return { iv: iv.toString('base64'), tag: cipher.getAuthTag().toString('base64'), ciphertext: ciphertext.toString('base64') };
139
+ }
140
+
141
+ function _decryptBuffer(envelope, key) {
142
+ const decipher = crypto.createDecipheriv(ALGORITHM, key, Buffer.from(envelope.iv, 'base64'));
143
+ decipher.setAuthTag(Buffer.from(envelope.tag, 'base64'));
144
+ return Buffer.concat([decipher.update(Buffer.from(envelope.ciphertext, 'base64')), decipher.final()]);
145
+ }
146
+
147
+ /**
148
+ * Is `parsed` (an already-JSON.parsed value) an encryption envelope this
149
+ * module produced?
150
+ */
151
+ export function isEncryptedEnvelope(parsed) {
152
+ return !!(parsed && typeof parsed === 'object' && parsed[ENCRYPTION_MARKER] === true);
153
+ }
154
+
155
+ /**
156
+ * The fail-closed gate. `artifactName` is matched against
157
+ * artifact-registry.js's `confidential` flag — an artifact not marked
158
+ * confidential is always {ok:true, content} unchanged, regardless of
159
+ * policy, so this function is a safe no-op to call for every write site
160
+ * without needing to duplicate the confidentiality check at each caller.
161
+ *
162
+ * @returns {{ok:true, content:string, encrypted:boolean} | {ok:false, reason:string}}
163
+ */
164
+ export function maybeEncryptForWrite(scanRoot, artifactName, content) {
165
+ if (!confidentialOf(artifactName)) return { ok: true, content, encrypted: false };
166
+
167
+ const policy = loadEncryptionPolicy(scanRoot);
168
+ if (!policy || !policy.provider) {
169
+ if (policy && policy.required) {
170
+ return { ok: false, reason: `encryption is required for confidential artifact "${artifactName}" but no provider is configured (.agentic-security/${ENCRYPTION_POLICY_FILE})` };
171
+ }
172
+ return { ok: true, content, encrypted: false };
173
+ }
174
+
175
+ try {
176
+ const key = _readOrGenerateKey();
177
+ const envelope = _encryptBuffer(Buffer.from(content, 'utf8'), key);
178
+ return { ok: true, content: JSON.stringify({ [ENCRYPTION_MARKER]: true, provider: 'local-key', ...envelope }), encrypted: true };
179
+ } catch (e) {
180
+ if (policy.required) return { ok: false, reason: `encryption required but failed for "${artifactName}": ${e.message}` };
181
+ return { ok: true, content, encrypted: false };
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Transparent decrypt-on-read: if `rawContent` parses as an envelope this
187
+ * module produced, decrypt and return the original plaintext string;
188
+ * otherwise return `rawContent` unchanged (a plaintext file, or a file
189
+ * from before encryption was ever configured). Never throws — a corrupt
190
+ * or undecryptable envelope degrades to returning the raw envelope JSON
191
+ * back (a caller expecting markdown/plaintext will visibly get neither,
192
+ * which is the correct, honest failure mode for a decrypt this module
193
+ * cannot perform, rather than silently returning empty content).
194
+ */
195
+ export function maybeDecryptForRead(rawContent) {
196
+ let parsed;
197
+ try { parsed = JSON.parse(rawContent); } catch { return rawContent; }
198
+ if (!isEncryptedEnvelope(parsed)) return rawContent;
199
+ try {
200
+ const key = _readOrGenerateKey();
201
+ return _decryptBuffer(parsed, key).toString('utf8');
202
+ } catch { return rawContent; }
203
+ }
204
+
205
+ export const _internals = { _readOrGenerateKey, _encryptBuffer, _decryptBuffer, _keyPath };
@@ -0,0 +1,71 @@
1
+ // Canonical evidence-grade wording (assurance-hardening PRD FR-507).
2
+ //
3
+ // Every compliance-adjacent artifact this engine emits needs to disclaim
4
+ // the same thing, and four of them had already drifted into hand-rolled,
5
+ // SLIGHTLY WRONG phrasings before this module existed: compliance-policy.js
6
+ // and auditor-walkthrough.js both said "A licensed assessor... is
7
+ // responsible for the final attestation" — which conflates two DIFFERENT
8
+ // things the PRD (and the standards this tooling maps to) treat as
9
+ // distinct:
10
+ //
11
+ // - An ATTESTATION is the ORGANIZATION'S OWN management formally
12
+ // asserting that stated controls are in place and operating (SOC 2's
13
+ // "management assertion" is the canonical example). This tool has no
14
+ // visibility into organizational process, personnel, or intent, so it
15
+ // cannot produce one.
16
+ // - A CERTIFICATION is a formal opinion or certificate issued by an
17
+ // ACCREDITED, INDEPENDENT third party (an ISO 27001 certification
18
+ // body, a SOC 2 auditor, a PCI DSS QSA) after their own examination.
19
+ // A "licensed assessor" produces a certification/independent
20
+ // assessment, not an attestation — the exact word swap the old
21
+ // wording got backwards.
22
+ //
23
+ // This tool produces neither. It produces the third, distinct tier:
24
+ //
25
+ // - AUTOMATED TECHNICAL ASSESSMENT — deterministic, code-derived
26
+ // evidence for a bounded, disclosed set of controls. Real evidence,
27
+ // scoped honestly, but not a judgment about the organization.
28
+ //
29
+ // Naming all three, every time, is what FR-507 asks for ("UI and docs
30
+ // distinguish automated technical assessment, management attestation, and
31
+ // independent certification") — not just a generic "this isn't
32
+ // certification" caveat, which is necessary but not sufficient: a reader
33
+ // who has never heard the word "attestation" used correctly has no way to
34
+ // know a real management attestation is a DIFFERENT, valid artifact they
35
+ // might separately need.
36
+ //
37
+ // Every artifact quoting from here keeps the exact substring "does not
38
+ // certify compliance" — several existing tests (world-class-batch2.test.js's
39
+ // CMP-5 cases) already pin that phrase, and there's no reason to break a
40
+ // correct, if incomplete, assertion while fixing the incompleteness.
41
+
42
+ export const ASSURANCE_TIERS = Object.freeze({
43
+ automatedTechnicalAssessment:
44
+ 'Automated technical assessment — what THIS TOOL produces: deterministic, ' +
45
+ 'code-derived evidence for a bounded, disclosed set of controls.',
46
+ managementAttestation:
47
+ 'Management attestation — the ORGANIZATION\'S OWN leadership formally ' +
48
+ 'asserting that stated controls are in place and operating. This tool ' +
49
+ 'cannot produce one; only the organization\'s management can.',
50
+ independentCertification:
51
+ 'Independent certification — a formal opinion or certificate issued by an ' +
52
+ 'ACCREDITED, INDEPENDENT third party (e.g. an ISO 27001 certification body, ' +
53
+ 'a SOC 2 auditor, a PCI DSS QSA) after their own examination. This tool ' +
54
+ 'cannot produce one; only an accredited independent party can.',
55
+ });
56
+
57
+ /** One-paragraph disclaimer for machine-consumed / GRC-ingested artifacts. */
58
+ export const EVIDENCE_GRADE_DISCLAIMER =
59
+ 'This artifact organizes automated technical assessment evidence produced by this scanner. ' +
60
+ 'It does not certify compliance, and it is not a management attestation: only the ' +
61
+ 'organization\'s own leadership can formally assert that stated controls are in place ' +
62
+ 'and operating. It is also not an independent certification: only an accredited, ' +
63
+ 'independent third party (an auditor, assessor, or certification body) examining the ' +
64
+ 'organization directly can issue one.';
65
+
66
+ /** Shorter inline variant for a markdown blockquote or narrow UI surface. */
67
+ export const EVIDENCE_GRADE_DISCLAIMER_SHORT =
68
+ 'This is automated technical assessment evidence — it does not certify compliance. ' +
69
+ 'It is neither a management attestation (the organization\'s own sign-off) nor an ' +
70
+ 'independent certification (an accredited third party\'s opinion); a licensed assessor ' +
71
+ 'or the organization\'s management, not this tool, is responsible for either.';
@@ -58,10 +58,6 @@ export function fixAcceptanceRate(scanRoot) {
58
58
  return acceptanceFromEntries(readLog(scanRoot));
59
59
  }
60
60
 
61
- function writeLog(scanRoot, log) {
62
- ensure(scanRoot);
63
- fs.writeFileSync(logPath(scanRoot), JSON.stringify(log, null, 2));
64
- }
65
61
  function sha(s) { return crypto.createHash('sha256').update(s).digest('hex').slice(0, 16); }
66
62
 
67
63
  // Premortem 3R-12: cross-check helpers for last-scan.json. We look up
@@ -242,7 +238,16 @@ function _countPriorAttempts(log, stableId, findingId) {
242
238
  return n;
243
239
  }
244
240
 
245
- export async function applyFix({ scanRoot, file, originalContent, newContent, findingId, ruleId, vuln, stableId }) {
241
+ // @param {boolean} [fileExisted] - did `file` exist on disk before this call?
242
+ // Determines what "restore" means on rollback: write `originalContent`
243
+ // back for a file that existed (default, for backward compatibility with
244
+ // callers that don't pass it — safer to assume "existed" than to risk
245
+ // deleting a real file), or delete the file entirely for one that did not
246
+ // (a fix that created a NEW file, which is `originalContent: ''`'s only
247
+ // real-world meaning in this codebase's callers — writing '' back would
248
+ // leave a phantom empty file where none existed before, not a true
249
+ // rollback).
250
+ export async function applyFix({ scanRoot, file, originalContent, newContent, findingId, ruleId, vuln, stableId, fileExisted = true }) {
246
251
  return _withLogLock(scanRoot, async () => {
247
252
  ensure(scanRoot);
248
253
  const absFile = path.resolve(scanRoot, file);
@@ -260,8 +265,10 @@ export async function applyFix({ scanRoot, file, originalContent, newContent, fi
260
265
  MAX_ATTEMPTS_PER_KEY,
261
266
  );
262
267
  }
263
- // Phase 1: backup + fsync.
264
- await _writeAndSync(bakPath, originalContent);
268
+ // Phase 1: backup + fsync. Atomic for the same reason the target write
269
+ // is below — a corrupted backup is worse than no backup, because it
270
+ // silently defeats rollback.
271
+ await _writeAtomicAndSync(bakPath, originalContent);
265
272
  const entry = {
266
273
  id,
267
274
  findingId,
@@ -269,6 +276,7 @@ export async function applyFix({ scanRoot, file, originalContent, newContent, fi
269
276
  ruleId: ruleId || null,
270
277
  vuln: vuln || null,
271
278
  file,
279
+ fileExisted,
272
280
  backupPath: path.relative(scanRoot, bakPath),
273
281
  originalSha: sha(originalContent),
274
282
  newSha: sha(newContent),
@@ -281,10 +289,38 @@ export async function applyFix({ scanRoot, file, originalContent, newContent, fi
281
289
  const log = priorLog;
282
290
  log.push(entry);
283
291
  await _writeLogAndSync(scanRoot, log);
284
- // Phase 3: write the new content to the target file + fsync.
292
+ // Phase 3: atomic write of the new content, then FR-306's post-write
293
+ // hash verification — read the file back and confirm it genuinely
294
+ // contains what was just written, not merely that the write call
295
+ // returned without throwing (a write can "succeed" against a
296
+ // corrupting filesystem, a truncated disk-full write that still exits
297
+ // 0, or a concurrent external modification landing between our write
298
+ // and the read-back proving it).
285
299
  try {
286
- await _writeAndSync(absFile, newContent);
300
+ await _writeAtomicAndSync(absFile, newContent);
301
+ const writtenBack = await fsp.readFile(absFile, 'utf8');
302
+ if (sha(writtenBack) !== entry.newSha) {
303
+ throw new Error(`post-write hash verification failed for ${file}: on-disk content does not match what was written`);
304
+ }
287
305
  } catch (e) {
306
+ // FR-306: "injected write failure restores all files" — a failure at
307
+ // this point (the write itself, or the verification catching a
308
+ // corrupted write) must not leave the target in a partial or wrong
309
+ // state. Restore it now, synchronously with the failure, not as a
310
+ // later manual `recover()` step.
311
+ try {
312
+ if (fileExisted) {
313
+ await _writeAtomicAndSync(absFile, originalContent);
314
+ } else {
315
+ await fsp.unlink(absFile).catch(() => {}); // the failed write may not have landed at all
316
+ }
317
+ entry.rolledBack = true;
318
+ } catch (restoreErr) {
319
+ // Genuinely worse case (e.g. the filesystem itself is unwritable) —
320
+ // recorded honestly rather than silently claimed as rolled back.
321
+ entry.rolledBack = false;
322
+ entry.restoreError = restoreErr.message;
323
+ }
288
324
  entry.status = 'failed';
289
325
  entry.error = e.message;
290
326
  await _writeLogAndSync(scanRoot, log);
@@ -308,6 +344,33 @@ async function _writeAndSync(fp, content) {
308
344
  }
309
345
  }
310
346
 
347
+ // FR-306: "atomic replacement". `_writeAndSync` above opens the TARGET path
348
+ // directly in truncate mode — a crash or thrown error between the truncate
349
+ // and the write completing leaves the file partially written, not atomically
350
+ // replaced. This writes to a sibling temp file first, fsyncs it, then
351
+ // renames it over the target — `rename()` is atomic on the same filesystem,
352
+ // so the target is either the old content or the new content in full, never
353
+ // a partial mix. The temp file is cleaned up on any failure before the
354
+ // rename so a crash never leaves an orphaned `.tmp-*` file behind.
355
+ async function _writeAtomicAndSync(fp, content) {
356
+ const dir = path.dirname(fp);
357
+ await fsp.mkdir(dir, { recursive: true });
358
+ const tmp = path.join(dir, `.${path.basename(fp)}.tmp-${process.pid}-${crypto.randomBytes(4).toString('hex')}`);
359
+ try {
360
+ const handle = await fsp.open(tmp, 'w');
361
+ try {
362
+ await handle.writeFile(content);
363
+ if (typeof handle.sync === 'function') await handle.sync();
364
+ } finally {
365
+ await handle.close();
366
+ }
367
+ await fsp.rename(tmp, fp);
368
+ } catch (e) {
369
+ try { await fsp.unlink(tmp); } catch { /* never existed, or already gone — fine either way */ }
370
+ throw e;
371
+ }
372
+ }
373
+
311
374
  async function _writeLogAndSync(scanRoot, log) {
312
375
  ensure(scanRoot);
313
376
  const fp = logPath(scanRoot);
@@ -369,22 +432,37 @@ async function _recoverInner(scanRoot) {
369
432
  return recovered;
370
433
  }
371
434
 
435
+ // Shared revert primitive. `fileExisted === false` means the fix created a
436
+ // file that did not exist beforehand — reverting deletes it rather than
437
+ // writing the empty-string sentinel back, which would leave a phantom empty
438
+ // file where none existed (see applyFix's `fileExisted` doc comment for why
439
+ // that distinction matters). Entries logged before FR-306 have no
440
+ // `fileExisted` field at all; `undefined !== false` so they fall through to
441
+ // the pre-existing "write original content back" behavior, unchanged.
442
+ async function _revertEntryInner(scanRoot, entry) {
443
+ const bak = path.resolve(scanRoot, entry.backupPath);
444
+ const absFile = path.resolve(scanRoot, entry.file);
445
+ if (!fs.existsSync(bak)) return { error: `backup missing: ${bak}` };
446
+ const original = await fsp.readFile(bak, 'utf8');
447
+ if (entry.fileExisted === false) {
448
+ await fsp.unlink(absFile).catch(() => {}); // may already be gone; deletion is the goal either way
449
+ } else {
450
+ await _writeAtomicAndSync(absFile, original);
451
+ }
452
+ entry.reverted = true;
453
+ entry.revertedAt = new Date().toISOString();
454
+ return entry;
455
+ }
456
+
372
457
  // Revert the most recent un-reverted fix. Returns the entry or null.
373
458
  export async function undoLast(scanRoot) {
374
459
  return _withLogLock(scanRoot, async () => {
375
460
  const log = readLog(scanRoot);
376
461
  for (let i = log.length - 1; i >= 0; i--) {
377
462
  if (!log[i].reverted) {
378
- const entry = log[i];
379
- const bak = path.resolve(scanRoot, entry.backupPath);
380
- const absFile = path.resolve(scanRoot, entry.file);
381
- if (!fs.existsSync(bak)) return { error: `backup missing: ${bak}` };
382
- const original = await fsp.readFile(bak, 'utf8');
383
- await fsp.writeFile(absFile, original);
384
- entry.reverted = true;
385
- entry.revertedAt = new Date().toISOString();
386
- writeLog(scanRoot, log);
387
- return entry;
463
+ const result = await _revertEntryInner(scanRoot, log[i]);
464
+ if (!result.error) await _writeLogAndSync(scanRoot, log);
465
+ return result;
388
466
  }
389
467
  }
390
468
  return null;
@@ -399,6 +477,22 @@ export async function undoAll(scanRoot) {
399
477
  return reverted;
400
478
  }
401
479
 
480
+ // FR-306: revert ONE specific entry by id, regardless of its position in the
481
+ // log. The building block for same-batch rollback — a multi-file apply that
482
+ // fails partway through must undo only the files THIS batch itself wrote,
483
+ // not every unrelated pending entry `undoAll` would touch.
484
+ export async function revertEntryById(scanRoot, entryId) {
485
+ return _withLogLock(scanRoot, async () => {
486
+ const log = readLog(scanRoot);
487
+ const entry = log.find(e => e.id === entryId);
488
+ if (!entry) return { error: `no such history entry: ${entryId}` };
489
+ if (entry.reverted) return entry;
490
+ const result = await _revertEntryInner(scanRoot, entry);
491
+ if (!result.error) await _writeLogAndSync(scanRoot, log);
492
+ return result;
493
+ });
494
+ }
495
+
402
496
  export function listHistory(scanRoot) { return readLog(scanRoot); }
403
497
 
404
498
  // Premortem 3R-17: fix-history/log.json grows monotonically. A long-running
@@ -141,23 +141,64 @@ export function computeFixTier(signals) {
141
141
  }
142
142
 
143
143
  /**
144
- * Compose the three gates for a single fix's output.
144
+ * FR-308: cross-check a fix-completeness TIER against MECHANICAL evidence,
145
+ * when any is available. `signals` (computeFixTier's input) is agent-
146
+ * self-reported — this module cannot compute sinkSignatureChanged /
147
+ * allCallersRouted / testDiscriminates itself (see the header above: "the
148
+ * gate can only run against claims the AGENT self-reports... nothing here
149
+ * is server-computable"). `pocLeg` is different: fix-verify.js's PoC leg is
150
+ * a REAL execution result (posture/CLAUDE.md's execution-proof tiers), not
151
+ * a claim. When it is available and shows the proof-of-concept STILL
152
+ * demonstrates the vulnerability against the patch, a self-reported FULL
153
+ * tier is not merely internally inconsistent — it is REFUTED by fact. This
154
+ * is the literal "a mitigation or workaround cannot be represented as a
155
+ * full fix" acceptance criterion, now backed by mechanical evidence where
156
+ * it exists rather than by self-report consistency alone.
145
157
  *
146
- * ok = residual-honesty ok AND evidence-citation ok, further constrained by the
147
- * tier/residual consistency invariant:
158
+ * A `pocLeg` of `not-requested` or `inconclusive` carries no mechanical
159
+ * signal either way and is a no-op here — this check can only ever ADD a
160
+ * violation on real contrary evidence, never manufacture one from absence.
161
+ *
162
+ * @param {string} tier
163
+ * @param {{status: string, reason?: string}|null} pocLeg
164
+ * @returns {{ ok: boolean, violations: string[] }}
165
+ */
166
+ export function checkMechanicalTierEvidence(tier, pocLeg) {
167
+ if (!pocLeg || typeof pocLeg !== 'object') return { ok: true, violations: [] };
168
+ if (tier === 'FULL' && pocLeg.status === 'still-exploitable') {
169
+ return {
170
+ ok: false,
171
+ violations: [`tier 'FULL' is refuted by mechanical evidence: the proof-of-concept still demonstrates the vulnerability against the patch${pocLeg.reason ? ` (${pocLeg.reason})` : ''}`],
172
+ };
173
+ }
174
+ return { ok: true, violations: [] };
175
+ }
176
+
177
+ /**
178
+ * Compose the four gates for a single fix's output.
179
+ *
180
+ * ok = residual-honesty ok AND evidence-citation ok AND mechanical-tier-
181
+ * evidence ok, further constrained by the tier/residual consistency
182
+ * invariant:
148
183
  * - a FULL tier must NOT carry a residual (a full fix has nothing left);
149
184
  * - a non-FULL tier MUST document a residual (say what's still open).
150
185
  *
151
186
  * @param {{ residual?: string, verdict?: string, evidence?: any, signals?: object }} input
187
+ * @param {{ pocLeg?: object|null }} [mechanical] - FR-308: optional real
188
+ * execution evidence (fix-verify.js's pocLeg) to cross-check the
189
+ * self-reported tier against. Omitted entirely by any caller that has no
190
+ * PoC leg to offer — this parameter never REQUIRES mechanical evidence,
191
+ * it only USES it when present.
152
192
  * @returns {{ ok: boolean, tier: string, violations: string[] }}
153
193
  */
154
- export function gateFixOutput({ residual, verdict, evidence, signals } = {}) {
194
+ export function gateFixOutput({ residual, verdict, evidence, signals } = {}, { pocLeg = null } = {}) {
155
195
  const tier = computeFixTier(signals);
156
196
  const residualCheck = checkResidualHonesty(residual);
157
197
  const evidenceCheck = requireCitedEvidence(verdict, evidence);
198
+ const mechanicalCheck = checkMechanicalTierEvidence(tier, pocLeg);
158
199
 
159
- const violations = [...residualCheck.violations, ...evidenceCheck.violations];
160
- let ok = residualCheck.ok && evidenceCheck.ok;
200
+ const violations = [...residualCheck.violations, ...evidenceCheck.violations, ...mechanicalCheck.violations];
201
+ let ok = residualCheck.ok && evidenceCheck.ok && mechanicalCheck.ok;
161
202
 
162
203
  const residualEmpty = typeof residual !== 'string' || residual.trim() === '';
163
204
  if (tier === 'FULL' && !residualEmpty) {
@@ -187,12 +187,6 @@ export async function verifyFix({
187
187
  // and on the result so a caller cannot mistake it for a verified patch.
188
188
  const _testedPrePatch = !tests.skipped && _candidateDiffersFromDisk(scanRoot, files);
189
189
  const testsOk = tests.skipped ? true : tests.passed === true;
190
- let honesty = null;
191
- if (fixMeta && typeof fixMeta === 'object') {
192
- try { honesty = gateFixOutput(fixMeta); } catch { honesty = null; }
193
- }
194
- _lap('honesty');
195
-
196
190
  // R5 — the PoC leg. Re-run the finding's proof-of-concept against the
197
191
  // CANDIDATE patch inside R1's sandbox. A patch that still lets the PoC
198
192
  // demonstrate the predicted effect has not fixed anything, however green the
@@ -204,6 +198,11 @@ export async function verifyFix({
204
198
  // to run, or a sandbox that could not start, is recorded as `inconclusive`
205
199
  // and left out of the verdict entirely. Treating "could not prove it" as
206
200
  // "fixed" is exactly the false confidence this leg exists to prevent.
201
+ //
202
+ // Computed BEFORE the honesty gate (FR-308): a still-exploitable PoC is
203
+ // MECHANICAL evidence, not a self-report, and gateFixOutput cross-checks a
204
+ // self-reported FULL tier against it below — the ordering matters, not
205
+ // just the value.
207
206
  let pocLeg = { status: 'not-requested', reason: null, tier: null };
208
207
  if (poc?.code) {
209
208
  try {
@@ -222,7 +221,54 @@ export async function verifyFix({
222
221
  _lap('poc');
223
222
  const pocOk = pocLeg.status !== 'still-exploitable';
224
223
 
224
+ // FR-308: "a mitigation or workaround cannot be represented as a full
225
+ // fix" — gateFixOutput's tier/residual check is a pure self-consistency
226
+ // check (fixMeta.signals is agent-self-reported; see fix-honesty-gate.js's
227
+ // header for why nothing there is server-computable). pocLeg IS
228
+ // server-computable — a real execution result, not a claim — so it is
229
+ // passed through as the one MECHANICAL cross-check available: a
230
+ // self-reported FULL tier is refuted, not just internally inconsistent,
231
+ // when the PoC still demonstrates the vulnerability against the patch.
232
+ // D-0024: `fixMeta` is a shared envelope — FR-307/FR-1002's `approval`
233
+ // key lives alongside FR-308's completeness self-report (`residual`/
234
+ // `verdict`/`evidence`/`signals`). Gating on mere object-truthiness meant
235
+ // a caller supplying ONLY `approval` (a real, common shape once the CLI's
236
+ // --approved-by flag and the MCP schema fix made that reachable) got
237
+ // silently gated on an UNRELATED FR-308 self-consistency check it never
238
+ // engaged with — computeFixTier(undefined) defaults to MITIGATION, which
239
+ // then demands a `residual` nobody was ever asked to supply, blocking an
240
+ // otherwise-genuine, approved fix for a reason that has nothing to do
241
+ // with completeness honesty. Scope the gate to fixMeta shapes that
242
+ // actually make a completeness-adjacent claim.
243
+ const hasHonestyClaim = fixMeta && typeof fixMeta === 'object' &&
244
+ (fixMeta.residual !== undefined || fixMeta.verdict !== undefined ||
245
+ fixMeta.evidence !== undefined || fixMeta.signals !== undefined);
246
+ let honesty = null;
247
+ if (hasHonestyClaim) {
248
+ try { honesty = gateFixOutput(fixMeta, { pocLeg }); } catch { honesty = null; }
249
+ }
250
+ _lap('honesty');
251
+
225
252
  const ok = rescan.ok && (lint.ok || lint.skipped) && testsOk && pocOk && (honesty ? honesty.ok : true);
253
+
254
+ // FR-305 (assurance-hardening PRD): `ok` alone conflates "every leg
255
+ // genuinely ran and passed" with "passed, but a required leg was skipped
256
+ // or unavailable" — `lint.ok`/`testsOk` are both true in the skipped case
257
+ // by design (this codebase does not fail-closed just because a repo has
258
+ // no linter or no detected test runner), so a caller checking only `ok`
259
+ // cannot tell the difference. `lint.skipped` is only ever true when a
260
+ // linter WAS configured but its binary could not be run (missing config
261
+ // entirely returns `runner: 'none'` with no `skipped` field at all — that
262
+ // is a genuine N/A, nothing was required, not a degradation). `verifiedFull`
263
+ // is the honest label: true only when nothing that WAS required was
264
+ // skipped. A caller must never present `ok: true, verifiedFull: false` as
265
+ // "fully verified" — `degradedLegs` names exactly what was skipped so a
266
+ // report can say so plainly instead of a bare pass.
267
+ const degradedLegs = [];
268
+ if (lint.skipped) degradedLegs.push(`lint: ${lint.runner} not installed`);
269
+ if (tests.skipped) degradedLegs.push(`tests: skipped (${tests.reason})`);
270
+ const verifiedFull = ok && degradedLegs.length === 0;
271
+
226
272
  const durations = { ...stages, totalMs: Date.now() - t0 };
227
273
  const summary = [
228
274
  `re-scan: ${rescan.ok ? 'PASS' : 'FAIL — ' + rescan.reason}`,
@@ -244,6 +290,8 @@ export async function verifyFix({
244
290
  : pocLeg.status === 'still-exploitable' ? `poc: FAIL — the proof-of-concept still demonstrates the vulnerability against the patch`
245
291
  : pocLeg.status === 'no-longer-proven' ? 'poc: PASS (ran against the patch and no longer demonstrates the vulnerability)'
246
292
  : `poc: inconclusive — not counted either way (${pocLeg.reason || 'no detail reported'})`,
293
+ // FR-305: never let a degraded pass read the same as a full one.
294
+ ok && !verifiedFull ? `NOTE: PASSED, but NOT fully verified — ${degradedLegs.join('; ')}` : null,
247
295
  ].filter(Boolean).join('\n');
248
296
  // Persist the attempt so the distribution can be reported from real runs.
249
297
  // `testsRan` is the load-bearing field: it is what keeps "verified with no
@@ -256,6 +304,7 @@ export async function verifyFix({
256
304
  at: new Date().toISOString(),
257
305
  stableId: originalFindingStableId || null,
258
306
  ok,
307
+ verifiedFull,
259
308
  testsRan: !tests.skipped,
260
309
  testsPassed: tests.skipped ? null : tests.passed === true,
261
310
  testedPrePatch: _testedPrePatch,
@@ -268,5 +317,5 @@ export async function verifyFix({
268
317
  });
269
318
  }
270
319
 
271
- return { ok, rescan, lint, tests, testedPrePatch: _testedPrePatch, honesty, poc: pocLeg, durations, summary };
320
+ return { ok, verifiedFull, degradedLegs, rescan, lint, tests, testedPrePatch: _testedPrePatch, honesty, poc: pocLeg, durations, summary };
272
321
  }
Binary file