@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,191 @@
1
+ export const id = 265;
2
+ export const ids = [265];
3
+ export const modules = {
4
+
5
+ /***/ 4265:
6
+ /***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
7
+
8
+ /* harmony export */ __webpack_require__.d(__webpack_exports__, {
9
+ /* harmony export */ createCascadePool: () => (/* binding */ createCascadePool)
10
+ /* harmony export */ });
11
+ /* unused harmony export DEFAULT_GRACE_MS */
12
+ /* harmony import */ var node_worker_threads__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(5919);
13
+ /* harmony import */ var node_path__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(6760);
14
+ /* harmony import */ var node_url__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(3136);
15
+ // FR-202 phase 3b (D-0050): a pool of long-lived workers, each running engine.js's
16
+ // _runFileCascade for many files over its lifetime, instead of the one-shot-per-call
17
+ // model runWithDeadline/analyzer-supervisor.js uses (which is correct for FR-202's
18
+ // primitive-level proof but would force whole-project setup — _buildProjectIndex,
19
+ // _buildGlobalJavaTaintedMethodIndex, _loadCustomRules — to redo per FILE, an O(n^2)
20
+ // cost across a scan). Each worker performs that setup ONCE at creation via
21
+ // _initCascadeWorkerState, then answers task messages over its lifetime.
22
+ //
23
+ // A task that exceeds its deadline+grace gets its OWN worker terminated (genuinely
24
+ // preemptive, mirroring analyzer-supervisor.js's worker.terminate() precedent) and a
25
+ // fresh replacement worker is spawned to keep the pool at full size for later files —
26
+ // one hung file costs one worker restart, not the whole scan.
27
+
28
+
29
+
30
+
31
+ // `new Worker()` needs a REAL file on disk -- once ncc bundles this module's code
32
+ // into a dist/ chunk, `import.meta.url` resolves to that chunk's location (dist/),
33
+ // not to src/pipeline/, so a naive path.join(HERE, 'cascade-worker.js') would look
34
+ // for a file that was never copied there. src/ always ships as a sibling of dist/
35
+ // (see package.json's "files"), in both a dev checkout and the published npm
36
+ // package, so fall back to the real, unbundled worker script in that case.
37
+ function _resolveDefaultWorkerScript() {
38
+ const hereDir = node_path__WEBPACK_IMPORTED_MODULE_1__.dirname((0,node_url__WEBPACK_IMPORTED_MODULE_2__.fileURLToPath)(import.meta.url));
39
+ if (node_path__WEBPACK_IMPORTED_MODULE_1__.basename(hereDir) === 'pipeline') return node_path__WEBPACK_IMPORTED_MODULE_1__.join(hereDir, 'cascade-worker.js');
40
+ return node_path__WEBPACK_IMPORTED_MODULE_1__.join(hereDir, '..', 'src', 'pipeline', 'cascade-worker.js');
41
+ }
42
+ const DEFAULT_WORKER_SCRIPT = _resolveDefaultWorkerScript();
43
+ const DEFAULT_GRACE_MS = 500;
44
+
45
+ function spawnReadyWorker({ workerScript, modulePath, fileContents, scanRoot }) {
46
+ return new Promise((resolve, reject) => {
47
+ let settled = false;
48
+ const worker = new node_worker_threads__WEBPACK_IMPORTED_MODULE_0__.Worker(workerScript, { workerData: { modulePath, fileContents, scanRoot } });
49
+ const finish = (fn, value) => {
50
+ if (settled) return;
51
+ settled = true;
52
+ worker.off('message', onMessage);
53
+ worker.off('error', onError);
54
+ fn(value);
55
+ };
56
+ const onMessage = (msg) => {
57
+ if (!msg) return;
58
+ if (msg.type === 'ready') finish(resolve, worker);
59
+ else if (msg.type === 'init-error') { worker.terminate().catch(() => {}); finish(reject, new Error(msg.error)); }
60
+ };
61
+ const onError = (err) => { worker.terminate().catch(() => {}); finish(reject, err); };
62
+ worker.on('message', onMessage);
63
+ worker.on('error', onError);
64
+ });
65
+ }
66
+
67
+ /**
68
+ * @param {object} opts
69
+ * @param {Record<string,string>} opts.fileContents - broadcast once to every worker at creation.
70
+ * @param {string|null} opts.scanRoot
71
+ * @param {number} [opts.poolSize=4]
72
+ * @param {string} opts.modulePath - absolute path to the engine.js each worker imports.
73
+ * @param {string} [opts.workerScript] - defaults to cascade-worker.js next to this file.
74
+ * @param {number} [opts.graceMs]
75
+ */
76
+ function createCascadePool(opts = {}) {
77
+ const {
78
+ fileContents = {},
79
+ scanRoot = null,
80
+ poolSize = 4,
81
+ workerScript = DEFAULT_WORKER_SCRIPT,
82
+ modulePath,
83
+ graceMs = DEFAULT_GRACE_MS,
84
+ } = opts;
85
+ if (!modulePath) throw new Error('createCascadePool requires opts.modulePath');
86
+ if (!(Number.isInteger(poolSize) && poolSize > 0)) throw new Error('createCascadePool requires opts.poolSize to be a positive integer');
87
+
88
+ const idle = [];
89
+ const pendingIdle = [];
90
+ const allWorkers = new Set();
91
+ let nextTaskId = 1;
92
+ let closed = false;
93
+ let spawnFailureCount = 0;
94
+ let lastSpawnError = null;
95
+
96
+ function spawnAndRegister() {
97
+ return spawnReadyWorker({ workerScript, modulePath, fileContents, scanRoot }).then((w) => {
98
+ allWorkers.add(w);
99
+ return w;
100
+ });
101
+ }
102
+
103
+ // Background respawn after a killed worker (see runFile's timeout branch)
104
+ // is deliberately fire-and-forget -- the timed-out task has already
105
+ // resolved and nothing is waiting on this promise -- but a swallowed
106
+ // failure here would otherwise shrink the pool invisibly (e.g. thread
107
+ // exhaustion making every future respawn fail silently forever). Track
108
+ // it instead of a bare `.catch(() => {})`.
109
+ function respawnInBackground() {
110
+ spawnAndRegister().then(releaseWorker).catch((err) => {
111
+ spawnFailureCount += 1;
112
+ lastSpawnError = err;
113
+ });
114
+ }
115
+
116
+ function releaseWorker(w) {
117
+ if (closed) { allWorkers.delete(w); w.terminate().catch(() => {}); return; }
118
+ if (pendingIdle.length) pendingIdle.shift()(w);
119
+ else idle.push(w);
120
+ }
121
+
122
+ function acquireWorker() {
123
+ if (idle.length) return Promise.resolve(idle.pop());
124
+ return new Promise((resolve) => pendingIdle.push(resolve));
125
+ }
126
+
127
+ const ready = Promise.all(
128
+ Array.from({ length: poolSize }, () => spawnAndRegister().then(releaseWorker))
129
+ );
130
+
131
+ /**
132
+ * Run one file's cascade on a pooled worker. Resolves with
133
+ * {ok:true, result} | {ok:false, error} | {ok:false, timedOut:true, timeoutMs, graceMs}.
134
+ * Never rejects.
135
+ */
136
+ async function runFile(p, c, taskScanRoot, detectorErrors, taskOpts = {}) {
137
+ await ready;
138
+ if (closed) return { ok: false, error: 'pool is closed' };
139
+ const timeoutMs = taskOpts.timeoutMs;
140
+ const worker = await acquireWorker();
141
+ const id = nextTaskId++;
142
+ return new Promise((resolve) => {
143
+ let settled = false;
144
+ let killTimer = null;
145
+ const onMessage = (msg) => {
146
+ if (settled || !msg || msg.id !== id) return;
147
+ settled = true;
148
+ if (killTimer) clearTimeout(killTimer);
149
+ worker.off('message', onMessage);
150
+ releaseWorker(worker);
151
+ resolve(msg.ok ? { ok: true, result: msg.result } : { ok: false, error: msg.error });
152
+ };
153
+ worker.on('message', onMessage);
154
+ if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
155
+ killTimer = setTimeout(() => {
156
+ if (settled) return;
157
+ settled = true;
158
+ worker.off('message', onMessage);
159
+ allWorkers.delete(worker);
160
+ worker.terminate().catch(() => {});
161
+ if (!closed) respawnInBackground();
162
+ resolve({ ok: false, timedOut: true, timeoutMs, graceMs });
163
+ }, timeoutMs + graceMs);
164
+ killTimer.unref?.();
165
+ }
166
+ worker.postMessage({ type: 'task', id, p, c, scanRoot: taskScanRoot, detectorErrors });
167
+ });
168
+ }
169
+
170
+ async function shutdown() {
171
+ closed = true;
172
+ await ready.catch(() => {});
173
+ for (const w of allWorkers) await w.terminate().catch(() => {});
174
+ allWorkers.clear();
175
+ idle.length = 0;
176
+ }
177
+
178
+ return {
179
+ runFile,
180
+ shutdown,
181
+ ready,
182
+ DEFAULT_GRACE_MS: graceMs,
183
+ getSpawnFailureCount: () => spawnFailureCount,
184
+ getLastSpawnError: () => lastSpawnError,
185
+ };
186
+ }
187
+
188
+
189
+ /***/ })
190
+
191
+ };
package/dist/384.index.js CHANGED
@@ -8,7 +8,7 @@ export const modules = {
8
8
  /* harmony export */ __webpack_require__.d(__webpack_exports__, {
9
9
  /* harmony export */ scanCredentials: () => (/* reexport safe */ _engine_js__WEBPACK_IMPORTED_MODULE_0__.Sv)
10
10
  /* harmony export */ });
11
- /* harmony import */ var _engine_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(3474);
11
+ /* harmony import */ var _engine_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(7087);
12
12
  // Secrets submodule view of the engine — credential + entropy + TODO scanning.
13
13
 
14
14
 
package/dist/435.index.js CHANGED
@@ -23,6 +23,12 @@ var external_node_url_ = __webpack_require__(3136);
23
23
  var promises_ = __webpack_require__(1455);
24
24
  // EXTERNAL MODULE: ./src/posture/fix-history.js
25
25
  var fix_history = __webpack_require__(4407);
26
+ // EXTERNAL MODULE: ./src/fix/apply-fix-service.js
27
+ var apply_fix_service = __webpack_require__(7730);
28
+ // EXTERNAL MODULE: ./src/posture/material-change.js
29
+ var material_change = __webpack_require__(4629);
30
+ // EXTERNAL MODULE: ./src/fix/approver-registry.js
31
+ var approver_registry = __webpack_require__(437);
26
32
  ;// CONCATENATED MODULE: ./src/posture/deterministic-fix.js
27
33
  // Deterministic fix synthesis (#1) — for the narrow set of vulnerability classes
28
34
  // where a context-INDEPENDENT literal swap is a safe, correct fix, produce a
@@ -92,6 +98,8 @@ function synthesizeDeterministicPatch(finding, fileContent) {
92
98
 
93
99
  // EXTERNAL MODULE: ./src/posture/integrity.js
94
100
  var integrity = __webpack_require__(1130);
101
+ // EXTERNAL MODULE: ./src/posture/state-dir.js
102
+ var state_dir = __webpack_require__(1174);
95
103
  // EXTERNAL MODULE: ./src/posture/cache-economics.js
96
104
  var cache_economics = __webpack_require__(8752);
97
105
  ;// CONCATENATED MODULE: ./src/mcp/redact.js
@@ -197,10 +205,8 @@ function redactArgsBlob(s) {
197
205
  return redactString(s);
198
206
  }
199
207
 
200
- // EXTERNAL MODULE: ./src/report/index.js + 2 modules
201
- var report = __webpack_require__(3550);
202
- // EXTERNAL MODULE: ./src/posture/state-dir.js
203
- var state_dir = __webpack_require__(1174);
208
+ // EXTERNAL MODULE: ./src/report/index.js + 3 modules
209
+ var report = __webpack_require__(457);
204
210
  ;// CONCATENATED MODULE: ./src/posture/agents-memory.js
205
211
  // AGENTS.md — writable continual-learning memory (harness-anatomy #2).
206
212
  //
@@ -499,6 +505,10 @@ const cve_lookup_internals = { CACHE_DIR, CVE_RE, _stalenessTier };
499
505
 
500
506
 
501
507
 
508
+
509
+
510
+
511
+
502
512
  // Lazy-loaded: these transitively pull in npm packages (@babel/core and
503
513
  // friends) that aren't available in the plugin-cache install path
504
514
  // (no node_modules). Deferring keeps the MCP server bootable everywhere;
@@ -863,26 +873,44 @@ const scan_diff = {
863
873
  // / MAX_TOTAL_SCAN_BYTES, so this does not turn scan_diff into a
864
874
  // full-project deep scan).
865
875
  const runScan = await getRunScan();
866
- const result = await runScan(sessionRoot, { network: false, fileContents, deep: true, deepInCi: true });
876
+ // FR-704 (assurance-hardening PRD): this tool's own description promises
877
+ // "runs scan in memory" — without this, runFullScan's own state writers
878
+ // (dpia.md, ropa.md, privacy-framework.json, threat-model.json, and
879
+ // others) fire unconditionally on every call, silently mutating the
880
+ // user's real project on every pre-write self-correction scan. Confirmed
881
+ // by direct execution before this fix (11 state artifacts written by a
882
+ // single scan_diff-shaped call).
883
+ const result = await (0,state_dir/* withStateWritesDisabled */.Ao)(() =>
884
+ runScan(sessionRoot, { network: false, fileContents, deep: true, deepInCi: true }));
867
885
  const wantSet = new Set(Object.keys(fileContents));
868
886
  const sevRank = { info: 0, low: 1, medium: 2, high: 3, critical: 4 };
869
887
  const min = sevRank[severity] ?? 0;
870
- // Stage 6 correctness audit: this only ever read result.scan.findings
871
- // (the SAST channel) — scan.secrets and scan.logicVulns are separate
872
- // arrays on the raw runScan() result (report/index.js's normalizeFindings
873
- // is what merges all four channels, and that merge hasn't run yet here).
874
- // A file containing a bare hardcoded credential reported findingCount: 0
875
- // through a tool whose own description promises "Use BEFORE writing a
876
- // Write/Edit to disk so the agent can self-correct". Also reused
877
- // _remediationOf so a fix-string detector (the majority of engine.js's
878
- // own, ~127 call sites) doesn't silently report an empty `description`
879
- // the way reading only `.remediation` did.
880
- const findings = [...(result.scan.findings || []), ...(result.scan.secrets || []), ...(result.scan.logicVulns || [])]
888
+ // Stage 6 correctness audit (historical): this used to only read
889
+ // result.scan.findings (the SAST channel) — scan.secrets and
890
+ // scan.logicVulns are separate arrays on the raw runScan() result, and a
891
+ // hand-rolled 3-channel concat here was a second, divergent copy of the
892
+ // merge report/index.js's normalizeFindings() already does (four
893
+ // channels, plus per-channel defaulting and remediation-string
894
+ // resolution the old concat re-implemented separately and could drift
895
+ // from). Assurance-hardening PRD FR-105 ("JSON, SARIF, HTML, CSV, JUnit,
896
+ // and MCP outputs derive from the same validated object"): route through
897
+ // the same canonical merge every other output format uses.
898
+ //
899
+ // This closes the field-mapping/dedup divergence, but does NOT make
900
+ // scan_diff surface SCA/supply-chain findings end to end: this handler
901
+ // never builds a `depFileContents` map (everything a caller passes in
902
+ // `files`, manifests included, lands in `fileContents`), and manifest-
903
+ // based supply-chain detection in engine.js reads only `depFileContents`
904
+ // — so `result.scan.supplyChain` is always empty for this tool today
905
+ // regardless of this fix. That is a separate, real limitation (scan_diff
906
+ // was designed for pre-write code self-correction, not manifest
907
+ // scanning), left as-is rather than silently claimed fixed here.
908
+ const findings = (0,report.normalizeFindings)(result.scan)
881
909
  .filter(f => wantSet.has(String(f.file || '').replace(/\\/g, '/')) && (sevRank[f.severity] ?? 0) >= min)
882
910
  .map(f => redactFinding({
883
911
  id: f.id, severity: f.severity, file: f.file, line: f.line,
884
- title: f.title || f.vuln, cwe: f.cwe,
885
- description: f.description, remediation: (0,report/* _remediationOf */.uV)(f),
912
+ title: f.vuln, cwe: f.cwe,
913
+ description: f.description, remediation: f.remediation,
886
914
  }));
887
915
  // Harness-anatomy #1: offload when the result exceeds OFFLOAD_THRESHOLD.
888
916
  // The agent gets a head+tail preview plus a path it can page through;
@@ -1032,7 +1060,7 @@ const explain_finding = {
1032
1060
  // ─── apply_fix ───────────────────────────────────────────────────────────────
1033
1061
  const apply_fix = {
1034
1062
  name: 'apply_fix',
1035
- description: 'Apply a fix for a finding. Two modes: (1) the stored fix.replacement, or (2) a caller-supplied `patch` (a files map) which is RE-VERIFIED inline (rescan-clean + no new ≥medium + lint) before any write — this unblocks findings that ship only a template or description. Refuses if last-scan.json fails its HMAC check, if the finding is shadow-marked, or if a path escapes the session root via lexical traversal OR a symlink. Requires confirm:true. Supports dry_run:true to preview without writing.',
1063
+ description: 'Apply a fix for a finding. Two modes: (1) the stored fix.replacement, or (2) a caller-supplied `patch` (a files map) which is RE-VERIFIED inline (rescan-clean + no new ≥medium + lint) before any write — this unblocks findings that ship only a template or description. Refuses if last-scan.json fails its HMAC check, if the finding is shadow-marked, or if a path escapes the session root via lexical traversal OR a symlink. Requires confirm:true. Supports dry_run:true to preview without writing. On success, `verified:true` means verification passed but `verifiedFull:true` is the honest signal that every required leg (lint when configured, tests when a runner exists) genuinely ran — a false `verifiedFull` with `verified:true` means the pass is real but degraded (see `verify.degradedLegs`), not a full verification.',
1036
1064
  inputSchema: {
1037
1065
  type: 'object',
1038
1066
  additionalProperties: false,
@@ -1073,6 +1101,27 @@ const apply_fix = {
1073
1101
  partialSanitization: { type: 'boolean' },
1074
1102
  },
1075
1103
  },
1104
+ // FR-307/FR-1002: this schema had `additionalProperties: false`
1105
+ // and never declared `approval` — the property apply-fix-
1106
+ // service.js's high-impact-change gate has required since FR-307
1107
+ // was built. A real MCP caller supplying fixMeta.approval was
1108
+ // rejected by validate.js at the schema layer before the handler
1109
+ // ever ran, silently making the approval gate (and FR-1002's
1110
+ // identity check layered on it) unreachable from this tool's
1111
+ // only real production entry point. See D-0024.
1112
+ approval: {
1113
+ type: 'object',
1114
+ additionalProperties: false,
1115
+ properties: {
1116
+ approvedBy: { type: 'string', minLength: 1, maxLength: 200 },
1117
+ reason: { type: 'string', minLength: 1, maxLength: 1000 },
1118
+ },
1119
+ },
1120
+ // FR-1003: separation-of-duties. Self-reported the same way
1121
+ // approvedBy is — this tool has no way to determine who actually
1122
+ // wrote a patch, so `author` is a claim, checked against a
1123
+ // configurable policy the same way `approval` is.
1124
+ author: { type: 'string', minLength: 1, maxLength: 200 },
1076
1125
  },
1077
1126
  },
1078
1127
  },
@@ -1147,20 +1196,78 @@ const apply_fix = {
1147
1196
  verify: { rescan: verdict.rescan, lint: { runner: verdict.lint?.runner, ok: verdict.lint?.ok }, honesty: verdict.honesty || null },
1148
1197
  };
1149
1198
  }
1199
+ // FR-307/FR-1002/D-0024: this caller-supplied-patch branch writes via
1200
+ // applyFixHistory() directly and never called applyVerifiedFix() — so
1201
+ // the high-impact-change approval gate (auth/authZ/crypto/PII/schema/
1202
+ // infra-privilege/public-API) built for the OTHER apply_fix branch
1203
+ // (stored fix.replacement) never ran here at all, for any input. Since
1204
+ // this is the branch the tool's own description calls the one that
1205
+ // covers "~100% of findings that ship only a template," that gap was
1206
+ // the larger of the two found this cycle. Same before/after content
1207
+ // shape `apply-fix-service.js` already uses — read-first-in-try/catch
1208
+ // (D-0012), never existsSync-then-readFileSync.
1209
+ const filesForMaterialClassification = {};
1210
+ for (const [rel, v] of Object.entries(confinedAbs)) {
1211
+ let before = '';
1212
+ try { before = await promises_.readFile(v.abs, 'utf8'); } catch { /* new file — before stays '' */ }
1213
+ filesForMaterialClassification[rel] = { before, after: v.content };
1214
+ }
1215
+ const materialClassification = (0,material_change/* classifyFixMaterialRisk */.kz)(filesForMaterialClassification);
1150
1216
  if (dry_run) {
1151
- return { _meta: META, applied: false, dryRun: true, verified: true, files: Object.keys(confinedAbs), summary: verdict.summary };
1217
+ return { _meta: META, applied: false, dryRun: true, verified: true, files: Object.keys(confinedAbs), summary: verdict.summary, materialClassification };
1218
+ }
1219
+ if (materialClassification.highImpactCategories.length) {
1220
+ const approval = fixMeta && typeof fixMeta === 'object' ? fixMeta.approval : null;
1221
+ const hasApprovalEvidence = !!(approval && typeof approval === 'object' &&
1222
+ typeof approval.approvedBy === 'string' && approval.approvedBy.trim().length > 0 &&
1223
+ typeof approval.reason === 'string' && approval.reason.trim().length > 0);
1224
+ if (!hasApprovalEvidence) {
1225
+ return {
1226
+ _meta: META, applied: false,
1227
+ reason: `high-impact change (${materialClassification.highImpactCategories.join(', ')}) requires approval evidence — pass fixMeta.approval: {approvedBy, reason} — before it can be applied`,
1228
+ materialClassification,
1229
+ };
1230
+ }
1231
+ const approverRegistry = (0,approver_registry/* loadApproverRegistry */.P4)(ctx.sessionRoot);
1232
+ const requiredRoles = (0,approver_registry/* requiredRolesFor */.Kq)(approverRegistry, materialClassification.highImpactCategories);
1233
+ const identityCheck = (0,approver_registry/* verifyApprover */.$N)(approverRegistry, approval.approvedBy, requiredRoles);
1234
+ if (!identityCheck.verified) {
1235
+ return {
1236
+ _meta: META, applied: false,
1237
+ reason: `high-impact change (${materialClassification.highImpactCategories.join(', ')}) approval rejected: ${identityCheck.reason}`,
1238
+ materialClassification,
1239
+ };
1240
+ }
1241
+ // FR-1003: separation-of-duties, same no-op-unless-configured gate
1242
+ // as apply-fix-service.js's own copy — see approver-registry.js.
1243
+ const sodCheck = (0,approver_registry/* checkSeparationOfDuties */.I0)(approverRegistry, fixMeta?.author, approval.approvedBy);
1244
+ if (!sodCheck.ok) {
1245
+ return {
1246
+ _meta: META, applied: false,
1247
+ reason: `high-impact change (${materialClassification.highImpactCategories.join(', ')}) approval rejected: ${sodCheck.reason}`,
1248
+ materialClassification,
1249
+ };
1250
+ }
1152
1251
  }
1153
1252
  const written = [];
1154
1253
  try {
1155
1254
  for (const [rel, v] of Object.entries(confinedAbs)) {
1156
- const originalContent = external_node_fs_.existsSync(v.abs) ? await promises_.readFile(v.abs, 'utf8') : '';
1255
+ const fileExisted = external_node_fs_.existsSync(v.abs);
1256
+ const originalContent = fileExisted ? await promises_.readFile(v.abs, 'utf8') : '';
1157
1257
  const entry = await (0,fix_history/* applyFix */.oM)({
1158
- scanRoot: ctx.sessionRoot, file: rel, originalContent, newContent: v.content,
1258
+ scanRoot: ctx.sessionRoot, file: rel, originalContent, newContent: v.content, fileExisted,
1159
1259
  findingId: f.id, stableId: f.stableId, ruleId: f.ruleId || f.cwe || f.family || null, vuln: f.vuln || f.title || null,
1160
1260
  });
1161
1261
  written.push({ file: rel, historyId: entry.id, backupPath: entry.backupPath });
1162
1262
  }
1163
1263
  } catch (e) {
1264
+ // FR-306: roll back every file THIS batch already wrote before the
1265
+ // failure — applyFixHistory already restored the one file that just
1266
+ // failed; this covers the rest, so a multi-file patch never leaves
1267
+ // some files patched and others not.
1268
+ for (const w of written) {
1269
+ try { await (0,fix_history/* revertEntryById */.rJ)(ctx.sessionRoot, w.historyId); } catch { /* best-effort; original error still propagates below */ }
1270
+ }
1164
1271
  if (e && e.name === 'FixAttemptBudgetExceededError') {
1165
1272
  return { _meta: META, applied: false, reason: `budget-exceeded: ${e.message}`, budgetExceeded: true, attempts: e.attempts, maxAttempts: e.max, key: e.key };
1166
1273
  }
@@ -1168,7 +1275,7 @@ const apply_fix = {
1168
1275
  }
1169
1276
  let acceptance = null;
1170
1277
  try { acceptance = (0,fix_history/* fixAcceptanceRate */.XR)(ctx.sessionRoot); } catch { /* best-effort */ }
1171
- return { _meta: META, applied: true, verified: true, patched: written, integrity: status, verify: { summary: verdict.summary }, acceptance };
1278
+ return { _meta: META, applied: true, verified: true, patched: written, integrity: status, verify: { summary: verdict.summary }, acceptance, materialClassification };
1172
1279
  }
1173
1280
 
1174
1281
  if (typeof f.fix?.replacement !== 'string') {
@@ -1205,41 +1312,47 @@ const apply_fix = {
1205
1312
  };
1206
1313
  }
1207
1314
 
1208
- let entry;
1209
- try {
1210
- entry = await (0,fix_history/* applyFix */.oM)({
1211
- scanRoot: ctx.sessionRoot,
1212
- file: f.file,
1213
- originalContent,
1214
- newContent: f.fix.replacement,
1215
- findingId: f.id,
1216
- stableId: f.stableId || null, // premortem 4R-8
1217
- ruleId: f.ruleId || f.cwe || f.family || null,
1218
- vuln: f.vuln || f.title || null,
1219
- });
1220
- } catch (e) {
1221
- // Harness-engineering: step-budget refusal (post-derived). The
1222
- // deterministic layer enforces at-most-N attempts per stableId. When
1223
- // exceeded, surface it as a structured `budget-exceeded` outcome the
1224
- // agent can recognize — not a generic error.
1225
- if (e && e.name === 'FixAttemptBudgetExceededError') {
1226
- return {
1227
- _meta: META,
1228
- applied: false,
1229
- reason: `budget-exceeded: ${e.message}`,
1230
- budgetExceeded: true,
1231
- attempts: e.attempts,
1232
- maxAttempts: e.max,
1233
- key: e.key,
1234
- };
1315
+ // FR-301/A-08 (assurance-hardening PRD): this branch used to write
1316
+ // f.fix.replacement straight to disk with NO fresh verification — no
1317
+ // rescan, no lint, nothing confirming the stored replacement actually
1318
+ // closes the finding it claims to fix. The caller-patch branch above
1319
+ // already required this; there is no reason a STORED fix should be
1320
+ // trusted more than a caller-supplied one just because it shipped with
1321
+ // the finding. Routed through the same applyVerifiedFix() service the
1322
+ // CLI's `fix --apply` now also uses (src/fix/apply-fix-service.js) —
1323
+ // confinement/reserved-path are re-checked there too (harmless
1324
+ // redundancy with the dry_run preview above, kept for that preview's
1325
+ // size-diff shape) but the load-bearing addition is the verification
1326
+ // gate before the write.
1327
+ if (!f.stableId) {
1328
+ return { _meta: META, applied: false, reason: 'finding has no stableId — cannot verify a stored fix against it' };
1329
+ }
1330
+ const result = await (0,apply_fix_service/* applyVerifiedFix */.On)({
1331
+ scanRoot: ctx.sessionRoot,
1332
+ finding: f,
1333
+ files: { [f.file]: f.fix.replacement },
1334
+ fixMeta,
1335
+ });
1336
+ if (!result.ok) {
1337
+ if (result.budgetExceeded) {
1338
+ return { _meta: META, applied: false, reason: result.reason, budgetExceeded: true, attempts: result.attempts, maxAttempts: result.maxAttempts, key: result.key };
1235
1339
  }
1236
- throw e;
1340
+ return { _meta: META, applied: false, reason: result.reason, verify: result.verify || null };
1237
1341
  }
1238
1342
  // R25 (PRD §5): surface the running auto-fix acceptance rate after each
1239
1343
  // applied fix, so the closed loop reports its own success metric.
1240
1344
  let acceptance = null;
1241
1345
  try { acceptance = (0,fix_history/* fixAcceptanceRate */.XR)(ctx.sessionRoot); } catch { /* metric is best-effort */ }
1242
- return { _meta: META, applied: true, historyId: entry.id, file: f.file, backupPath: entry.backupPath, integrity: status, attemptOrdinal: entry.attemptOrdinal, acceptance };
1346
+ const entry = result.written[0];
1347
+ return {
1348
+ // FR-305: verifiedFull distinguishes "every required leg (lint, tests)
1349
+ // genuinely ran and passed" from "passed, but a required leg was
1350
+ // skipped or unavailable" — verified:true alone conflates them.
1351
+ _meta: META, applied: true, verified: true, verifiedFull: result.verifiedFull,
1352
+ historyId: entry.historyId, file: entry.file, backupPath: entry.backupPath,
1353
+ integrity: status, attemptOrdinal: entry.attemptOrdinal, acceptance,
1354
+ verify: result.verify,
1355
+ };
1243
1356
  },
1244
1357
  };
1245
1358