@clear-capabilities/agentic-security-scanner 0.128.1 → 0.130.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 (79) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/bin/agentic-security.js +33 -0
  3. package/dist/11.index.js +2 -2
  4. package/dist/113.index.js +209 -7
  5. package/dist/178.index.js +1 -1
  6. package/dist/207.index.js +217 -0
  7. package/dist/384.index.js +1 -1
  8. package/dist/415.index.js +1 -1
  9. package/dist/435.index.js +2 -2
  10. package/dist/526.index.js +555 -0
  11. package/dist/637.index.js +1 -1
  12. package/dist/830.index.js +1 -1
  13. package/dist/agentic-security.mjs +113 -162
  14. package/dist/agentic-security.mjs.sha256 +1 -1
  15. package/package.json +22 -14
  16. package/src/dataflow/CLAUDE.md +4 -1
  17. package/src/dataflow/async-sequencing.js +8 -3
  18. package/src/dataflow/catalog.js +278 -11
  19. package/src/dataflow/cross-repo.js +1 -1
  20. package/src/dataflow/cross-service-taint.js +1 -1
  21. package/src/dataflow/engine.js +182 -61
  22. package/src/dataflow/ifds.js +10 -5
  23. package/src/dataflow/index.js +15 -3
  24. package/src/dataflow/points-to.js +8 -2
  25. package/src/dataflow/proof-gate.js +7 -0
  26. package/src/dataflow/sanitizer-gate.js +89 -0
  27. package/src/dataflow/tabulation.js +14 -3
  28. package/src/engine.js +154 -7
  29. package/src/integrations/index.js +1 -1
  30. package/src/ir/CLAUDE.md +49 -4
  31. package/src/ir/call-sites.js +66 -0
  32. package/src/ir/callgraph.js +174 -7
  33. package/src/ir/class-hierarchy.js +22 -2
  34. package/src/ir/index.js +138 -51
  35. package/src/ir/ir-stats.js +126 -0
  36. package/src/ir/parser-cpp.js +829 -0
  37. package/src/ir/parser-cs.js +4 -1
  38. package/src/ir/parser-go.js +4 -1
  39. package/src/ir/parser-js.js +5 -1
  40. package/src/ir/parser-kt.js +4 -1
  41. package/src/ir/parser-php.js +10 -3
  42. package/src/ir/parser-py-cst.js +62 -10
  43. package/src/ir/tree-sitter-loader.js +13 -1
  44. package/src/llm-validator/index.js +9 -2
  45. package/src/llm-validator/redact.js +157 -0
  46. package/src/posture/CLAUDE.md +115 -0
  47. package/src/posture/accuracy-scorecard.js +317 -0
  48. package/src/posture/api-contract.js +1 -1
  49. package/src/posture/attestation.js +199 -0
  50. package/src/posture/auditor-walkthrough.js +12 -3
  51. package/src/posture/compliance-policy.js +1 -1
  52. package/src/posture/cross-lang-openapi.js +1 -1
  53. package/src/posture/custom-rules.js +1 -1
  54. package/src/posture/execution-proof.js +52 -0
  55. package/src/posture/exploitability-probability.js +1 -1
  56. package/src/posture/falsification.js +45 -1
  57. package/src/posture/fix-verify.js +55 -2
  58. package/src/posture/license-policy.js +1 -1
  59. package/src/posture/profile.js +1 -1
  60. package/src/posture/proof-tier.js +33 -0
  61. package/src/posture/relevance.js +379 -0
  62. package/src/posture/rule-overrides.js +1 -1
  63. package/src/posture/sca-policy.js +1 -1
  64. package/src/posture/scan-checkpoint.js +277 -0
  65. package/src/posture/suppressions.js +1 -1
  66. package/src/posture/test-runner.js +147 -0
  67. package/src/posture/verification-separation.js +131 -0
  68. package/src/report/index.js +11 -0
  69. package/src/runScan.js +3 -1
  70. package/src/sandbox/CLAUDE.md +218 -0
  71. package/src/sandbox/backend-disabled.js +14 -0
  72. package/src/sandbox/backend-namespace.js +83 -0
  73. package/src/sandbox/backend-userspace.js +100 -0
  74. package/src/sandbox/capabilities.js +53 -0
  75. package/src/sandbox/index.js +30 -0
  76. package/src/sandbox/limits.js +42 -0
  77. package/src/sandbox/result.js +104 -0
  78. package/src/sca/dep-confusion.js +1 -1
  79. package/src/util/yaml.js +24 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,106 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.130.0 — the roadmap's first ten: provable security over orchestration parity
4
+
5
+ A capability roadmap (`docs/ROADMAP.md`) plus its first ten items, derived from a
6
+ survey of the current agentic security-review field. The strategic call: that
7
+ field cannot state a false-positive rate or prove it did not regress, because its
8
+ core is a model call. This project's core is a deterministic engine behind three
9
+ gates, so the work doubles down on provable, measurable, reproducible.
10
+
11
+ - **Execution sandbox** (`src/sandbox/`) — fail-closed confined execution. Verified
12
+ by execution: writes outside the sandbox root blocked, network blocked, wall-clock
13
+ overrun terminated, benign work still succeeds. With no confinement primitive
14
+ available, execution is REFUSED, never run unconfined.
15
+ - **Execution-verified findings** (`posture/execution-proof.js`) — a finding can be
16
+ promoted to `execution-proven` by running its proof-of-concept in the sandbox.
17
+ Proof is a marker file, never an exit code, because the sandbox cannot reliably
18
+ distinguish a denied run from a clean exit. `proof-failed` is a triage signal,
19
+ NOT a false-positive verdict.
20
+ - **Published accuracy scorecard** (`npm run scorecard`) — detection and correct-
21
+ silence rates sliced by language and CWE, every figure with its denominator,
22
+ regeneration-stable. F1 deliberately omitted: no labelled real-world population
23
+ exists to measure precision over, and the document says so.
24
+ - **Determinism attestation** (`posture/attestation.js`) — an order-independent
25
+ signed digest. Cross-machine reproducibility is explicitly NOT claimed.
26
+ - **Fixes must pass your tests** (`posture/test-runner.js`) — `verifyFix` previously
27
+ proved only that the finding disappeared, which deleting the feature also
28
+ achieves. It now runs the project's own suite, and says so when the suite ran
29
+ against unpatched code.
30
+ - **Relevance ranking** (`posture/relevance.js`) — re-ranks by entry-point
31
+ reachability. Recall-preserving: nothing deleted, severity never touched,
32
+ `unreachable` only on positive evidence.
33
+ - **Enforced verification separation** (`posture/verification-separation.js`) — a
34
+ verifier cannot rubber-stamp its own finding.
35
+ - **Resumable scans** (`posture/scan-checkpoint.js`) — opt-in via
36
+ `AGENTIC_SECURITY_RESUME=1`, crash-safe, conservatively invalidated.
37
+ - **Secret redaction** (`llm-validator/redact.js`) — credentials removed before any
38
+ code leaves the machine; ordinary code passes through byte-unchanged.
39
+
40
+ Also: dependencies updated to latest (Babel 7→8 with the removed preset options
41
+ migrated, js-yaml 4→5 with an empty-input shim). One dependency deliberately held
42
+ back: bumping the grammar runtime silently drops all six long-tail language
43
+ grammars, so it stays pinned and the reason is documented.
44
+
45
+ `npm test` 1989/0; cve-replay 199/199; self-scan no drift.
46
+
47
+ ## 0.129.0 — closing two taint-engine recall gaps
48
+
49
+ Two defects were found by execution on the merged tree, each silencing real findings across
50
+ every supported language. Both are now fixed, measured, and gated.
51
+
52
+ - **Sinks are matched on assignment right-hand sides** (`dataflow/engine.js`). The engine only
53
+ ever sink-matched in statement position, so `db.query(tainted)` was reported while
54
+ `const rows = db.query(tainted)` was silent — in every language. The sink-matching logic is
55
+ now extracted into shared helpers called from both `case 'call'` and `case 'assign'` rather
56
+ than duplicated, and the pre-existing statement-position path is unchanged (measured control:
57
+ `1/1` before and after; assignment position `0/0 -> 1/1`).
58
+ - **`match.type:'global'` catalog entries are indexed and reachable** (`dataflow/catalog.js`).
59
+ All 10 global entries were unreachable from `matchSource()` — including `$_GET`/`$_POST`/
60
+ `$_REQUEST`, the canonical PHP taint sources, in a language that already had interprocedural
61
+ analysis. A new `GLOBAL_INDEX`, plus lookup-side sigil normalization (`_globalKey()`) so PHP's
62
+ `$` prefix matches sigil-free catalog keys, takes catalog reachability `0/10 -> 10/10` with
63
+ language scoping preserved.
64
+ - **Seven self-scan false positives eliminated at source.** Raising sink recall exposed
65
+ pre-existing catalog imprecision: `py-yaml-load`/`py-pickle-load` matched bare callee `load`
66
+ with no receiver constraint, so ordinary `json.load(fh)` was flagged as unsafe deserialization.
67
+ Each finding was inspected individually and all seven were false positives; the entries are now
68
+ pinned to their receiver. **Nothing was baselined** — `bench/self-scan/BASELINE.json` is
69
+ unchanged and the gate is green on the source fix.
70
+ - **New**: `bench/engine-recall` before/after harness (`npm run bench:engine-recall`) and
71
+ `bench/engine-recall/RESULTS.md`, the full measurement record including what the fixes cost.
72
+ - **Corpus 197 -> 199**: two deep-tier entries, each verified missed-before / found-after against
73
+ its specific fix.
74
+
75
+ Known trade, recorded rather than papered over: pinning the receiver drops `import yaml as y;
76
+ y.load(f)` and `from yaml import load; load(x)`, which are now covered at no layer. A corpus guard
77
+ was attempted and deliberately withheld because it would score `pre:TN`; closing it needs
78
+ import-alias resolution in the Python IR. Separately, `10/10` is catalog reachability, not
79
+ end-to-end recall — only PHP is proven end to end; Ruby's deep engine does not complete those
80
+ flows (pre-existing). Both are documented in `RESULTS.md` §3 and §8.
81
+
82
+ `npm test` 1854/0; cve-replay 199/199; self-scan no drift.
83
+
84
+ ## 0.128.2 — compliance attestation accuracy + quieter self-scans
85
+
86
+ Two fixes surfaced while dogfooding the compliance flow on this repo:
87
+
88
+ - **Fixed a compliance-attestation path bug** (`posture/auditor-walkthrough.js`). Three
89
+ evidence checks (`mcp-tools`, `security-fixer`, `pre-edit-bodyguard`) carried a literal
90
+ `.../` placeholder path that `path.join(scanRoot, STATE, '.../x')` could never resolve, so
91
+ they read **"not present" for every project** — falsely dragging OWASP LLM08/LLM09 (and any
92
+ framework mapping to those modules) to "manual/not-present". A `.../` sentinel now resolves
93
+ against the scan root itself. On a self-attestation this flips LLM09 → satisfied and makes
94
+ LLM08 honestly partial.
95
+ - **Repo `ignorePaths` for meaningful self-scans** (`.agentic-security/rules.yml`). Added
96
+ `bench/**` and `scanner/test/fixtures/**` so a repo-root `/scan` no longer counts the ~600
97
+ intentionally-vulnerable benchmark corpora and test fixtures as findings. Safe: `rules.yml`
98
+ is loaded from the exact scan root only, so the cve-replay runner (which scans each
99
+ pre/post fixture as its own root) and the unit tests are unaffected — corpus gate stays
100
+ 185/185.
101
+
102
+ `npm test` 1695/0; cve-replay 185/185.
103
+
3
104
  ## 0.128.1 — patch dependency vulnerabilities (11 Dependabot alerts → 0)
4
105
 
5
106
  Security maintenance. Cleared all 11 open Dependabot alerts by updating the two lockfiles to
@@ -274,6 +274,21 @@ function renderV3Blocks(scan, flags) {
274
274
  }
275
275
 
276
276
  // Always-on machine output (R2). Vibecoder gets JSON only; pro gets JSON+SARIF+CSV.
277
+ // SHA-256 of the running bundle, read from the sidecar `npm run build` emits
278
+ // NEXT TO the bundle. Running from source (bin/ + src/) yields 'unavailable'
279
+ // rather than the checkout's dist hash: src and a previously-built dist can
280
+ // disagree, and attesting a bundle that did not produce this run would be a
281
+ // false claim.
282
+ function _bundleSha() {
283
+ const here = path.dirname(new URL(import.meta.url).pathname);
284
+ try {
285
+ const raw = fs.readFileSync(path.join(here, 'agentic-security.mjs.sha256'), 'utf8').trim();
286
+ const m = /^([0-9a-f]{64})\b/.exec(raw);
287
+ if (m) return m[1];
288
+ } catch { /* not running from the bundle */ }
289
+ return 'unavailable';
290
+ }
291
+
277
292
  async function writeMachineOutput(targetAbs, scan, meta, profile) {
278
293
  const stateDir = path.join(targetAbs, '.agentic-security');
279
294
  const { isSafeStateDir: _isSafe } = await import('../src/posture/state-dir.js');
@@ -553,6 +568,24 @@ async function cmdScan(args) {
553
568
  // Deterministic post-process: stable-sort findings + zero out timing.
554
569
  if (isDeterministic()) makeDeterministic(scan, meta);
555
570
 
571
+ // R4 — determinism as a contract. Bind the PUBLISHED finding set (the same
572
+ // normalization every report format emits) to the engine version, ruleset
573
+ // version and bundle hash that produced it, via an order-independent digest.
574
+ // Runs after every filter above so it attests what actually ships. Metadata
575
+ // only — a failure here must never fail a scan.
576
+ try {
577
+ const { computeRunAttestation } = await import('../src/posture/attestation.js');
578
+ const { effectiveVersion } = await import('../src/posture/ruleset-version.js');
579
+ scan.attestation = computeRunAttestation({
580
+ findings: normalizeFindings(scan),
581
+ engineVersion: PKG_VERSION,
582
+ rulesetVersion: effectiveVersion(targetAbs).version,
583
+ bundleSha: _bundleSha(),
584
+ root: targetAbs,
585
+ sign: true,
586
+ });
587
+ } catch { /* attestation is metadata; never fail a scan over it */ }
588
+
556
589
  // R2: Always emit machine-readable artifacts to .agentic-security/.
557
590
  await writeMachineOutput(targetAbs, scan, meta, profile);
558
591
 
package/dist/11.index.js CHANGED
@@ -21,8 +21,8 @@ var external_node_child_process_ = __webpack_require__(1421);
21
21
  var external_node_fs_ = __webpack_require__(3024);
22
22
  // EXTERNAL MODULE: external "node:path"
23
23
  var external_node_path_ = __webpack_require__(6760);
24
- // EXTERNAL MODULE: ./src/engine.js + 524 modules
25
- var engine = __webpack_require__(8215);
24
+ // EXTERNAL MODULE: ./src/engine.js + 592 modules
25
+ var engine = __webpack_require__(9408);
26
26
  ;// CONCATENATED MODULE: ./src/posture/fix-honesty-gate.js
27
27
  // Deterministic honesty gates on fix / finding output (#7).
28
28
  //
package/dist/113.index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export const id = 113;
2
- export const ids = [113,11];
2
+ export const ids = [113,526];
3
3
  export const modules = {
4
4
 
5
5
  /***/ 4113:
@@ -12,7 +12,7 @@ export const modules = {
12
12
  /* harmony import */ var node_child_process__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(1421);
13
13
  /* harmony import */ var node_fs__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(3024);
14
14
  /* harmony import */ var node_path__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(6760);
15
- /* harmony import */ var _fix_verify_js__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(11);
15
+ /* harmony import */ var _fix_verify_js__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(3526);
16
16
  // Closed-loop fix verification (v0.68).
17
17
  //
18
18
  // Existing `fix-verify.js` does scan + lint. This module adds the third
@@ -174,7 +174,7 @@ function _summarize(legs, verdict) {
174
174
 
175
175
  /***/ }),
176
176
 
177
- /***/ 11:
177
+ /***/ 3526:
178
178
  /***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
179
179
 
180
180
  // ESM COMPAT FLAG
@@ -193,8 +193,8 @@ var external_node_child_process_ = __webpack_require__(1421);
193
193
  var external_node_fs_ = __webpack_require__(3024);
194
194
  // EXTERNAL MODULE: external "node:path"
195
195
  var external_node_path_ = __webpack_require__(6760);
196
- // EXTERNAL MODULE: ./src/engine.js + 524 modules
197
- var engine = __webpack_require__(8215);
196
+ // EXTERNAL MODULE: ./src/engine.js + 595 modules
197
+ var engine = __webpack_require__(4660);
198
198
  ;// CONCATENATED MODULE: ./src/posture/fix-honesty-gate.js
199
199
  // Deterministic honesty gates on fix / finding output (#7).
200
200
  //
@@ -372,6 +372,155 @@ function gateFixOutput({ residual, verdict, evidence, signals } = {}) {
372
372
 
373
373
  const _internals = Object.freeze({ BANNED_RESIDUAL_PHRASES, CITATION_RE, FP_VERDICTS });
374
374
 
375
+ ;// CONCATENATED MODULE: ./src/posture/test-runner.js
376
+ // R5 (partial, roadmap) — the project's own test suite as a verification
377
+ // stage for `verifyFix()` (see `fix-verify.js`). Closes the gap where a
378
+ // "verified" fix only proved a finding's stableId stopped firing — a patch
379
+ // that deletes the feature entirely would satisfy that just as well as a
380
+ // real fix. Running the project's own tests is the cheapest available check
381
+ // that the application still works.
382
+ //
383
+ // Execution-safety note: this spawns the TARGET PROJECT's own test command
384
+ // in the target project's own directory. That is deliberately NOT routed
385
+ // through the R1 confinement sandbox (`../sandbox/`). That sandbox exists to
386
+ // contain untrusted proof-of-concept exploit code the scanner itself
387
+ // synthesizes — code nobody has vetted, being run for the first time. A
388
+ // project's pre-existing test suite is the opposite case: it is the
389
+ // project's own trusted source, already sitting on disk, and running it is
390
+ // exactly what a human developer does by hand before trusting a fix.
391
+ // Wrapping "npm test" / "pytest" / "go test" in the PoC sandbox's
392
+ // syscall/network/filesystem restrictions would break the large majority of
393
+ // real test suites (they bind local ports, spawn child processes, write temp
394
+ // fixtures, etc.) for no corresponding security benefit.
395
+
396
+
397
+
398
+
399
+
400
+ const DEFAULT_TIMEOUT_MS = 300_000;
401
+ const NPM_PLACEHOLDER = /Error: no test specified/i;
402
+
403
+ function _exists(scanRoot, rel) {
404
+ try { return external_node_fs_.existsSync(external_node_path_.join(scanRoot, rel)); } catch { return false; }
405
+ }
406
+
407
+ function _isDir(scanRoot, rel) {
408
+ try { return external_node_fs_.statSync(external_node_path_.join(scanRoot, rel)).isDirectory(); } catch { return false; }
409
+ }
410
+
411
+ function _binaryAvailable(cmd) {
412
+ try {
413
+ const r = (0,external_node_child_process_.spawnSync)(cmd, ['--version'], { timeout: 5_000, stdio: 'ignore' });
414
+ return !(r.error && r.error.code === 'ENOENT');
415
+ } catch {
416
+ return false;
417
+ }
418
+ }
419
+
420
+ // Detect the project's test command. Read-only — never spawns the actual
421
+ // test run, only (optionally) a cheap `--version` probe to confirm a tool
422
+ // like `pytest` is actually installed before committing to it. Returns
423
+ // `null` when nothing detectable is found — most scanned repos will hit
424
+ // this path, and that must not be treated as a failure by callers.
425
+ function detectTestCommand(scanRoot) {
426
+ if (!scanRoot) return null;
427
+
428
+ // JS/TS — package.json with a real (non-placeholder) `scripts.test`.
429
+ let pkg = null;
430
+ try { pkg = JSON.parse(external_node_fs_.readFileSync(external_node_path_.join(scanRoot, 'package.json'), 'utf8')); } catch { pkg = null; }
431
+ const testScript = pkg && pkg.scripts && pkg.scripts.test;
432
+ if (testScript && !NPM_PLACEHOLDER.test(String(testScript))) {
433
+ if (_exists(scanRoot, 'pnpm-lock.yaml')) return { cmd: 'pnpm', args: ['test'], kind: 'pnpm' };
434
+ if (_exists(scanRoot, 'yarn.lock')) return { cmd: 'yarn', args: ['test'], kind: 'yarn' };
435
+ if (_exists(scanRoot, 'bun.lockb') || _exists(scanRoot, 'bun.lock')) return { cmd: 'bun', args: ['test'], kind: 'bun' };
436
+ return { cmd: 'npm', args: ['test', '--silent'], kind: 'npm' };
437
+ }
438
+
439
+ // Python — pytest.ini / pyproject.toml / tox.ini / a tests/ dir, and the
440
+ // `pytest` binary actually available. If pytest isn't installed we do NOT
441
+ // report a python test command — falling through lets a later language
442
+ // marker (e.g. go.mod in a polyglot repo) still be detected.
443
+ const pyMarker = _exists(scanRoot, 'pytest.ini') || _exists(scanRoot, 'pyproject.toml') ||
444
+ _exists(scanRoot, 'tox.ini') || _isDir(scanRoot, 'tests');
445
+ if (pyMarker && _binaryAvailable('pytest')) {
446
+ return { cmd: 'pytest', args: ['-q'], kind: 'pytest' };
447
+ }
448
+
449
+ // Go
450
+ if (_exists(scanRoot, 'go.mod')) {
451
+ return { cmd: 'go', args: ['test', './...'], kind: 'go' };
452
+ }
453
+
454
+ return null;
455
+ }
456
+
457
+ // Run the detected test command with a walltime budget. Always returns a
458
+ // result object — never throws. Distinguishes four outcomes:
459
+ // - no detectable/runnable command -> status: 'skipped' (does NOT fail)
460
+ // - ran and exited 0 -> status: 'passed'
461
+ // - ran and exited non-zero -> status: 'failed'
462
+ // - ran past the timeout budget -> status: 'failed', timedOut: true
463
+ function runProjectTests(scanRoot, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
464
+ const startedAt = Date.now();
465
+ const command = detectTestCommand(scanRoot);
466
+ if (!command) {
467
+ return {
468
+ status: 'skipped', passed: null, skipped: true,
469
+ reason: 'no-test-command-detected', exitCode: null, timedOut: false,
470
+ durationMs: Date.now() - startedAt,
471
+ };
472
+ }
473
+
474
+ let r;
475
+ try {
476
+ r = (0,external_node_child_process_.spawnSync)(command.cmd, command.args, {
477
+ cwd: scanRoot,
478
+ encoding: 'utf8',
479
+ timeout: timeoutMs,
480
+ env: { ...process.env, CI: '1' },
481
+ });
482
+ } catch (e) {
483
+ // The spawn call itself threw (rare — e.g. cwd vanished). Treat as
484
+ // "could not run", not "ran and failed".
485
+ return {
486
+ status: 'skipped', passed: null, skipped: true,
487
+ reason: `spawn-error: ${e.message}`, exitCode: null, timedOut: false,
488
+ durationMs: Date.now() - startedAt,
489
+ };
490
+ }
491
+ const durationMs = Date.now() - startedAt;
492
+
493
+ if (r.error && r.error.code === 'ENOENT') {
494
+ // The detected tool isn't actually installed on this machine. Not a
495
+ // test failure — the suite never ran.
496
+ return {
497
+ status: 'skipped', passed: null, skipped: true,
498
+ reason: `${command.kind}-not-installed`, exitCode: null, timedOut: false, durationMs,
499
+ };
500
+ }
501
+
502
+ if (r.status === null) {
503
+ // spawnSync sets status:null both on timeout-kill and on being killed by
504
+ // another signal; either way the run did not complete, which is a
505
+ // verification failure, never a skip — we asked for a result and the
506
+ // process was terminated before producing one.
507
+ return {
508
+ status: 'failed', passed: false, skipped: false,
509
+ reason: 'timed-out', exitCode: null, timedOut: true, durationMs,
510
+ };
511
+ }
512
+
513
+ return {
514
+ status: r.status === 0 ? 'passed' : 'failed',
515
+ passed: r.status === 0,
516
+ skipped: false,
517
+ reason: r.status === 0 ? null : 'test-failures',
518
+ exitCode: r.status,
519
+ timedOut: false,
520
+ durationMs,
521
+ };
522
+ }
523
+
375
524
  ;// CONCATENATED MODULE: ./src/posture/fix-verify.js
376
525
  // Closed-loop /fix verification (Sentinel-parity FR-L4-4, FR-L4-5).
377
526
  //
@@ -381,6 +530,10 @@ const _internals = Object.freeze({ BANNED_RESIDUAL_PHRASES, CITATION_RE, FP_VERD
381
530
  // 1. The original finding's stableId no longer fires on the patched file.
382
531
  // 2. No new findings at severity ≥ medium were introduced by the patch.
383
532
  // 3. The project's existing linter (when present) passes on the patched file.
533
+ // 4. The project's own test suite (when detectable) still passes. This is
534
+ // the R5 gap-closer: a patch that silently deletes the feature would
535
+ // satisfy (1) and (2) just as well as a real fix — only running the
536
+ // tests catches that. See `test-runner.js` for detection + execution.
384
537
  //
385
538
  // If any of those fail, the caller is expected to NOT apply the patch and
386
539
  // instead surface a "fix plan" — a numbered list of steps the engineer can
@@ -392,6 +545,7 @@ const _internals = Object.freeze({ BANNED_RESIDUAL_PHRASES, CITATION_RE, FP_VERD
392
545
 
393
546
 
394
547
 
548
+
395
549
  const SEVERITY_RANK = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
396
550
 
397
551
  // Run a focused re-scan over just the patched file(s) using the in-memory
@@ -494,29 +648,77 @@ function runLinter(cwd, cmd, args) {
494
648
  // dishonest or over-claiming fix fails the gate. When `fixMeta` is absent
495
649
  // (the deterministic MCP write path, which has no claims to check) the honesty
496
650
  // gate is skipped and behavior is unchanged.
651
+ // R5 (partial) — the test-suite stage. Runs the target project's own tests,
652
+ // in the target project's own directory, against whatever is currently on
653
+ // disk there. See `test-runner.js`'s header comment for why that run is
654
+ // deliberately NOT routed through the R1 PoC-confinement sandbox: this is
655
+ // the project's own already-trusted suite, not untrusted synthesized code.
656
+ //
657
+ // Caveat that matters for callers: `verifyPatch` above re-scans the
658
+ // candidate patch purely in memory (no write to disk), but a test runner
659
+ // needs real files — there is no cheap way to hand a runner an in-memory
660
+ // overlay. So this leg reports on the CURRENT on-disk tree, not the
661
+ // candidate `files` map, when `verifyFix` is used as a pre-write preview
662
+ // (e.g. the `verify_fix` MCP tool). Callers that apply the patch first and
663
+ // then re-verify get the strongest signal; that ordering is not enforced
664
+ // here — it's the caller's responsibility, same as it already is for the
665
+ // closed-loop `fix-verify-loop.js` path.
666
+ // Does the caller's candidate patch differ from what is on disk right now?
667
+ // If so, any test run necessarily exercised the pre-patch tree. Compared by
668
+ // content so a patch that happens to match disk (already applied) is correctly
669
+ // treated as NOT pre-patch.
670
+ function _candidateDiffersFromDisk(scanRoot, files) {
671
+ if (!files || typeof files !== 'object') return false;
672
+ for (const [rel, content] of Object.entries(files)) {
673
+ if (typeof content !== 'string') continue;
674
+ try {
675
+ const abs = external_node_path_.resolve(scanRoot, rel);
676
+ if (external_node_fs_.readFileSync(abs, 'utf8') !== content) return true;
677
+ } catch {
678
+ return true; // candidate file absent on disk -> definitely not applied
679
+ }
680
+ }
681
+ return false;
682
+ }
683
+
497
684
  async function verifyFix({
498
685
  scanRoot,
499
686
  originalFindingStableId,
500
687
  files,
501
688
  depFileContents,
502
689
  fixMeta,
690
+ testTimeoutMs,
503
691
  } = {}) {
504
692
  const rescan = await verifyPatch({ scanRoot, originalFindingStableId, files, depFileContents });
505
693
  const lint = runProjectLinter(scanRoot, Object.keys(files || {}));
694
+ const tests = runProjectTests(scanRoot, testTimeoutMs != null ? { timeoutMs: testTimeoutMs } : {});
695
+ // True when a candidate patch was supplied but has not been written, so the
696
+ // suite necessarily ran against the pre-patch tree. Surfaced in the summary
697
+ // and on the result so a caller cannot mistake it for a verified patch.
698
+ const _testedPrePatch = !tests.skipped && _candidateDiffersFromDisk(scanRoot, files);
699
+ const testsOk = tests.skipped ? true : tests.passed === true;
506
700
  let honesty = null;
507
701
  if (fixMeta && typeof fixMeta === 'object') {
508
702
  try { honesty = gateFixOutput(fixMeta); } catch { honesty = null; }
509
703
  }
510
- const ok = rescan.ok && (lint.ok || lint.skipped) && (honesty ? honesty.ok : true);
704
+ const ok = rescan.ok && (lint.ok || lint.skipped) && testsOk && (honesty ? honesty.ok : true);
511
705
  const summary = [
512
706
  `re-scan: ${rescan.ok ? 'PASS' : 'FAIL — ' + rescan.reason}`,
513
707
  `linter: ${lint.runner === 'none' ? 'skipped (no linter config)'
514
708
  : lint.skipped ? `${lint.runner} not installed`
515
709
  : lint.ok ? `${lint.runner} PASS`
516
710
  : `${lint.runner} FAIL (exit ${lint.exitCode})`}`,
711
+ // Say which tree the suite actually ran against. `files` is a candidate
712
+ // patch held in memory; the runner needs real files, so it sees whatever is
713
+ // on disk. Reporting a bare "PASS" here would let a caller believe the
714
+ // PATCH passed the tests when the suite may have run on unpatched code.
715
+ `tests: ${tests.skipped ? `skipped (${tests.reason})`
716
+ : tests.timedOut ? 'FAIL (timed out)'
717
+ : tests.passed ? `PASS${_testedPrePatch ? ' — on the CURRENT on-disk tree, NOT the candidate patch' : ''}`
718
+ : `FAIL (exit ${tests.exitCode})`}`,
517
719
  honesty ? `honesty: ${honesty.ok ? `PASS (${honesty.tier})` : 'FAIL — ' + honesty.violations.join('; ')}` : null,
518
720
  ].filter(Boolean).join('\n');
519
- return { ok, rescan, lint, honesty, summary };
721
+ return { ok, rescan, lint, tests, testedPrePatch: _testedPrePatch, honesty, summary };
520
722
  }
521
723
 
522
724
 
package/dist/178.index.js CHANGED
@@ -13,7 +13,7 @@ export const modules = {
13
13
  /* harmony import */ var node_child_process__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(1421);
14
14
  /* harmony import */ var node_fs__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(3024);
15
15
  /* harmony import */ var node_path__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(6760);
16
- /* harmony import */ var _engine_js__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(8215);
16
+ /* harmony import */ var _engine_js__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(4660);
17
17
  // Time-travel + counterfactual scanning (v0.68).
18
18
  //
19
19
  // Two new modes that exploit the pure-input shape of runFullScan: