@clear-capabilities/agentic-security-scanner 0.160.0 → 0.162.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/bin/agentic-security.js +11 -2
  3. package/dist/1919.index.js +1 -1
  4. package/dist/2432.index.js +10 -1
  5. package/dist/4113.index.js +60 -9
  6. package/dist/5613.index.js +500 -0
  7. package/dist/6239.index.js +469 -0
  8. package/dist/7033.index.js +4 -1
  9. package/dist/7838.index.js +39 -3
  10. package/dist/8714.index.js +1 -1
  11. package/dist/8798.index.js +2 -2
  12. package/dist/9207.index.js +5 -0
  13. package/dist/9560.index.js +205 -62
  14. package/dist/{1905.index.js → 9999.index.js} +265 -8
  15. package/dist/agentic-security.mjs +12 -12
  16. package/dist/agentic-security.mjs.sha256 +1 -1
  17. package/package.json +14 -9
  18. package/src/dataflow/engine.js +20 -4
  19. package/src/discovery/CLAUDE.md +9 -0
  20. package/src/discovery/index.js +28 -0
  21. package/src/discovery/memory.js +7 -5
  22. package/src/engine.js +29 -8
  23. package/src/fix/apply-fix-service.js +33 -3
  24. package/src/language/CLAUDE.md +6 -1
  25. package/src/language/aibom.js +4 -1
  26. package/src/language/fix-lifecycle.js +9 -0
  27. package/src/language/haskell-ir.js +47 -7
  28. package/src/language/haskell-sca.js +87 -7
  29. package/src/language/haskell-supply.js +175 -6
  30. package/src/language/haskell-web.js +200 -18
  31. package/src/language/nix-fix.js +1 -1
  32. package/src/language/resolved-pass.js +4 -1
  33. package/src/language/witness.js +1 -1
  34. package/src/mcp/CLAUDE.md +43 -4
  35. package/src/mcp/tools.js +185 -43
  36. package/src/posture/CLAUDE.md +174 -0
  37. package/src/posture/assurance/baseline-inventory.js +218 -0
  38. package/src/posture/assurance/baseline.js +322 -0
  39. package/src/posture/assurance/bounded-io.js +145 -0
  40. package/src/posture/assurance/config.js +299 -0
  41. package/src/posture/assurance/contracts.js +315 -0
  42. package/src/posture/assurance/identity.js +62 -0
  43. package/src/posture/assurance/migrations.js +259 -0
  44. package/src/posture/assurance/schema-kit.js +111 -0
  45. package/src/posture/assurance/verification-record.js +258 -0
  46. package/src/posture/attestation.js +5 -0
  47. package/src/posture/autopilot.js +28 -1
  48. package/src/posture/blast-radius.js +15 -1
  49. package/src/posture/evidence-bundle.js +93 -7
  50. package/src/posture/execution-proof.js +11 -1
  51. package/src/posture/fix-verify-loop.js +20 -6
  52. package/src/posture/fix-verify.js +37 -2
  53. package/src/posture/oracles/adapters.js +360 -0
  54. package/src/posture/oracles/conformance.js +275 -0
  55. package/src/posture/oracles/oracle.js +433 -0
  56. package/src/posture/oracles/registry.js +76 -0
  57. package/src/posture/oracles/scenario-classes.js +223 -0
  58. package/src/posture/remediation-ledger.js +10 -1
  59. package/src/posture/replay/replay.js +249 -0
  60. package/src/posture/verification/advisory-state.js +71 -0
  61. package/src/posture/verification/emit.js +257 -0
  62. package/src/posture/verification/hypothesis-promotion.js +211 -0
  63. package/src/posture/verification/patch-negative.js +214 -0
  64. package/src/posture/verification/patch-promotion.js +105 -0
  65. package/src/posture/verification/projection.js +183 -0
  66. package/src/posture/verification/repair-records.js +124 -0
  67. package/src/posture/verifier.js +16 -0
  68. package/src/report/index.js +49 -0
  69. package/src/sandbox/CLAUDE.md +41 -0
  70. package/src/sandbox/backend-namespace.js +7 -0
  71. package/src/sandbox/backend-userspace.js +68 -29
  72. package/src/sandbox/control-probes.js +215 -0
  73. package/src/sandbox/supervise.js +201 -0
  74. package/src/sandbox/trust-boundary.js +113 -0
  75. package/src/sandbox/trust-domains.js +171 -0
  76. package/src/sast/pipeline.js +51 -4
@@ -0,0 +1,218 @@
1
+ // The recommendation mappings and the capability inventory that baseline capture
2
+ // (CORE-001) records. Pure data. Every path is relative to the repository root and
3
+ // is VERIFIED against the checkout at capture time: a path that does not exist is
4
+ // reported as a problem, never carried as if it did.
5
+ //
6
+ // Status vocabulary (one per capability, never combined):
7
+ // implemented the capability exists, has a source path and a test or artifact,
8
+ // and no gap against the differentiation PRD is known.
9
+ // partial it exists and is tested, and `gap` names exactly what the PRD
10
+ // still needs on top of it.
11
+ // unsupported the PRD calls for it and the checkout does not provide it; `source`
12
+ // is the existing extension point it would attach to, `evidence` the
13
+ // test that covers that neighbour, and `gap` says what is absent.
14
+ // unmeasured it exists but no current-revision measurement of it is recorded;
15
+ // `evidence` is the artifact that exists and `gap` says why it is not a
16
+ // current measurement.
17
+ //
18
+ // This is a statement about what was inspected, not a quality score. Moving an
19
+ // entry to a stronger status needs the new source and evidence paths to land first.
20
+
21
+ export const RECOMMENDATION_MAPPINGS = Object.freeze([
22
+ {
23
+ n: 1, title: 'Refresh real-code evaluation and repair measured misses', priority: 'P0', slice: 'QA-001..QA-008',
24
+ extensionPoints: ['bench/independent', 'scanner/src/posture/accuracy-scorecard.js', 'scripts/scorecard.mjs', 'docs/scorecard.json'],
25
+ },
26
+ {
27
+ n: 2, title: 'Unify verification and expand trusted runtime oracles', priority: 'P0', slice: 'X-201..X-208',
28
+ extensionPoints: ['scanner/src/posture/fix-verify.js', 'scanner/src/posture/execution-proof.js', 'scanner/src/posture/verifier.js', 'scanner/src/sandbox/index.js', 'scanner/src/posture/autopilot.js', 'scanner/src/mcp/tools.js'],
29
+ },
30
+ {
31
+ n: 3, title: 'Add a deployment-aware Code Boundaries graph', priority: 'P1', slice: 'X-301..X-308',
32
+ extensionPoints: ['scanner/src/lineage/runtime-observation.js', 'scanner/src/lineage/observation-adapters.js', 'scanner/src/lineage/observation-correlation.js', 'scanner/src/lineage/cross-repo-link.js', 'scanner/src/lineage/federation-loader.js', 'scanner/src/lineage/drift-policy.js'],
33
+ },
34
+ {
35
+ n: 4, title: 'Execute tenant and business invariants', priority: 'P1', slice: 'X-401..X-408',
36
+ extensionPoints: ['scanner/src/posture/business-logic.js', 'scanner/src/posture/logic-claims.js', 'scanner/src/posture/specification-mining.js', 'scanner/src/discovery'],
37
+ },
38
+ {
39
+ n: 5, title: 'Enforce coding-agent capabilities', priority: 'P1', slice: 'X-501..X-508',
40
+ extensionPoints: ['hooks/pre-bash-guard.js', 'hooks/pre-edit-bodyguard.js', 'hooks/dispatch-pre-tool.js', 'scanner/src/egress/policy.js', 'scanner/src/egress/redact.js', 'scanner/src/egress/audit.js', 'scanner/src/sandbox/index.js', 'scanner/src/mcp/server.js'],
41
+ },
42
+ {
43
+ n: 6, title: 'Calibrate model routing against outcomes', priority: 'P2', slice: 'X-601..X-608',
44
+ extensionPoints: ['scanner/src/posture/model-routing.js', 'scanner/src/posture/model-trust.js', 'scanner/src/posture/cache-economics.js', 'scanner/src/posture/provider-catalog.js'],
45
+ },
46
+ {
47
+ n: 7, title: 'Deliver portable release assurance and resumable portfolios', priority: 'P2', slice: 'X-701..X-708',
48
+ extensionPoints: ['scanner/src/posture/fleet.js', 'scanner/src/posture/scan-checkpoint.js', 'scanner/src/posture/attestation.js', 'scanner/src/posture/evidence-bundle.js', 'scanner/src/posture/integrity.js', 'scanner/src/posture/retention-policy.js'],
49
+ },
50
+ ]);
51
+
52
+ const t = (p) => ({ kind: 'test', path: p });
53
+ const a = (p) => ({ kind: 'artifact', path: p });
54
+
55
+ export const CAPABILITY_INVENTORY = Object.freeze([
56
+ {
57
+ id: 'poc-replay', workstream: 2, title: 'Proof-of-concept generation and confined replay',
58
+ status: 'partial',
59
+ source: ['scanner/src/posture/poc-generator.js', 'scanner/src/posture/poc-inprocess.js', 'scanner/src/posture/execution-proof.js', 'scanner/src/posture/proof-tier.js'],
60
+ entryPoints: [{ path: 'scanner/src/posture/execution-proof.js', symbol: 'proveFinding' }, { path: 'scanner/src/posture/proof-tier.js', symbol: 'attachProofTier' }],
61
+ evidence: [t('scanner/test/poc-generator.test.js'), t('scanner/test/execution-proof.test.js'), t('scanner/test/proof-artifact.test.js')],
62
+ gap: 'replay is JavaScript/taint oriented and records a proof tier on the finding; there is no class-specific oracle adapter, no patched-negative replay, and no single verification record',
63
+ prd: ['X-201', 'X-202', 'X-203', 'X-204', 'X-205'],
64
+ },
65
+ {
66
+ id: 'fix-verification', workstream: 2, title: 'Fix verification (rescan, lint, tests, honesty gate, PoC re-check)',
67
+ status: 'partial',
68
+ source: ['scanner/src/posture/fix-verify.js', 'scanner/src/posture/fix-verify-loop.js', 'scanner/src/fix/apply-fix-service.js', 'scanner/src/posture/fix-honesty-gate.js'],
69
+ entryPoints: [{ path: 'scanner/src/posture/fix-verify.js', symbol: 'verifyFix' }, { path: 'scanner/src/fix/apply-fix-service.js', symbol: 'applyVerifiedFix' }],
70
+ evidence: [t('scanner/test/fix-verify-loop.test.js'), t('scanner/test/fix-verify-tests.test.js'), t('scanner/test/apply-fix-service.test.js'), t('scanner/test/fix-honesty-gate.test.js')],
71
+ gap: 'results are per-surface shapes, not one versioned record; original-positive, patched-negative and functional-regression evidence are not bound together by digest',
72
+ prd: ['X-201', 'X-204', 'X-206'],
73
+ },
74
+ {
75
+ id: 'verifier-separation', workstream: 2, title: 'Independent verifier and verification separation',
76
+ status: 'partial',
77
+ source: ['scanner/src/posture/verifier.js', 'scanner/src/posture/verification-separation.js', 'scanner/src/posture/verifier-target.js', 'scanner/src/posture/verifier-ephemeral.js'],
78
+ entryPoints: [],
79
+ evidence: [t('scanner/test/verifier.test.js'), t('scanner/test/verifier-independence.test.js'), t('scanner/test/verification-separation.test.js')],
80
+ gap: 'separation is enforced for the existing verifier path; key and label isolation for the new oracle workers is the CORE-003 trust-boundary work',
81
+ prd: ['CORE-003', 'X-202'],
82
+ },
83
+ {
84
+ id: 'sandbox-confinement', workstream: 2, title: 'Confinement sandbox (namespace, userspace and disabled backends)',
85
+ status: 'partial',
86
+ source: ['scanner/src/sandbox/index.js', 'scanner/src/sandbox/capabilities.js', 'scanner/src/sandbox/limits.js', 'scanner/src/sandbox/backend-namespace.js', 'scanner/src/sandbox/backend-userspace.js', 'scanner/src/sandbox/backend-disabled.js'],
87
+ entryPoints: [{ path: 'scanner/src/sandbox/index.js', symbol: 'runConfined' }],
88
+ evidence: [t('scanner/test/sandbox.test.js'), t('scanner/test/sandbox-escape.test.js')],
89
+ gap: 'isolation is probed per backend; descendant-process cleanup, read confinement and per-backend active probes are the CORE-003 work, and enforcement is only advertised on Linux',
90
+ prd: ['CORE-003', 'X-502', 'X-503'],
91
+ },
92
+ {
93
+ id: 'runtime-observation-imports', workstream: 3, title: 'Runtime observation imports and correlation',
94
+ status: 'partial',
95
+ source: ['scanner/src/lineage/runtime-observation.js', 'scanner/src/lineage/observation-adapters.js', 'scanner/src/lineage/observation-store.js', 'scanner/src/lineage/observation-correlation.js', 'scanner/src/posture/telemetry-ingest.js'],
96
+ entryPoints: [{ path: 'scanner/src/lineage/runtime-observation.js', symbol: 'validateRuntimeObservation' }, { path: 'scanner/src/lineage/observation-store.js', symbol: 'persistObservationImport' }],
97
+ evidence: [t('scanner/test/lineage/runtime-observation.test.js'), t('scanner/test/lineage/observation-adapters.test.js'), t('scanner/test/lineage/observation-store.test.js'), t('scanner/test/lineage/observation-correlation.test.js')],
98
+ gap: 'one adapter (native-jsonl) is implemented; observations corroborate lineage edges but do not yet bind to vulnerability hypotheses, and freshness and sampling are not modelled as path states',
99
+ prd: ['X-302', 'X-303', 'X-305'],
100
+ },
101
+ {
102
+ id: 'cross-repo-federation-drift', workstream: 3, title: 'Cross-repository links, federation and drift policy',
103
+ status: 'implemented',
104
+ source: ['scanner/src/lineage/cross-repo-link.js', 'scanner/src/lineage/federation-loader.js', 'scanner/src/lineage/drift-policy.js', 'scanner/src/lineage/graph-diff.js'],
105
+ entryPoints: [{ path: 'scanner/src/lineage/cross-repo-link.js', symbol: 'validateCrossRepoLink' }],
106
+ evidence: [t('scanner/test/lineage/cross-repo-link.test.js'), t('scanner/test/lineage/federation-loader.test.js'), t('scanner/test/lineage/drift-policy.test.js')],
107
+ gap: null,
108
+ prd: ['X-304', 'X-306'],
109
+ },
110
+ {
111
+ id: 'business-logic-claims', workstream: 4, title: 'Business-logic detection, logic claims and specification mining',
112
+ status: 'partial',
113
+ source: ['scanner/src/posture/business-logic.js', 'scanner/src/posture/logic-claims.js', 'scanner/src/posture/specification-mining.js'],
114
+ entryPoints: [],
115
+ evidence: [t('scanner/test/business-logic.test.js'), t('scanner/test/logic-claims.test.js'), t('scanner/test/phase3-v3.test.js')],
116
+ gap: 'claims are advisory findings; there are no declarative approved invariants and no bounded stateful scenario execution',
117
+ prd: ['X-401', 'X-402', 'X-403', 'X-404'],
118
+ },
119
+ {
120
+ id: 'stateful-invariant-execution', workstream: 4, title: 'Approved invariants, stateful scenarios, state oracles and sequence shrinking',
121
+ status: 'unsupported',
122
+ source: ['scanner/src/posture/logic-claims.js', 'scanner/src/discovery'],
123
+ entryPoints: [],
124
+ evidence: [t('scanner/test/logic-claims.test.js')],
125
+ gap: 'no executable invariant contract, approval state, scenario generator, durable-effect oracle or minimizer exists in the checkout',
126
+ prd: ['X-401', 'X-403', 'X-404', 'X-405'],
127
+ },
128
+ {
129
+ id: 'egress-policy-redaction-audit', workstream: 5, title: 'Central egress policy, outbound redaction and tamper-evident audit',
130
+ status: 'implemented',
131
+ source: ['scanner/src/egress/policy.js', 'scanner/src/egress/redact.js', 'scanner/src/egress/audit.js'],
132
+ entryPoints: [{ path: 'scanner/src/egress/policy.js', symbol: 'evaluateEgress' }, { path: 'scanner/src/egress/redact.js', symbol: 'redactPayload' }, { path: 'scanner/src/egress/audit.js', symbol: 'recordEgressCall' }],
133
+ evidence: [t('scanner/test/egress-policy.test.js'), t('scanner/test/egress-redact.test.js'), t('scanner/test/egress-audit.test.js'), t('scanner/test/egress-policy-completeness.test.js')],
134
+ gap: null,
135
+ prd: ['CORE-004', 'X-504'],
136
+ },
137
+ {
138
+ id: 'agent-hooks', workstream: 5, title: 'Pre-edit, pre-bash and dispatch hooks',
139
+ status: 'partial',
140
+ source: ['hooks/pre-bash-guard.js', 'hooks/pre-edit-bodyguard.js', 'hooks/dispatch-pre-tool.js', 'hooks/hooks.json'],
141
+ entryPoints: [],
142
+ evidence: [t('scanner/test/pre-bash-guard.test.js'), t('scanner/test/bodyguard.test.js'), t('scanner/test/dispatch-pre-tool.test.js')],
143
+ gap: 'hooks explain and advise at the prompt layer; no runner mediates filesystem, command or network access, so a hook response is not an enforcement receipt',
144
+ prd: ['X-501', 'X-502', 'X-505', 'X-507'],
145
+ },
146
+ {
147
+ id: 'mcp-server', workstream: 2, title: 'MCP server tools (including the write-gated fix tools)',
148
+ status: 'partial',
149
+ source: ['scanner/src/mcp/server.js', 'scanner/src/mcp/tools.js', 'scanner/src/mcp/validate.js', 'scanner/src/mcp/audit.js'],
150
+ entryPoints: [],
151
+ evidence: [t('scanner/test/mcp.test.js'), t('scanner/test/mcp-audit.test.js'), t('scanner/test/mcp-protocol-smoke.test.js')],
152
+ gap: 'tool results are not yet emitted as the unified verification record',
153
+ prd: ['X-206'],
154
+ },
155
+ {
156
+ id: 'model-routing-and-trust', workstream: 6, title: 'Model routing, measured trust, cache economics and provider catalog',
157
+ status: 'partial',
158
+ source: ['scanner/src/posture/model-routing.js', 'scanner/src/posture/model-trust.js', 'scanner/src/posture/cache-economics.js', 'scanner/src/posture/provider-catalog.js'],
159
+ entryPoints: [{ path: 'scanner/src/posture/model-trust.js', symbol: 'createTrustLedger' }, { path: 'scanner/src/posture/model-routing.js', symbol: 'routeModelWithTrust' }],
160
+ evidence: [t('scanner/test/model-routing.test.js'), t('scanner/test/model-trust.test.js'), t('scanner/test/cache-economics.test.js'), t('scanner/test/providers.test.js'), a('bench/router-replay/baseline.json')],
161
+ gap: 'trust is a per-key miss-rate ledger; there are no adjudicated task strata, calibrated quality intervals, shadow validation or drift circuit breakers',
162
+ prd: ['X-601', 'X-603', 'X-604', 'X-605'],
163
+ },
164
+ {
165
+ id: 'fleet-checkpoints', workstream: 7, title: 'Fleet runs and scan checkpoints',
166
+ status: 'partial',
167
+ source: ['scanner/src/posture/fleet.js', 'scanner/src/posture/scan-checkpoint.js', 'scripts/fleet.mjs'],
168
+ entryPoints: [{ path: 'scanner/src/posture/scan-checkpoint.js', symbol: 'openCheckpoint' }],
169
+ evidence: [t('scanner/test/fleet.test.js'), t('scanner/test/scan-checkpoint.test.js')],
170
+ gap: 'checkpoints resume a single scan; there are no durable leased work units, dependency-hash invalidation or portfolio budgets',
171
+ prd: ['X-704', 'X-705', 'X-706'],
172
+ },
173
+ {
174
+ id: 'attestation-and-evidence-bundles', workstream: 7, title: 'Run attestation, signed per-finding evidence bundles, integrity and retention',
175
+ status: 'partial',
176
+ source: ['scanner/src/posture/attestation.js', 'scanner/src/posture/evidence-bundle.js', 'scanner/src/posture/integrity.js', 'scanner/src/posture/retention-policy.js'],
177
+ entryPoints: [{ path: 'scanner/src/posture/evidence-bundle.js', symbol: 'signEvidenceBundle' }, { path: 'scanner/src/posture/attestation.js', symbol: 'computeRunAttestation' }],
178
+ evidence: [t('scanner/test/attestation.test.js'), t('scanner/test/evidence-bundle.test.js'), t('scanner/test/retention-policy.test.js'), t('scanner/test/verify-attestation-cli.test.js')],
179
+ gap: 'bundles are per finding with a local signer; there is no release assurance manifest, portable package or explicit trust-root policy',
180
+ prd: ['X-701', 'X-702', 'X-703', 'CORE-003'],
181
+ },
182
+ {
183
+ id: 'accuracy-scorecard', workstream: 1, title: 'Published accuracy scorecard and corpus replay',
184
+ status: 'implemented',
185
+ source: ['scanner/src/posture/accuracy-scorecard.js', 'scripts/scorecard.mjs'],
186
+ entryPoints: [],
187
+ evidence: [t('scanner/test/accuracy-scorecard.test.js'), t('scanner/test/scorecard.test.js'), a('docs/scorecard.json')],
188
+ gap: null,
189
+ prd: ['QA-004'],
190
+ },
191
+ {
192
+ id: 'independent-accuracy-current-engine', workstream: 1, title: 'Current-engine accuracy on independent real code',
193
+ status: 'unmeasured',
194
+ source: ['bench/independent/runner.mjs', 'bench/independent/manifest.json'],
195
+ entryPoints: [],
196
+ evidence: [a('docs/scorecard.json'), a('docs/INDEPENDENT_POPULATION_ROOT_CAUSE.md')],
197
+ gap: 'the recorded independent result belongs to an older engine revision; no current-engine adjudicated measurement exists in the checkout',
198
+ prd: ['QA-001', 'QA-002', 'QA-003'],
199
+ },
200
+ {
201
+ id: 'loop-controller', workstream: 'foundation', title: 'Supervised background implementation controller (Haskell/Nix profile)',
202
+ status: 'partial',
203
+ source: ['scripts/loop-engineering/run.mjs', 'scripts/loop-engineering/profiles/haskell-nix.json'],
204
+ entryPoints: [],
205
+ evidence: [t('scripts/loop-engineering/test/loop-manifest.test.js'), t('scripts/loop-engineering/test/loop-watchdog.test.js'), t('scripts/loop-engineering/test/loop-recovery.test.js')],
206
+ gap: 'only the Haskell/Nix profile exists; the assurance-differentiation profile and its protected acceptance suites are not present',
207
+ prd: ['LOOP-001', 'LOOP-002', 'LOOP-003'],
208
+ },
209
+ {
210
+ id: 'lineage-graph', workstream: 3, title: 'Data Flow Explorer lineage graph (DataFlowGraph v1)',
211
+ status: 'implemented',
212
+ source: ['scanner/src/lineage/graph-builder.js', 'scanner/src/lineage/ids.js', 'scanner/src/lineage/schema.js', 'scanner/src/lineage/dataflow-graph.schema.json'],
213
+ entryPoints: [{ path: 'scanner/src/lineage/ids.js', symbol: 'graphId' }],
214
+ evidence: [t('scanner/test/lineage/schema.test.js'), t('scanner/test/lineage/ids.test.js')],
215
+ gap: null,
216
+ prd: ['X-301'],
217
+ },
218
+ ]);
@@ -0,0 +1,322 @@
1
+ // Baseline capture (CORE-001): what this checkout actually is, before the
2
+ // differentiation work changes it.
3
+ //
4
+ // Principles:
5
+ // - READ-ONLY. Capture reads source, runs read-only git and version queries, and
6
+ // returns a manifest. It writes nothing; the CLI writes only to a path that
7
+ // `assertSafeOutput` accepts, which is never inside an application source root.
8
+ // - NOTHING IS FABRICATED. Every environmental fact is `{status:'known', value}`
9
+ // or `{status:'unknown', reason}`. A tool that is missing, a bundle that is
10
+ // absent, a repository with no HEAD: each is recorded as unknown with the reason.
11
+ // - EXACT BYTES. Digests are over working-tree bytes, so uncommitted edits move
12
+ // them, and the dirty path list says which files differ from HEAD.
13
+ // - EVIDENCE IS BOUND TO WHAT IT COVERS. Each capability records the digest of
14
+ // every source and evidence file it cites. `evaluateBaseline` compares a fresh
15
+ // capture to a recorded one and invalidates exactly the capabilities whose cited
16
+ // files changed; unrelated user changes are kept, listed and never touched.
17
+ //
18
+ // The manifest is not deterministic across machines (it records HEAD, dirty paths
19
+ // and tool versions), so it is generated on demand and not committed.
20
+
21
+ import * as fs from 'node:fs';
22
+ import * as path from 'node:path';
23
+ import { spawnSync } from 'node:child_process';
24
+ import { hardenGitArgs, hardenGitEnv } from '../../util/git-hardening.js';
25
+ import { STATE_DIR_NAME } from '../state-dir.js';
26
+ import { digestOf, digestOfBytes } from './identity.js';
27
+ import { RECOMMENDATION_MAPPINGS, CAPABILITY_INVENTORY } from './baseline-inventory.js';
28
+
29
+ export const BASELINE_SCHEMA = 'agentic-security/baseline-manifest';
30
+ export const BASELINE_VERSION = '1.0.0';
31
+ export const CAPABILITY_STATUSES = Object.freeze(['implemented', 'partial', 'unsupported', 'unmeasured']);
32
+
33
+ // Directories whose contents are application source: capture never writes here.
34
+ export const SOURCE_ROOTS = Object.freeze(['scanner/src', 'scanner/bin', 'scanner/scripts', 'hooks', 'commands', 'agents', 'scripts']);
35
+ // What the source digest covers.
36
+ export const SOURCE_DIGEST_PATHS = Object.freeze(['scanner/src', 'scanner/bin', 'scanner/package.json', 'scanner/package-lock.json']);
37
+ const BUNDLE_PATH = 'scanner/dist/agentic-security.mjs';
38
+
39
+ const known = (value) => ({ status: 'known', value });
40
+ const unknown = (reason) => ({ status: 'unknown', reason });
41
+
42
+ function defaultExec(cmd, args, { cwd, timeoutMs = 15_000 } = {}) {
43
+ const r = spawnSync(cmd, args, { cwd, encoding: 'utf8', timeout: timeoutMs, env: cmd === 'git' ? hardenGitEnv() : process.env, maxBuffer: 64 * 1024 * 1024 });
44
+ if (r.error) return { status: null, stdout: '', stderr: String(r.error.code || r.error.message) };
45
+ return { status: r.status, stdout: r.stdout || '', stderr: r.stderr || '' };
46
+ }
47
+
48
+ function git(runCmd, root, args) {
49
+ return runCmd('git', hardenGitArgs(args), { cwd: root });
50
+ }
51
+
52
+ function fileDigest(root, rel) {
53
+ try {
54
+ const st = fs.lstatSync(path.join(root, rel));
55
+ if (!st.isFile()) return null;
56
+ return digestOfBytes(fs.readFileSync(path.join(root, rel)));
57
+ } catch { return null; }
58
+ }
59
+
60
+ function tool(runCmd, root, cmd, args) {
61
+ const r = runCmd(cmd, args, { cwd: root });
62
+ if (r.status !== 0 || !r.stdout.trim()) return unknown(`${cmd} ${args.join(' ')} did not report a version${r.stderr ? ` (${String(r.stderr).trim().split('\n')[0]})` : ''}`);
63
+ return known(r.stdout.trim().split('\n')[0]);
64
+ }
65
+
66
+ /** Parse `git status --porcelain=v1 -z`. Renames carry two paths; both are reported. */
67
+ export function parsePorcelain(out) {
68
+ const parts = out.split('\0').filter(Boolean);
69
+ const entries = [];
70
+ for (let i = 0; i < parts.length; i++) {
71
+ const rec = parts[i];
72
+ const xy = rec.slice(0, 2); const p = rec.slice(3);
73
+ if (xy[0] === 'R' || xy[0] === 'C') { entries.push({ path: p, xy }); i++; if (parts[i]) entries.push({ path: parts[i], xy: 'R-from' }); continue; }
74
+ entries.push({ path: p, xy });
75
+ }
76
+ return entries;
77
+ }
78
+
79
+ function classify(xy) {
80
+ if (xy === '??') return 'untracked';
81
+ if (xy.includes('D')) return 'deleted';
82
+ if (xy === 'R-from') return 'renamed-from';
83
+ if (xy[0] !== ' ' && xy[1] !== ' ') return 'staged-and-modified';
84
+ if (xy[0] !== ' ') return 'staged';
85
+ return 'modified';
86
+ }
87
+
88
+ function collectSourceFiles(runCmd, root) {
89
+ const r = git(runCmd, root, ['ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', ...SOURCE_DIGEST_PATHS]);
90
+ if (r.status !== 0) return null;
91
+ return [...new Set(r.stdout.split('\0').filter(Boolean))].sort();
92
+ }
93
+
94
+ /**
95
+ * Capture the baseline. `exec` and `now` are injectable so tests can prove the
96
+ * unknown paths and stay clock-free; `inventory`/`mappings` default to the real ones.
97
+ */
98
+ export function captureBaseline({
99
+ root, exec: runCmd = defaultExec, now = () => new Date().toISOString(),
100
+ inventory = CAPABILITY_INVENTORY, mappings = RECOMMENDATION_MAPPINGS,
101
+ } = {}) {
102
+ if (!root) throw new Error('captureBaseline needs a root');
103
+ const problems = [];
104
+ const abs = path.resolve(root);
105
+
106
+ // ---- git state
107
+ const headR = git(runCmd, abs, ['rev-parse', 'HEAD']);
108
+ const head = headR.status === 0 && /^[0-9a-f]{40,64}$/.test(headR.stdout.trim()) ? known(headR.stdout.trim()) : unknown('no git HEAD could be resolved');
109
+ const branchR = git(runCmd, abs, ['rev-parse', '--abbrev-ref', 'HEAD']);
110
+ const branch = branchR.status === 0 ? known(branchR.stdout.trim()) : unknown('branch could not be resolved');
111
+ const statusR = git(runCmd, abs, ['status', '--porcelain=v1', '-z', '--untracked-files=all']);
112
+ let dirty;
113
+ if (statusR.status !== 0) dirty = unknown('git status failed; the worktree state is not known');
114
+ else {
115
+ dirty = known(parsePorcelain(statusR.stdout).map(e => ({ path: e.path, change: classify(e.xy), digest: fileDigest(abs, e.path) })).sort((x, y) => x.path.localeCompare(y.path)));
116
+ }
117
+
118
+ // ---- digests
119
+ const files = collectSourceFiles(runCmd, abs);
120
+ let sourceDigest;
121
+ if (!files) sourceDigest = unknown('source file list unavailable (git ls-files failed)');
122
+ else {
123
+ const lines = [];
124
+ for (const f of files) { const d = fileDigest(abs, f); if (d) lines.push(`${f}\0${d}`); }
125
+ sourceDigest = known({ digest: digestOf(lines), fileCount: lines.length, covers: [...SOURCE_DIGEST_PATHS] });
126
+ }
127
+ const bundleBytes = fileDigest(abs, BUNDLE_PATH);
128
+ let bundle;
129
+ if (!bundleBytes) bundle = unknown(`${BUNDLE_PATH} is not present in this checkout`);
130
+ else {
131
+ let sidecar = null;
132
+ try { sidecar = fs.readFileSync(path.join(abs, `${BUNDLE_PATH}.sha256`), 'utf8').trim().split(/\s+/)[0] || null; } catch { /* no sidecar */ }
133
+ bundle = known({ path: BUNDLE_PATH, digest: bundleBytes, sidecar: sidecar ? `sha256:${sidecar}` : null, sidecarMatches: sidecar ? `sha256:${sidecar}` === bundleBytes : null });
134
+ }
135
+
136
+ // ---- versions
137
+ let pkg = null;
138
+ try { pkg = JSON.parse(fs.readFileSync(path.join(abs, 'scanner/package.json'), 'utf8')); } catch { /* reported below */ }
139
+ const versions = {
140
+ node: known(process.version),
141
+ platform: known(`${process.platform}-${process.arch}`),
142
+ scanner: pkg?.version ? known(pkg.version) : unknown('scanner/package.json has no readable version'),
143
+ npm: tool(runCmd, abs, 'npm', ['--version']),
144
+ git: tool(runCmd, abs, 'git', ['--version']),
145
+ python3: tool(runCmd, abs, 'python3', ['--version']),
146
+ };
147
+
148
+ // ---- feature entry points that exist now
149
+ const entryPoints = {
150
+ packageBin: pkg?.bin ? known(pkg.bin) : unknown('no bin map in scanner/package.json'),
151
+ npmScripts: pkg?.scripts ? known(Object.keys(pkg.scripts).sort()) : unknown('no scripts in scanner/package.json'),
152
+ commands: listDir(abs, 'commands', f => f.endsWith('.md')),
153
+ hooks: listDir(abs, 'hooks', f => f.endsWith('.js') && !f.endsWith('.test.js')),
154
+ };
155
+
156
+ // ---- seven recommendation mappings, each extension point verified
157
+ const recMappings = mappings.map(m => {
158
+ const ext = m.extensionPoints.map(p => ({ path: p, exists: exists(abs, p) }));
159
+ for (const e of ext) if (!e.exists) problems.push(`mapping ${m.n}: extension point '${e.path}' does not exist`);
160
+ return { n: m.n, title: m.title, priority: m.priority, slice: m.slice, extensionPoints: ext };
161
+ });
162
+
163
+ // ---- capability inventory
164
+ const capabilities = inventory.map(c => {
165
+ const sourceDigests = {}; const evidenceDigests = {};
166
+ for (const p of c.source) sourceDigests[p] = pathDigest(abs, p);
167
+ for (const e of c.evidence) evidenceDigests[e.path] = pathDigest(abs, e.path);
168
+ const missing = [...Object.entries(sourceDigests), ...Object.entries(evidenceDigests)].filter(([, d]) => d === null).map(([p]) => p);
169
+ for (const p of missing) problems.push(`capability '${c.id}': path '${p}' does not exist`);
170
+ const symbols = c.entryPoints.map(ep => ({ ...ep, exported: symbolExported(abs, ep.path, ep.symbol) }));
171
+ for (const s of symbols) if (!s.exported) problems.push(`capability '${c.id}': '${s.symbol}' is not exported by ${s.path}`);
172
+ if (!CAPABILITY_STATUSES.includes(c.status)) problems.push(`capability '${c.id}': status '${c.status}' is not one of ${CAPABILITY_STATUSES.join(', ')}`);
173
+ if (c.status === 'implemented' && c.gap) problems.push(`capability '${c.id}': 'implemented' must not carry a gap`);
174
+ if (c.status !== 'implemented' && !c.gap) problems.push(`capability '${c.id}': '${c.status}' must say what is missing`);
175
+ if (!c.source.length) problems.push(`capability '${c.id}': no source path`);
176
+ if (!c.evidence.length) problems.push(`capability '${c.id}': no test or artifact`);
177
+ return {
178
+ id: c.id, workstream: c.workstream, title: c.title, status: c.status, gap: c.gap, prd: c.prd,
179
+ source: c.source, evidence: c.evidence, entryPoints: symbols,
180
+ pathsMissing: missing, sourceDigests, evidenceDigests,
181
+ };
182
+ });
183
+
184
+ const manifest = {
185
+ schema: BASELINE_SCHEMA, schemaVersion: BASELINE_VERSION,
186
+ repository: { head, branch, dirtyPaths: dirty },
187
+ digests: { source: sourceDigest, bundle },
188
+ versions, entryPoints,
189
+ recommendationMappings: recMappings,
190
+ capabilities,
191
+ problems,
192
+ };
193
+ manifest.baselineId = digestOf(manifest);
194
+ // The capture time is real but is not part of the identity.
195
+ manifest.capturedAt = now();
196
+ return manifest;
197
+ }
198
+
199
+ function exists(root, rel) { try { fs.accessSync(path.join(root, rel)); return true; } catch { return false; } }
200
+
201
+ function listDir(root, rel, keep) {
202
+ try { return known(fs.readdirSync(path.join(root, rel)).filter(keep).sort()); } catch { return unknown(`${rel}/ is not readable`); }
203
+ }
204
+
205
+ /** Digest of a file, or of a directory as the digest of its files' digests; null when the path does not exist. */
206
+ function pathDigest(root, rel) {
207
+ const p = path.join(root, rel);
208
+ let st;
209
+ try { st = fs.lstatSync(p); } catch { return null; }
210
+ if (st.isFile()) return digestOfBytes(fs.readFileSync(p));
211
+ if (!st.isDirectory()) return null;
212
+ const lines = [];
213
+ const walk = (d, prefix) => {
214
+ for (const e of fs.readdirSync(d, { withFileTypes: true }).sort((x, y) => x.name.localeCompare(y.name))) {
215
+ if (e.name === STATE_DIR_NAME || e.name === 'node_modules') continue;
216
+ const full = path.join(d, e.name);
217
+ if (e.isDirectory()) walk(full, `${prefix}${e.name}/`);
218
+ else if (e.isFile()) lines.push(`${prefix}${e.name}\0${digestOfBytes(fs.readFileSync(full))}`);
219
+ }
220
+ };
221
+ walk(p, '');
222
+ return digestOf(lines);
223
+ }
224
+
225
+ function symbolExported(root, rel, symbol) {
226
+ try {
227
+ const text = fs.readFileSync(path.join(root, rel), 'utf8');
228
+ return new RegExp(`^\\s*export\\s+(?:async\\s+)?(?:function\\*?|const|class)\\s+${symbol}\\b`, 'm').test(text)
229
+ || new RegExp(`^\\s*export\\s*\\{[^}]*\\b${symbol}\\b`, 'm').test(text);
230
+ } catch { return false; }
231
+ }
232
+
233
+ /**
234
+ * Compare a recorded baseline with a fresh capture of the same checkout.
235
+ *
236
+ * - A capability is `invalidated` when any source or evidence file it cites
237
+ * changed or disappeared; it lists the changed paths. Otherwise it stays `valid`.
238
+ * - Dirty paths are classified: `retained` (already dirty at baseline, same bytes),
239
+ * `changedSince` (dirty at baseline, bytes now different), `introduced` (newly
240
+ * dirty), `resolved` (was dirty, now clean). Each is split into `applicable`
241
+ * (cited by some capability) and `unrelated`. Unrelated changes never invalidate
242
+ * anything and are always disclosed.
243
+ */
244
+ export function evaluateBaseline(recorded, current) {
245
+ const capabilities = recorded.capabilities.map(rc => {
246
+ const cc = current.capabilities.find(x => x.id === rc.id);
247
+ const changed = [];
248
+ if (!cc) changed.push('(capability no longer inventoried)');
249
+ else {
250
+ for (const [p, d] of Object.entries(rc.sourceDigests)) if (cc.sourceDigests[p] !== d) changed.push(p);
251
+ for (const [p, d] of Object.entries(rc.evidenceDigests)) if (cc.evidenceDigests[p] !== d) changed.push(p);
252
+ }
253
+ return { id: rc.id, status: rc.status, validity: changed.length ? 'invalidated' : 'valid', changedPaths: changed };
254
+ });
255
+
256
+ const cited = new Set();
257
+ const citedDirs = [];
258
+ for (const c of recorded.capabilities) for (const p of [...c.source, ...c.evidence.map(e => e.path)]) { cited.add(p); citedDirs.push(p.replace(/\/$/, '')); }
259
+ const isApplicable = (p) => citedDirs.some(c => p === c || p.startsWith(`${c}/`));
260
+
261
+ const rd = recorded.repository.dirtyPaths; const cd = current.repository.dirtyPaths;
262
+ const userChanges = { comparable: rd.status === 'known' && cd.status === 'known', retained: [], changedSince: [], introduced: [], resolved: [] };
263
+ if (userChanges.comparable) {
264
+ const before = new Map(rd.value.map(e => [e.path, e])); const after = new Map(cd.value.map(e => [e.path, e]));
265
+ const tag = (e, kind) => ({ path: e.path, change: e.change, scope: isApplicable(e.path) ? 'applicable' : 'unrelated', kind });
266
+ for (const [p, e] of after) {
267
+ const b = before.get(p);
268
+ if (!b) userChanges.introduced.push(tag(e, 'introduced'));
269
+ else if (b.digest === e.digest) userChanges.retained.push(tag(e, 'retained'));
270
+ else userChanges.changedSince.push(tag(e, 'changedSince'));
271
+ }
272
+ for (const [p, e] of before) if (!after.has(p)) userChanges.resolved.push(tag(e, 'resolved'));
273
+ }
274
+
275
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
276
+ return {
277
+ baselineId: recorded.baselineId,
278
+ headChanged: !same(recorded.repository.head, current.repository.head),
279
+ bundleChanged: !same(recorded.digests.bundle, current.digests.bundle),
280
+ sourceChanged: !same(recorded.digests.source, current.digests.source),
281
+ capabilities,
282
+ invalidated: capabilities.filter(c => c.validity === 'invalidated').map(c => c.id),
283
+ valid: capabilities.filter(c => c.validity === 'valid').map(c => c.id),
284
+ userChanges,
285
+ };
286
+ }
287
+
288
+ /** A manifest is only as trustworthy as its own shape: check statuses, unknown-marking and the id. */
289
+ export function validateBaseline(m) {
290
+ const errors = [];
291
+ if (!m || typeof m !== 'object') return { ok: false, errors: ['not an object'] };
292
+ if (m.schema !== BASELINE_SCHEMA) errors.push('schema mismatch');
293
+ if (!/^1\./.test(String(m.schemaVersion))) errors.push('unsupported schemaVersion');
294
+ const check = (name, v) => {
295
+ if (!v || (v.status !== 'known' && v.status !== 'unknown')) errors.push(`${name}: not a known/unknown fact`);
296
+ else if (v.status === 'unknown' && (!v.reason || 'value' in v)) errors.push(`${name}: an unknown fact needs a reason and no value`);
297
+ else if (v.status === 'known' && !('value' in v)) errors.push(`${name}: a known fact needs a value`);
298
+ };
299
+ check('head', m.repository?.head); check('branch', m.repository?.branch); check('dirtyPaths', m.repository?.dirtyPaths);
300
+ check('digests.source', m.digests?.source); check('digests.bundle', m.digests?.bundle);
301
+ for (const [k, v] of Object.entries(m.versions || {})) check(`versions.${k}`, v);
302
+ if (!Array.isArray(m.recommendationMappings) || m.recommendationMappings.length !== 7) errors.push('exactly seven recommendation mappings are required');
303
+ for (const c of m.capabilities || []) if (!CAPABILITY_STATUSES.includes(c.status)) errors.push(`capability ${c.id}: bad status`);
304
+ const { baselineId, capturedAt, ...rest } = m;
305
+ if (baselineId !== digestOf(rest)) errors.push('baselineId does not match the manifest content');
306
+ return { ok: errors.length === 0, errors };
307
+ }
308
+
309
+ /**
310
+ * Where a baseline may be written. Never inside an application source root, and
311
+ * never over a file git tracks (which would change application source).
312
+ */
313
+ export function assertSafeOutput(root, outPath, runCmd = defaultExec) {
314
+ const abs = path.resolve(root); const out = path.resolve(outPath);
315
+ const rel = path.relative(abs, out);
316
+ if (rel.startsWith('..') || path.isAbsolute(rel)) return { ok: true, reason: 'outside the repository' };
317
+ const inSource = SOURCE_ROOTS.some(r => rel === r || rel.startsWith(`${r}${path.sep}`));
318
+ if (inSource) return { ok: false, reason: `'${rel}' is inside an application source root` };
319
+ const tracked = git(runCmd, abs, ['ls-files', '--error-unmatch', '--', rel]);
320
+ if (tracked.status === 0) return { ok: false, reason: `'${rel}' is tracked by git; refusing to overwrite application files` };
321
+ return { ok: true, reason: 'untracked path outside the source roots' };
322
+ }