@clear-capabilities/agentic-security-scanner 0.188.0 → 0.190.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.
@@ -1 +1 @@
1
- f70bb09f8b0030636bb4ba27a72de9beb7087bcedadb2d27127bad3cfb960f01 agentic-security.mjs
1
+ 31c77828cee38ed79def22f8e0fb1260d494b05cdd00948438e85f1b89083aca agentic-security.mjs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clear-capabilities/agentic-security-scanner",
3
- "version": "0.188.0",
3
+ "version": "0.190.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",
@@ -93,9 +93,9 @@
93
93
  "test:lineage": "node --test test/lineage/analysis-completion.test.js 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 test/lineage/runtime-assurance.test.js test/lineage/runtime-edge-states.test.js test/lineage/runtime-explorer-view.test.js test/cli/runtime-explorer-cli.test.js test/cli/runtime-identity-cli.test.js test/cli/runtime-twin-cli.test.js test/lineage/cut-planner.test.js test/lineage/scc.test.js test/lineage/scc-solver.test.js test/graph/graph-views.test.js test/lineage/dag07-completion.test.js test/lineage/owned-build.test.js test/dataflow/boundary-propagation.test.js test/lineage/boundary-contract.test.js test/lineage/correlation-v2.test.js test/lineage/path-control-evidence.test.js test/lineage/smt-z3-backend.test.js test/cli/dag-commands.test.js",
94
94
  "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",
95
95
  "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",
96
- "test:haskell": "node --test test/haskell/haskell-alias-taint.test.js test/haskell/haskell-weak-hash-names.test.js test/haskell/haskell-models-extended.test.js test/haskell/haskell-rules-extended.test.js 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 test/haskell/haskell-supply-bounds.test.js test/haskell/haskell-ir-scaling.test.js test/haskell/haskell-boundary-propagation.test.js test/haskell/haskell-generalization.test.js test/haskell/haskell-trust-boundary.test.js test/haskell/haskell-comment-invariance.test.js test/haskell/haskell-remediation-classes.test.js test/haskell/haskell-trust-boundary-frameworks.test.js test/haskell/haskell-generic-detectors.test.js",
96
+ "test:haskell": "node --test test/haskell/haskell-reachability.test.js test/haskell/haskell-alias-taint.test.js test/haskell/haskell-weak-hash-names.test.js test/haskell/haskell-models-extended.test.js test/haskell/haskell-rules-extended.test.js 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-remediation-0190.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 test/haskell/haskell-supply-bounds.test.js test/haskell/haskell-ir-scaling.test.js test/haskell/haskell-boundary-propagation.test.js test/haskell/haskell-generalization.test.js test/haskell/haskell-trust-boundary.test.js test/haskell/haskell-comment-invariance.test.js test/haskell/haskell-remediation-classes.test.js test/haskell/haskell-trust-boundary-frameworks.test.js test/haskell/haskell-generic-detectors.test.js",
97
97
  "test:nix": "node --test test/nix/nix-option-types.test.js test/nix/nixos-hardening-daemon.test.js test/nix/nixos-hardening-extended.test.js 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 test/nix/nix-fix-prewrite.test.js test/nix/nix-boundary-scope.test.js",
98
- "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-parity-haskell.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",
98
+ "test:language": "node --test test/bench-haskell-precision.test.js test/bench-independent-haskell-panel.test.js 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-parity-haskell.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",
99
99
  "test:language-stress": "node --test --test-concurrency=1 test/language/language-stress-offline.test.js",
100
100
  "test:language-tools": "node --test test/haskell/haskell-web-auth.test.js test/haskell/haskell-support-gate.test.js",
101
101
  "bench:language-support:check": "node ../bench/language-support/check.mjs",
@@ -242,6 +242,9 @@
242
242
  "bench:sard:php:test-split": "node ../bench/sard/scripts/score-php.mjs --deep --split test --json",
243
243
  "bench:layer-recall": "node ../bench/layer-recall/runner.mjs",
244
244
  "bench:layer-recall:check": "node ../bench/layer-recall/runner.mjs --check",
245
+ "bench:haskell-precision:check": "node ../bench/haskell-precision/runner.mjs --check",
246
+ "bench:haskell-precision:release": "node ../bench/haskell-precision/runner.mjs --check --require-sealed",
247
+ "bench:haskell-precision:update": "node ../bench/haskell-precision/runner.mjs --update-baseline",
245
248
  "bench:layer-recall:update-baseline": "node ../bench/layer-recall/runner.mjs --update-baseline",
246
249
  "bench:dag": "node ../bench/dag/run.mjs",
247
250
  "bench:dag:quick": "node ../bench/dag/run.mjs --quick",
@@ -3086,7 +3086,8 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
3086
3086
  if (fn.hs && (fn.hs.exported || /\.main$/.test(fn.name || ''))) continue;
3087
3087
  f._inDeadCode = true;
3088
3088
  const dg = { critical: 'high', high: 'medium', medium: 'low', low: 'info' };
3089
- if (dg[f.severity]) f.severity = dg[f.severity];
3089
+ // The severity the finding would have had is kept (a demotion nobody can see is indistinguishable from a finding that was never that serious).
3090
+ if (dg[f.severity]) { f.originalSeverity = f.originalSeverity || f.severity; f.severity = dg[f.severity]; }
3090
3091
  }
3091
3092
  Object.defineProperty(all, '_summaryCache', { value: summaryCache, enumerable: false });
3092
3093
  if (summaryCache._budgetHits) _noteIncomplete('summary-budget', `${summaryCache._budgetHits} summary requests after ${summaryCache._maxIter} computations`);
package/src/engine.js CHANGED
@@ -321,6 +321,7 @@ import { annotateHaskellLlm, analyzeHaskellLlmRules } from './language/haskell-l
321
321
  import { languagePragmaOnLine as _languagePragmaOnLine, languagePragmaSuppresses as _languagePragmaSuppresses } from './language/pragma.js';
322
322
  import { applyHaskellGuards, consolidateFlowSinks } from './language/haskell-guards.js';
323
323
  import { applyTrustBoundary } from './language/haskell-trust.js';
324
+ import { applyHaskellReachability } from './language/haskell-reachability.js';
324
325
  import { annotateUnresolvedTargets, attachEvidenceChains } from './language/haskell-disclosure.js';
325
326
  import { analyzeHaskellRules } from './language/haskell-security-rules.js';
326
327
  import { scanLanguageSecretConcat, scanDependencyUrlCredentials, providerInfo as _secretProvider } from './language/secrets.js';
@@ -9522,12 +9523,31 @@ function _deterministicFileTimings(timings) {
9522
9523
  const _hasJava = Object.keys(fc || {}).some(f => /\.java$/i.test(f));
9523
9524
  // DAG-05: with the cross-scan cache on, an unchanged JavaScript/TypeScript file is read back from the IR cache
9524
9525
  // instead of parsed again (dataflow/ir-cache.js; the content is the same, only the cost differs).
9526
+ // What the Haskell project-reachability verdict needs besides the sources (language/haskell-ir.js `projectReachability`): the manifests that say
9527
+ // which modules the package exposes, whether this file set is the whole project, and every gap the scan itself knows about. A Haskell source that
9528
+ // was offered to the scan and is not in the IR input (path policy, size, density, or a format the IR does not read) could hold a caller, so
9529
+ // each is a named hole and a verdict that needs a complete picture degrades to `unknown`.
9530
+ const _hsReachCtx = () => {
9531
+ const manifests = {};
9532
+ for (const [p, t] of Object.entries(depFileContents || {})) if (/\.cabal$|(?:^|\/)package\.yaml$/i.test(p) && typeof t === 'string') manifests[p] = t;
9533
+ const hsSrc = /\.(?:hs|lhs|hs-boot|lhs-boot|hsc|hsig)$/i;
9534
+ const holes = new Set();
9535
+ if ((policyExcluded || []).some((p) => hsSrc.test(String(p)))) holes.add('policy-excluded');
9536
+ if ((sizeSkipped || []).some((s) => hsSrc.test(String((s && s.file) || s)))) holes.add('size-skipped');
9537
+ for (const f of Object.keys(fileContents || {})) {
9538
+ if (!hsSrc.test(f)) continue;
9539
+ if (!/\.hs$/i.test(f)) holes.add('opaque-boundary'); // literate, hsc and boot files: the IR reads `.hs` only, so their calls are not in the graph
9540
+ else if (!(f in fc)) holes.add(typeof fileContents[f] === 'string' && fileContents[f].length > 500000 ? 'size-skipped' : 'policy-excluded');
9541
+ }
9542
+ return { manifests, completeScan: completeScan === true, scanHoles: [...holes] };
9543
+ };
9525
9544
  const _buildIR = async () => {
9526
9545
  let irCache = null;
9527
9546
  if (process.env.AGENTIC_SECURITY_INCREMENTAL === '1' && scanRoot) {
9528
9547
  try { irCache = openIrCache(scanRoot); } catch { irCache = null; }
9529
9548
  }
9530
- return _hasJava ? await buildProjectIRAsync(fc, { irCache }) : buildProjectIR(fc, { irCache });
9549
+ const haskell = _hsReachCtx();
9550
+ return _hasJava ? await buildProjectIRAsync(fc, { irCache, haskell }) : buildProjectIR(fc, { irCache, haskell });
9531
9551
  };
9532
9552
  const _irStatsTarget = irStatsTarget();
9533
9553
  if (_irStatsTarget) {
@@ -9620,7 +9640,7 @@ function _deterministicFileTimings(timings) {
9620
9640
  f.unvalidated = true;
9621
9641
  f.validator_verdict = 'unvalidated';
9622
9642
  }
9623
- try { applyHaskellGuards(irFindings, perFile); applyTrustBoundary(irFindings, perFile); consolidateFlowSinks(irFindings, perFile); annotateUnresolvedTargets(irFindings, perFile); attachEvidenceChains(irFindings, perFile); annotateHaskellFindings(irFindings, fc); annotateHaskellLlm(irFindings, fc); } catch (_) { /* metadata only; the finding stands without it */ }
9643
+ try { applyHaskellGuards(irFindings, perFile); applyTrustBoundary(irFindings, perFile); try { applyHaskellReachability(irFindings, perFile); } catch (_) { /* the verdict is metadata; the finding stands without it */ } consolidateFlowSinks(irFindings, perFile); annotateUnresolvedTargets(irFindings, perFile); attachEvidenceChains(irFindings, perFile); annotateHaskellFindings(irFindings, fc); annotateHaskellLlm(irFindings, fc); } catch (_) { /* metadata only; the finding stands without it */ }
9624
9644
  aF.push(...irFindings);
9625
9645
  } catch (e) {
9626
9646
  // Deep mode is best-effort. A parser blowup in one file shouldn't kill
@@ -9831,12 +9851,12 @@ function _deterministicFileTimings(timings) {
9831
9851
  // An `unsafePerformIO` / `unsafeCoerce` / `unsafeInterleaveIO` boundary note says (in its own text) that it claims no vulnerability class: it marks where the
9832
9852
  // type system is stepped around. In a scan it is information, not a finding that gates a release; the rules module keeps emitting it at `low` (its contract),
9833
9853
  // and the severity is lowered here with the original kept.
9834
- try{aF.push(...analyzeHaskellRules(fc).findings.map((f)=>f&&f.rule==='hs-unsafe-io-boundary'?{...f,originalSeverity:f.severity,severity:'info'}:f));}catch(_){}
9854
+ try{aF.push(...analyzeHaskellRules(fc,{reachability:_hsReachCtx()}).findings.map((f)=>f&&f.rule==='hs-unsafe-io-boundary'?{...f,originalSeverity:f.severity,severity:'info'}:f));}catch(_){}
9835
9855
  try{aF.push(...analyzeHaskellLlmRules(fc));}catch(_){}
9836
9856
  try{const{analyzeLanguageFindings}=await import('./language/engine-pass.js');aF.push(...analyzeLanguageFindings(allFileContents).findings);}catch(_){}
9837
9857
  let _languageBridges=null;try{if(Object.keys(allFileContents).some(f=>/\.(?:l?hs|nix)$/i.test(f))){const{analyzePolyglotBridges}=await import('./language/bridges.js');_languageBridges=analyzePolyglotBridges(allFileContents);}}catch(_){}
9838
9858
  try{for(const[_p,_t]of Object.entries(allFileContents))if(typeof _t==='string')aF.push(...scanDependencyUrlCredentials(_p,_t));}catch(_){}
9839
- try{const _hw=analyzeHaskellWeb(fc);aF.push(..._hw.findings);aR.push(...toEngineRoutes(_hw));}catch(_){}
9859
+ try{const _hw=analyzeHaskellWeb(fc,{reachability:_hsReachCtx()});aF.push(..._hw.findings);aR.push(...toEngineRoutes(_hw));}catch(_){}
9840
9860
  let finalFindings;try{finalFindings=dedupeFindingsWithEvidence(aF);}catch(_){finalFindings=dd(aF,f=>f.id);}
9841
9861
  // Inline `agentic-security-ignore` pragmas, pass 1 of 2. This covers every
9842
9862
  // finding that exists BY THIS POINT — the pattern detectors, the cross-file
package/src/ir/index.js CHANGED
@@ -202,7 +202,7 @@ export function buildProjectIR(fileContents, opts = {}) {
202
202
  // the CFGs (SSA numbering included), since PHP's `include` genuinely
203
203
  // executes in the caller's variable namespace. See php-include-merge.js's
204
204
  // module header for the full rationale and scope.
205
- const hsCg = mergeHaskellIR(perFile, hsFiles);
205
+ const hsCg = mergeHaskellIR(perFile, hsFiles, opts && opts.haskell);
206
206
  try { mergePhpIncludes(perFile); } catch {}
207
207
  if (isSSAEnabled()) {
208
208
  for (const ir of Object.values(perFile)) {
@@ -225,10 +225,11 @@ export function buildProjectIR(fileContents, opts = {}) {
225
225
  // Haskell is lowered as one project (module resolution crosses files), then its
226
226
  // per-file IR joins the same perFile map every consumer already reads. Its call graph
227
227
  // keeps its richer edge statuses and is merged into the shared one below.
228
- function mergeHaskellIR(perFile, hsFiles) {
228
+ function mergeHaskellIR(perFile, hsFiles, hsContext) {
229
229
  if (!Object.keys(hsFiles).length) return null;
230
230
  try {
231
- const hs = buildHaskellIR(hsFiles);
231
+ // `hsContext` ({manifests, completeScan, scanHoles}) is what the project-reachability verdict needs besides the sources; without it every verdict is `unknown`.
232
+ const hs = buildHaskellIR(hsFiles, hsContext || {});
232
233
  for (const [file, ir] of Object.entries(hs.perFile)) perFile[file] = ir;
233
234
  for (const d of hs.diagnostics) _noteParseFailure(d.file, new Error(`haskell ${d.kind}: ${d.detail}`));
234
235
  return hs.callGraph;
@@ -247,7 +248,7 @@ function withHaskellCallGraph(cg, hsCg) {
247
248
  const isHs = (f) => typeof f === 'string' && /\.hs$/i.test(f);
248
249
  const resolve = (name, callerFile) => (isHs(callerFile) ? hsCg.resolve(name) : cg.resolve(name, callerFile));
249
250
  const resolveKnownCallee = (name, callerFile) => (isHs(callerFile) ? hsCg.resolveKnownCallee(name) : cg.resolveKnownCallee(name, callerFile));
250
- return { ...cg, functions, edges, callersOf, resolve, resolveKnownCallee, haskell: hsCg };
251
+ return { ...cg, functions, edges, callersOf, resolve, resolveKnownCallee, haskell: hsCg, haskellReachability: hsCg.haskellReachability || null };
251
252
  }
252
253
 
253
254
  // Async variant — includes Java IR via java-parser.
@@ -306,7 +307,7 @@ export async function buildProjectIRAsync(fileContents, opts = {}) {
306
307
  // the CFGs (SSA numbering included), since PHP's `include` genuinely
307
308
  // executes in the caller's variable namespace. See php-include-merge.js's
308
309
  // module header for the full rationale and scope.
309
- const hsCg = mergeHaskellIR(perFile, hsFiles);
310
+ const hsCg = mergeHaskellIR(perFile, hsFiles, opts && opts.haskell);
310
311
  try { mergePhpIncludes(perFile); } catch {}
311
312
  if (isSSAEnabled()) {
312
313
  for (const ir of Object.values(perFile)) {
@@ -7,7 +7,7 @@ or a capability.** Product-facing guides: `docs/guides/haskell.md`, `docs/guides
7
7
  ## Invariants (the ones you will get wrong without reading)
8
8
 
9
9
  - **Static by default.** A default scan never runs GHC, Cabal, Stack, `nix`, `Setup.hs`, Template Haskell or a build. Compile
10
- checks (`haskell-fix.js` `compileGate`) and evaluation (`nix-eval-isolation.js`) are opt-in, and a tool that is absent is a
10
+ checks (`haskell-fix.js` `compileGate`: requested by the FIX PREVIEW and by `validateHaskellFix({compile:true})`, never by a scan) and evaluation (`nix-eval-isolation.js`) are opt-in, and a tool that is absent is a
11
11
  stated `not-run` / `unsupported`, never a pass. A required criterion that needs a tool **fails** (and the support registry
12
12
  marks the capability `blocked`); it never skips.
13
13
  - **Never derive an answer from ground truth.** The engine must not read the corpus under `test/language/corpora/`, fixture
@@ -69,9 +69,15 @@ or a capability.** Product-facing guides: `docs/guides/haskell.md`, `docs/guides
69
69
  - **A Haskell taint finding says who controls its input.** `haskell-trust.js` `applyTrustBoundary` (run in `engine.js` right after `applyHaskellGuards`) reads the origins the engine recorded (`sourceRoots`, or the evidence chain) and stamps `trustBoundary {verdict, reason, role, roots}`. Verdicts: `attacker-reachable` (request, socket, header, cookie, remote response; never lowered), `operator-controlled` (cli/env roots, and file-read roots in a command-line program; also an EMPTY origin in a command-line program, marked `inferred: true`), `api-contract` (a bare/whole-handoff parameter of an uncalled exported function reaching a process, CWE-78/88 only), `entry-parameter`, `data-input`, `unattributed`. `operator-controlled` and `api-contract` are lowered to `info` (never dropped; `originalSeverity`, `controls`, `guardNote` kept), the same convention `haskell-guards.js` uses. **Standard input is deliberately NOT covered by the command-line rule** (`getLine >>= callCommand` in `main` stays reported; `test/haskell/haskell-generalization.test.js` pins it), and **path wrappers are deliberately not `api-contract`** (`withFile name WriteMode` on a caller-named file is the vulnerable shape of three development splits). The role is `fn.hs.entryRole` from `assignEntryRoles` in `haskell-ir.js`: `cli` (reached only from `main`s whose reachable modules import no server framework or socket library), `service`, or `library` (no `main` reaches it; an incomplete call graph therefore yields `library`, never `cli`). Pinned both ways in `test/haskell/haskell-trust-boundary.test.js`.
70
70
  - **Origins are recorded, not guessed** (`dataflow/engine.js`). The engine records a source only against the variable a source call was assigned to; three additions make an evidence chain begin at the real origin: a value derived from a source inherits its records (`_inheritSources`), a callee analysed under an entry-taint state names its tainted parameters with placeholder records (`_paramPlaceholders`) that `_mergeSummaryFindings` resolves to the sources of the arguments at the merging call site (`_resolveParams`), and a function return keeps the sources behind it (`returnSources`, followed through `from` into `sourceRoots`). A parameter annotated as a source (`hs:caller-controlled`, `hs:handler-param:*`) is recorded too (`matchAnnotationParamSources`, `source.kind: 'parameter'` so stable ids do not change). This is language-neutral (`test/dataflow-source-attribution.test.js` pins it on JavaScript). `System.IO.hGetContents`/`hGetLine`/`hGetChar` (and the Text/ByteString forms) are modelled as `file-read`, not `stdin`: they read whatever handle they are given.
71
71
  - **Reachability must survive platform variants.** An import two modules both satisfy (the Posix and Windows variants behind an undecided CPP conditional) resolves to `unknown: ambiguous-import` with its `candidates`; `haskell-ir.js` records each candidate as a reference of the caller, so neither is an "uncalled entry point" whose parameters are caller-controlled. Without it every function of the platform module that was not chosen was reported.
72
+ - **"No caller can exist" is a verdict with preconditions, and `null !== false`.** `haskell-ir.js` `projectReachability` stamps `fn.hs.reachability {status, rootKind, holeReason}` (`entrypoint-reachable | unreachable-in-project | external-api | unknown`); `haskell-reachability.js` `applyHaskellReachability` copies it onto each IR-TAINT finding as `f.reachability {status, reason, premise, roots, holeReason}`, a record SEPARATE from `trustBoundary` (that one says who controls the input, this one whether a caller can exist; either may be `unknown`). `unreachable-in-project` needs ALL of: `completeScan` (runScan/engine thread it; a changed-file, PR, subdirectory or caller-supplied scan is a subset, under which every export looks uncalled), no `policy-excluded`/`size-skipped` Haskell, `entrypointCount > 0` (a `main`; the sealed single-file corpora have none, so the rule is structurally inert on them), `exposedModules !== null` (`haskell-manifests.js` `exposedModuleIndex`: cabal `exposed-modules`, or hpack `exposed-modules`; an hpack library that exposes everything, `-main-is`, `-F/-pgmF`, a plugin, a non-test `build-tool-depends`, a foreign library, Backpack signatures are all `null`), the function's module not exposed (re-exports count: `reexportClosure`), not an instance method (instance methods are ROOTS, so what they call is reached, but their own verdict is `unknown/instance-method`), and not reached from a root. Roots: every `main`, framework-handler-typed functions (`hs.entryHandler`), `foreign export`s, instance methods, exported functions of exposed modules, and `withheld-caller` roots. Gaps are bounded where they can be and global where they cannot: Template Haskell/quasi-quotes the allow-lists did not accept (`preprocessForSyntaxInfo(...).opaque`), a foreign export, the function cap, an unresolved name nothing defines = global holes; a declaration the parser withheld or an expression it could not lower makes that module's own functions and the exported functions of every module it can name (re-export closure) roots, because withheld code can call only what its module can see. A call through a function VALUE is not a gap (its callee was mentioned in value position, which is a reference the walk follows). Holes are `unknown`, never `unreachable`; the order of `holeReason` is `HOLE_PRIORITY`. Pinned in `test/haskell/haskell-reachability.test.js`; the pin at `haskell-trust-boundary.test.js` (a lone `Svc.hs`) asserts `unknown/no-entrypoint`. **Severity:** only `unreachable-in-project` with `premise === 'caller-controlled-parameter'` (every recorded origin is the weak parameter label) is lowered to `info` (`lowerFinding`, guard `_reachLowered`, composes with `_trustLowered`); a finding with a recorded read keeps its severity and may only rank lower (`posture/relevance.js` `languageVerdict`). The verdict is recomputed in place per context (`buildHaskellIR(files, {manifests, completeScan, scanHoles})`; the BUILD cache is keyed by sources alone and the manifests are parsed once per context, counted by `EXPOSED_INDEX_WORK`). `fn.hs.reachedBy` and `fn.hs.paramEntry` record how a function was reached and what its parameters were before `paramAnnotations` is removed; the removal itself is unchanged. `callersUnknown(fn)` (`haskell-security-rules.js`) reads the verdict for the partial-function and unbounded-read rules; `external-api` is deliberately answered as before (turning it on added seven flagged sites and no true positive on the development set). Nothing reads a comment, a name or a path. Measured effect on real code is in `bench/dag/dev-haskell/results/README.md` (it changed no severity there) and the recorded baseline is gated by `bench/haskell-precision`.
72
73
  - **Comments never change a verdict, an id or a cluster.** `haskell-comments.js` finds Haskell comments lexically; a finding's `snippet` is `codeOfLine(line)` (code only), because the snippet feeds the stable id and the clone-shape hash, and both changed when someone edited a trailing comment. `test/haskell/haskell-comment-invariance.test.js` scans the examples and corpus-shaped cases as written, with every comment blanked, and with every comment (and a trailing comment on every line) replaced by text that lies in each direction; `bench/dag/dev-haskell/comment-invariance.mjs` does the same to a real project. An engine that varies is wrong.
73
74
  - **Rules that read the function around a call** (`haskell-security-rules.js`): `hs-weak-hash` is a vulnerability only where `weakDigestUse` finds a use that depends on the digest's strength (compared, hashed secret, credential-shaped binding or function, handed to a signing/verifying call); otherwise it is emitted at `info`. `hs-unbounded-input-read` needs a socket handle, or standard input in a function nothing calls (`readsOutsideInput`). Partial-function and allocation rules use a parameter as caller-supplied only for a framework-handler parameter or an uncalled function (`fn.hs.reached`). A salt is not an unpredictability-sensitive value. A cookie whose whole value is constant is not a credential. Own-property lookups only (`Object.hasOwn`): `constructor` and `toString` are legal Haskell names and were reported as "Unsafe boundary: function Object() { [native code] }".
74
- - **Fixes name the library the file uses.** `sqlDriverOf` picks `Database.PostgreSQL.Simple` or `Database.SQLite.Simple` from the file's own imports and refuses anything else; `hs-html-escape` needs blaze-html or a re-exporter; `hs-hash-sha256` refuses when the algorithm is named on another line (a type); `hs-path-takefilename` rewrites only a String/FilePath parameter spliced under a fixed directory in a FILE operation. Classes with no safe rewrite refuse with a class-level reason (`CLASS_REASONS`), `proposal: 'model-assisted'`.
75
+ - **Fixes name the library the file uses.** `sqlDriverOf` picks `Database.PostgreSQL.Simple` or `Database.SQLite.Simple` from the file's own imports and refuses anything else; `hs-html-escape` needs blaze-html or a re-exporter; `hs-hash-sha256` refuses when the algorithm is named on another line (a type); `hs-path-takefilename` rewrites only a String/FilePath parameter spliced under a fixed directory in a FILE operation. Classes with no safe rewrite refuse with a class-level reason (`CLASS_REASONS`), `proposal: 'model-assisted'`, `tier: 'guidance-only'`, and every reason ends `The decision needed: ...` (returned as `decision`). Never invent a library API: the TLS, cookie, entropy and wai shapes were written against the package documentation and their tests type-check against DECLARED STUBS of that API shape, which proves coherence with the documented shape, not a build against the real package.
76
+ - **Haskell plans carry `tier`, `consequences` and (for attribute fixes) `expectEffective`.** `planHaskellFix` returns `tier: 'full-source-edit'` and a `consequences` array for every plan (a patch that changes behavior must say how: stored digests, rejected connections, HTTP-only cookies, a request-body limit), the way `nix-fix.js` does. Ten fixers: the six rewrites above plus `hs-tls-validate` (CWE-295, the `connection` package's `TLSSettingsSimple` literal `True` to `False`; OpenSSL `VerifyNone` refused), `hs-cookie-attributes` (CWE-1004, four framework shapes; the scotty helper is written out as the record update because the wrapped form is invisible to the rule), `hs-random-os-entropy` (CWE-338, only `name = randomIO` of type `IO <fixed-width integer>` in a file that already imports `System.Entropy`) and `hs-request-body-bound` (CWE-770, a MITIGATION: wai `strictRequestBody` becomes a bounded reader defined in the file, with a 1 MiB limit that is a declared default, not a measured need). Password hashing (CWE-916), SSRF, argument injection, missing auth and hard-coded secrets keep refusing and name the decision; do not add a KDF fixer without a migration story for stored hashes.
77
+ - **The effective-change check is not the rescan.** `haskellEffectiveCheck` (wired as the lifecycle's `effectiveCheck`) re-reads the intended attribute from the patched file with readers independent of the editor (`tlsSettingsOf`, `cookieSitesOf`, call counts) and fails when the planned site did not change or another site did. A cookie renamed to a non-session name silences the rule without adding an attribute; the check says so (`haskell-remediation-0190.test.js`).
78
+ - **The compile gate is requested by the preview.** `languageFixPreview` passes `compile: true` (`compile: false` opts out). `compileGate` type-checks only the patched file(s) (`targets`; local imports found through the `module` header's source root) and takes the original tree as `baseline`: a project that does not type-check on this host (a package it lacks), or a patch that imports a module this host lacks, is `{ran:false, ok:null}` (not-run), a patch that breaks a compiling file is `{ran:true, ok:false}`. A missing GHC is not-run, never a pass. `requireCompile` turns not-run into a block.
79
+ - **Witnesses.** `witness.js` `haskellFixWitness(finding)` runs the original and the patched file under `ghc -e ':load *File' -e 'fn "<payload>"'` in the sandbox and judges them with `judgeFixWitness`. It is built only for command-injection fixes in a top-level `String -> IO` function whose file imports only `bootModuleSet` modules; every other class returns `verified: false` with the reason (a TLS peer, a framework response header, a database, a digest, a distribution cannot be shown by a program GHC runs alone). Opt in with `languageFixPreview(..., { witness: true })`.
80
+ - **A supplied patch goes through the same gates.** `verifyHaskellProposal` / `verifyNixProposal` are reached from `languageProposalPreview` (context.js) and the CLI `fix --finding <id> --proposal <file>`; verify-only, never writes.
75
81
  - **Pragma** (`pragma.js`): comment-aware, line-scoped, exact rule-id match, logged. A line-less finding cannot be suppressed.
76
82
  - **Scale: no per-use scan of the project.** Pandoc and cabal-install (hundreds to thousands of modules) did not finish a scan because of four superlinear costs, now fixed; do not reintroduce the shapes. (1) `haskell-ir.js`: anything a lowering step needs "from every module" (a constructor by name, the record types, a definition by qid, a name's resolution) is an index on `Project` / a per-module memo (`findCon`, `recordTypes`, `defByQid`, `RESOLVE_MEMO`), built once, never a loop over `proj.modules` per use. Only a top-level resolution (depth 0) is memoised: a resolution inside a re-export chain depends on the path taken. (2) `aibom.js` looks a file's imports up in a map, not `Object.values(perFile).find`. (3) `sast/pipeline.js`: the workflow-wide `env:` secret rule is a one-pass matcher (`scanEnvSecrets`), because the single regular expression it replaced backtracked catastrophically on a comment-blanked workflow (blanking leaves runs of blank lines). A detector expression that runs on comment-blanked text must not let `\s*`, `\n` and a lazy line loop claim the same blank lines. (4) `dataflow/engine.js` `exprTaint`: a call's argument taint is computed once per visit (`_argTaint`); computing it twice re-walked the subtree and cost 2^N for N nested calls. Guards: `test/pipeline-env-secret.test.js`, `test/dataflow-expr-taint-scaling.test.js`, `test/haskell/haskell-ir-scaling.test.js`, `test/mark-used-vuln-functions.test.js` compare work counters between two sizes, never a clock.
77
83
 
@@ -87,7 +93,7 @@ or a capability.** Product-facing guides: `docs/guides/haskell.md`, `docs/guides
87
93
  | 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` |
88
94
  | 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` |
89
95
  | 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` |
90
- | Haskell trust | `haskell-trust.js` (trust-boundary verdicts and the lowering of operator-controlled and pass-through findings), `haskell-comments.js` (lexical Haskell comments; the code-only snippet) |
96
+ | Haskell trust | `haskell-trust.js` (trust-boundary verdicts, the shared `lowerFinding`, and the lowering of operator-controlled and pass-through findings), `haskell-reachability.js` (caller-aware reachability: copies the IR's per-function verdict onto each taint finding and lowers the ones whose only premise a missing caller withdraws), `haskell-comments.js` (lexical Haskell comments; the code-only snippet) |
91
97
  | Measurement | `accuracy.js` (family-scoped scoring, per-layer), `support-registry.js` (status rules, promotion refusals, frozen-hash demotion) |
92
98
 
93
99
  Related, outside this directory: `dataflow/catalog-haskell.js`, `lineage/haskell-view.js`, `lineage/nix-view.js`,
@@ -132,6 +138,7 @@ Pinned both ways in `test/nix/nix-option-types.test.js`.
132
138
  | Stress | `npm run test:language-stress` | offline scale and a peak-memory ceiling; excluded from `npm test` (it must own the machine) |
133
139
  | Needs a compiler | `npm run test:language-tools` | HS-006.AC01 and the support gate; excluded from `npm test`; **fails** without `ghc` |
134
140
  | Support registry | `npm run bench:language-support:check` | recomputes the registry from the STORED measurement; fails on any difference |
141
+ | Real-code precision | `npm run bench:haskell-precision:check` | `bench/haskell-precision/`: the labelled development and fresh real-code sets, compared for EQUALITY against `baseline.json` (a regression and an unrecorded improvement both fail); the live scan runs only where the pinned clones exist; the sealed independent set is read from its committed result only and is required by `bench:haskell-precision:release`. `bench:haskell-precision:update` re-records deliberately |
135
142
  | 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 |
136
143
 
137
144
  The `unseen` split (unseen-v3) is a **DEVELOPMENT set since 2026-10-26**: its failures were read and the engine was changed for their causes (provenance.json, `unseen-v3-development`; manifest `role: development`). `measure.mjs` records `splitRole` with every result, the registry labels the column "DEVELOPMENT set", and a generalisation claim needs a new unseen set (unseen-v4).
@@ -10,8 +10,8 @@ import fs from 'node:fs';
10
10
  import path from 'node:path';
11
11
  import { STATE_DIR_NAME } from '../posture/state-dir.js';
12
12
  import { isLanguageSource, isLanguageManifest, isLanguageExcludedPath, buildImportGraph, importClosure } from './discovery.js';
13
- import { planHaskellFix, validateHaskellFix, unifiedDiff as hsDiff } from './haskell-fix.js';
14
- import { planNixFix, validateNixFix, planNixUpgrade } from './nix-fix.js';
13
+ import { planHaskellFix, validateHaskellFix, verifyHaskellProposal, unifiedDiff as hsDiff } from './haskell-fix.js';
14
+ import { planNixFix, validateNixFix, verifyNixProposal, planNixUpgrade } from './nix-fix.js';
15
15
  import { planHaskellUpgrade, unifiedDiff as _hsDiff } from './haskell-fix.js';
16
16
  import { writeWithBackup } from './fix-lifecycle.js';
17
17
  import { recordExternalFix } from '../posture/fix-history.js';
@@ -98,12 +98,16 @@ export function addedDatabaseImports(plan) {
98
98
  /**
99
99
  * A READ-ONLY fix preview for a Haskell/Nix finding: the planned patch, its tier, and what the verification gates say.
100
100
  * Never writes. `files` is the project's language sources (see loadLanguageProject).
101
+ *
102
+ * For Haskell the compile gate is requested (`compile: true`) unless the caller passes `compile: false`: GHC type-checks the patched file
103
+ * against the original (a host without GHC, or without the project's packages, records the gate as not-run, never as a pass).
104
+ * `witness: true` additionally runs the controlled Haskell fix witness where one can honestly be built (see witness.js `haskellFixWitness`).
101
105
  */
102
- export async function languageFixPreview(finding, files, { apply = false, root = null } = {}) {
106
+ export async function languageFixPreview(finding, files, { apply = false, root = null, compile = true, witness = false } = {}) {
103
107
  const lang = languageOfFinding(finding);
104
108
  if (!lang) return { ok: false, status: 'unsupported', reason: 'not a Haskell or Nix finding' };
105
109
  const plan = lang === 'haskell' ? planHaskellFix(finding, files) : planNixFix(finding, files);
106
- if (!plan || plan.ok === false) return { ok: false, status: (plan && plan.status) || 'unsupported', tier: (plan && plan.tier) || null, reason: (plan && plan.reason) || 'no deterministic fix', proposal: (plan && plan.proposal) || null };
110
+ if (!plan || plan.ok === false) return { ok: false, status: (plan && plan.status) || 'unsupported', tier: (plan && plan.tier) || null, reason: (plan && plan.reason) || 'no deterministic fix', decision: (plan && plan.decision) || null, proposal: (plan && plan.proposal) || null };
107
111
  if (lang === 'haskell') {
108
112
  // A static rescan cannot see a wrong library, so this is decided here, before any gate can call the patch verified.
109
113
  const added = addedDatabaseImports(plan);
@@ -112,7 +116,13 @@ export async function languageFixPreview(finding, files, { apply = false, root =
112
116
  }
113
117
  }
114
118
  let res;
115
- try { res = lang === 'haskell' ? await validateHaskellFix(finding, { files, apply, root }) : await validateNixFix(finding, { files, apply, root }); } catch (e) { res = { status: 'blocked', reason: `verification failed: ${String((e && e.message) || e)}` }; }
119
+ try {
120
+ if (lang === 'haskell') {
121
+ let witnessHook;
122
+ if (witness) { const w = await import('./witness.js'); witnessHook = w.haskellFixWitness(finding); }
123
+ res = await validateHaskellFix(finding, { files, apply, root, compile: compile !== false, witness: witnessHook });
124
+ } else res = await validateNixFix(finding, { files, apply, root });
125
+ } catch (e) { res = { status: 'blocked', reason: `verification failed: ${String((e && e.message) || e)}` }; }
116
126
  return {
117
127
  ok: res.status === 'verified' || res.status === 'applied',
118
128
  status: res.status, applied: res.applied === true, backup: res.backup || null,
@@ -126,6 +136,23 @@ export async function languageFixPreview(finding, files, { apply = false, root =
126
136
  };
127
137
  }
128
138
 
139
+ /**
140
+ * Runs a patch SUPPLIED BY SOMEONE ELSE (a person, a model) through the same gates as a deterministic fix, for one Haskell or Nix finding:
141
+ * syntax, a rescan in which the original finding is gone and nothing new appears at medium or above, and (Haskell) a GHC type-check against the
142
+ * original. `proposal` is the full new text of `file`. Never writes. This is what `agentic-security fix <id> --proposal <file>` calls.
143
+ */
144
+ export async function languageProposalPreview(finding, files, { file, proposal, compile = true }) {
145
+ const lang = languageOfFinding(finding);
146
+ if (!lang) return { ok: false, status: 'unsupported', reason: 'not a Haskell or Nix finding' };
147
+ if (!file || typeof proposal !== 'string') return { ok: false, status: 'blocked', reason: 'a proposal needs the file it replaces and its full new text' };
148
+ if (typeof files[file] !== 'string') return { ok: false, status: 'blocked', reason: `${file} is not in the project tree` };
149
+ let res;
150
+ try { res = lang === 'haskell' ? await verifyHaskellProposal(finding, { files, file, proposal, compile: compile !== false }) : await verifyNixProposal(finding, { files, file, proposal }); }
151
+ catch (e) { res = { status: 'blocked', reason: `verification failed: ${String((e && e.message) || e)}` }; }
152
+ const ok = res.status === 'verified';
153
+ return { ok, status: res.status, applied: false, label: 'WORKAROUND', tier: ok ? 'proposal-verified' : 'blocked', file, before: files[file], after: proposal, diff: res.preview || hsDiff(file, files[file], proposal), gates: res.gates || null, reason: ok ? null : (res.reason || null), explanation: 'A patch supplied by someone else, run through the same gates as a deterministic fix; it is not a rewrite this tool derived.', consequences: ['Not derived by the scanner: its consequences are the author\'s to state.'] };
154
+ }
155
+
129
156
  /** A minimal line-range text edit turning `before` into `after` (for an LSP/editor workspace edit). */
130
157
  export function minimalEdit(before, after) {
131
158
  const a = before.split('\n'); const b = after.split('\n');