@clear-capabilities/agentic-security-scanner 0.158.0 → 0.159.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 (43) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/1919.index.js +5 -2
  3. package/dist/2862.index.js +29 -9
  4. package/dist/3531.index.js +1 -1
  5. package/dist/4171.index.js +4 -4
  6. package/dist/{8551.index.js → 7033.index.js} +415 -22
  7. package/dist/9560.index.js +3 -1
  8. package/dist/agentic-security.mjs +15 -15
  9. package/dist/agentic-security.mjs.sha256 +1 -1
  10. package/package.json +6 -4
  11. package/src/engine.js +6 -1
  12. package/src/language/CLAUDE.md +48 -1
  13. package/src/language/assurance.js +21 -4
  14. package/src/language/context.js +2 -0
  15. package/src/language/cvss.js +91 -0
  16. package/src/language/fix-lifecycle.js +114 -21
  17. package/src/language/haskell-adapter.js +4 -3
  18. package/src/language/haskell-advisory-feed.js +15 -0
  19. package/src/language/haskell-boundaries.js +220 -0
  20. package/src/language/haskell-cpp.js +460 -0
  21. package/src/language/haskell-ir.js +6 -3
  22. package/src/language/haskell-models.js +4 -3
  23. package/src/language/haskell-parser.js +122 -38
  24. package/src/language/haskell-sca.js +10 -6
  25. package/src/language/haskell-supply.js +42 -8
  26. package/src/language/haskell-web.js +128 -23
  27. package/src/language/nix-advisory-feed.js +275 -0
  28. package/src/language/nix-fix.js +162 -24
  29. package/src/language/nix-ir.js +16 -12
  30. package/src/language/nix-sca.js +71 -12
  31. package/src/language/nixos-eval-forms.js +162 -0
  32. package/src/language/nixos-module-resolver.js +346 -51
  33. package/src/language/resolved-pass.js +57 -3
  34. package/src/lsp/server.js +5 -2
  35. package/src/mcp/tools.js +3 -1
  36. package/src/posture/fix-history.js +30 -3
  37. package/src/report/index.js +4 -1
  38. package/dist/1310.index.js +0 -3161
  39. package/dist/2376.index.js +0 -3151
  40. package/dist/3526.index.js +0 -1019
  41. package/dist/7310.index.js +0 -520
  42. package/dist/8520.index.js +0 -13
  43. package/dist/9091.index.js +0 -3135
@@ -1 +1 @@
1
- 18b81da52b654c93c6240dfc2909e049da0accb60cd1b2a8dca439d05cca7fd9 agentic-security.mjs
1
+ 6a0bcec455607538bd4485ce3b9cd5a266f51daf636b0ef6260cdc3bacf01776 agentic-security.mjs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clear-capabilities/agentic-security-scanner",
3
- "version": "0.158.0",
3
+ "version": "0.159.0",
4
4
  "description": "Scanner engine for the agentic-security Claude Code plugin: SAST, SCA with reachability and CISA KEV, secrets, IaC, Haskell and Nix/NixOS, prompt-injection, MCP/agent-tool audit, authZ analysis, SBOM, compliance attestation, and more.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -79,14 +79,15 @@
79
79
  "test:lineage": "node --test test/lineage/schema.test.js test/lineage/ids.test.js test/lineage/protection.test.js test/lineage/classification.test.js test/lineage/validate.test.js test/lineage/json-schema-parity.test.js test/lineage/flagship-fixture.test.js test/lineage/flagship-fixture-semantics.test.js test/lineage/field-identity.test.js test/lineage/engine-expr-resolver.test.js test/lineage/engine-walker.test.js test/lineage/engine-integration.test.js test/lineage/engine-receiver-identity.test.js test/lineage/summaries.test.js test/lineage/driver.test.js test/lineage/engine-provenance.test.js test/lineage/engine-provenance-interprocedural.test.js test/lineage/path-store.test.js test/lineage/path-query.test.js test/lineage/flow-grade.test.js test/lineage/source-registry.test.js test/lineage/sink-registry.test.js test/lineage/transform-catalog.test.js test/lineage/handling-analyzer.test.js test/lineage/registry-real-code.test.js test/lineage/source-seeding.test.js test/lineage/graph-builder.test.js test/lineage/coverage.test.js test/lineage/resolve-destination.test.js test/lineage/queue-detail.test.js test/lineage/index.test.js test/lineage/obligation-mapping.test.js test/lineage/obligation-predicates.test.js test/lineage/obligation-predicates-walkthrough.test.js test/lineage/transit-protection.test.js test/lineage/at-rest-protection.test.js test/lineage/protection-summary.test.js test/lineage/ac01-multi-sink.test.js test/lineage/policy-verdict.test.js test/lineage/governance-refs.test.js test/lineage/export-privacy.test.js test/lineage/export-json.test.js test/lineage/export-csv.test.js test/lineage/decision-story.test.js test/lineage/export-briefing.test.js test/lineage/graph-snapshot.test.js test/lineage/graph-diff.test.js test/lineage/drift-policy.test.js test/lineage/recipient-profile.test.js test/lineage/recipient-registry.test.js test/lineage/recipient-wiring.test.js test/lineage/redact-graph.test.js test/lineage-scan-wiring.test.js test/lineage-artifact-write.test.js test/bench-data-lineage-runner.test.js test/bundle-frontend.test.js test/bundle-frontend-golden.test.js test/generate-html-report.test.js test/export-image.test.js test/lineage/scenario.test.js test/lineage/scenario-engine.test.js test/lineage/scenario-diff.test.js test/lineage/scenario-no-obligation-wiring.test.js test/lineage/impact-assessment.test.js test/lineage/impact-engine.test.js test/lineage/impact-no-obligation-wiring.test.js test/lineage/governance-edit.test.js test/lineage/remediation.test.js test/lineage/runtime-observation.test.js test/lineage/observation-correlation.test.js test/lineage/observation-adapters.test.js test/lineage/observation-store.test.js test/lineage/runtime-corroboration-wiring.test.js test/lineage/cross-repo-link.test.js test/lineage/federation-loader.test.js test/lineage/cross-repo-link-wiring.test.js",
80
80
  "test:bench-modules": "node --test test/phase4-harness.test.js test/pipeline.test.js test/proof-corpus-lib.test.js test/proof-corpus-runner.test.js",
81
81
  "test:server": "node --test test/server/graph-loader.test.js test/server/security.test.js test/server/routes.test.js test/server/http-server.test.js test/server/cmd-explore.test.js test/server/cmd-dataflow-export.test.js test/server/static-assets.test.js test/server/dataflow-scenario-cli.test.js test/server/dataflow-impact-cli.test.js",
82
- "test:haskell": "node --test test/haskell/haskell-injection.test.js test/haskell/haskell-ir.test.js test/haskell/haskell-manifests.test.js test/haskell/haskell-parser.test.js test/haskell/haskell-remediation.test.js test/haskell/haskell-resolved-graph.test.js test/haskell/haskell-sca.test.js test/haskell/haskell-advisory-feed.test.js test/haskell/haskell-security-rules.test.js test/haskell/haskell-taint.test.js",
83
- "test:nix": "node --test test/nix/nix-build-trust.test.js test/nix/nix-eval-isolation.test.js test/nix/nix-input-inventory.test.js test/nix/nix-parser-ir.test.js test/nix/nix-remediation.test.js test/nix/nix-resolved-closure.test.js test/nix/nix-sca-patches.test.js test/nix/nix-haskell-closure-names.test.js test/nix/nix-script-taint.test.js test/nix/nix-secrets-store.test.js test/nix/nixos-hardening.test.js test/nix/nixos-module-resolution.test.js",
82
+ "test:haskell": "node --test test/haskell/haskell-injection.test.js test/haskell/haskell-ir.test.js test/haskell/haskell-manifests.test.js test/haskell/haskell-parser.test.js test/haskell/haskell-remediation.test.js test/haskell/haskell-resolved-graph.test.js test/haskell/haskell-sca.test.js test/haskell/haskell-sca-severity.test.js test/haskell/haskell-web-guards.test.js test/haskell/haskell-advisory-feed.test.js test/haskell/haskell-security-rules.test.js test/haskell/haskell-taint.test.js test/haskell/haskell-boundaries.test.js test/haskell/haskell-live-feed-scan.test.js",
83
+ "test:nix": "node --test test/nix/nix-build-trust.test.js test/nix/nix-eval-isolation.test.js test/nix/nix-input-inventory.test.js test/nix/nix-parser-ir.test.js test/nix/nix-remediation.test.js test/nix/nix-resolved-closure.test.js test/nix/nix-sca-patches.test.js test/nix/nix-sca-severity.test.js test/nix/nix-haskell-closure-names.test.js test/nix/nix-script-taint.test.js test/nix/nix-secrets-store.test.js test/nix/nixos-hardening.test.js test/nix/nixos-module-resolution.test.js test/nix/nix-fix-multifile.test.js test/nix/nixos-evaluator-forms.test.js test/nix/nix-advisory-feed.test.js",
84
84
  "test:language": "node --test test/language/capability-ledger.test.js test/language/language-accuracy-regression.test.js test/language/language-aibom.test.js test/language/language-assurance.test.js test/language/language-bom.test.js test/language/language-compliance.test.js test/language/language-contracts.test.js test/language/language-corpora-integrity.test.js test/language/language-discovery.test.js test/language/language-doc-coverage.test.js test/language/language-evidence-posture.test.js test/language/language-explorer.test.js test/language/language-gap-fixes.test.js test/language/language-gap-fixes-2.test.js test/language/language-governance.test.js test/language/language-integrations.test.js test/language/language-llm-agent.test.js test/language/language-local-ai.test.js test/language/language-polyglot-bridges.test.js test/language/language-privacy-lineage.test.js test/language/language-proof-remediation.test.js test/language/language-report-formats.test.js test/language/language-secrets.test.js test/language/language-trust-boundary.test.js",
85
85
  "test:language-stress": "node --test --test-concurrency=1 test/language/language-stress-offline.test.js",
86
86
  "test:language-tools": "node --test test/haskell/haskell-web-auth.test.js test/haskell/haskell-support-gate.test.js",
87
87
  "bench:language-support:check": "node ../bench/language-support/check.mjs",
88
+ "bench:live-feed": "node ../bench/live-feed/run.mjs",
88
89
  "bench:language-support:perf": "node ../bench/language-support/perf.mjs",
89
- "test:lifecycle": "node --test test/pre-bash-guard.test.js test/lsp-protocol-smoke.test.js test/ide-surfaces.test.js test/detector-liveness.test.js test/bench-watchdog.test.js test/ci-parity.test.js test/dead-code.test.js test/no-dead-modules.test.js test/no-orphan-scripts.test.js test/no-stray-state.test.js test/tree-integrity.test.js test/stop-hook.test.js test/plugin-self-check.test.js test/skills-registry.test.js test/bodyguard.test.js test/cache-invalidator-guard.test.js test/dispatch-pre-tool.test.js test/dispatch-user-prompt.test.js test/session-start-model-capture.test.js test/sandbox.test.js test/sandbox-escape.test.js test/ci-templates.test.js test/agent-mcp-tools.test.js test/triage-command.test.js test/no-orphan-tests.test.js test/supply-command.test.js test/posture-command.test.js test/labs-command.test.js test/compliance-command.test.js test/check-doc-drift.test.js test/dist-chunks-tracked.test.js && node ../scripts/lint-command-descriptions.mjs",
90
+ "test:lifecycle": "node --test test/ci-hang-protection.test.js test/pre-bash-guard.test.js test/lsp-protocol-smoke.test.js test/ide-surfaces.test.js test/detector-liveness.test.js test/bench-watchdog.test.js test/ci-parity.test.js test/dead-code.test.js test/no-dead-modules.test.js test/no-orphan-scripts.test.js test/no-stray-state.test.js test/tree-integrity.test.js test/stop-hook.test.js test/plugin-self-check.test.js test/skills-registry.test.js test/bodyguard.test.js test/cache-invalidator-guard.test.js test/dispatch-pre-tool.test.js test/dispatch-user-prompt.test.js test/session-start-model-capture.test.js test/sandbox.test.js test/sandbox-escape.test.js test/ci-templates.test.js test/agent-mcp-tools.test.js test/triage-command.test.js test/no-orphan-tests.test.js test/supply-command.test.js test/posture-command.test.js test/labs-command.test.js test/compliance-command.test.js test/check-doc-drift.test.js test/dist-chunks-tracked.test.js && node ../scripts/lint-command-descriptions.mjs",
90
91
  "test:eval": "node --test test/independent-eval.test.js test/realworld-recall.test.js",
91
92
  "test:discovery": "node --test test/discovery-partition.test.js test/discovery-lenses.test.js test/discovery-hunter.test.js test/discovery-confirm.test.js test/discovery-disprove.test.js test/discovery-judge.test.js test/discovery-run.test.js test/discovery-wiring.test.js test/discovery-llm-invoke.test.js test/local-only-mode.test.js",
92
93
  "eval:independent": "node ../bench/independent-eval/runner.mjs",
@@ -186,6 +187,7 @@
186
187
  "release:check": "node ../scripts/release-check.mjs",
187
188
  "release:check:fast": "node ../scripts/release-check.mjs --fast",
188
189
  "gate:prepush": "node ../scripts/pre-push-gate.mjs",
190
+ "gate:trace": "node ../scripts/gate-trace-reads.mjs",
189
191
  "gate:prepush:install": "node ../scripts/pre-push-gate.mjs --install-hook",
190
192
  "bench:independent": "node ../bench/independent/runner.mjs",
191
193
  "bench:independent:fetch": "node ../bench/independent/fetch.mjs",
package/src/engine.js CHANGED
@@ -8436,6 +8436,9 @@ function genHAR(routes,sources){const baseUrl="http://localhost:3000";const entr
8436
8436
 
8437
8437
  async function queryRegistries(components){
8438
8438
  const infoMap=new Map();
8439
+ // Registry metadata (latest version, deprecation, age) is optional enrichment. Offline mode (--no-network, --deterministic, AGENTIC_SECURITY_OFFLINE=1)
8440
+ // must not touch the network for it: found by the live Hackage feed bench, where an "offline" scan still fetched registry.npmjs.org/jquery.
8441
+ if(process.env.AGENTIC_SECURITY_OFFLINE==='1')return infoMap;
8439
8442
  const npmNames=[...new Set(components.filter(c=>c.ecosystem==='npm').map(c=>c.name))];
8440
8443
  const pypiNames=[...new Set(components.filter(c=>c.ecosystem==='pypi').map(c=>c.name))];
8441
8444
  const CHUNK=8;
@@ -9229,6 +9232,7 @@ function _deterministicFileTimings(timings) {
9229
9232
  // X-010: Hackage and Nix components, dependency edges and target provenance. Kept beside (not inside) the ordinary
9230
9233
  // component list so the existing consumers are untouched; the BOM emitters and the SBOM diff read it explicitly.
9231
9234
  try{if(process.env.AGENTIC_SECURITY_HACKAGE_ADVISORIES_LIVE==='1'){const{prefetchHackageFeed}=await import('./language/haskell-supply.js');const{resolvedHackageComponents,nixHaskellPackageNames}=await import('./language/resolved-pass.js');let _rc=[],_nx=[];try{_rc=resolvedHackageComponents(allFileContents).components||[];}catch(_){}try{_nx=nixHaskellPackageNames(allFileContents);}catch(_){}await prefetchHackageFeed(allFileContents,{resolvedComponents:_rc,extraNames:_nx});}}catch(_){/* the live feed is optional: a failure leaves the scan on whatever snapshot exists */}
9235
+ try{if(process.env.AGENTIC_SECURITY_NIX_ADVISORIES_LIVE==='1'){const{prefetchNixAdvisoryFeed}=await import('./language/resolved-pass.js');await prefetchNixAdvisoryFeed(allFileContents);}}catch(_){/* the live feed is optional: a failure leaves the scan on whatever snapshot exists */}
9232
9236
  let _languageBom=null;try{if(Object.keys(allFileContents).some(f=>/\.(?:l?hs|nix)$|\.cabal$|(?:^|\/)(?:cabal\.project(?:\.freeze)?|package\.yaml|stack\.yaml(?:\.lock)?|flake\.lock)$/i.test(f))){const{languageBom}=await import('./language/bom.js');const{resolvedHaskellGraph,nixClosureOf}=await import('./language/resolved-pass.js');const _rg=resolvedHaskellGraph(allFileContents);const _nc=nixClosureOf(allFileContents);_languageBom=languageBom(allFileContents,{...(_rg?{resolved:_rg.graph}:{}),...(_nc&&!_nc.refused.length?{closure:_nc.closure}:{})});}}catch(_){_languageBom=null;}
9233
9237
  aF.push(...(runDetector(_detectorErrors,'<project>','scanDbTaintCrossFile',()=>scanDbTaintCrossFile(fc))||[]));
9234
9238
  aF.push(...(runDetector(_detectorErrors,'<project>','scanCsharpCrossFile',()=>scanCsharpCrossFile(fc))||[]));
@@ -11031,6 +11035,7 @@ function _deterministicFileTimings(timings) {
11031
11035
  if (Object.keys(allFileContents).some(f => /\.(?:l?hs|hs-boot|hsc|nix)$|\.cabal$|(?:^|\/)(?:cabal\.project|package\.yaml|stack\.yaml)/i.test(f))) {
11032
11036
  const { assessLanguageAssurance } = await import('./language/assurance.js');
11033
11037
  const { configuredAdvisoryDb } = await import('./language/haskell-supply.js');
11038
+ const { liveFeedOptionalState } = await import('./language/haskell-advisory-feed.js');
11034
11039
  const env = process.env;
11035
11040
  const { resolvedHackageComponents, analyzeNixClosure, runSelectedNixEval } = await import('./language/resolved-pass.js');
11036
11041
  let _languageSupplyGaps = [];
@@ -11044,7 +11049,7 @@ function _deterministicFileTimings(timings) {
11044
11049
  sizeSkipped,
11045
11050
  supplyGaps: _languageSupplyGaps,
11046
11051
  licenseUnavailable: ((_languageBom && _languageBom.components) || []).filter((c) => c && !c.license && !(Array.isArray(c.licenses) && c.licenses.length)).length,
11047
- optional: { 'nix-eval': { selected: env.AGENTIC_SECURITY_NIX_EVAL === '1', result: _nixEvalResult }, 'cabal-plan': { selected: false }, 'hackage-live': { selected: false } },
11052
+ optional: { 'nix-eval': { selected: env.AGENTIC_SECURITY_NIX_EVAL === '1', result: _nixEvalResult }, 'cabal-plan': { selected: false }, 'hackage-live': liveFeedOptionalState(env)},
11048
11053
  })).languageCoverage;
11049
11054
  }
11050
11055
  } catch (e) { _languageCoverage = { totals: null, byKind: null, conditions: [`language assurance could not be computed: ${String((e && e.message) || e)}`] }; }
@@ -31,6 +31,31 @@ or a capability.** Product-facing guides: `docs/guides/haskell.md`, `docs/guides
31
31
  queried (`covered`); a package it did not cover is `feed-incomplete` and a stale one `feed-stale`, in BOTH matchers
32
32
  (`evaluateComponents` and `nix-sca.js`). Any new consumer of an `AdvisoryDb` must check `db.coverage(name)` before saying "no advisory".
33
33
  The snapshot lives in the operator configuration directory, never the project (`trusted-inputs.js`).
34
+ - **Auth guards must stop the handler.** In `haskell-web.js` a Scotty `status 40x` only sets the code, so both the named-guard path (`guardSummary`) and the inline path (`handlerFacts`) need a halting call (`finish`, `raise`, `raiseStatus`, `redirect`; `finish` is an IR identifier with no line of its own). WAI, Servant and Yesod inline guards order the credential READ against the first sensitive call, because a tail-position rejection reports the enclosing line (a disclosed gap, pinned in `test/haskell/haskell-web-guards.test.js`). A WAI `responseLBS` rejection counts only when handed back (`respond`/`return`).
35
+ - **Severity is the advisory's own** (`cvss.js`): CVSS v3.x base score, else a named `database_specific.severity`, else `medium` with the basis stated. Never guess from an unparseable or v4 vector.
36
+ - **Option fixes work on the effective configuration, across files.** `nix-fix.js` edits EVERY contributing definition of an option
37
+ (all winners at the winning priority, plus conditional branches holding the weak literal), never one line: editing one of two
38
+ equal-priority winners turns a finding into a module-system conflict that a rescan alone reports as "fixed". Verification is
39
+ the `effective` gate (`makeEffectiveCheck`: resolve the option again over the patched tree). A plan may carry `edits` (several
40
+ files); `runFixLifecycle` applies them through `writeManyWithBackup` (checks everything and backs up everything before the
41
+ first write, rolls back on a failed write) and records one history entry per file sharing `languageGroupId`; `undoFix` and
42
+ `posture/fix-history.js` `undoLast`/`revertEntryById` restore a group as a unit. A winner outside the root or not a single
43
+ literal is refused with an `override` suggestion (`overrideSuggestion`), never an automatic edit. Any new consumer of a fix
44
+ plan (LSP, MCP, CLI) must honour `plan.edits`: `apply_fix` writes one file, so a multi-file fix is not offered there.
45
+ - **Boundaries are narrowed only by sound, closed rules.** `haskell-cpp.js` decides a CPP conditional only from the file, a project-stated
46
+ compiler (`with-compiler` pin; `tested-with` only when every item is an exact version and all agree, recorded as an assumption) or the hull
47
+ of cabal `build-depends` bounds; an unknown macro is undecided, never 0, and an undecided conditional keeps every branch. `haskell-boundaries.js`
48
+ accepts a TH declaration splice only when the generator is IMPORTED from its upstream module (not shadowed/hidden) and every token is a name
49
+ quote, literal, documented setting or restricted option update; a quasi-quote only when it is a raw-string or non-interpolating quoter. The
50
+ accepted kinds (`th-safe-splice`, `quasiquote-inert`) are disclosed, never in the adapter's opaque set, and `preprocessForSyntax` feeds the same
51
+ decisions to the semantic IR. Widening any allowlist needs a both-directions test in `test/haskell/haskell-boundaries.test.js`.
52
+ - **The Nix feed is the same contract over a different source.** `nix-advisory-feed.js` uses the NVD CVE API 2.0 keyed by CPE
53
+ `vendor:product` (OSV has no nixpkgs ecosystem), behind `AGENTIC_SECURITY_NIX_ADVISORIES_LIVE=1`. Coverage is per CPE identity
54
+ (`NixAdvisoryData.cpeCoverage`), and `nix-sca.js` reports an uncovered identity, or a component with no CPE, as `unknown` with
55
+ `feedCoverage: 'incomplete'`, never `not-affected`. A snapshot with no `covered` table (pinned, hand-written) is matched exactly as
56
+ before. The prefetch (`prefetchNixAdvisoryFeed`, `resolved-pass.js`) asks only for identities `closureIdentities` says the matcher
57
+ will use; metadata comes only from the operator (`AGENTIC_SECURITY_NIX_META`). NVD is rate limited (5/30 s keyless): never raise
58
+ the request caps or lower the spacing in `nix-advisory-feed.js` without re-reading the NVD limits.
34
59
  - **Pragma** (`pragma.js`): comment-aware, line-scoped, exact rule-id match, logged. A line-less finding cannot be suppressed.
35
60
 
36
61
  ## Module map
@@ -38,13 +63,34 @@ or a capability.** Product-facing guides: `docs/guides/haskell.md`, `docs/guides
38
63
  | Group | Files |
39
64
  |---|---|
40
65
  | Shared | `discovery.js` (sources, manifests, explicit exports, exclusions, import graph, invalidation digests), `contracts.js`, `assurance.js` (scan-health inputs, conditions, limitations), `engine-pass.js` (language supply chain wired into the engine), `resolved-pass.js` (plan / Stack export / Nix closure / opt-in evaluation reachable from the CLI), `context.js` (project context for partial scans, fix previews and dependency upgrades, model prompt extras), `state-artifacts.js`, `pragma.js`, `secrets.js`, `bom.js`, `bom-validate.js`, `aibom.js`, `compliance-map.js`, `bridges.js`, `witness.js`, `fix-lifecycle.js` (the one fix lifecycle: path, syntax, rescan, compile, backup, history, undo) |
41
- | Haskell | `haskell-parser.js`, `haskell-grammar.js`, `haskell-adapter.js`, `haskell-ir.js`, `haskell-models.js` (the single registry of sources, sinks, sanitizers; `dataflow/catalog-haskell.js` is generated from it), `haskell-guards.js`, `haskell-security-rules.js`, `haskell-web.js`, `haskell-findings.js`, `haskell-syntax.js`, `haskell-sweep.js`, `haskell-disclosure.js`, `haskell-llm.js`, `haskell-manifests.js`, `haskell-resolved-graph.js`, `haskell-sca.js`, `haskell-advisory-feed.js` (the opt-in live Hackage feed: OSV fetch, per-package coverage, operator-only snapshot), `haskell-supply.js`, `haskell-fix.js` |
66
+ | Haskell | `haskell-parser.js`, `haskell-grammar.js`, `haskell-adapter.js`, `haskell-ir.js`, `haskell-models.js` (the single registry of sources, sinks, sanitizers; `dataflow/catalog-haskell.js` is generated from it), `haskell-guards.js`, `haskell-security-rules.js`, `haskell-web.js`, `haskell-findings.js`, `haskell-syntax.js`, `haskell-sweep.js`, `haskell-disclosure.js`, `haskell-llm.js`, `haskell-manifests.js`, `haskell-resolved-graph.js`, `haskell-sca.js`, `cvss.js` (CVSS v3.x base-score calculator and advisory-severity selection, shared by the Haskell and Nix matchers; v4 is not scored and says so), `haskell-advisory-feed.js` (the opt-in live Hackage feed: OSV fetch, per-package coverage, operator-only snapshot), `haskell-supply.js`, `haskell-fix.js` |
67
+ | Haskell | `haskell-parser.js`, `haskell-cpp.js` (three-valued CPP conditional evaluation + project context from cabal), `haskell-boundaries.js` (closed set of safe TH declaration splices and inert quasi-quotes), `haskell-grammar.js`, `haskell-adapter.js`, `haskell-ir.js`, `haskell-models.js` (the single registry of sources, sinks, sanitizers; `dataflow/catalog-haskell.js` is generated from it), `haskell-guards.js`, `haskell-security-rules.js`, `haskell-web.js`, `haskell-findings.js`, `haskell-syntax.js`, `haskell-sweep.js`, `haskell-disclosure.js`, `haskell-llm.js`, `haskell-manifests.js`, `haskell-resolved-graph.js`, `haskell-sca.js`, `haskell-advisory-feed.js` (the opt-in live Hackage feed: OSV fetch, per-package coverage, operator-only snapshot), `haskell-supply.js`, `haskell-fix.js` |
42
68
  | Nix | `nix-parser.js`, `nix-grammar.js`, `nix-ir.js`, `nix-adapter.js`, `nixos-module-resolver.js`, `nixos-option-catalog.js`, `nixos-hardening.js`, `nix-build-trust.js`, `nix-secrets.js`, `nix-script-taint.js`, `nix-privacy.js`, `nix-agents.js`, `nix-inventory.js`, `nix-closure.js`, `nix-sca.js`, `nix-eval-isolation.js`, `nix-fix.js` |
69
+ | Haskell | `haskell-parser.js`, `haskell-grammar.js`, `haskell-adapter.js`, `haskell-ir.js`, `haskell-models.js` (the single registry of sources, sinks, sanitizers; `dataflow/catalog-haskell.js` is generated from it), `haskell-guards.js`, `haskell-security-rules.js`, `haskell-web.js`, `haskell-findings.js`, `haskell-syntax.js`, `haskell-sweep.js`, `haskell-disclosure.js`, `haskell-llm.js`, `haskell-manifests.js`, `haskell-resolved-graph.js`, `haskell-sca.js`, `haskell-advisory-feed.js` (the opt-in live Hackage feed: OSV fetch, per-package coverage, operator-only snapshot), `haskell-supply.js`, `haskell-fix.js` |
70
+ | Nix | `nix-parser.js`, `nix-grammar.js`, `nix-ir.js`, `nix-adapter.js`, `nixos-module-resolver.js`, `nixos-eval-forms.js`, `nixos-option-catalog.js`, `nixos-hardening.js`, `nix-build-trust.js`, `nix-secrets.js`, `nix-script-taint.js`, `nix-privacy.js`, `nix-agents.js`, `nix-inventory.js`, `nix-closure.js`, `nix-sca.js`, `nix-eval-isolation.js`, `nix-fix.js` |
71
+ | Haskell | `haskell-parser.js`, `haskell-grammar.js`, `haskell-adapter.js`, `haskell-ir.js`, `haskell-models.js` (the single registry of sources, sinks, sanitizers; `dataflow/catalog-haskell.js` is generated from it), `haskell-guards.js`, `haskell-security-rules.js`, `haskell-web.js`, `haskell-findings.js`, `haskell-syntax.js`, `haskell-sweep.js`, `haskell-disclosure.js`, `haskell-llm.js`, `haskell-manifests.js`, `haskell-resolved-graph.js`, `haskell-sca.js`, `haskell-advisory-feed.js` (the opt-in live Hackage feed: OSV fetch, per-package coverage, operator-only snapshot), `haskell-supply.js`, `haskell-fix.js` |
72
+ | Nix | `nix-parser.js`, `nix-grammar.js`, `nix-ir.js`, `nix-adapter.js`, `nixos-module-resolver.js`, `nixos-option-catalog.js`, `nixos-hardening.js`, `nix-build-trust.js`, `nix-secrets.js`, `nix-script-taint.js`, `nix-privacy.js`, `nix-agents.js`, `nix-inventory.js`, `nix-closure.js`, `nix-sca.js`, `nix-advisory-feed.js`, `nix-eval-isolation.js`, `nix-fix.js` |
43
73
  | Measurement | `accuracy.js` (family-scoped scoring, per-layer), `support-registry.js` (status rules, promotion refusals, frozen-hash demotion) |
44
74
 
45
75
  Related, outside this directory: `dataflow/catalog-haskell.js`, `lineage/haskell-view.js`, `lineage/nix-view.js`,
46
76
  `ir/` Haskell/Nix adapters, `report/` format handling, `egress/redact.js`, `llm-validator/`, `discovery/` (hunt partitions Nix).
47
77
 
78
+ ## NixOS option evaluator (soundness rules)
79
+
80
+ `nixos-module-resolver.js` `evalExpr` is a soundness-first subset; `nixos-eval-forms.js` holds its value classes and the
81
+ **allow-list** of library functions (there is no deny-list to forget an entry in). Rules to keep when extending it:
82
+
83
+ - **Any unknown part makes the whole value unknown.** Never default, never pick a branch you cannot decide.
84
+ - **Scope is a correctness issue.** Lexical bindings (let, lambda) beat every `with`; innermost `with` beats outer; the module's
85
+ own `let`s are IR bindings (`ir.bindings`, `scope: 'let'`) and are used only when unique in the file, not also a function
86
+ parameter, not in a `rec` set, and when their `letSpan` encloses `env.pos` (the absolute span of the text being evaluated).
87
+ `evalSpan` on a definition (set by `nix-ir.js` when a leaf sits under let/with/assert only) makes the resolver evaluate the
88
+ whole wrapper. A priority/condition/merge combinator clears it, because those are the collector's job.
89
+ - **Final values must be plain data** (`sealed`/`isPlain`): a closure, partial application or namespace is `unknown`. A
90
+ priority wrapper inside a value is unknown (priority is not recoverable); at the top of a definition the collector reads it.
91
+ - **Bounded**: `maxEvaluations`, `maxExprDepth`, `maxValueSize` are caps that surface as `truncated`, not as a guess.
92
+ - Assumption stated, not proven: `lib` is nixpkgs' `lib` whenever the module declares a `lib` parameter and the file does not rebind it.
93
+
48
94
  ## Tests and benches
49
95
 
50
96
  | Scope | Command | Notes |
@@ -55,6 +101,7 @@ Related, outside this directory: `dataflow/catalog-haskell.js`, `lineage/haskell
55
101
  | Stress | `npm run test:language-stress` | offline scale and a peak-memory ceiling; excluded from `npm test` (it must own the machine) |
56
102
  | Needs a compiler | `npm run test:language-tools` | HS-006.AC01 and the support gate; excluded from `npm test`; **fails** without `ghc` |
57
103
  | Support registry | `npm run bench:language-support:check` | recomputes the registry from the STORED measurement; fails on any difference |
104
+ | Live feed | `npm run bench:live-feed -- <fetch\|plan\|scan\|merge\|sample\|report>` | `bench/live-feed/`: the opt-in Hackage feed against real projects and real OSV. Needs the network and is NOT in `npm test` or any gate. `RESULTS.md` is a dated measurement; labels in it are model-assessed |
58
105
 
59
106
  The support registry is measured **once per promotion** on a frozen holdout (`bench/language-support/promote.mjs`); never tune
60
107
  against the holdout, and any further holdout run counts as repeated use. A change to the engine, the corpus or a label changes the
@@ -19,6 +19,7 @@
19
19
 
20
20
  import { runLanguageAnalysis, reconcileLanguageLedger, languageHealth } from './contracts.js';
21
21
  import { createHaskellAdapter } from './haskell-adapter.js';
22
+ import { deriveCppContext } from './haskell-cpp.js';
22
23
  import { createNixAdapter } from './nix-adapter.js';
23
24
  import { analyzeHaskellManifests } from './haskell-manifests.js';
24
25
  import { packageOfModule } from './haskell-models.js';
@@ -71,14 +72,21 @@ export async function assessLanguageAssurance(input = {}) {
71
72
  // 1. parse adapters over every language source
72
73
  // Boundaries that do not make a file unresolved but that a reader must be told about: CPP (every branch was parsed, the
73
74
  // chosen one is not known) and FFI (the foreign code is not analysed).
75
+ const DISCLOSED_KINDS = new Set(['cpp', 'ffi', 'th-safe-splice', 'quasiquote-inert']);
74
76
  const disclosed = new Map(); // boundary kind -> {count, files:Set}
75
77
  const projectModules = new Set(); const importedModules = new Map(); // module -> files
76
78
  const onParse = (file, parse) => {
77
79
  if (parse && parse.module && parse.module.name) projectModules.add(parse.module.name);
78
80
  for (const i of (parse && parse.imports) || []) if (i && i.module) { const e = importedModules.get(i.module) || new Set(); e.add(file); importedModules.set(i.module, e); }
79
- for (const b of (parse && parse.boundaries) || []) if (b.kind === 'cpp' || b.kind === 'ffi') { const e = disclosed.get(b.kind) || { count: 0, files: new Set() }; e.count += b.count || 1; e.files.add(file); disclosed.set(b.kind, e); }
81
+ for (const b of (parse && parse.boundaries) || []) {
82
+ if (!DISCLOSED_KINDS.has(b.kind)) continue;
83
+ const e = disclosed.get(b.kind) || { count: 0, files: new Set(), decided: 0, undecided: 0, assumptions: new Set() };
84
+ e.count += b.count || 1; e.files.add(file);
85
+ if (b.kind === 'cpp') { e.decided += b.decided || 0; e.undecided += b.undecided || 0; for (const a of b.assumptions || []) e.assumptions.add(a); }
86
+ disclosed.set(b.kind, e);
87
+ }
80
88
  };
81
- const adapters = input.adapters || [createHaskellAdapter({ onParse }), createNixAdapter()];
89
+ const adapters = input.adapters || [createHaskellAdapter({ onParse, cpp: deriveCppContext(files) }), createNixAdapter()];
82
90
  const run = await runLanguageAnalysis({ files, adapters, timeoutMs: input.timeoutMs || 0 });
83
91
  const reconcile = reconcileLanguageLedger(run.ledger, files);
84
92
  const base = languageHealth({ ledger: run.ledger, outcomes: run.outcomes });
@@ -97,8 +105,17 @@ export async function assessLanguageAssurance(input = {}) {
97
105
  for (const o of opaque) { const k = String(o.detail).replace(/^opaque-boundary:\s*/, ''); kinds[k] = (kinds[k] || 0) + 1; }
98
106
  for (const [k, n] of Object.entries(kinds).sort()) limitations.push({ kind: 'opaque-boundary', boundary: k, count: n, note: 'a documented limit of static analysis: this code is not analysed, and is not reported clean' });
99
107
  }
100
- const DISCLOSED_NOTE = { cpp: 'preprocessor conditionals: every branch was parsed, but which branch is compiled is not known', ffi: 'foreign declarations: the foreign code is not analysed and its effects are not modelled' };
101
- for (const [k, e] of [...disclosed.entries()].sort()) limitations.push({ kind: 'opaque-boundary', boundary: k, count: e.count, files: [...e.files].sort().slice(0, 20), note: `a documented limit of static analysis (${DISCLOSED_NOTE[k]})` });
108
+ const DISCLOSED_NOTE = {
109
+ cpp: 'preprocessor conditionals: a conditional the file or the project decides keeps only its live branch, every other one keeps all of its branches, and which branch is compiled is then not known',
110
+ ffi: 'foreign declarations: the foreign code is not analysed and its effects are not modelled',
111
+ 'th-safe-splice': 'declaration-level Template Haskell splices of a known generator applied to names and literals only: assumed to be the upstream generator, nothing is run and the generated declarations are not modelled',
112
+ 'quasiquote-inert': 'quasi-quotes of a known raw-string or non-interpolating quoter: read as a string literal on the assumption that the quoter is the upstream one',
113
+ };
114
+ for (const [k, e] of [...disclosed.entries()].sort()) {
115
+ const extra = k === 'cpp' ? { decidedConditionals: e.decided, undecidedConditionals: e.undecided, ...(e.assumptions.size ? { assumptions: [...e.assumptions].sort() } : {}) } : {};
116
+ const assumed = k === 'cpp' && e.assumptions.size ? `; some conditionals were decided on a stated project assumption (${[...e.assumptions].sort().join(', ')}), so a branch dead under it was not analysed` : '';
117
+ limitations.push({ kind: 'opaque-boundary', boundary: k, count: e.count, files: [...e.files].sort().slice(0, 20), ...extra, note: `a documented limit of static analysis (${DISCLOSED_NOTE[k]}${assumed})` });
118
+ }
102
119
  // Imported modules this scan has no model for: their functions are ordinary, opaque calls (never a source, sink or
103
120
  // sanitizer), so a taint flow through them is widened and disclosed on the finding. Listed so a reader knows how much of the
104
121
  // import surface the models cover. A limitation, not a condition: most real projects import unmodelled modules.
@@ -98,6 +98,8 @@ export async function languageFixPreview(finding, files, { apply = false, root =
98
98
  status: res.status, applied: res.applied === true, backup: res.backup || null,
99
99
  label: plan.label || null, tier: res.tier || plan.tier || null,
100
100
  file: plan.file, before: plan.before, after: plan.after,
101
+ // A fix that spans several files lists every one of them; `file`/`before`/`after` stay the first for single-file callers.
102
+ edits: Array.isArray(plan.edits) && plan.edits.length > 1 ? plan.edits.map((e) => ({ file: e.file, before: e.before, after: e.after })) : null,
101
103
  diff: res.preview || hsDiff(plan.file, plan.before, plan.after),
102
104
  gates: res.gates || null, reason: (res.status === 'verified' || res.status === 'applied') ? null : (res.reason || null),
103
105
  explanation: plan.explanation || null, consequences: res.consequences || plan.consequences || [],
@@ -0,0 +1,91 @@
1
+ // CVSS v3.x base-score calculator and advisory-severity selection for the Haskell and Nix SCA matchers.
2
+ // Pure: no I/O. Never invents a rating: a vector that cannot be parsed, or a CVSS version this module does not compute
3
+ // (v4 needs lookup tables that are not shipped), yields no score and says why.
4
+
5
+ const W = {
6
+ AV: { N: 0.85, A: 0.62, L: 0.55, P: 0.2 },
7
+ AC: { L: 0.77, H: 0.44 },
8
+ UI: { N: 0.85, R: 0.62 },
9
+ CIA: { H: 0.56, L: 0.22, N: 0 },
10
+ };
11
+ const PR = { U: { N: 0.85, L: 0.62, H: 0.27 }, C: { N: 0.85, L: 0.68, H: 0.5 } };
12
+ const REQUIRED = ['AV', 'AC', 'PR', 'UI', 'S', 'C', 'I', 'A'];
13
+
14
+ // CVSS 3.1 Roundup (spec appendix A, integer arithmetic so float error cannot move a score across a boundary).
15
+ function roundUp31(x) {
16
+ const i = Math.round(x * 100000);
17
+ return i % 10000 === 0 ? i / 100000 : (Math.floor(i / 10000) + 1) / 10;
18
+ }
19
+ const roundUp30 = (x) => Math.ceil(x * 10) / 10;
20
+
21
+ /** @returns {{ok:true, version:string, score:number, vector:string}|{ok:false, reason:string}} */
22
+ export function cvssV3BaseScore(vector) {
23
+ if (typeof vector !== 'string') return { ok: false, reason: 'the CVSS vector is not a string' };
24
+ const parts = vector.trim().split('/');
25
+ const m = /^CVSS:(3\.[01])$/.exec(parts[0] || '');
26
+ if (!m) return { ok: false, reason: /^CVSS:4/.test(parts[0] || '') ? 'CVSS v4 vectors are not scored by this tool' : 'the vector is not a CVSS v3.0/v3.1 vector' };
27
+ const version = m[1];
28
+ const metrics = {};
29
+ for (const p of parts.slice(1)) {
30
+ const kv = /^([A-Za-z]+):([A-Za-z])$/.exec(p);
31
+ if (!kv || kv[1] in metrics) return { ok: false, reason: `malformed or repeated metric "${p}"` };
32
+ metrics[kv[1]] = kv[2];
33
+ }
34
+ for (const k of REQUIRED) if (!(k in metrics)) return { ok: false, reason: `missing base metric ${k}` };
35
+ if (metrics.S !== 'C' && metrics.S !== 'U') return { ok: false, reason: `invalid scope value "${metrics.S}"` };
36
+ const changed = metrics.S === 'C';
37
+ const av = W.AV[metrics.AV], ac = W.AC[metrics.AC], ui = W.UI[metrics.UI];
38
+ const c = W.CIA[metrics.C], i = W.CIA[metrics.I], a = W.CIA[metrics.A];
39
+ const pr = PR[metrics.S][metrics.PR];
40
+ if ([av, ac, ui, c, i, a, pr].some((x) => x === undefined)) return { ok: false, reason: 'an unknown value in a base metric' };
41
+ const iss = 1 - (1 - c) * (1 - i) * (1 - a);
42
+ const impact = changed ? 7.52 * (iss - 0.029) - 3.25 * Math.pow(iss - 0.02, 15) : 6.42 * iss;
43
+ const expl = 8.22 * av * ac * pr * ui;
44
+ const up = version === '3.1' ? roundUp31 : roundUp30;
45
+ const score = impact <= 0 ? 0 : changed ? up(Math.min(1.08 * (impact + expl), 10)) : up(Math.min(impact + expl, 10));
46
+ return { ok: true, version, score, vector: vector.trim() };
47
+ }
48
+
49
+ export function levelFromScore(score) {
50
+ if (!(score > 0)) return null;
51
+ return score >= 9 ? 'critical' : score >= 7 ? 'high' : score >= 4 ? 'medium' : 'low';
52
+ }
53
+
54
+ const NAMED = { critical: 'critical', high: 'high', moderate: 'medium', medium: 'medium', low: 'low' };
55
+ export const normalizeSeverityName = (s) => (typeof s === 'string' ? NAMED[s.trim().toLowerCase()] || null : null);
56
+
57
+ export const NO_RATING_BASIS = 'the advisory carries no severity rating';
58
+
59
+ /**
60
+ * Pick the advisory's own severity from an OSV record: CVSS vectors at record level and on each affected entry, then
61
+ * database_specific.severity (record, then affected). The highest parseable CVSS score wins; a named severity is used
62
+ * only when no vector could be scored. Returns {level:null, basis} when nothing usable exists.
63
+ */
64
+ export function advisorySeverity(rec) {
65
+ const vectors = [];
66
+ const named = [];
67
+ const take = (sevArr, dbs) => {
68
+ for (const s of Array.isArray(sevArr) ? sevArr : []) if (s && typeof s.score === 'string') vectors.push(s.score);
69
+ if (dbs && typeof dbs.severity === 'string') named.push(dbs.severity);
70
+ };
71
+ if (rec && typeof rec === 'object') {
72
+ take(rec.severity, rec.database_specific);
73
+ for (const a of Array.isArray(rec.affected) ? rec.affected : []) if (a && typeof a === 'object') take(a.severity, a.database_specific);
74
+ }
75
+ let best = null; const why = [];
76
+ for (const v of vectors) {
77
+ const r = cvssV3BaseScore(v);
78
+ if (r.ok) { if (!best || r.score > best.score) best = r; } else why.push(r.reason);
79
+ }
80
+ if (best) {
81
+ const level = levelFromScore(best.score);
82
+ if (level) return { level, score: best.score, vector: best.vector, basis: `CVSS v${best.version} base score ${best.score.toFixed(1)} from the advisory` };
83
+ why.push('the CVSS base score is 0.0 (no impact)');
84
+ }
85
+ for (const n of named) {
86
+ const level = normalizeSeverityName(n);
87
+ if (level) return { level, score: null, vector: null, basis: `severity "${n}" stated by the advisory (database_specific)` };
88
+ why.push(`unrecognised severity "${String(n).slice(0, 40)}"`);
89
+ }
90
+ return { level: null, score: null, vector: null, basis: why.length ? `${NO_RATING_BASIS} that could be used (${[...new Set(why)].join('; ')})` : NO_RATING_BASIS };
91
+ }
@@ -10,8 +10,9 @@
10
10
  // Preview never writes. Apply writes a backup first and refuses a file that changed since planning.
11
11
  // Undo restores the backup byte for byte.
12
12
 
13
- import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
13
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs';
14
14
  import { join } from 'node:path';
15
+ import { randomBytes } from 'node:crypto';
15
16
  import { statePath } from '../posture/state-dir.js';
16
17
  import { maybeEncryptForWrite, maybeDecryptForRead } from '../posture/encryption-provider.js';
17
18
  import { recordExternalFix, markRevertedByBackup } from '../posture/fix-history.js';
@@ -30,31 +31,102 @@ export function unifiedDiff(file, a, b) {
30
31
  }
31
32
  const safeRel = (root, rel) => { const p = join(root, rel); if (!p.startsWith(join(root) + '/')) throw new Error(`path escapes the root: ${rel}`); return p; };
32
33
 
33
- export function writeWithBackup(root, rel, before, after) {
34
+ /** Save the pre-image of `rel` (customer source: it follows the encryption policy) and return its backup. Writes nothing to the target. */
35
+ function saveBackup(root, rel, before, after) {
34
36
  const target = safeRel(root, rel);
35
- const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
37
+ const id = `${Date.now().toString(36)}-${randomBytes(5).toString('hex')}`;
36
38
  const dir = statePath(root, 'fix-backups', id);
37
39
  const disk = existsSync(target) ? readFileSync(target, 'utf8') : null;
38
40
  if (disk !== before) throw new Error('the file changed on disk since the fix was planned; refusing to overwrite');
39
- // The pre-image is customer source: it follows the project's encryption policy for confidential state, and a policy
40
- // that REQUIRES encryption with no working provider refuses the fix before anything is written.
41
+ // A policy that REQUIRES encryption with no working provider refuses the fix before anything is written.
41
42
  const enc = maybeEncryptForWrite(root, 'fix-backups', before);
42
43
  if (!enc.ok) throw new Error(enc.reason);
43
44
  mkdirSync(dir, { recursive: true });
44
45
  writeFileSync(join(dir, 'original'), enc.content);
45
46
  writeFileSync(join(dir, 'meta.json'), JSON.stringify({ file: rel, appliedAt: new Date().toISOString(), sizeBefore: before.length, sizeAfter: after.length }));
47
+ return { id, dir, target };
48
+ }
49
+
50
+ export function writeWithBackup(root, rel, before, after) {
51
+ const { id, dir, target } = saveBackup(root, rel, before, after);
46
52
  writeFileSync(target, after);
47
53
  return { id, dir };
48
54
  }
49
55
 
50
- export function undoFix(root, backupId) {
51
- const _afterUndo = () => { try { markRevertedByBackup(root, backupId); } catch { /* history is best effort */ } };
56
+ /**
57
+ * Apply several file edits as ONE unit. Nothing is written until every file has been checked (inside the root, unchanged
58
+ * since planning) and every pre-image is backed up. If a write then fails, every file already written (and the one that
59
+ * failed, which may be truncated) is put back, and the error says whether that rollback was complete.
60
+ * @param {string} root
61
+ * @param {Array<{file:string,before:string,after:string}>} edits
62
+ * @param {{writeFile?:Function}} [hooks] test seam for the file writer
63
+ * @returns {{id:string, dir:string, members:Array<{id:string,dir:string,file:string}>}}
64
+ */
65
+ export function writeManyWithBackup(root, edits, hooks = {}) {
66
+ const write = hooks.writeFile || writeFileSync;
67
+ const seen = new Set();
68
+ for (const e of edits) { if (seen.has(e.file)) throw new Error(`the same file is edited twice in one fix: ${e.file}`); seen.add(e.file); safeRel(root, e.file); }
69
+ const members = [];
70
+ const cleanup = () => { for (const m of members) { try { rmSync(m.dir, { recursive: true, force: true }); } catch { /* best effort */ } } };
71
+ try { for (const e of edits) { const b = saveBackup(root, e.file, e.before, e.after); members.push({ id: b.id, dir: b.dir, file: e.file, target: b.target, before: e.before, after: e.after }); } }
72
+ catch (err) { cleanup(); throw new Error(`${err.message} (no file was written)`); }
73
+ const written = [];
74
+ for (const m of members) {
75
+ written.push(m);
76
+ try { write(m.target, m.after); } catch (err) {
77
+ const failed = [];
78
+ for (const w of written.slice().reverse()) { try { write(w.target, w.before); } catch { failed.push(w.file); } }
79
+ if (!failed.length) cleanup();
80
+ const e = new Error(failed.length
81
+ ? `write to ${m.file} failed (${err.message}); ROLLBACK INCOMPLETE, restore these from the backups under .agentic-security/fix-backups: ${failed.join(', ')}`
82
+ : `write to ${m.file} failed (${err.message}); every file in this fix was restored to its original content`);
83
+ e.rolledBack = failed.length === 0; e.rollbackFailed = failed; e.failedFile = m.file;
84
+ throw e;
85
+ }
86
+ }
87
+ const groupId = `g${Date.now().toString(36)}-${randomBytes(5).toString('hex')}`;
88
+ const dir = statePath(root, 'fix-backups', groupId);
89
+ mkdirSync(dir, { recursive: true });
90
+ writeFileSync(join(dir, 'meta.json'), JSON.stringify({ file: members[0].file, group: members.map((m) => ({ id: m.id, file: m.file })), appliedAt: new Date().toISOString() }));
91
+ return { id: groupId, dir, members: members.map((m) => ({ id: m.id, dir: m.dir, file: m.file })) };
92
+ }
93
+
94
+ /**
95
+ * Undo a fix. A multi-file fix is restored as a unit: every pre-image is read and every target resolved before the first
96
+ * write, and if a restore fails the files already restored are put back to the content they had, so a partial undo is
97
+ * never left behind.
98
+ */
99
+ export function undoFix(root, backupId, hooks = {}) {
100
+ const write = hooks.writeFile || writeFileSync;
52
101
  const dir = statePath(root, 'fix-backups', backupId);
53
102
  const meta = JSON.parse(readFileSync(join(dir, 'meta.json'), 'utf8'));
54
- const target = safeRel(root, meta.file);
55
- writeFileSync(target, maybeDecryptForRead(readFileSync(join(dir, 'original'), 'utf8')));
56
- _afterUndo();
57
- return { ok: true, file: meta.file };
103
+ if (!Array.isArray(meta.group)) {
104
+ const target = safeRel(root, meta.file);
105
+ writeFileSync(target, maybeDecryptForRead(readFileSync(join(dir, 'original'), 'utf8')));
106
+ try { markRevertedByBackup(root, backupId); } catch { /* history is best effort */ }
107
+ return { ok: true, file: meta.file, files: [meta.file] };
108
+ }
109
+ const items = meta.group.map((m) => {
110
+ const mdir = statePath(root, 'fix-backups', m.id);
111
+ const md = JSON.parse(readFileSync(join(mdir, 'meta.json'), 'utf8'));
112
+ return { file: md.file, target: safeRel(root, md.file), original: maybeDecryptForRead(readFileSync(join(mdir, 'original'), 'utf8')), id: m.id };
113
+ });
114
+ const current = items.map((it) => (existsSync(it.target) ? readFileSync(it.target, 'utf8') : null));
115
+ const touched = [];
116
+ for (let i = 0; i < items.length; i++) {
117
+ touched.push(i);
118
+ try { write(items[i].target, items[i].original); } catch (err) {
119
+ const failed = [];
120
+ for (const j of touched.slice().reverse()) { try { if (current[j] !== null) write(items[j].target, current[j]); } catch { failed.push(items[j].file); } }
121
+ const e = new Error(failed.length
122
+ ? `undo failed at ${items[i].file} (${err.message}); ROLLBACK INCOMPLETE for: ${failed.join(', ')}`
123
+ : `undo failed at ${items[i].file} (${err.message}); no file was changed`);
124
+ e.rolledBack = failed.length === 0; e.rollbackFailed = failed;
125
+ throw e;
126
+ }
127
+ }
128
+ for (const id of [backupId, ...items.map((it) => it.id)]) { try { markRevertedByBackup(root, id); } catch { /* history is best effort */ } }
129
+ return { ok: true, file: items[0].file, files: items.map((it) => it.file) };
58
130
  }
59
131
 
60
132
  /**
@@ -75,10 +147,14 @@ export async function runFixLifecycle(o) {
75
147
  const { plan, files, finding } = o;
76
148
  const gates = {};
77
149
  // A patch may only touch a file inside the project: a target that escapes the root is refused before anything else runs.
78
- gates.path = { ok: isRootRelativePath(plan.file), detail: plan.file };
79
- if (!gates.path.ok) return { status: 'blocked', applied: false, reason: `path-escape: ${String(plan.file).slice(0, 120)} is outside the project root`, plan, gates, label: plan.label };
150
+ // A plan may carry `edits` (several files, one logical fix); a plan without them is the single edit of `plan.file`.
151
+ const edits = Array.isArray(plan.edits) && plan.edits.length ? plan.edits : [{ file: plan.file, before: plan.before, after: plan.after }];
152
+ const escaping = edits.filter((e) => !isRootRelativePath(e.file));
153
+ gates.path = { ok: escaping.length === 0, detail: edits.map((e) => e.file).join(', ') };
154
+ if (!gates.path.ok) return { status: 'blocked', applied: false, reason: `path-escape: ${escaping.map((e) => String(e.file).slice(0, 120)).join(', ')} is outside the project root`, plan, gates, label: plan.label };
80
155
  gates.syntax = o.syntax(plan);
81
- const patched = { ...files, [plan.file]: plan.after };
156
+ const patched = { ...files };
157
+ for (const e of edits) patched[e.file] = e.after;
82
158
  const blocked = (why) => ({ status: 'blocked', applied: false, reason: why, plan, gates, label: plan.label });
83
159
  if (!gates.syntax.ok) return blocked(`syntax: ${gates.syntax.detail}`);
84
160
  if (o.behaviorCheck) { gates.behavior = o.behaviorCheck(plan); if (gates.behavior && gates.behavior.ok === false) return blocked(`behavior: ${gates.behavior.detail || 'unrelated behavior change'}`); }
@@ -94,6 +170,12 @@ export async function runFixLifecycle(o) {
94
170
  gates.rescan = { ok: gone && added.length === 0, originalGone: gone, newMediumOrHigher: [...new Set(added.map((f) => `${f.cwe || f.vuln}@${f.file}:${f.line}`))] };
95
171
  if (!gone) return blocked('the original finding is still reported after the patch');
96
172
  if (added.length) return blocked(`the patch introduces ${added.length} new medium-or-higher finding(s)`);
173
+ // Optional effective-configuration gate: the rescan above only proves a finding stopped being REPORTED, which a patch that
174
+ // turns the option into a module-system conflict also achieves. The check says whether the option now HAS the intended value.
175
+ if (o.effectiveCheck) {
176
+ gates.effective = o.effectiveCheck(plan, patched, files) || { ok: false, detail: 'no effective-configuration result' };
177
+ if (gates.effective.ok === false) return blocked(`effective: ${gates.effective.detail}`);
178
+ }
97
179
  // Optional controlled-witness gate (X-009): the hook returns judgeFixWitness(before, after). A patch that STILL
98
180
  // reproduces the effect is blocked; a witness that could not be judged (unsupported, invalid, unavailable) verifies
99
181
  // nothing and, unless `requireWitness` is set, does not block (it is recorded as not-verified).
@@ -106,15 +188,26 @@ export async function runFixLifecycle(o) {
106
188
  if (gates.compile.ran && !gates.compile.ok) return blocked(`compile: ${gates.compile.detail}`);
107
189
  if (o.requireCompile && !gates.compile.ran) return blocked(`compile verification required but ${gates.compile.detail}`);
108
190
  const verified = { syntax: true, rescan: true, compile: gates.compile.ran ? 'passed' : 'not-run', witness: gates.witness ? (gates.witness.verified ? 'passed' : 'not-verified') : 'not-run' };
109
- const res = { status: 'verified', applied: false, plan, gates, label: plan.label, verified, preview: unifiedDiff(plan.file, plan.before, plan.after) };
191
+ const res = { status: 'verified', applied: false, plan, gates, label: plan.label, verified, preview: edits.map((e) => unifiedDiff(e.file, e.before, e.after)).join('\n'), files: edits.map((e) => e.file) };
110
192
  if (o.apply) {
111
193
  if (!o.root) return blocked('apply requires a root directory');
112
- res.backup = writeWithBackup(o.root, plan.file, plan.before, plan.after);
113
- res.applied = true; res.status = 'applied';
114
- // One ledger: the same fix-history `agentic-security undo` reads, with the tier and the gates that passed.
115
- try {
116
- res.history = await recordExternalFix({ scanRoot: o.root, file: plan.file, originalContent: plan.before, newContent: plan.after, findingId: finding.id || `${finding.file}:${finding.line}`, ruleId: finding.rule || null, vuln: finding.vuln || null, stableId: finding.stableId || null, fixLabel: plan.label || null, verification: verified, backup: res.backup, findingProvenance: finding.findingProvenance || null });
117
- } catch { res.history = null; }
194
+ const base = { scanRoot: o.root, findingId: finding.id || `${finding.file}:${finding.line}`, ruleId: finding.rule || null, vuln: finding.vuln || null, stableId: finding.stableId || null, fixLabel: plan.label || null, verification: verified, findingProvenance: finding.findingProvenance || null };
195
+ if (edits.length === 1) {
196
+ res.backup = writeWithBackup(o.root, plan.file, plan.before, plan.after);
197
+ res.applied = true; res.status = 'applied';
198
+ // One ledger: the same fix-history `agentic-security undo` reads, with the tier and the gates that passed.
199
+ try { res.history = await recordExternalFix({ ...base, file: plan.file, originalContent: plan.before, newContent: plan.after, backup: res.backup }); } catch { res.history = null; }
200
+ } else {
201
+ try { res.backup = writeManyWithBackup(o.root, edits, { writeFile: o.writeFile }); } catch (err) {
202
+ return { ...blocked(`apply failed, nothing was left changed: ${err.message}`), rolledBack: err.rolledBack !== false, rollbackFailed: err.rollbackFailed || [] };
203
+ }
204
+ res.applied = true; res.status = 'applied';
205
+ // One ledger entry per touched file, all carrying the group id so `undo` reverts them together.
206
+ res.history = [];
207
+ for (let i = 0; i < edits.length; i++) {
208
+ try { res.history.push(await recordExternalFix({ ...base, file: edits[i].file, originalContent: edits[i].before, newContent: edits[i].after, backup: res.backup.members[i], languageGroupId: res.backup.id })); } catch { res.history.push(null); }
209
+ }
210
+ }
118
211
  }
119
212
  return res;
120
213
  }
@@ -13,7 +13,8 @@ export const HASKELL_PARSE_PRODUCER = 'language:haskell-parse';
13
13
 
14
14
  // Boundaries whose content the parser could not see. cpp and ffi are disclosed on
15
15
  // the parse result but do not make the file unresolved: both CPP branches are
16
- // parsed and a foreign declaration's own Haskell signature is.
16
+ // parsed and a foreign declaration's own Haskell signature is. `th-safe-splice` and `quasiquote-inert` are matched by a
17
+ // closed, sound pattern (haskell-boundaries.js) and stay disclosed as assumptions; every other TH/quasi-quote boundary is opaque.
17
18
  const OPAQUE_BOUNDARIES = new Set(['th-splice', 'th-quote', 'th-name-quote', 'th-top-level-splice', 'quasiquote', 'hsc', 'generated']);
18
19
 
19
20
  export function ensureHaskellParseProducer() {
@@ -24,7 +25,7 @@ export function ensureHaskellParseProducer() {
24
25
  }
25
26
 
26
27
  /**
27
- * @param {{grammarSource?:Function, budgets?:object, mode?:string, onParse?:(file:string, parse:object)=>void}} [opts]
28
+ * @param {{grammarSource?:Function, budgets?:object, mode?:string, cpp?:object, onParse?:(file:string, parse:object)=>void}} [opts]
28
29
  */
29
30
  export function createHaskellAdapter(opts = {}) {
30
31
  ensureHaskellParseProducer();
@@ -33,7 +34,7 @@ export function createHaskellAdapter(opts = {}) {
33
34
  language: 'haskell',
34
35
  hasGrammar: () => loadHaskellGrammar({ grammarSource: opts.grammarSource }).available,
35
36
  analyze(file, content) {
36
- const parse = parseHaskell(content, { file, grammarSource: opts.grammarSource, budgets: opts.budgets, mode: opts.mode });
37
+ const parse = parseHaskell(content, { file, grammarSource: opts.grammarSource, budgets: opts.budgets, mode: opts.mode, cpp: opts.cpp });
37
38
  if (typeof opts.onParse === 'function') opts.onParse(file, parse);
38
39
  if (parse.status === 'budget_exceeded' && parse.budget && parse.budget.name === 'deadlineMs') {
39
40
  throw Object.assign(new Error(`Haskell parser deadline exceeded for ${file}`), { code: 'LANG_TIMEOUT' });
@@ -177,3 +177,18 @@ export async function refreshHackageAdvisories(names, opts = {}) {
177
177
  packages: wanted.length, requested: need.length, fetched: fetched.size, records: records.size, uncovered, failures: failures.slice(0, 10), path, cacheProblem: cur.problem,
178
178
  });
179
179
  }
180
+
181
+ /**
182
+ * The scan-health view of this feed, in the shape language/assurance.js takes for an optional mode: `selected` is whether the operator
183
+ * opted in, `result` is what the refresh did (absent when none happened, which assurance reports as "selected but did not run").
184
+ * Outcomes that mean the feed answered are `ok`; a partial refresh is `partial` (some packages stay unknown) and is not dressed as success.
185
+ */
186
+ export function liveFeedOptionalState(env = process.env, refresh = getLastRefresh()) {
187
+ if (env[FEED_ENV] !== '1') return { selected: false };
188
+ if (!refresh) return { selected: true, result: undefined };
189
+ const ok = new Set(['refreshed', 'current', 'nothing-to-do']);
190
+ if (ok.has(refresh.status)) return { selected: true, result: { status: 'ok' } };
191
+ if (refresh.status === 'partial') return { selected: true, result: { status: 'partial', reason: refresh.detail } };
192
+ if (refresh.status === 'failed') return { selected: true, result: { status: 'failed', reason: refresh.detail } };
193
+ return { selected: true, result: { status: refresh.status, ran: false, reason: refresh.detail } };
194
+ }