@clear-capabilities/agentic-security-scanner 0.195.25 → 0.195.26

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
- ea2e63293cf62b7a65de89db1da0f32c8490a8b41c5d9bbfd20fba1c3acb284f agentic-security.mjs
1
+ ee0c77de1bba97155580cc7647e6723e10bde63e9944dbc5050f1db7b54d46a2 agentic-security.mjs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clear-capabilities/agentic-security-scanner",
3
- "version": "0.195.25",
3
+ "version": "0.195.26",
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",
@@ -90,7 +90,7 @@
90
90
  "test:dataflow": "node --test test/privacy-catalog.test.js test/privacy-deep-walker.test.js test/privacy-deep-e2e.test.js test/container-taint.test.js test/stored-taint-families.test.js test/py-annotation-sources.test.js test/fn-reach.test.js test/deep-taint.test.js test/calibration.test.js test/holdout-eval.test.js test/cross-lang-meta.test.js test/cross-lang-queues.test.js test/phase5-xlang.test.js test/phase5-coverage.test.js test/phase6-taint.test.js test/llm-validator-consistency.test.js test/llm-validator-default-on.test.js test/llm-validator-preset.test.js test/parser-py-cst.test.js test/chrome-probe.test.js test/parser-cs-kt.test.js test/parser-cs-annotations.test.js test/parser-cs-control-flow.test.js test/parser-kt-control-flow.test.js test/parser-js-annotations.test.js test/parser-java-annotations.test.js test/parser-java-control-flow.test.js test/parser-java-calls.test.js test/parser-go.test.js test/parser-rust.test.js test/rust-taint-flow.test.js test/parser-php-rb.test.js test/parser-php-control-flow.test.js test/interproc-k2.test.js test/proven-clean.test.js test/backward-default.test.js test/incremental-cache.test.js test/string-regex-lattice.test.js test/closure-capture.test.js test/points-to.test.js test/type-stubs.test.js test/soft-taint.test.js test/ifds.test.js test/symbolic-exec-proof.test.js test/ifds-summary-edges.test.js test/stub-aware-filter.test.js test/cross-repo.test.js test/proof-gate.test.js test/layer-recall.test.js test/java-taint-flow.test.js test/kt-taint-flow.test.js test/sanitizer-typed-flow.test.js test/sanitizer-gate-unit.test.js test/proof-safe.test.js test/collection-taint.test.js test/kcfa-context.test.js test/kcfa-callstring.test.js test/flow-parity.test.js test/callgraph-resolve.test.js test/import-reachability.test.js test/ir-stats.test.js test/parser-cpp.test.js test/parser-js-decorators.test.js test/parser-js-if-else-cfg.test.js test/cpp-integration.test.js test/engine-reconnect.test.js test/phase2-scoping.test.js test/engine-recall.test.js test/catalog-annotation-source.test.js test/catalog-cs-p1.test.js test/catalog-expanded.test.js test/builtin-summaries.test.js test/interproc-mutation-crash.test.js test/mutated-param-taint.test.js test/return-sink-check.test.js test/higher-order-inline-callback.test.js test/interproc-findings-discard.test.js test/runscan-deep-option.test.js test/summary-cache-convergence.test.js test/parser-rb-calls.test.js test/parser-rb-module-level.test.js test/balanced-call.test.js test/destructuring-taint.test.js test/computed-member-taint.test.js test/string-interpolation-taint.test.js test/dataflow-deadcode-severity.test.js test/catalog-dotted-callee-lookup.test.js test/builtin-summary-family-blind-kill.test.js test/deep-mode-annotator-pipeline.test.js test/receiver-type-and-nested-calls.test.js test/member-write-and-loop-taint.test.js test/parser-py-module-level.test.js test/parser-php-module-level.test.js test/r14b-module-level-e2e.test.js test/annotation-taint-engine.test.js test/interproc-nested-call-taint.test.js test/catalog-xpath-injection.test.js test/catalog-ldap-injection.test.js test/catalog-xxe.test.js test/catalog-response-splitting.test.js test/catalog-code-injection.test.js test/parser-rb-control-flow.test.js test/catalog-ruby-p1.test.js test/callee-receiver-taint.test.js test/catalog-command-injection-p4.test.js test/catalog-xss-p4.test.js test/catalog-path-ssrf-p3.test.js test/catalog-ai-model-provider-precision.test.js test/catalog-orm-write.test.js test/dataflow/provenance-pipeline-integration.test.js test/dataflow-progress.test.js test/scan-progress.test.js test/sard-leakage-pipeline.test.js test/sard-split-scoring.test.js test/sard-llm-isolation.test.js test/sard-mutation-adversarial.test.js test/sard-baseline-adaptive-tolerance.test.js test/sard-independent-holdout.test.js test/sard-flow-aware-scoring.test.js test/sard-macro-score-support-floor.test.js test/sard-php-split.test.js test/parser-java-nary-concat.test.js test/sard-naive-baseline.test.js test/field-taint-cross-method.test.js test/field-taint-engine-unit.test.js test/coercion-sanitizer-kill.test.js test/constant-propagation-path-sensitivity.test.js test/parser-java-assignments.test.js test/catalog-java-sard.test.js test/parser-cs-juliet-shapes.test.js test/catalog-cs-sard-p2.test.js test/sard-cwe-key-merge.test.js test/parser-php-backtick.test.js test/parser-php-include.test.js test/php-include-merge.test.js test/parser-php-array-field-taint.test.js test/parser-php-cond-assign.test.js test/parser-rust-redos.test.js test/multi-sink-taint-chain.test.js test/static-field-cross-class-call-string.test.js test/regex-guard-narrowing.test.js test/object-instance-field-taint.test.js test/param-type-inference.test.js test/smt-path-feasibility.test.js test/cross-service-taint.test.js test/dataflow-expr-taint-scaling.test.js test/evaluation/guard-dominance.test.js test/evaluation/engine-mechanisms.test.js test/rx-exec-receiver.test.js test/incremental-dependency.test.js test/incremental-integrity.test.js test/demand-query.test.js test/ir-cache.test.js test/canonicalize-parity.test.js test/dataflow-source-attribution.test.js",
91
91
  "test:mcp": "node --test test/mcp.test.js test/mcp-apply-fix-multifile.test.js test/mcp-protocol-smoke.test.js test/mcp-audit.test.js test/audit-cli.test.js test/mcp-scratchpad.test.js test/mcp-offload.test.js test/sca-upgrade.test.js test/lsp-server.test.js test/mcp-dataflow-tools.test.js test/cli/governance-propose-edit.test.js test/cli/remediation-open-update.test.js test/cli/remediation-verify-reopen.test.js test/cli/dataflow-observations.test.js test/cli/federate-declare-list.test.js test/capabilities/tools.test.js",
92
92
  "test:report": "node --test test/report-scan-invariants.test.js test/junit.test.js test/ci.test.js test/poc-generator.test.js test/verifier.test.js test/verifier-target.test.js test/annotator-errors.test.js test/git-history-batch.test.js test/default-scan-drift.test.js test/bundle-optional-deps.test.js test/scan-health.test.js test/finding-schema.test.js test/pipeline/finding-schema-provenance.test.js test/evidence-provenance.test.js test/report/provenance-output.test.js test/report/provenance-format-parity.test.js test/legacy-compat.test.js test/annotator-runner.test.js test/detector-runner.test.js test/detector-errors.test.js test/analyzer-supervisor.test.js test/cascade-worker-pool.test.js test/cascade-pool-wiring.test.js test/coverage-ledger.test.js test/coverage-ledger-completeness.test.js test/assurance-mode.test.js test/artifact-registry.test.js test/artifact-registry-completeness.test.js test/retention-policy.test.js test/state-lifecycle-report.test.js test/legal-hold.test.js test/production-feedback.test.js test/calibration-feedback.test.js test/encryption-provider.test.js test/grader-calibration.test.js test/pr-delta-gate.test.js test/vex.test.js test/report-render.test.js test/oscal-conformance.test.js test/cli-fix-apply.test.js test/apply-fix-service.test.js test/high-impact-approval-gate.test.js test/approver-registry.test.js test/suppression-exceptions.test.js test/fix-history.test.js test/producer-registry.test.js test/producer-collector.test.js test/enrichment-completion.test.js",
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/seeding-across-calls.test.js test/lineage/boundary-join.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",
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/seeding-across-calls.test.js test/lineage/boundary-join.test.js test/contracts/code-boundaries.contract.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
96
  "test:haskell": "node --test test/haskell/haskell-reachability.test.js test/haskell/haskell-real-code-shapes.test.js test/haskell/haskell-unseen-v5-shapes.test.js test/haskell/haskell-unseen-v4-shapes.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-remediation-kdf.test.js test/haskell/haskell-remediation-html-text.test.js test/haskell/haskell-remediation-scotty-body.test.js test/haskell/haskell-remediation-entropy.test.js test/haskell/haskell-remediation-text-command.test.js test/haskell/haskell-remediation-path-contain.test.js test/haskell/haskell-remediation-0195-16.test.js test/haskell/haskell-taint-shapes.test.js test/haskell/haskell-real-advisory-shapes.test.js test/haskell/haskell-discovery-shapes.test.js test/haskell/haskell-quasiquote-interp.test.js test/haskell/haskell-record-dot.test.js test/haskell/haskell-global-cell.test.js test/haskell/haskell-syntax-extensions.test.js test/haskell/haskell-rule-precision.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-trust-fixed-file.test.js test/haskell/haskell-predictable-random-flow.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",
@@ -133,7 +133,7 @@
133
133
  "verification:replay-example": "node ../scripts/verification-replay-example.mjs",
134
134
  "test:lifecycle": "node --test test/ci-hang-protection.test.js test/gate-select.test.js test/ci-test-shards.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/trust-boundary.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/gate-warm.test.js test/docs-positioning.test.js test/dist-chunks-tracked.test.js test/ga-program.test.js test/workflow-confinement.test.js test/check-bundle-source.test.js test/ship.test.js test/release-prepare.test.js && node ../scripts/lint-command-descriptions.mjs",
135
135
  "test:eval": "node --test test/independent-eval.test.js test/realworld-recall.test.js",
136
- "test:discovery": "node --test test/discovery-spec-synthesis.test.js 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 test/context-intelligence/build-js.test.js test/context-intelligence/build-python-haskell.test.js test/context-intelligence/hie-adapter.test.js test/context-intelligence/store.test.js test/context-intelligence/review-queue.test.js test/context-intelligence/packer-egress.test.js test/context-intelligence/evaluation.test.js test/context-intelligence/queue-diff.test.js test/context-intelligence/cli.test.js test/context-intelligence/resilience.test.js test/context-intelligence/frameworks.test.js test/context-intelligence/skew-git-rls.test.js test/context-intelligence/premortem.test.js test/context-intelligence/git-diff-candidates.test.js",
136
+ "test:discovery": "node --test test/discovery-spec-synthesis.test.js 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 test/context-intelligence/build-js.test.js test/context-intelligence/build-python-haskell.test.js test/context-intelligence/hie-adapter.test.js test/context-intelligence/store.test.js test/context-intelligence/review-queue.test.js test/context-intelligence/route-mounts.test.js test/context-intelligence/packer-egress.test.js test/context-intelligence/evaluation.test.js test/context-intelligence/queue-diff.test.js test/context-intelligence/cli.test.js test/context-intelligence/resilience.test.js test/context-intelligence/frameworks.test.js test/context-intelligence/skew-git-rls.test.js test/context-intelligence/premortem.test.js test/context-intelligence/git-diff-candidates.test.js",
137
137
  "eval:independent": "node ../bench/independent-eval/runner.mjs",
138
138
  "eval:independent:gate": "node ../bench/independent-eval/runner.mjs --gate default",
139
139
  "smoke": "node bin/agentic-security.js scan test/fixtures/vulnerable-js",
@@ -23,3 +23,7 @@ See the module table in the architecture doc. Tests: `test/context-intelligence/
23
23
  ## Reuse, do not fork
24
24
 
25
25
  Model calls go through `discovery/llm-invoke.js` and the egress policy. Confirmation, refutation and judging are `discovery/{confirm,disprove,judge}.js`. Grouped intervals come from `posture/evaluation/interval.js`. State paths come from `posture/state-dir.js`.
26
+
27
+ ## Router mounts and stale citations (GA-03.2, 0.195.26)
28
+
29
+ `entrypoints.js` composes router mounts: `router.get('/:id')` with `app.use('/api', auth, router)` is the entry point `/api/:id` (`pathTemplate`), with `registeredPath` keeping `/:id` and `mount` recording the chain (root first) and the number of alternatives. A mount is found in the same file, through the import map (`import users from './users.js'`, `const users = require('./users')`) or an inline `require('./users')`, when the target module's `module.exports` / `export default` receiver registers routes or mounts; mounts compose through intermediate routers (depth 4, at most 8 paths); a router mounted at several prefixes is one entry point per prefix; the mount statement's other identifier arguments are inherited middleware scoped to that mount (`viaMount: true`), per chain. A dynamic prefix (`app.use(prefixFor(), router)`) mounts nothing and the route keeps `router-mount-not-resolved`. `review.js`'s `resolveCitation` refuses `stale-context` when the cited lines sit in a segment whose current text no longer hashes to the segment's `spanHash`. Tests: `test/context-intelligence/route-mounts.test.js`.
@@ -16,6 +16,7 @@ import { hashOf, safeLabel } from './canonical.js';
16
16
  import { callStatement, inlineBlockSpan, splitArgs } from './spans.js';
17
17
  import { dependencyParams, signatureText } from './controls.js';
18
18
  import { buildEntrypointInventory } from '../posture/entrypoint-inventory.js';
19
+ import { buildImportMap, resolveViaImports, resolveJsModule } from './imports.js';
19
20
 
20
21
  export const DISPOSITIONS = Object.freeze(['resolved', 'partially_resolved', 'unresolved', 'excluded_by_policy']);
21
22
  export const ENTRYPOINT_KINDS = Object.freeze(['http', 'webhook', 'queue', 'cron', 'upload', 'cli']);
@@ -88,8 +89,11 @@ function collectUses(fileContents) {
88
89
  let rest = args;
89
90
  const q = args[0] && /^(['"`])(.*)\1$/.exec(args[0]);
90
91
  if (q) { prefix = q[2]; rest = args.slice(1); }
92
+ // GA-03.2: a first argument that is a call, a template with interpolation or a concatenation is a prefix
93
+ // the scan cannot read; the use still records its middleware, but nothing is mounted under a guessed '/'.
94
+ const prefixDynamic = !q && !!args[0] && (/^[A-Za-z_$][\w$.]*\s*\(/.test(args[0]) && !/^require\s*\(/.test(args[0]) || /\$\{|\+/.test(args[0]));
91
95
  const names = rest.map(a => { const id = IDENT_CHAIN.exec(a.replace(/^\[|\]$/g, '')); return id ? { name: id[0], text: a } : { name: null, text: a }; });
92
- uses.push({ file, line: i + 1, receiver: m[1], prefix, args: names });
96
+ uses.push({ file, line: i + 1, receiver: m[1], prefix, prefixDynamic, args: names });
93
97
  }
94
98
  }
95
99
  return uses;
@@ -212,6 +216,107 @@ function resolveHandler(route, ctx) {
212
216
  return out;
213
217
  }
214
218
 
219
+ // ── Router mounts (GA-03.2, 0.195.26) ──────────────────────────────────────
220
+ //
221
+ // `router.get('/:id', h)` is reachable at `/users/:id` only through `app.use('/users', router)`, and that mount
222
+ // can sit in another file (`app.use('/users', require('./users'))`, `import users from './users.js'`). Before
223
+ // this, every route on a router-looking receiver was `partially_resolved` with `router-mount-not-resolved` and
224
+ // its path was the local one. Now a mount is found when the mounted argument is (a) a receiver with routes in
225
+ // the same file, (b) a name imported from a file whose `module.exports` / `export default` is that file's
226
+ // router receiver, or (c) an inline `require('./x')` of such a file. Mounts compose: `app.use('/api', v1)` and
227
+ // `v1.use('/users', users)` make `/api/users/:id`. A router mounted at several prefixes is reachable at each,
228
+ // so it yields one entry point per prefix (sorted; at most MAX_MOUNT_PATHS). Depth is bounded; a cycle stops.
229
+ // A mount's other identifier arguments (`app.use('/api', auth, router)`) are middleware every mounted route
230
+ // inherits, recorded with the mount's prefix as their scope. What stays unresolved: a router passed through a
231
+ // variable the import map cannot follow, a mount whose prefix is not a string literal, a dynamic
232
+ // `app.use(prefixVar, router)`, and anything registered outside the scanned files.
233
+ const MAX_MOUNT_DEPTH = 4;
234
+ const MAX_MOUNT_PATHS = 8;
235
+ const ROUTE_RECEIVER_RE = /([A-Za-z_$][\w$]*)\.(?:get|post|put|patch|delete|all|options|head|route)\s*\(/;
236
+ const ROUTER_NAME_RE = /^(?:router|routes?|r|api|\w*Router)$/i;
237
+
238
+ function routeReceiver(fileContents, route) {
239
+ if (!JS_FILE.test(route.file)) return null;
240
+ const line = (fileContents[route.file] || '').split('\n')[route.line - 1] || '';
241
+ const m = ROUTE_RECEIVER_RE.exec(line);
242
+ return m ? m[1] : null;
243
+ }
244
+
245
+ /** The receiver a file exports as its module (`module.exports = router`, `export default router`), or null. */
246
+ function exportedReceiver(text) {
247
+ const t = String(text || '');
248
+ const m = /\bmodule\.exports\s*=\s*([A-Za-z_$][\w$]*)\s*;?/.exec(t) || /\bexport\s+default\s+([A-Za-z_$][\w$]*)\s*;?/.exec(t);
249
+ return m ? m[1] : null;
250
+ }
251
+
252
+ function joinMountPath(prefix, routePath) {
253
+ const p = String(prefix || '/').replace(/\/+$/, '');
254
+ const r = String(routePath || '');
255
+ if (!p) return r.startsWith('/') ? r : '/' + r;
256
+ if (!r || r === '/') return p;
257
+ return p + (r.startsWith('/') ? r : '/' + r);
258
+ }
259
+
260
+ /**
261
+ * Every mount of a router: key `${file}|${receiver}` -> [{ prefix, file, line, receiver, middleware }], where
262
+ * `receiver` is the receiver the router was mounted ON (so a chain can be followed upward).
263
+ */
264
+ function buildMountIndex(fileContents, uses, routes) {
265
+ const files = new Set(Object.keys(fileContents));
266
+ const imports = buildImportMap(fileContents);
267
+ const receiversByFile = new Map();
268
+ for (const r of routes) {
269
+ const recv = routeReceiver(fileContents, r);
270
+ if (!recv) continue;
271
+ if (!receiversByFile.has(r.file)) receiversByFile.set(r.file, new Set());
272
+ receiversByFile.get(r.file).add(recv);
273
+ }
274
+ // a receiver that mounts something is a router too (an intermediate `v1.use('/users', users)`)
275
+ for (const u of uses) { if (!receiversByFile.has(u.file)) receiversByFile.set(u.file, new Set()); receiversByFile.get(u.file).add(u.receiver); }
276
+ const index = new Map();
277
+ const add = (file, receiver, mount) => { const k = `${file}|${receiver}`; if (!index.has(k)) index.set(k, []); index.get(k).push(mount); };
278
+ // a module is a router module only when its exported receiver registers routes or mounts in that file
279
+ const isRouter = (file, receiver) => !!receiver && (receiversByFile.get(file) || new Set()).has(receiver);
280
+ for (const u of uses) {
281
+ if (u.prefixDynamic) continue;
282
+ const middleware = [];
283
+ for (const a of u.args) {
284
+ let target = null;
285
+ const req = /^require\(\s*(['"])([^'"]+)\1\s*\)$/.exec(String(a.text || '').trim());
286
+ if (req) {
287
+ const res = resolveJsModule(u.file, req[2], files);
288
+ const recv = res && res.target && files.has(res.target) ? exportedReceiver(fileContents[res.target]) : null;
289
+ if (recv && isRouter(res.target, recv)) target = { file: res.target, receiver: recv };
290
+ } else if (a.name && a.name !== u.receiver && isRouter(u.file, a.name)) {
291
+ target = { file: u.file, receiver: a.name };
292
+ } else if (a.name) {
293
+ const via = resolveViaImports(imports, u.file, a.name);
294
+ const recv = via && via.kind === 'exact-import' && via.targetFile && files.has(via.targetFile) ? exportedReceiver(fileContents[via.targetFile]) : null;
295
+ if (recv && isRouter(via.targetFile, recv)) target = { file: via.targetFile, receiver: recv };
296
+ }
297
+ if (target) add(target.file, target.receiver, { prefix: u.prefix, file: u.file, line: u.line, receiver: u.receiver, middleware: [...middleware] });
298
+ else if (a.name && !req) middleware.push(a.name);
299
+ }
300
+ }
301
+ return index;
302
+ }
303
+
304
+ /** The mount chains (root first) through which (file, receiver) is reachable; [] when it is mounted nowhere. */
305
+ function mountChains(index, file, receiver, depth = 0, seen = new Set()) {
306
+ const key = `${file}|${receiver}`;
307
+ const mounts = index.get(key) || [];
308
+ if (!mounts.length || depth >= MAX_MOUNT_DEPTH || seen.has(key)) return [];
309
+ const out = [];
310
+ const next = new Set(seen); next.add(key);
311
+ for (const m of mounts) {
312
+ const parents = mountChains(index, m.file, m.receiver, depth + 1, next);
313
+ if (!parents.length) out.push([m]);
314
+ else for (const pc of parents) out.push([...pc, m]);
315
+ if (out.length >= MAX_MOUNT_PATHS) break;
316
+ }
317
+ return out.slice(0, MAX_MOUNT_PATHS);
318
+ }
319
+
215
320
  function pathMatchesPrefix(routePath, prefix) {
216
321
  if (prefix === '/' || prefix === '') return true;
217
322
  const p = prefix.replace(/\/+$/, '');
@@ -226,23 +331,24 @@ export async function collectEntrypoints({ fileContents, graph, config = {} }) {
226
331
  const kinds = new Set(config.entrypointKinds && config.entrypointKinds.length ? config.entrypointKinds : ['http']);
227
332
  const idx = symbolIndexes(graph);
228
333
  const uses = collectUses(fileContents);
334
+ const routes = (kinds.has('http') || kinds.has('webhook')) ? await collectRoutes(fileContents) : [];
335
+ const mountIndex = buildMountIndex(fileContents, uses, routes);
229
336
  const exclude = (config.exclude || []).map(globToRe);
230
337
  const cap = Number.isInteger(config.maxEntrypoints) && config.maxEntrypoints > 0 ? config.maxEntrypoints : 5000;
231
338
  const ctx = { fileContents, idx, graph };
232
339
  const records = [];
233
340
 
234
341
  if (kinds.has('http') || kinds.has('webhook')) {
235
- for (const route of await collectRoutes(fileContents)) {
342
+ for (const route of routes) {
236
343
  const isWebhook = /webhook/i.test(route.path || '');
237
344
  if (isWebhook && !kinds.has('webhook') && !kinds.has('http')) continue;
238
345
  const h = resolveHandler(route, ctx);
239
346
  const reasons = [...h.reasons];
240
347
  const middleware = [...h.middleware];
348
+ const receiver = routeReceiver(fileContents, route);
349
+ const chains = receiver ? mountChains(mountIndex, route.file, receiver) : [];
241
350
  // inherited middleware registered with `X.use(...)` on the same receiver, earlier in the same file
242
351
  if (JS_FILE.test(route.file)) {
243
- const lines = fileContents[route.file].split('\n');
244
- const recvM = /([A-Za-z_$][\w$]*)\.(?:get|post|put|patch|delete|all|options|head|route)\s*\(/.exec(lines[route.line - 1] || '');
245
- const receiver = recvM ? recvM[1] : null;
246
352
  for (const u of uses) {
247
353
  if (u.file !== route.file || u.line >= route.line || u.receiver !== receiver) continue;
248
354
  if (!pathMatchesPrefix(String(route.path || ''), u.prefix)) continue;
@@ -253,17 +359,35 @@ export async function collectEntrypoints({ fileContents, graph, config = {} }) {
253
359
  if (!r.symbol) reasons.push(`inherited-middleware-${r.resolution}:${a.name}`);
254
360
  }
255
361
  }
256
- if (receiver && /^(?:router|routes?|r|api|\w*Router)$/i.test(receiver) && !uses.some(u => u.receiver !== receiver && u.args.some(a => a.name === receiver))) {
257
- reasons.push('router-mount-not-resolved');
258
- }
362
+ if (receiver && !chains.length && ROUTER_NAME_RE.test(receiver)) reasons.push('router-mount-not-resolved');
259
363
  }
364
+ // middleware the mount statements apply to everything mounted under them (`app.use('/api', auth, router)`):
365
+ // per chain, so a router mounted twice inherits each mount's own middleware and not the other's
366
+ const chainMiddleware = (chain) => {
367
+ const mw = []; const why = [];
368
+ for (const hop of chain) {
369
+ for (const name of hop.middleware) {
370
+ const r = resolveName(name, hop.file, idx);
371
+ mw.push({ name, symbolId: r.symbol ? r.symbol.id : null, resolution: r.resolution, line: hop.line, scope: hop.prefix, inherited: true, viaMount: true });
372
+ if (!r.symbol) why.push(`inherited-middleware-${r.resolution}:${name}`);
373
+ }
374
+ }
375
+ return { mw, why };
376
+ };
260
377
  const authScheme = route.webModel
261
378
  ? { status: route.webModel.auth.status, kind: route.webModel.auth.kind || null, basis: 'haskell-web-model', role: route.webModel.role.status, ownership: route.webModel.ownership || null, idLookups: route.webModel.idLookups || [], csrf: !!route.webModel.csrf }
262
379
  : { status: route.hasAuth ? 'authenticated' : 'none', basis: 'route-scan-heuristic', note: 'authentication seen near the registration; says nothing about ownership' };
380
+ const mountsOf = chains.map(chain => ({
381
+ prefix: chain.reduce((acc, hop) => joinMountPath(acc, hop.prefix), '/'),
382
+ chain: chain.map(hop => ({ file: hop.file, line: hop.line, receiver: hop.receiver, prefix: hop.prefix })),
383
+ ...chainMiddleware(chain),
384
+ })).sort((a, b) => (a.prefix < b.prefix ? -1 : a.prefix > b.prefix ? 1 : 0));
263
385
  const base = {
264
386
  kind: isWebhook ? 'webhook' : 'http',
265
387
  method: String(route.method || 'GET'),
266
388
  pathTemplate: String(route.path || ''),
389
+ registeredPath: String(route.path || ''),
390
+ mount: null,
267
391
  framework: route.framework || null,
268
392
  language: route.language === 'haskell' ? 'haskell' : (JS_FILE.test(route.file) ? 'javascript' : (route.file.split('.').pop())),
269
393
  registrationSite: { file: route.file, line: route.line },
@@ -276,7 +400,15 @@ export async function collectEntrypoints({ fileContents, graph, config = {} }) {
276
400
  externalDependencies: [],
277
401
  resolutionReasons: [...new Set(reasons)].sort(),
278
402
  };
279
- records.push(base);
403
+ if (!mountsOf.length) { records.push(base); continue; }
404
+ for (const m of mountsOf) {
405
+ const { mw, why, ...mount } = m;
406
+ records.push({
407
+ ...base, pathTemplate: joinMountPath(mount.prefix, base.registeredPath), mount: { ...mount, alternatives: mountsOf.length },
408
+ middleware: [...base.middleware.map(x => ({ ...x })), ...mw],
409
+ resolutionReasons: [...new Set([...base.resolutionReasons, ...why])].sort(),
410
+ });
411
+ }
280
412
  }
281
413
  }
282
414
 
@@ -17,7 +17,7 @@ import * as path from 'node:path';
17
17
 
18
18
  const JS_EXTS = ['', '.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '/index.js', '/index.mjs', '/index.ts', '/index.tsx'];
19
19
 
20
- function resolveJsModule(fromFile, source, files) {
20
+ export function resolveJsModule(fromFile, source, files) {
21
21
  if (!source.startsWith('.')) return { kind: 'third-party', target: null };
22
22
  const base = path.posix.normalize(path.posix.join(path.posix.dirname(fromFile), source));
23
23
  for (const ext of JS_EXTS) if (files.has(base + ext)) return { kind: 'repo', target: base + ext };
@@ -20,7 +20,7 @@ import { redactPayload } from '../egress/redact.js';
20
20
  import { evaluateEgress } from '../egress/policy.js';
21
21
  import { resolveProvider } from '../llm-validator/providers.js';
22
22
  import { resolveLlmInvokeWithDecision } from '../discovery/llm-invoke.js';
23
- import { hashOf, safeLabel, safeRelPath, sha256 } from './canonical.js';
23
+ import { hashOf, safeLabel, safeRelPath, sha256, sliceLines } from './canonical.js';
24
24
  import { estimateTokens } from './packer.js';
25
25
  import { REVIEW_SCHEMA_VERSION, packRef } from './prompts/packs.js';
26
26
 
@@ -249,8 +249,13 @@ export function resolveCitation(c, segments, fileContents) {
249
249
  if (typeof text !== 'string') return 'file-not-in-snapshot';
250
250
  const fileLines = text.split('\n');
251
251
  if (c.endLine > fileLines.length) return 'beyond-end-of-file';
252
- const inside = segments.some(s => s.file === c.file && c.startLine >= s.startLine && c.endLine <= s.endLine);
253
- if (!inside) return 'outside-provided-context';
252
+ const inside = segments.filter(s => s.file === c.file && c.startLine >= s.startLine && c.endLine <= s.endLine);
253
+ if (!inside.length) return 'outside-provided-context';
254
+ // GA-03.2 (0.195.26): the citation is bound to the exact lines the bundle was built from. A segment carries the
255
+ // hash of its span at build time; if the snapshot's lines no longer hash to it, the model reviewed text that is
256
+ // not what is on disk now, and the citation is stale rather than evidence. A segment with no recorded hash
257
+ // (a hand-built one) cannot be judged and is not refused for that reason.
258
+ if (inside.every(s => typeof s.spanHash === 'string' && sha256(sliceLines(text, s.startLine, s.endLine)) !== s.spanHash)) return 'stale-context';
254
259
  return claimGrounded(c.claim, fileLines.slice(c.startLine - 1, c.endLine).join('\n')) ? null : 'claim-not-grounded-in-cited-lines';
255
260
  }
256
261
 
package/src/engine.js CHANGED
@@ -222,6 +222,7 @@ import { createStringMemo } from './util/string-memo.js';
222
222
  import { collectIrStats, irStatsTarget, writeIrStats } from './ir/ir-stats.js';
223
223
  import { runDeepAnalysis } from './dataflow/index.js';
224
224
  import { buildLineageGraph } from './lineage/index.js';
225
+ import { getRepoState, worktreeDirtyExcluding } from './posture/provenance/git-evidence.js';
225
226
  import { buildLineageGraphOwned, resolveLineageDeadlineMs } from './lineage/owned-build.js';
226
227
  import { completionOfGraph, summarizeForHealth as summarizeCompletionForHealth } from './lineage/completion.js';
227
228
  // v3 next-gen — Pillars 1, 4, 5, 6, 8, 9.
@@ -9747,6 +9748,12 @@ function _deterministicFileTimings(timings) {
9747
9748
  const _lineageDeadlineMs = resolveLineageDeadlineMs();
9748
9749
  const { perFile, callGraph } = _lineageDeadlineMs ? {} : (_sharedIR || (_sharedIR = await _buildIR()));
9749
9750
  const _lineageT0 = Date.now();
9751
+ let _lineageRepo = null;
9752
+ try {
9753
+ _lineageRepo = scanRoot ? getRepoState(scanRoot) : null;
9754
+ // The scanner's own state directory must not make an unchanged tree read as dirty between two scans.
9755
+ if (_lineageRepo) _lineageRepo = { ..._lineageRepo, dirty: worktreeDirtyExcluding(scanRoot) };
9756
+ } catch { _lineageRepo = null; }
9750
9757
  setProgress({ phase: 'Lineage graph', current: 0, total: 0, file: `starting (budget ${Math.round(_lineageBudgetMs / 1000)}s)` });
9751
9758
  const _lineageOpts = {
9752
9759
  // Live per-function progress from the field-identity driver's own
@@ -9777,6 +9784,9 @@ function _deterministicFileTimings(timings) {
9777
9784
  // GA-03.3 (0.195.25): cross-service handoff joins in the lineage
9778
9785
  // graph, opt-in. Off, the graph is the one 0.195.24 built.
9779
9786
  ...(process.env.AGENTIC_SECURITY_LINEAGE_BOUNDARIES === '1' ? { boundaries: { enabled: true } } : {}),
9787
+ // GA-03.7 (0.195.26): the revision the graph is built from, so a
9788
+ // consumer can refuse a graph from another one. Null outside git.
9789
+ ...(_lineageRepo && _lineageRepo.head ? { commit: _lineageRepo.head, worktreeDirty: _lineageRepo.dirty } : {}),
9780
9790
  };
9781
9791
  // DAG-07 R07.7: with AGENTIC_SECURITY_LINEAGE_DEADLINE_MS set, the build runs in an
9782
9792
  // owned worker with a watchdog. Unset: the in-process call, exactly as before.
@@ -1353,3 +1353,7 @@ A value leaving one function through a queue publish or a store write and re-ent
1353
1353
  | `index.js` / `engine.js` (scan) | `AGENTIC_SECURITY_LINEAGE_BOUNDARIES=1` sets `boundaries.enabled`; `index.js` reads `.agentic-security/boundary-contracts.json` (the file `dataflow contracts` reads) once, existence-gated, a malformed file printed and treated as no contracts. `bench/dag/lib/scan-unit.mjs` forces the variable on, as it does deep mode. |
1354
1354
 
1355
1355
  Measured on the multi-service fixture, scan path (`node bench/dag/run.mjs --only=fixture`): flow labels 6 of 6 positive and 3 of 3 negative, where 0.195.24 found 3 of 6; the two site labels (stored value into HTML) stay FN, they need a storage-read source in the taint engine; one reported flow matches no label (`qty` into `orders.total`, a derived numeric write the label set does not list; left unlabelled under the no-relabelling rule). Precision of the join rests on three things stated above: a literal identity on both sides, the ids that really reached the producer site, and outermost-path field mapping. What it does not do: a queue consumed through a callback (`consumer.on('message', cb)`), a store read through an ORM model, a batch send's per-entry bodies, an identity that is a template string or a concatenation, and a producer in another repository (that is `cross-repo-link.js`'s declared edge, unchanged).
1356
+
1357
+ ## Frozen artifact contracts and the scanned revision (GA-03.7, 0.195.26)
1358
+
1359
+ `docs/reference/code-boundaries-contract.md` freezes the shape of the signed lineage graph, witness bundles, boundary contracts, context bundles, sink query keys and cut plans, and `test/contracts/code-boundaries.contract.test.js` holds every table in both directions and exercises each rule with a real tamper, a real wrong revision and a real partial analysis (it is the `contractTest` of the `code-boundaries`, `graph-precision` and `context-intelligence` registry rows, so `check-ga-contracts` runs it). Two things changed in code for it: `graph-builder.js` records `graph.scope.revision = { commit, worktreeDirty }` when the caller passes `commit` (the scan passes the repository head from `getRepoState`; a build without one is byte-identical to before), and `server/graph-loader.js`'s `loadSignedGraph(root, { expectRevision })` refuses with `wrong-revision` a graph that records another commit, or none (`dataflow view --expect-revision <sha|HEAD>`). The incremental 3x end-to-end target (GA-03.6) was measured, not met, and the measured reason is under "Open items" in `docs/implementation-status/dag-improvements.md`.
@@ -602,6 +602,14 @@ export function buildDataFlowGraph(callGraph, opts = {}) {
602
602
  // module ever passed `undefined`/omitted the key entirely).
603
603
  generatedAt: opts.generatedAt ?? '1970-01-01T00:00:00.000Z',
604
604
  });
605
+ // GA-03.7 (0.195.26): the revision the graph was built from, when the
606
+ // caller knows it (a scan passes the repository head). A consumer can then
607
+ // refuse a graph built from another revision (`loadSignedGraph`'s
608
+ // `expectRevision`). Absent when no commit was given, so a build without
609
+ // one is byte-identical to before.
610
+ if (typeof opts.commit === 'string' && opts.commit) {
611
+ graph.scope.revision = { commit: opts.commit, worktreeDirty: typeof opts.worktreeDirty === 'boolean' ? opts.worktreeDirty : null };
612
+ }
605
613
 
606
614
  const nodesById = new Map();
607
615
  const edgesById = new Map();
@@ -301,6 +301,8 @@ export function buildLineageGraph(callGraph, opts = {}) {
301
301
  const built = buildGraphWithCoverage(lineageCallGraph, {
302
302
  repository: opts.repository,
303
303
  ...(boundaries ? { boundaries } : {}),
304
+ // GA-03.7: the scanned revision, recorded in `graph.scope.revision` and in the graph id.
305
+ ...(typeof opts.commit === 'string' && opts.commit ? { commit: opts.commit, worktreeDirty: opts.worktreeDirty } : {}),
304
306
  generatedAt: opts.deterministic ? undefined : new Date().toISOString(),
305
307
  perFile: opts.perFile,
306
308
  parseFailures: opts.parseFailures,
@@ -79,7 +79,7 @@ async function loadProgram(root) {
79
79
  // ---------------------------------------------------------------------------------------------------------------------
80
80
 
81
81
  /**
82
- * agentic-security dataflow view [path] [--node <id> [--direction in|out|both] [--relations a,b] [--origins a,b]] [--output <file>]
82
+ * agentic-security dataflow view [path] [--node <id> [--direction in|out|both] [--relations a,b] [--origins a,b]] [--expect-revision <sha|HEAD>] [--output <file>]
83
83
  * Read-only over the signed lineage graph. Without --node it reports the view identity, counts, completion and the SCC
84
84
  * condensation of the data-flow relation. With --node it lists the neighbours the filter allows.
85
85
  */
@@ -95,7 +95,18 @@ export async function cmdDataflowView(args) {
95
95
  if (f.node === undefined && (f.direction || f.relations || f.origins)) { err(`${who}: --direction, --relations and --origins apply to --node.`); return 2; }
96
96
 
97
97
  const { loadSignedGraph } = await import('../server/graph-loader.js');
98
- const loaded = loadSignedGraph(root);
98
+ // GA-03.7: `--expect-revision <sha|HEAD>` refuses a graph built from another revision (or from none).
99
+ let expectRevision = null;
100
+ if (f['expect-revision'] !== undefined) {
101
+ if (typeof f['expect-revision'] !== 'string' || !f['expect-revision']) { err(`${who}: --expect-revision needs a commit or HEAD.`); return 2; }
102
+ if (f['expect-revision'] === 'HEAD') {
103
+ const { getRepoState } = await import('./provenance/git-evidence.js');
104
+ const st = getRepoState(root);
105
+ if (!st || !st.head) { err(`${who}: --expect-revision HEAD needs a git repository at ${root}.`); return 2; }
106
+ expectRevision = st.head;
107
+ } else expectRevision = f['expect-revision'];
108
+ }
109
+ const loaded = loadSignedGraph(root, { expectRevision });
99
110
  if (!loaded.ok) { err(`${who}: ${loaded.message}`); return 1; }
100
111
  const { dataflowView, GraphViewError } = await import('../graph/index.js');
101
112
  const { computeScc, verifyScc } = await import('../graph/scc.js');
@@ -107,6 +118,8 @@ export async function cmdDataflowView(args) {
107
118
  schema: 'agentic-security/graph-view-report@1',
108
119
  maturity: 'preview',
109
120
  identity: view.identity,
121
+ revision: loaded.graph.scope && loaded.graph.scope.revision ? loaded.graph.scope.revision : null,
122
+ expectedRevision: expectRevision,
110
123
  metadata: view.metadata(),
111
124
  viewCompletion: view.completion(),
112
125
  note: 'A view is a read-only lens over the signed graph. An absent edge means the model has no such edge, not that the software has none.',
@@ -1,4 +1,5 @@
1
1
  import * as cp from 'node:child_process';
2
+ import { STATE_DIR_NAME } from '../state-dir.js';
2
3
  import * as fs from 'node:fs';
3
4
  import * as path from 'node:path';
4
5
  import { hardenGitArgs, hardenGitEnv } from '../../util/git-hardening.js';
@@ -95,6 +96,26 @@ export function getRemoteUrl(scanRoot) {
95
96
  return r.ok ? r.stdout.trim() : null;
96
97
  }
97
98
 
99
+ /**
100
+ * Whether the working tree has changes OUTSIDE the given path prefixes (default: the scanner's own state
101
+ * directory). `getRepoState().dirty` counts the scanner's own untracked state directory as dirty, which would make the
102
+ * lineage graph's recorded revision flip between two scans of an unchanged tree merely because the first
103
+ * scan created that directory. Null when not a git repository or when git fails.
104
+ */
105
+ export function worktreeDirtyExcluding(scanRoot, ignorePrefixes = [`${STATE_DIR_NAME}/`]) {
106
+ if (!isGitRepo(scanRoot)) return null;
107
+ const r = _run(scanRoot, ['status', '--porcelain', '--untracked-files=all']);
108
+ if (!r.ok) return null;
109
+ for (const line of r.stdout.split('\n')) {
110
+ if (!line.trim()) continue;
111
+ const p = line.slice(3).replace(/^"|"$/g, '');
112
+ const renamedTo = p.includes(' -> ') ? p.split(' -> ')[1] : p;
113
+ if (ignorePrefixes.some((pre) => p.startsWith(pre) || renamedTo.startsWith(pre))) continue;
114
+ return true;
115
+ }
116
+ return false;
117
+ }
118
+
98
119
  export function getRepoState(scanRoot) {
99
120
  if (!isGitRepo(scanRoot)) return null;
100
121
  const head = _run(scanRoot, ['rev-parse', 'HEAD']);
@@ -19,7 +19,7 @@ import { verifyLastScan } from '../posture/integrity.js';
19
19
 
20
20
  /**
21
21
  * @param {string} scanRoot
22
- * @returns {{ok:true, graph:object} | {ok:false, reason:'missing'|'unsigned'|'tampered'|'malformed', message:string}}
22
+ * @returns {{ok:true, graph:object} | {ok:false, reason:'missing'|'unsigned'|'tampered'|'malformed'|'wrong-revision', message:string}}
23
23
  *
24
24
  * Four, and only four, distinct failure reasons — each with its own clear
25
25
  * message so an operator knows exactly what to do next:
@@ -36,7 +36,7 @@ import { verifyLastScan } from '../posture/integrity.js';
36
36
  * valid JSON. Should not happen from a normal scan; the
37
37
  * file may be corrupted on disk after signing.
38
38
  */
39
- export function loadSignedGraph(scanRoot) {
39
+ export function loadSignedGraph(scanRoot, { expectRevision = null } = {}) {
40
40
  const graphPath = statePath(scanRoot, 'lineage-graph.json');
41
41
  const sigPath = graphPath + '.sig';
42
42
 
@@ -86,6 +86,23 @@ export function loadSignedGraph(scanRoot) {
86
86
  };
87
87
  }
88
88
 
89
+ // GA-03.7 (0.195.26): wrong-revision refusal. A caller that knows which
90
+ // revision it expects (`dataflow view --expect-revision`) gets a graph
91
+ // only when the graph records that revision; a graph that records none
92
+ // cannot prove it and is refused for the same reason, never served on
93
+ // the hope that it matches.
94
+ if (expectRevision) {
95
+ const have = graph && graph.scope && graph.scope.revision ? graph.scope.revision.commit : null;
96
+ if (have !== expectRevision) {
97
+ return {
98
+ ok: false,
99
+ reason: 'wrong-revision',
100
+ message: have
101
+ ? `Lineage graph at ${graphPath} was built from revision ${have}, not the expected ${expectRevision}. Re-run the scan on the expected revision.`
102
+ : `Lineage graph at ${graphPath} records no revision, so it cannot be shown to match the expected ${expectRevision}. Re-run the scan inside the repository (AGENTIC_SECURITY_LINEAGE_DEEP=1) so the graph records its revision.`,
103
+ };
104
+ }
105
+ }
89
106
  return { ok: true, graph };
90
107
  }
91
108