@clear-capabilities/agentic-security-scanner 0.145.0 → 0.147.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.
- package/CHANGELOG.md +139 -0
- package/bin/agentic-security.js +3508 -69
- package/dist/1122.index.js +702 -0
- package/dist/{301.index.js → 1301.index.js} +2 -2
- package/dist/1379.index.js +591 -0
- package/dist/{444.index.js → 1444.index.js} +2 -2
- package/dist/{660.index.js → 1660.index.js} +2 -2
- package/dist/{700.index.js → 1700.index.js} +2 -2
- package/dist/{905.index.js → 1905.index.js} +3 -3
- package/dist/{920.index.js → 1920.index.js} +3 -3
- package/dist/{238.index.js → 2238.index.js} +3 -3
- package/dist/{271.index.js → 2271.index.js} +2 -2
- package/dist/{985.index.js → 2376.index.js} +1260 -340
- package/dist/2432.index.js +793 -0
- package/dist/2659.index.js +93 -0
- package/dist/{826.index.js → 2826.index.js} +2 -2
- package/dist/{830.index.js → 2830.index.js} +2 -2
- package/dist/2923.index.js +298 -0
- package/dist/{1.index.js → 3001.index.js} +5 -5
- package/dist/{117.index.js → 3117.index.js} +3 -3
- package/dist/3180.index.js +307 -0
- package/dist/3276.index.js +117 -0
- package/dist/{415.index.js → 3415.index.js} +2 -2
- package/dist/{499.index.js → 3499.index.js} +2 -2
- package/dist/3518.index.js +450 -0
- package/dist/{526.index.js → 3526.index.js} +5 -5
- package/dist/{736.index.js → 3736.index.js} +4 -4
- package/dist/{839.index.js → 3839.index.js} +4 -4
- package/dist/{113.index.js → 4113.index.js} +5 -5
- package/dist/{265.index.js → 4265.index.js} +2 -2
- package/dist/{384.index.js → 4384.index.js} +3 -3
- package/dist/4547.index.js +268 -0
- package/dist/4863.index.js +422 -0
- package/dist/{970.index.js → 4970.index.js} +2 -2
- package/dist/5051.index.js +770 -0
- package/dist/{144.index.js → 5144.index.js} +5 -5
- package/dist/{333.index.js → 5333.index.js} +3 -3
- package/dist/5343.index.js +185 -0
- package/dist/5350.index.js +866 -0
- package/dist/5561.index.js +436 -0
- package/dist/{637.index.js → 5637.index.js} +3 -3
- package/dist/{449.index.js → 5830.index.js} +2 -2
- package/dist/6626.index.js +532 -0
- package/dist/6662.index.js +297 -0
- package/dist/{675.index.js → 6675.index.js} +5 -5
- package/dist/{730.index.js → 6730.index.js} +6 -6
- package/dist/6829.index.js +225 -0
- package/dist/6944.index.js +130 -0
- package/dist/{178.index.js → 7178.index.js} +3 -3
- package/dist/{227.index.js → 7227.index.js} +2 -2
- package/dist/7310.index.js +520 -0
- package/dist/{552.index.js → 7552.index.js} +4 -4
- package/dist/7709.index.js +78 -0
- package/dist/8218.index.js +160 -0
- package/dist/{476.index.js → 8476.index.js} +4 -4
- package/dist/{513.index.js → 8513.index.js} +5 -5
- package/dist/{520.index.js → 8520.index.js} +2 -2
- package/dist/{718.index.js → 8718.index.js} +2 -2
- package/dist/{752.index.js → 8752.index.js} +2 -2
- package/dist/8846.index.js +100 -0
- package/dist/{435.index.js → 9091.index.js} +560 -189
- package/dist/{207.index.js → 9207.index.js} +2 -2
- package/dist/{220.index.js → 9220.index.js} +2 -2
- package/dist/9390.index.js +163 -0
- package/dist/{503.index.js → 9503.index.js} +2 -2
- package/dist/{801.index.js → 9801.index.js} +2 -2
- package/dist/{824.index.js → 9824.index.js} +2 -2
- package/dist/agentic-security.mjs +16 -16
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/compliance-frameworks/hipaa-security-rule.json +3 -2
- package/package.json +12 -5
- package/src/dataflow/CLAUDE.md +1 -1
- package/src/dataflow/catalog.js +42 -0
- package/src/dataflow/orm-write-catalog.js +175 -0
- package/src/engine.js +92 -1
- package/src/ir/CLAUDE.md +1 -0
- package/src/ir/chrome-probe.mjs +150 -0
- package/src/ir/parser-js.js +94 -7
- package/src/lineage/CLAUDE.md +1203 -0
- package/src/lineage/DESIGN_DESTINATION_RESOLVER.md +156 -0
- package/src/lineage/DESIGN_GRAPH_BUILDER.md +938 -0
- package/src/lineage/DESIGN_HANDLING_ANALYZER.md +355 -0
- package/src/lineage/DESIGN_INTRAPROCEDURAL.md +628 -0
- package/src/lineage/DESIGN_PATH_PROVENANCE.md +3451 -0
- package/src/lineage/DESIGN_QUEUE_DETAIL.md +120 -0
- package/src/lineage/DESIGN_REGISTRIES.md +880 -0
- package/src/lineage/DESIGN_STORE_DETAIL.md +143 -0
- package/src/lineage/DESIGN_TRANSIT_PROTECTION.md +245 -0
- package/src/lineage/classification.js +56 -0
- package/src/lineage/coverage.js +658 -0
- package/src/lineage/cross-repo-link.js +107 -0
- package/src/lineage/dataflow-graph.schema.json +184 -0
- package/src/lineage/decision-story.js +206 -0
- package/src/lineage/drift-policy.js +279 -0
- package/src/lineage/driver.js +135 -0
- package/src/lineage/engine.js +992 -0
- package/src/lineage/export-briefing.js +628 -0
- package/src/lineage/export-csv.js +62 -0
- package/src/lineage/export-json.js +238 -0
- package/src/lineage/export-privacy.js +258 -0
- package/src/lineage/federation-loader.js +111 -0
- package/src/lineage/field-identity.js +78 -0
- package/src/lineage/fixtures/build-flagship-fixture.mjs +272 -0
- package/src/lineage/fixtures/flagship-graph.json +1453 -0
- package/src/lineage/flow-grade.js +221 -0
- package/src/lineage/governance-edit.js +169 -0
- package/src/lineage/graph-builder.js +1114 -0
- package/src/lineage/graph-diff.js +431 -0
- package/src/lineage/graph-snapshot.js +180 -0
- package/src/lineage/handling-analyzer.js +168 -0
- package/src/lineage/ids.js +349 -0
- package/src/lineage/impact-assessment.js +76 -0
- package/src/lineage/impact-engine.js +268 -0
- package/src/lineage/index.js +281 -0
- package/src/lineage/language-coverage-tiers.js +58 -0
- package/src/lineage/obligation-mapping.js +126 -0
- package/src/lineage/obligation-predicates.js +235 -0
- package/src/lineage/observation-adapters.js +282 -0
- package/src/lineage/observation-correlation.js +622 -0
- package/src/lineage/observation-store.js +497 -0
- package/src/lineage/path-query.js +410 -0
- package/src/lineage/path-store.js +400 -0
- package/src/lineage/protection.js +53 -0
- package/src/lineage/recipient-profile.js +192 -0
- package/src/lineage/recipient-registry.js +394 -0
- package/src/lineage/redact-graph.js +224 -0
- package/src/lineage/remediation.js +417 -0
- package/src/lineage/resolve-destination.js +91 -0
- package/src/lineage/runtime-observation.js +464 -0
- package/src/lineage/scenario-diff.js +84 -0
- package/src/lineage/scenario-engine.js +251 -0
- package/src/lineage/scenario.js +101 -0
- package/src/lineage/schema.js +167 -0
- package/src/lineage/sink-registry.js +427 -0
- package/src/lineage/source-registry.js +357 -0
- package/src/lineage/source-seeding.js +212 -0
- package/src/lineage/summaries.js +590 -0
- package/src/lineage/transform-catalog.js +397 -0
- package/src/lineage/transit-protection.js +150 -0
- package/src/lineage/validate.js +285 -0
- package/src/mcp/CLAUDE.md +7 -1
- package/src/mcp/dataflow-tools.js +160 -0
- package/src/mcp/server.js +1 -1
- package/src/mcp/tools.js +2 -1
- package/src/pipeline/scan-health.js +19 -1
- package/src/posture/CLAUDE.md +19 -0
- package/src/posture/artifact-registry.js +52 -0
- package/src/posture/auditor-walkthrough.js +76 -0
- package/src/posture/compliance-frameworks/hipaa-security-rule.json +3 -2
- package/src/posture/obligation-evidence-pack.js +202 -0
- package/src/posture/remediation-ledger.js +337 -0
- package/src/server/CLAUDE.md +47 -0
- package/src/server/graph-loader.js +141 -0
- package/src/server/http-server.js +325 -0
- package/src/server/routes.js +129 -0
- package/src/server/security.js +111 -0
- package/src/server/static-assets.js +139 -0
- package/dist/11.index.js +0 -353
- package/dist/259.index.js +0 -975
- package/dist/317.index.js +0 -300
- package/dist/609.index.js +0 -741
- package/dist/838.index.js +0 -152
package/bin/agentic-security.js
CHANGED
|
@@ -4,12 +4,14 @@
|
|
|
4
4
|
import * as fs from 'node:fs';
|
|
5
5
|
import * as fsp from 'node:fs/promises';
|
|
6
6
|
import * as path from 'node:path';
|
|
7
|
+
import * as crypto from 'node:crypto';
|
|
7
8
|
import { createRequire } from 'node:module';
|
|
8
9
|
const __require = createRequire(import.meta.url);
|
|
9
10
|
const PKG_VERSION = __require('../package.json').version;
|
|
10
11
|
import { signLastScan as _signLastScan, verifyLastScan as _verifyLastScanShared } from '../src/posture/integrity.js';
|
|
11
12
|
import { isProvenanceHealthy, sanitizeForTerminal } from '../src/posture/provenance/schema.js';
|
|
12
13
|
import { runScan } from '../src/runScan.js';
|
|
14
|
+
import { persistGraphSnapshot, loadSnapshots, loadSnapshot, mostRecentPriorSnapshot } from '../src/lineage/graph-snapshot.js';
|
|
13
15
|
|
|
14
16
|
// Every command is dispatched as `process.exit(await cmdX(args))`, and
|
|
15
17
|
// process.exit() does NOT flush an asynchronous stdout. stdout is asynchronous
|
|
@@ -70,7 +72,7 @@ import { decide as decideNextAction, explain as explainDecision } from '../src/p
|
|
|
70
72
|
import * as triage from '../src/posture/triage.js';
|
|
71
73
|
import { buildSlackDigest, buildDiscordDigest, postWebhook, buildJiraIssue, buildPrComment, buildSiemEvent, loadIntegrationConfig } from '../src/integrations/index.js';
|
|
72
74
|
|
|
73
|
-
import { stateDir, statePath } from '../src/posture/state-dir.js';
|
|
75
|
+
import { stateDir, statePath, withStateWritesDisabled } from '../src/posture/state-dir.js';
|
|
74
76
|
import { listGeneratedArtifacts } from '../src/posture/artifact-registry.js';
|
|
75
77
|
// last-scan.json integrity helpers — implementation in posture/integrity.js
|
|
76
78
|
// so the MCP server tools can share verification.
|
|
@@ -139,6 +141,74 @@ Commands:
|
|
|
139
141
|
scan-baseline --current <f> --previous <f>
|
|
140
142
|
Finding-level diff between two scan JSON outputs.
|
|
141
143
|
Reports added / removed / changed findings.
|
|
144
|
+
explore [path] [--port <n>] [--keep-open]
|
|
145
|
+
Start a local, read-only server over an
|
|
146
|
+
already-scanned lineage graph (run
|
|
147
|
+
AGENTIC_SECURITY_LINEAGE_DEEP=1 scan first)
|
|
148
|
+
dataflow export [path] --format png|pdf|svg|json|csv|html|dpia|ropa|briefing|recipients|coverage --output <file>
|
|
149
|
+
Export the already-scanned lineage graph.
|
|
150
|
+
--view architecture|privacy|trace|inventory (default: architecture;
|
|
151
|
+
png/pdf/svg only — no-op + warning for json/csv/html/dpia/ropa/briefing/recipients/coverage)
|
|
152
|
+
--size standard|2x AC-23 pinned PNG sizes (default: standard)
|
|
153
|
+
--width <n> --height <n> custom PNG size, <= 20000 (mutually exclusive with --size)
|
|
154
|
+
--no-redact include unredacted content (json/html only; no-op + warning for csv/dpia/ropa/briefing/recipients/coverage)
|
|
155
|
+
--filter <path.json> {nodeIds,edgeIds} to scope the export (no-op + warning for csv/coverage)
|
|
156
|
+
--audience board|ciso|privacy|compliance|regulator|technical
|
|
157
|
+
(default: technical; briefing only — wording/verbosity, never facts)
|
|
158
|
+
dataflow scenario apply [path] --operations <file.json> --output <file> [--format json|markdown]
|
|
159
|
+
Simulate a hypothetical architecture change against the
|
|
160
|
+
already-scanned lineage graph — never mutates the real scan.
|
|
161
|
+
--privacy-sink-policy <path-to-json> re-evaluate each touched
|
|
162
|
+
flow's policyVerdict under this policy file
|
|
163
|
+
--environment <name> environment context for --privacy-sink-policy
|
|
164
|
+
dataflow impact assess [path] --target <canonical-id> --output <file> [--format json|markdown]
|
|
165
|
+
Blast-radius impact assessment over the already-scanned lineage
|
|
166
|
+
graph — read-only, never mutates anything, never re-runs a scan.
|
|
167
|
+
--target node:*|edge:*|flow:*|data:* the compromised entity's id
|
|
168
|
+
governance propose-edit [path] --patch <file.json> [--output <file>] [--yes] [--base-digest <hex>]
|
|
169
|
+
Propose a validated, reviewable edit to
|
|
170
|
+
recipient-profiles.json. Without --yes,
|
|
171
|
+
previews the diff and writes nothing.
|
|
172
|
+
federate declare [path] --local-node <node-id> --remote-graph <file>
|
|
173
|
+
--remote-node <node-id> [--repository <label>]
|
|
174
|
+
[--relationship data_flow] [--rationale <text>]
|
|
175
|
+
[--output <file>] [--yes] [--base-digest <hex>]
|
|
176
|
+
Declare a CrossRepoLink between a node in the
|
|
177
|
+
current locally-scanned graph and a node in a
|
|
178
|
+
remote graph export (dataflow export --format
|
|
179
|
+
json). Without --yes, previews and writes nothing.
|
|
180
|
+
federate list [path] [--output <file>]
|
|
181
|
+
List every declared cross-repo link, reporting
|
|
182
|
+
whether each side still resolves.
|
|
183
|
+
remediation open [path] --assessment <impact-report.json> --owner <id> --due <YYYY-MM-DD>
|
|
184
|
+
--control <text> --required-evidence <flowId,...>
|
|
185
|
+
[--id <itemId>] [--snapshot <commit>] [--allow-manual-attestation]
|
|
186
|
+
[--output <file>] [--yes]
|
|
187
|
+
Open a new remediation work item against an incident
|
|
188
|
+
snapshot. Without --yes, previews the folded item and
|
|
189
|
+
writes nothing to the append-only ledger.
|
|
190
|
+
remediation update [path] --id <itemId> --state <in_progress|awaiting_verification>
|
|
191
|
+
[--base-event <hash>] [--output <file>] [--yes]
|
|
192
|
+
Advance a remediation item's state. --state verified is
|
|
193
|
+
always refused — see remediation verify (AC-31).
|
|
194
|
+
remediation verify [path] --id <itemId> [--against <commit>]
|
|
195
|
+
[--manual-attestation --approver <id> --reason <text> [--author <id>]]
|
|
196
|
+
[--base-event <hash>] [--output <file>] [--yes]
|
|
197
|
+
Verify a remediation item against a fresh lineage scan (or an
|
|
198
|
+
explicitly-permitted manual attestation). AC-31: marking work
|
|
199
|
+
complete never sets "verified" — only this command can.
|
|
200
|
+
remediation reopen-check [path] [--drift-policy <file>] [--against <commit>]
|
|
201
|
+
[--output <file>] [--yes]
|
|
202
|
+
Reopen verified items whose control regressed (drift-policy match,
|
|
203
|
+
or an affected flow removed/changed in the latest diff). No
|
|
204
|
+
--base-event: this can append events across many items at once.
|
|
205
|
+
remediation accept-risk [path] --id <itemId> --approver <id> --reason <text>
|
|
206
|
+
--scope <text> --expires <YYYY-MM-DD> [--author <id>]
|
|
207
|
+
[--base-event <hash>] [--output <file>] [--yes]
|
|
208
|
+
Record an accepted-risk exception, gated on the operator's
|
|
209
|
+
authorized-approvers registry when one exists.
|
|
210
|
+
remediation list [path] [--format json|markdown] [--output <file>]
|
|
211
|
+
List every remediation item folded from the ledger.
|
|
142
212
|
|
|
143
213
|
Options:
|
|
144
214
|
--profile vibecoder|pro Override profile for this run
|
|
@@ -957,6 +1027,12 @@ async function cmdScan(args) {
|
|
|
957
1027
|
if (_writesOnScan() && _isSafeStateDir(stateDirPath)) {
|
|
958
1028
|
await fsp.mkdir(stateDirPath, { recursive: true });
|
|
959
1029
|
persistedScan = toJSON(scan, meta);
|
|
1030
|
+
// Sub-project E, increment 5: the lineage graph gets its OWN artifact
|
|
1031
|
+
// file (below), never duplicated inside last-scan.json — a
|
|
1032
|
+
// DataFlowGraph v1 document is a separate, potentially large artifact,
|
|
1033
|
+
// and embedding it a second time here would bloat the file every other
|
|
1034
|
+
// consumer of last-scan.json already reads in full.
|
|
1035
|
+
delete persistedScan.lineageGraph;
|
|
960
1036
|
// #10 — MTTR: stamp firstSeenAt/lastSeenAt/ageDays from the PREVIOUS scan so
|
|
961
1037
|
// every finding carries an age, SLA breaches can be surfaced, and the fix
|
|
962
1038
|
// loop can report time-to-clean. Best-effort; skipped under --deterministic
|
|
@@ -1050,6 +1126,32 @@ async function cmdScan(args) {
|
|
|
1050
1126
|
try {
|
|
1051
1127
|
await fsp.writeFile(path.join(stateDirPath, 'last-scan.json.sig'), _signLastScan(lastScanBody));
|
|
1052
1128
|
} catch { /* non-fatal — sig file is best-effort */ }
|
|
1129
|
+
// Sub-project E, increment 5: persist the lineage graph as its own
|
|
1130
|
+
// artifact, mirroring last-scan.json's own write+sign pattern exactly
|
|
1131
|
+
// — signLastScan/verifyLastScan are fully generic (an arbitrary string
|
|
1132
|
+
// body + an explicit sig-file path, no filename baked in), confirmed by
|
|
1133
|
+
// reading posture/integrity.js directly, so no new signing mechanism
|
|
1134
|
+
// is introduced. Written only when a graph actually exists — an
|
|
1135
|
+
// ordinary scan (AGENTIC_SECURITY_LINEAGE_DEEP unset) writes nothing
|
|
1136
|
+
// new here at all.
|
|
1137
|
+
if (scan.lineageGraph) {
|
|
1138
|
+
try {
|
|
1139
|
+
const lineageBody = JSON.stringify(scan.lineageGraph, null, 2);
|
|
1140
|
+
await fsp.writeFile(path.join(stateDirPath, 'lineage-graph.json'), lineageBody);
|
|
1141
|
+
try {
|
|
1142
|
+
await fsp.writeFile(path.join(stateDirPath, 'lineage-graph.json.sig'), _signLastScan(lineageBody));
|
|
1143
|
+
} catch { /* non-fatal — sig file is best-effort, same precedent as last-scan.json.sig above */ }
|
|
1144
|
+
} catch { /* non-fatal — the lineage artifact write is best-effort and must never block a scan */ }
|
|
1145
|
+
// M4 deliverable #8 (FR-503 §14, DFG-022, sub-project 8a): ADDITIVELY
|
|
1146
|
+
// persist the SAME graph into the commit-keyed snapshot history
|
|
1147
|
+
// (.agentic-security/lineage-snapshots/<HEAD>.json), never replacing
|
|
1148
|
+
// the single-current-graph artifact written just above. Best-effort,
|
|
1149
|
+
// same precedent as the write above — a snapshot-history failure must
|
|
1150
|
+
// never block a scan.
|
|
1151
|
+
try {
|
|
1152
|
+
persistGraphSnapshot(scan.lineageGraph, targetAbs);
|
|
1153
|
+
} catch { /* non-fatal — snapshot history is best-effort and must never block a scan */ }
|
|
1154
|
+
}
|
|
1053
1155
|
} else {
|
|
1054
1156
|
if (process.env.AGENTIC_SECURITY_DEBUG === '1') process.stderr.write(`[agentic-security] refusing to write state at ${stateDirPath} — no project marker in ${path.resolve(target)}\n`);
|
|
1055
1157
|
}
|
|
@@ -2231,6 +2333,48 @@ async function cmdHunt(args) {
|
|
|
2231
2333
|
return 0;
|
|
2232
2334
|
}
|
|
2233
2335
|
|
|
2336
|
+
// Data Flow Explorer M4 sub-project 6c's final whole-branch review,
|
|
2337
|
+
// findings F2/F3/F5-followup: every caller that discards a
|
|
2338
|
+
// loadFreshLineageGraph result for staleness must also disclose WHY,
|
|
2339
|
+
// distinguishing "no graph exists at all" from "a graph exists but isn't
|
|
2340
|
+
// fresh for this scan" (never letting the latter print the former's
|
|
2341
|
+
// "no lineage graph found" message, which would be actively false — a
|
|
2342
|
+
// graph DOES exist, it just isn't trusted). Shared by cmdAttest's
|
|
2343
|
+
// --obligations branch and cmdCompliance's --walkthrough/--report branch
|
|
2344
|
+
// rather than duplicated a third time, since this predicate's own
|
|
2345
|
+
// duplication was already flagged once (F3) as unacceptable for a safety
|
|
2346
|
+
// disclosure.
|
|
2347
|
+
function _lineageStalenessNoteLines(graphIsFresh, loaded, scan, subject) {
|
|
2348
|
+
if (graphIsFresh) return null;
|
|
2349
|
+
const capSubject = subject.charAt(0).toUpperCase() + subject.slice(1);
|
|
2350
|
+
if (!loaded.ok) {
|
|
2351
|
+
if (loaded.reason === 'missing') {
|
|
2352
|
+
return [
|
|
2353
|
+
'NOTE: no lineage graph found (AGENTIC_SECURITY_LINEAGE_DEEP=1 was not set on',
|
|
2354
|
+
`the scan that produced last-scan.json) — ${subject} reads "unknown", by design.`,
|
|
2355
|
+
];
|
|
2356
|
+
}
|
|
2357
|
+
return [
|
|
2358
|
+
`NOTE: lineage graph could not be loaded (${loaded.reason}: ${loaded.message})`,
|
|
2359
|
+
`— ${subject} reads "unknown", by design.`,
|
|
2360
|
+
];
|
|
2361
|
+
}
|
|
2362
|
+
if (scan.scanHealth?.lineageAnalysis?.failure) {
|
|
2363
|
+
return [
|
|
2364
|
+
'NOTE: a lineage graph exists on disk, but this scan\'s own lineage build',
|
|
2365
|
+
`failed (${scan.scanHealth.lineageAnalysis.failure}) — ignoring the stale graph`,
|
|
2366
|
+
'rather than asserting a fact about code it may no longer reflect.',
|
|
2367
|
+
`${capSubject} reads "unknown", by design.`,
|
|
2368
|
+
];
|
|
2369
|
+
}
|
|
2370
|
+
return [
|
|
2371
|
+
'NOTE: a lineage graph exists on disk from an earlier deep scan, but this scan',
|
|
2372
|
+
'did not run one (AGENTIC_SECURITY_LINEAGE_DEEP=1 was not set) — ignoring the',
|
|
2373
|
+
'stale graph rather than asserting a fact about code it may no longer reflect.',
|
|
2374
|
+
`${capSubject} reads "unknown", by design.`,
|
|
2375
|
+
];
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2234
2378
|
// PRD D2 — emit signed, per-finding evidence bundles, and verify them.
|
|
2235
2379
|
//
|
|
2236
2380
|
// `attest` needs our private key. `verify-attestation` needs ONLY a public key,
|
|
@@ -2285,7 +2429,21 @@ async function cmdCompliance(args) {
|
|
|
2285
2429
|
console.error(`Unknown framework "${wt}". Try --list.`);
|
|
2286
2430
|
return 2;
|
|
2287
2431
|
}
|
|
2288
|
-
|
|
2432
|
+
// Final whole-branch review finding R2 (sub-project 6c), fixed here
|
|
2433
|
+
// rather than merely disclosed: `scan.lineageGraph` is NEVER present
|
|
2434
|
+
// in last-scan.json (see the identical, already-fixed defect in
|
|
2435
|
+
// cmdAttest's --obligations branch above — engine.js only assigns
|
|
2436
|
+
// scan.lineageGraph in-process; bin/agentic-security.js strips it
|
|
2437
|
+
// before persisting, since the real graph gets its own signed
|
|
2438
|
+
// artifact). --walkthrough has read scan.lineageGraph directly since
|
|
2439
|
+
// it shipped in sub-project 6b, so every graph: mapping has always
|
|
2440
|
+
// read "unknown" through this command regardless of whether
|
|
2441
|
+
// AGENTIC_SECURITY_LINEAGE_DEEP=1 was ever set. Same shared, freshness
|
|
2442
|
+
// -checked loader --obligations uses (loadFreshLineageGraph's own
|
|
2443
|
+
// header has the full staleness reasoning).
|
|
2444
|
+
const { loadFreshLineageGraph } = await import('../src/server/graph-loader.js');
|
|
2445
|
+
const { graph: wtGraph, fresh: wtGraphIsFresh, loaded: wtLoaded } = loadFreshLineageGraph(scanRoot, scan);
|
|
2446
|
+
const evaluation = evaluateFramework(scanRoot, fw, { ...scan, lineageGraph: wtGraph });
|
|
2289
2447
|
if (fmt === 'oscal') {
|
|
2290
2448
|
const { toOSCALCompliance, complianceRowsFromEvaluation } = await import('../src/report/oscal.js');
|
|
2291
2449
|
writeStdout(JSON.stringify(
|
|
@@ -2295,6 +2453,13 @@ async function cmdCompliance(args) {
|
|
|
2295
2453
|
}
|
|
2296
2454
|
if (fmt === 'json') { writeStdout(JSON.stringify(evaluation, null, 2) + '\n'); return 0; }
|
|
2297
2455
|
console.log(renderWalkthrough(fw, evaluation, {}));
|
|
2456
|
+
// Scoped re-review finding (non-blocking, fixed): a stale/missing
|
|
2457
|
+
// graph used to be discarded here with no disclosure at all — the
|
|
2458
|
+
// same F2 gap already fixed for `attest --obligations`, unfixed on
|
|
2459
|
+
// this sibling surface. Only the plain narrative gets the NOTE
|
|
2460
|
+
// (never --format json/oscal, which must stay machine-parseable).
|
|
2461
|
+
const wtNote = _lineageStalenessNoteLines(wtGraphIsFresh, wtLoaded, scan, 'every graph: mapping in this walkthrough');
|
|
2462
|
+
if (wtNote) { console.log(''); for (const line of wtNote) console.log(line); }
|
|
2298
2463
|
return 0;
|
|
2299
2464
|
}
|
|
2300
2465
|
|
|
@@ -2395,6 +2560,106 @@ async function cmdAttest(args) {
|
|
|
2395
2560
|
return 0;
|
|
2396
2561
|
}
|
|
2397
2562
|
|
|
2563
|
+
// Data Flow Explorer M4 sub-project 6c: a signed evidence pack for a
|
|
2564
|
+
// Regulatory Obligation Overlay framework evaluation (FR-504). Unlike
|
|
2565
|
+
// `--provenance` (a broad "attest everything" toggle with an optional
|
|
2566
|
+
// narrowing id), `--obligations` genuinely REQUIRES a framework id — an
|
|
2567
|
+
// evidence pack only exists per framework, and no "attest every
|
|
2568
|
+
// framework" loop exists anywhere in this codebase.
|
|
2569
|
+
if (args.flags.obligations) {
|
|
2570
|
+
const frameworkId = args.flags.obligations === true ? null : args.flags.obligations;
|
|
2571
|
+
if (!frameworkId) {
|
|
2572
|
+
const { listFrameworks } = await import('../src/posture/auditor-walkthrough.js');
|
|
2573
|
+
console.error('Usage: agentic-security attest --obligations <framework-id>');
|
|
2574
|
+
console.error(`Bundled frameworks: ${listFrameworks(scanRoot).map((f) => f.id).join(', ')}`);
|
|
2575
|
+
return 2;
|
|
2576
|
+
}
|
|
2577
|
+
const { loadFramework, evaluateFramework } = await import('../src/posture/auditor-walkthrough.js');
|
|
2578
|
+
const {
|
|
2579
|
+
buildObligationEvidencePack, signObligationEvidencePack, ensureKeyPair,
|
|
2580
|
+
} = await import('../src/posture/obligation-evidence-pack.js');
|
|
2581
|
+
// Task-2 review finding (blocking): scan.lineageGraph is NEVER present
|
|
2582
|
+
// in last-scan.json — bin/agentic-security.js's own scan persistence
|
|
2583
|
+
// deletes it before writing (`delete persistedScan.lineageGraph`,
|
|
2584
|
+
// Sub-project E increment 5's own comment: "the lineage graph gets its
|
|
2585
|
+
// OWN artifact file, never duplicated inside last-scan.json"). The real
|
|
2586
|
+
// graph lives at .agentic-security/lineage-graph.json, signed
|
|
2587
|
+
// separately — cmdExplore already established the correct way to read
|
|
2588
|
+
// it. Final-review finding B2/F1 (blocking): a stale copy of that file
|
|
2589
|
+
// must never be trusted just because it exists — see
|
|
2590
|
+
// loadFreshLineageGraph's own header for the full reasoning
|
|
2591
|
+
// (shared with compliance --walkthrough below, which had the
|
|
2592
|
+
// identical gap).
|
|
2593
|
+
const { loadFreshLineageGraph } = await import('../src/server/graph-loader.js');
|
|
2594
|
+
|
|
2595
|
+
let scan;
|
|
2596
|
+
try { scan = JSON.parse(fs.readFileSync(statePath(scanRoot, 'last-scan.json'), 'utf8')); }
|
|
2597
|
+
catch { console.error('No .agentic-security/last-scan.json — run a scan first.'); return 2; }
|
|
2598
|
+
|
|
2599
|
+
const fw = loadFramework(scanRoot, frameworkId);
|
|
2600
|
+
if (!fw) { console.error(`Unknown framework: ${frameworkId}`); return 2; }
|
|
2601
|
+
|
|
2602
|
+
const { graph, fresh: graphIsFresh, loaded } = loadFreshLineageGraph(scanRoot, scan);
|
|
2603
|
+
|
|
2604
|
+
const evaluation = evaluateFramework(scanRoot, fw, { ...scan, lineageGraph: graph });
|
|
2605
|
+
const pack = buildObligationEvidencePack({
|
|
2606
|
+
graph,
|
|
2607
|
+
framework: fw,
|
|
2608
|
+
evaluation,
|
|
2609
|
+
scanHealth: scan.scanHealth ?? null,
|
|
2610
|
+
// Final whole-branch review finding B1 (blocking): these three
|
|
2611
|
+
// fields live under scan.attestation (set by computeRunAttestation
|
|
2612
|
+
// at scan time — bin/agentic-security.js's own `scan.attestation =
|
|
2613
|
+
// computeRunAttestation({...})` call), never at the top level of
|
|
2614
|
+
// last-scan.json — reading scan.engineVersion etc. directly always
|
|
2615
|
+
// read undefined, leaving reproducibility permanently null through
|
|
2616
|
+
// the real CLI. NOTE: the sibling attest (finding-bundle) and
|
|
2617
|
+
// attest --provenance code paths a little above this branch read
|
|
2618
|
+
// the SAME wrong top-level fields and carry the identical,
|
|
2619
|
+
// pre-existing bug — real, disclosed, but out of this sub-project's
|
|
2620
|
+
// scope to fix (they predate this branch's own diff).
|
|
2621
|
+
engineVersion: scan.attestation?.engineVersion || null,
|
|
2622
|
+
rulesetVersion: scan.attestation?.rulesetVersion || null,
|
|
2623
|
+
bundleSha: scan.attestation?.bundleSha || null,
|
|
2624
|
+
});
|
|
2625
|
+
|
|
2626
|
+
const kp = ensureKeyPair();
|
|
2627
|
+
if (kp.created) console.error(`Generated a new signing key at ${kp.privateKey} (public: ${kp.publicKey}).`);
|
|
2628
|
+
|
|
2629
|
+
const outDir = statePath(scanRoot, 'attestations');
|
|
2630
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
2631
|
+
const signed = signObligationEvidencePack(pack, kp.privateKeyPem);
|
|
2632
|
+
const name = `evidence-pack-${frameworkId}.json`.replace(/[^\w.-]/g, '_');
|
|
2633
|
+
fs.writeFileSync(path.join(outDir, name), JSON.stringify(signed, null, 2) + '\n');
|
|
2634
|
+
|
|
2635
|
+
console.log(`Signed evidence pack for ${frameworkId} → ${path.relative(scanRoot, path.join(outDir, name))}`);
|
|
2636
|
+
console.log(`Public key (share this with whoever verifies): ${kp.publicKey}`);
|
|
2637
|
+
console.log('');
|
|
2638
|
+
// Final whole-branch review finding R1 (recommended, fixed): the
|
|
2639
|
+
// summary line named only unknown/manual/acceptedExceptions — an
|
|
2640
|
+
// auditor scanning this output for a real, assessed gap_detected fact
|
|
2641
|
+
// (the state a graph: predicate actually failing produces) saw "0 0 0"
|
|
2642
|
+
// and nothing else, the same "a real gap can hide under an all-clear
|
|
2643
|
+
// summary" failure class auditor-walkthrough.js's own ⚠️ prefix
|
|
2644
|
+
// already exists to prevent (sub-project 6b's own final review).
|
|
2645
|
+
const gapCount = pack.facts.filter((f) => f.state === 'gap_detected').length;
|
|
2646
|
+
console.log(` facts: ${pack.facts.length} ${gapCount > 0 ? '⚠️ gaps: ' + gapCount + ' ' : ''}unknown: ${pack.unknownItems.length} manual: ${pack.manualItems.length} accepted exceptions: ${pack.acceptedExceptions.length}`);
|
|
2647
|
+
console.log('');
|
|
2648
|
+
console.log('A pack proves its contents are unmodified since signing. It does NOT');
|
|
2649
|
+
console.log('certify compliance — read the pack\'s own `disclaimer` field.');
|
|
2650
|
+
// Final whole-branch review finding F2 (recommended, fixed): this
|
|
2651
|
+
// NOTE previously keyed on `!loaded.ok`, so the B2/F1 staleness guard
|
|
2652
|
+
// above could silently discard a real graph with NO disclosure at
|
|
2653
|
+
// all — an operator saw "unknown: 1" and nothing explaining why, even
|
|
2654
|
+
// though a signed graph genuinely existed on disk. Keyed on
|
|
2655
|
+
// `!graphIsFresh` instead (via the shared helper above
|
|
2656
|
+
// cmdCompliance), so every path that discards a graph also explains
|
|
2657
|
+
// why.
|
|
2658
|
+
const obNote = _lineageStalenessNoteLines(graphIsFresh, loaded, scan, 'every graph: fact in this pack');
|
|
2659
|
+
if (obNote) { console.log(''); for (const line of obNote) console.log(line); }
|
|
2660
|
+
return 0;
|
|
2661
|
+
}
|
|
2662
|
+
|
|
2398
2663
|
const {
|
|
2399
2664
|
ensureKeyPair, buildEvidenceBundle, signEvidenceBundle,
|
|
2400
2665
|
} = await import('../src/posture/evidence-bundle.js');
|
|
@@ -2566,6 +2831,31 @@ async function cmdVerifyAttestation(args) {
|
|
|
2566
2831
|
return 0;
|
|
2567
2832
|
}
|
|
2568
2833
|
|
|
2834
|
+
// Data Flow Explorer M4 sub-project 6c: an obligation evidence pack
|
|
2835
|
+
// (schema: agentic-security/obligation-evidence-pack@1) is a fifth
|
|
2836
|
+
// distinct shape this same command can be handed — same auto-detection
|
|
2837
|
+
// chain as the ComplianceEvidence/provenance branches above, dispatched
|
|
2838
|
+
// by schema marker. Must be checked BEFORE the fallback
|
|
2839
|
+
// verifyEvidenceBundle() call below, which assumes evidence-bundle.js's
|
|
2840
|
+
// own shape and would misinterpret an evidence pack.
|
|
2841
|
+
const { verifyObligationEvidencePack, OBLIGATION_EVIDENCE_PACK_SCHEMA } = await import('../src/posture/obligation-evidence-pack.js');
|
|
2842
|
+
if (bundle.schema === OBLIGATION_EVIDENCE_PACK_SCHEMA) {
|
|
2843
|
+
const or = verifyObligationEvidencePack(bundle, publicKeyPem);
|
|
2844
|
+
if (!or.ok) { console.error(`✗ INVALID — ${or.reason}`); return 1; }
|
|
2845
|
+
console.log('✓ VALID — the evidence pack is exactly what the signer produced.');
|
|
2846
|
+
console.log('');
|
|
2847
|
+
console.log(` framework: ${bundle.framework?.id} version: ${bundle.framework?.version}`);
|
|
2848
|
+
// Final whole-branch review finding R1 (recommended, fixed) — same
|
|
2849
|
+
// "a real gap must never hide under an all-clear summary" guard as
|
|
2850
|
+
// the signing side above.
|
|
2851
|
+
const bundleGapCount = (bundle.facts ?? []).filter((f) => f?.state === 'gap_detected').length;
|
|
2852
|
+
console.log(` facts: ${bundle.facts?.length ?? 0} ${bundleGapCount > 0 ? '⚠️ gaps: ' + bundleGapCount + ' ' : ''}unknown: ${bundle.unknownItems?.length ?? 0} manual: ${bundle.manualItems?.length ?? 0} accepted exceptions: ${bundle.acceptedExceptions?.length ?? 0}`);
|
|
2853
|
+
if (bundle.graphDigest) console.log(` graph digest: ${bundle.graphDigest}`);
|
|
2854
|
+
console.log('');
|
|
2855
|
+
console.log(` ${bundle.disclaimer}`);
|
|
2856
|
+
return 0;
|
|
2857
|
+
}
|
|
2858
|
+
|
|
2569
2859
|
const r = verifyEvidenceBundle(bundle, publicKeyPem);
|
|
2570
2860
|
if (!r.ok) {
|
|
2571
2861
|
console.error(`✗ INVALID — ${r.reason}`);
|
|
@@ -2974,76 +3264,3225 @@ Do not suppress anything you are not certain is a false positive. When in doubt,
|
|
|
2974
3264
|
return 0;
|
|
2975
3265
|
}
|
|
2976
3266
|
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
3267
|
+
// `agentic-security explore [path] [--port <n>] [--keep-open]` — Milestone 3,
|
|
3268
|
+
// sub-project Server, increment 1. Starts a local, read-only, loopback-only
|
|
3269
|
+
// HTTP server over an already-built, signed lineage graph
|
|
3270
|
+
// (.agentic-security/lineage-graph.json + .sig, written by a prior scan
|
|
3271
|
+
// with AGENTIC_SECURITY_LINEAGE_DEEP=1). Never triggers a scan itself
|
|
3272
|
+
// (Decision 2 of the scoping doc) — genuinely read-only end to end.
|
|
3273
|
+
//
|
|
3274
|
+
// Argument parsing mirrors cmdScan's own shape: scan root from args._[1]
|
|
3275
|
+
// (defaulting to cwd), flags parsed via args.flags. On a failed
|
|
3276
|
+
// loadSignedGraph, prints ONE of four distinct, clear error messages (see
|
|
3277
|
+
// graph-loader.js) and returns a non-zero exit code WITHOUT ever starting
|
|
3278
|
+
// the server. On success, starts the server and prints the URL + session
|
|
3279
|
+
// token to stdout — the ONLY place the token is ever displayed; it is
|
|
3280
|
+
// never written to a file and never logged again after this one print.
|
|
3281
|
+
//
|
|
3282
|
+
// The returned promise resolves (with exit code 0) only once the server
|
|
3283
|
+
// itself closes (idle-timeout auto-stop, or an external kill) — matching
|
|
3284
|
+
// every other cmdX(args) function's "resolves to an exit code" contract
|
|
3285
|
+
// while keeping the process alive for as long as the server is listening.
|
|
3286
|
+
async function cmdExplore(args) {
|
|
3287
|
+
const target = args._[1] || '.';
|
|
3288
|
+
const targetAbs = path.resolve(target);
|
|
3289
|
+
|
|
3290
|
+
const { loadSignedGraph } = await import('../src/server/graph-loader.js');
|
|
3291
|
+
const loaded = loadSignedGraph(targetAbs);
|
|
3292
|
+
if (!loaded.ok) {
|
|
3293
|
+
process.stderr.write(`agentic-security explore: ${loaded.message}\n`);
|
|
3294
|
+
return 1;
|
|
3295
|
+
}
|
|
3296
|
+
|
|
3297
|
+
let port = 0;
|
|
3298
|
+
if (args.flags.port !== undefined) {
|
|
3299
|
+
if (typeof args.flags.port !== 'string' || !/^\d+$/.test(args.flags.port)) {
|
|
3300
|
+
process.stderr.write(`agentic-security explore: invalid --port value "${args.flags.port}" — must be a non-negative integer.\n`);
|
|
3301
|
+
return 1;
|
|
3302
|
+
}
|
|
3303
|
+
port = Number(args.flags.port);
|
|
3304
|
+
if (port > 65535) {
|
|
3305
|
+
process.stderr.write(`agentic-security explore: invalid --port value "${args.flags.port}" — must be <= 65535.\n`);
|
|
3306
|
+
return 1;
|
|
3307
|
+
}
|
|
3308
|
+
}
|
|
3309
|
+
const keepOpen = !!args.flags['keep-open'];
|
|
3310
|
+
|
|
3311
|
+
const { createExploreServer } = await import('../src/server/http-server.js');
|
|
3312
|
+
const { generateSessionToken } = await import('../src/server/security.js');
|
|
3313
|
+
const sessionToken = generateSessionToken();
|
|
3314
|
+
|
|
3315
|
+
let started;
|
|
2980
3316
|
try {
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
3317
|
+
started = await createExploreServer({ graph: loaded.graph, port, sessionToken, keepOpen });
|
|
3318
|
+
} catch (e) {
|
|
3319
|
+
process.stderr.write(`agentic-security explore: failed to start server: ${e && e.message ? e.message : e}\n`);
|
|
3320
|
+
return 1;
|
|
3321
|
+
}
|
|
3322
|
+
const { server, port: actualPort } = started;
|
|
3323
|
+
|
|
3324
|
+
// THE ONLY place the session token is ever displayed — never written to
|
|
3325
|
+
// a file, never logged by the server itself after this one print. The
|
|
3326
|
+
// token travels as a URL FRAGMENT (`#token=...`), never a query string:
|
|
3327
|
+
// a fragment is never sent to the server in any HTTP request (so it can
|
|
3328
|
+
// never be captured in an access log), yet the page's own JS can read it
|
|
3329
|
+
// once via location.hash and attach it as a header on every subsequent
|
|
3330
|
+
// /api/v1/* fetch() call (frontend/src/lib/api-client.js).
|
|
3331
|
+
process.stdout.write(`agentic-security explore: serving ${targetAbs}\n`);
|
|
3332
|
+
process.stdout.write(` URL: http://127.0.0.1:${actualPort}/#token=${sessionToken}\n`);
|
|
3333
|
+
process.stdout.write(' Open this URL in a browser — the page authenticates itself automatically.\n');
|
|
3334
|
+
if (keepOpen) {
|
|
3335
|
+
process.stdout.write(' --keep-open set: no idle-timeout auto-stop. Ctrl-C to stop.\n');
|
|
3336
|
+
} else {
|
|
3337
|
+
process.stdout.write(' Server auto-stops after a period of inactivity, or Ctrl-C to stop now.\n');
|
|
3338
|
+
}
|
|
3339
|
+
|
|
3340
|
+
return new Promise((resolve) => {
|
|
3341
|
+
server.on('close', () => resolve(0));
|
|
3342
|
+
});
|
|
3343
|
+
}
|
|
3344
|
+
|
|
3345
|
+
// `agentic-security dataflow export [path] --format <fmt> --output <file>
|
|
3346
|
+
// [--view <name>] [--size standard|2x] [--width <n>] [--height <n>]
|
|
3347
|
+
// [--no-redact] [--filter <path>]` — Milestone 4, sub-project 5 (CLI +
|
|
3348
|
+
// slash commands). Wires the six already-shipped M4 export/report
|
|
3349
|
+
// functions (scanner/scripts/export-image.mjs's exportPng/exportPdf/
|
|
3350
|
+
// exportSvg, scanner/src/lineage/export-json.js's exportGraphJSON,
|
|
3351
|
+
// scanner/src/lineage/export-csv.js's exportFlowsCSV, and
|
|
3352
|
+
// scanner/scripts/generate-html-report.mjs's generateHtmlReport) into
|
|
3353
|
+
// one consistent CLI surface.
|
|
3354
|
+
//
|
|
3355
|
+
// Argument shape mirrors cmdExplore's own: scan root from args._[2]
|
|
3356
|
+
// (args._[0]='dataflow', args._[1]='export', so the path — if given —
|
|
3357
|
+
// is the THIRD positional), defaulting to cwd. Uses the identical
|
|
3358
|
+
// loadSignedGraph contract and error-message pass-through as cmdExplore
|
|
3359
|
+
// (scoping doc's own binding decision: never proceed past a graph-load
|
|
3360
|
+
// failure).
|
|
3361
|
+
//
|
|
3362
|
+
// Exit codes: 0 success. 1 graph-load failure (loadSignedGraph's own
|
|
3363
|
+
// four reasons). 2 export-stage failure — bad/missing flags, an
|
|
3364
|
+
// unsupported format+view combination (svg + non-architecture view,
|
|
3365
|
+
// rejected BEFORE any Chrome invocation — Chrome's own dump-failure
|
|
3366
|
+
// reason for this case is confusing, not a good user-facing error), or
|
|
3367
|
+
// a caught throw/{ok:false} from the underlying export function.
|
|
3368
|
+
const DATAFLOW_EXPORT_FORMATS = new Set(['png', 'pdf', 'svg', 'json', 'csv', 'html', 'dpia', 'ropa', 'briefing', 'recipients', 'coverage']);
|
|
3369
|
+
const DATAFLOW_EXPORT_VIEWS = new Set(['architecture', 'privacy', 'trace', 'inventory']);
|
|
3370
|
+
const DATAFLOW_EXPORT_SIZES = { standard: { width: 1680, height: 945 }, '2x': { width: 3360, height: 1890 } };
|
|
3371
|
+
|
|
3372
|
+
async function cmdDataflowExport(args) {
|
|
3373
|
+
const target = args._[2] || '.';
|
|
3374
|
+
const targetAbs = path.resolve(target);
|
|
3375
|
+
|
|
3376
|
+
const format = args.flags.format;
|
|
3377
|
+
if (!format || !DATAFLOW_EXPORT_FORMATS.has(format)) {
|
|
3378
|
+
process.stderr.write(`agentic-security dataflow export: --format must be one of ${[...DATAFLOW_EXPORT_FORMATS].join('|')} (got ${JSON.stringify(format)}).\n`);
|
|
3379
|
+
return 2;
|
|
3380
|
+
}
|
|
3381
|
+
const outputPath = args.flags.output;
|
|
3382
|
+
if (!outputPath || typeof outputPath !== 'string') {
|
|
3383
|
+
process.stderr.write('agentic-security dataflow export: --output <file> is required.\n');
|
|
3384
|
+
return 2;
|
|
3385
|
+
}
|
|
3386
|
+
const viewExplicit = args.flags.view !== undefined;
|
|
3387
|
+
const view = args.flags.view || 'architecture';
|
|
3388
|
+
if (!DATAFLOW_EXPORT_VIEWS.has(view)) {
|
|
3389
|
+
process.stderr.write(`agentic-security dataflow export: --view must be one of ${[...DATAFLOW_EXPORT_VIEWS].join('|')} (got ${JSON.stringify(view)}).\n`);
|
|
3390
|
+
return 2;
|
|
3391
|
+
}
|
|
3392
|
+
if (format === 'svg' && view !== 'architecture') {
|
|
3393
|
+
process.stderr.write('agentic-security dataflow export: --format svg only supports --view architecture — only the Architecture View renders a real <svg> element.\n');
|
|
3394
|
+
return 2;
|
|
3395
|
+
}
|
|
3396
|
+
// json/csv/html are not view-scoped (exportGraphJSON/exportFlowsCSV have
|
|
3397
|
+
// no view concept; generateHtmlReport embeds the full interactive report,
|
|
3398
|
+
// not one captured view) — found by the final whole-branch review: an
|
|
3399
|
+
// explicit --view silently did nothing for these three formats, with no
|
|
3400
|
+
// warning, while the docs presented --view as a universal option.
|
|
3401
|
+
// dpia/ropa (M4 deliverable #10) join this same non-view-scoped set —
|
|
3402
|
+
// emitGraphDpiaArtifact/emitGraphRopaArtifact have no --view concept
|
|
3403
|
+
// either, mirroring json/csv's own precedent exactly. briefing (FR-501)
|
|
3404
|
+
// joins the same set — emitDecisionStory has no --view concept either.
|
|
3405
|
+
// recipients (FR-506) joins the same set — a Markdown table over
|
|
3406
|
+
// graph.recipientProfiles[] has no --view concept either.
|
|
3407
|
+
// coverage (M5, language coverage-tier disclosure) joins the same set —
|
|
3408
|
+
// a Markdown table over graph.coverage.languages[] has no --view concept
|
|
3409
|
+
// either.
|
|
3410
|
+
if (viewExplicit && (format === 'json' || format === 'csv' || format === 'html' || format === 'dpia' || format === 'ropa' || format === 'briefing' || format === 'recipients' || format === 'coverage')) {
|
|
3411
|
+
process.stderr.write(`agentic-security dataflow export: --view has no effect on --format ${format} — ${format} exports are not view-scoped.\n`);
|
|
3412
|
+
}
|
|
3413
|
+
|
|
3414
|
+
// --audience (FR-501, briefing only): parsed and enum-validated
|
|
3415
|
+
// unconditionally, matching --view's own precedent above — an invalid
|
|
3416
|
+
// value is a clear exit-2 error regardless of --format, not silently
|
|
3417
|
+
// accepted and then ignored. Defaults to export-briefing.js's own
|
|
3418
|
+
// documented default ('technical') when omitted. AUDIENCE_MODES is
|
|
3419
|
+
// imported (never hand-copied) so this validation can never drift from
|
|
3420
|
+
// emitDecisionStory's own enum — the same single-source-of-truth
|
|
3421
|
+
// discipline every other enum in this file follows.
|
|
3422
|
+
const { AUDIENCE_MODES } = await import('../src/lineage/export-briefing.js');
|
|
3423
|
+
const audienceExplicit = args.flags.audience !== undefined;
|
|
3424
|
+
const audienceMode = args.flags.audience || 'technical';
|
|
3425
|
+
if (!AUDIENCE_MODES.includes(audienceMode)) {
|
|
3426
|
+
process.stderr.write(`agentic-security dataflow export: --audience must be one of ${AUDIENCE_MODES.join('|')} (got ${JSON.stringify(audienceMode)}).\n`);
|
|
3427
|
+
return 2;
|
|
3428
|
+
}
|
|
3429
|
+
// Final whole-branch review finding (NITPICK, fixed): --view/--no-redact/
|
|
3430
|
+
// --filter each warn when given for a format that ignores them; --audience
|
|
3431
|
+
// silently no-op'd on every non-briefing format with no such warning.
|
|
3432
|
+
if (audienceExplicit && format !== 'briefing') {
|
|
3433
|
+
process.stderr.write(`agentic-security dataflow export: --audience has no effect on --format ${format} — only --format briefing is audience-aware.\n`);
|
|
3434
|
+
}
|
|
3435
|
+
|
|
3436
|
+
// A bare flag (no following value, e.g. "--width --height 500") is
|
|
3437
|
+
// parsed as boolean `true` by parseArgs — found by the final
|
|
3438
|
+
// whole-branch review: `Number(true) === 1` passed the old
|
|
3439
|
+
// Number.isSafeInteger guard unchanged, silently producing a 1-pixel
|
|
3440
|
+
// image at exit 0. Require a plain, unsigned, non-hex/non-exponential
|
|
3441
|
+
// digit string (rejects "true", "0x10", "1e3", leading zeros) AND cap
|
|
3442
|
+
// the value — an absurd-but-safe-integer width/height (e.g.
|
|
3443
|
+
// Number.MAX_SAFE_INTEGER) silently made Chrome fall back to its own
|
|
3444
|
+
// default size instead of erroring; a sane ceiling turns that into a
|
|
3445
|
+
// clean argument error instead of a silently-wrong image.
|
|
3446
|
+
const MAX_DATAFLOW_EXPORT_DIMENSION = 20000;
|
|
3447
|
+
function _isPlainPositiveIntString(s) {
|
|
3448
|
+
return typeof s === 'string' && /^[1-9]\d*$/.test(s);
|
|
3449
|
+
}
|
|
3450
|
+
|
|
3451
|
+
const sizeFlag = args.flags.size;
|
|
3452
|
+
const hasWidthHeight = args.flags.width !== undefined || args.flags.height !== undefined;
|
|
3453
|
+
if (sizeFlag !== undefined && hasWidthHeight) {
|
|
3454
|
+
process.stderr.write('agentic-security dataflow export: --size and --width/--height are mutually exclusive — pick one.\n');
|
|
3455
|
+
return 2;
|
|
3456
|
+
}
|
|
3457
|
+
let width, height;
|
|
3458
|
+
if (sizeFlag !== undefined) {
|
|
3459
|
+
if (!Object.prototype.hasOwnProperty.call(DATAFLOW_EXPORT_SIZES, sizeFlag)) {
|
|
3460
|
+
process.stderr.write(`agentic-security dataflow export: --size must be one of ${Object.keys(DATAFLOW_EXPORT_SIZES).join('|')} (got ${JSON.stringify(sizeFlag)}).\n`);
|
|
3461
|
+
return 2;
|
|
3462
|
+
}
|
|
3463
|
+
({ width, height } = DATAFLOW_EXPORT_SIZES[sizeFlag]);
|
|
3464
|
+
} else if (hasWidthHeight) {
|
|
3465
|
+
if (!_isPlainPositiveIntString(args.flags.width) || !_isPlainPositiveIntString(args.flags.height)) {
|
|
3466
|
+
process.stderr.write('agentic-security dataflow export: --width/--height must both be positive integers.\n');
|
|
3467
|
+
return 2;
|
|
3468
|
+
}
|
|
3469
|
+
width = Number(args.flags.width);
|
|
3470
|
+
height = Number(args.flags.height);
|
|
3471
|
+
if (width > MAX_DATAFLOW_EXPORT_DIMENSION || height > MAX_DATAFLOW_EXPORT_DIMENSION) {
|
|
3472
|
+
process.stderr.write(`agentic-security dataflow export: --width/--height must both be <= ${MAX_DATAFLOW_EXPORT_DIMENSION}.\n`);
|
|
3473
|
+
return 2;
|
|
3474
|
+
}
|
|
3475
|
+
} else {
|
|
3476
|
+
({ width, height } = DATAFLOW_EXPORT_SIZES.standard);
|
|
3477
|
+
}
|
|
3478
|
+
|
|
3479
|
+
const redact = args.flags['no-redact'] ? false : true;
|
|
3480
|
+
if (!redact && format === 'csv') {
|
|
3481
|
+
process.stderr.write('agentic-security dataflow export: --no-redact has no effect on --format csv — CSV export does not support redaction yet.\n');
|
|
3482
|
+
}
|
|
3483
|
+
// dpia/ropa (M4 deliverable #10): emitGraphDpiaArtifact/emitGraphRopaArtifact
|
|
3484
|
+
// never call exportGraphJSON's own redaction path either — same
|
|
3485
|
+
// precedent as csv above, same guard. briefing (FR-501) joins the same
|
|
3486
|
+
// set — emitDecisionStory never calls exportGraphJSON's redaction path
|
|
3487
|
+
// either. recipients (FR-506) joins the same set too — a judgment call,
|
|
3488
|
+
// disclosed: as of fix-round-1 (B1), `redact-graph.js`'s `_redactGraph`
|
|
3489
|
+
// DOES cover `graph.recipientProfiles[].technicalEndpoint`/`.legalEntity`/
|
|
3490
|
+
// `.retentionCommitment`/`.transferMechanism` — but `--no-redact` is
|
|
3491
|
+
// still a no-op for `recipients` specifically because
|
|
3492
|
+
// `_renderDataflowRecipientsMarkdown` below never calls
|
|
3493
|
+
// `exportGraphJSON`'s redaction path at all (it reads `graph` directly,
|
|
3494
|
+
// the same way dpia/ropa/briefing's own renderers do) — a fact about
|
|
3495
|
+
// THIS renderer's own code path, unrelated to whether `_redactGraph`
|
|
3496
|
+
// itself has an opinion about the field.
|
|
3497
|
+
// coverage (M5, language coverage-tier disclosure) joins the same set —
|
|
3498
|
+
// graph.coverage.languages[] carries only curated static tier data and
|
|
3499
|
+
// plain per-repo file counts, never a destination literal or evidence
|
|
3500
|
+
// snippet redact-graph.js has any opinion about.
|
|
3501
|
+
if (!redact && (format === 'dpia' || format === 'ropa' || format === 'briefing' || format === 'recipients' || format === 'coverage')) {
|
|
3502
|
+
process.stderr.write(`agentic-security dataflow export: --no-redact has no effect on --format ${format} — ${format} export does not support redaction yet.\n`);
|
|
3503
|
+
}
|
|
3504
|
+
|
|
3505
|
+
let filter;
|
|
3506
|
+
if (args.flags.filter !== undefined) {
|
|
3507
|
+
// A bare "--filter" (no value) or "--filter=" (empty string) must be
|
|
3508
|
+
// rejected up front, not passed to path.resolve() — found by the
|
|
3509
|
+
// final whole-branch review: path.resolve(true) throws a raw
|
|
3510
|
+
// TypeError that escapes uncaught to exit 4 (the same defect class
|
|
3511
|
+
// Task 1's own review found and fixed at the write stage, surviving
|
|
3512
|
+
// here on a different flag); an empty string was falsy and silently
|
|
3513
|
+
// dropped the whole filter instead of erroring.
|
|
3514
|
+
if (typeof args.flags.filter !== 'string' || !args.flags.filter) {
|
|
3515
|
+
process.stderr.write('agentic-security dataflow export: --filter requires a file path.\n');
|
|
3516
|
+
return 2;
|
|
3517
|
+
}
|
|
3518
|
+
const filterPath = path.resolve(args.flags.filter);
|
|
3519
|
+
try {
|
|
3520
|
+
filter = JSON.parse(fs.readFileSync(filterPath, 'utf8'));
|
|
3521
|
+
} catch (e) {
|
|
3522
|
+
process.stderr.write(`agentic-security dataflow export: could not read/parse --filter file "${args.flags.filter}": ${e.message}\n`);
|
|
3523
|
+
return 2;
|
|
3524
|
+
}
|
|
3525
|
+
// Milestone 5, large-graph pagination: this shape check now lives in
|
|
3526
|
+
// export-json.js's validateFilterShape (extracted verbatim from what
|
|
3527
|
+
// was previously this file's own inline copy) so the server's new
|
|
3528
|
+
// POST /api/v1/query endpoint and the dataflow_get_graph MCP tool
|
|
3529
|
+
// share the identical protection rather than a third, drifting copy.
|
|
3530
|
+
const { validateFilterShape } = await import('../src/lineage/export-json.js');
|
|
3531
|
+
const filterCheck = validateFilterShape(filter);
|
|
3532
|
+
if (!filterCheck.valid) {
|
|
3533
|
+
process.stderr.write(`agentic-security dataflow export: --filter file "${args.flags.filter}" ${filterCheck.error}.\n`);
|
|
3534
|
+
return 2;
|
|
3535
|
+
}
|
|
3536
|
+
// exportFlowsCSV(graph) takes no opts at all — found by the final
|
|
3537
|
+
// whole-branch review: --filter silently did nothing for --format
|
|
3538
|
+
// csv (an operator scoping a CSV export to a safe subset silently
|
|
3539
|
+
// got everything instead), with no warning, while both this file's
|
|
3540
|
+
// own USAGE text and commands/dataflow.md presented --filter as a
|
|
3541
|
+
// universal option.
|
|
3542
|
+
// coverage (M5, language coverage-tier disclosure) joins the same set —
|
|
3543
|
+
// a per-language table has no node/edge-id-scoped meaning to narrow by.
|
|
3544
|
+
if (format === 'csv' || format === 'coverage') {
|
|
3545
|
+
process.stderr.write(`agentic-security dataflow export: --filter has no effect on --format ${format} — ${format} export does not support scoping yet.\n`);
|
|
3546
|
+
}
|
|
3547
|
+
}
|
|
3548
|
+
|
|
3549
|
+
const { loadSignedGraph } = await import('../src/server/graph-loader.js');
|
|
3550
|
+
const loaded = loadSignedGraph(targetAbs);
|
|
3551
|
+
if (!loaded.ok) {
|
|
3552
|
+
process.stderr.write(`agentic-security dataflow export: ${loaded.message}\n`);
|
|
3553
|
+
return 1;
|
|
3554
|
+
}
|
|
3555
|
+
const graph = loaded.graph;
|
|
3556
|
+
// Task-3 review finding (non-blocking, fixed): emitGraphDpiaArtifact's
|
|
3557
|
+
// own opts.generatedAt fallback is `new Date()` — omitting it here made
|
|
3558
|
+
// dpia/ropa the only two formats whose content reflects EXPORT-time
|
|
3559
|
+
// wall clock rather than the graph's own already-fixed generatedAt (the
|
|
3560
|
+
// same field json/html embed unconditionally), so exporting the
|
|
3561
|
+
// identical persisted graph twice on two different days produced two
|
|
3562
|
+
// different documents.
|
|
3563
|
+
const opts = { view, width, height, redact, filter, generatedAt: graph.generatedAt, audienceMode };
|
|
3564
|
+
|
|
3565
|
+
let data;
|
|
3566
|
+
try {
|
|
3567
|
+
if (format === 'png' || format === 'pdf' || format === 'svg') {
|
|
3568
|
+
const { exportPng, exportPdf, exportSvg } = await import('../scripts/export-image.mjs');
|
|
3569
|
+
const fn = { png: exportPng, pdf: exportPdf, svg: exportSvg }[format];
|
|
3570
|
+
const result = await fn(graph, opts);
|
|
3571
|
+
if (!result.ok) {
|
|
3572
|
+
process.stderr.write(`agentic-security dataflow export: ${result.reason}\n`);
|
|
3573
|
+
return 2;
|
|
3022
3574
|
}
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
3575
|
+
data = result.data;
|
|
3576
|
+
} else if (format === 'json') {
|
|
3577
|
+
const { exportGraphJSON } = await import('../src/lineage/export-json.js');
|
|
3578
|
+
data = JSON.stringify(exportGraphJSON(graph, opts), null, 2);
|
|
3579
|
+
} else if (format === 'csv') {
|
|
3580
|
+
const { exportFlowsCSV } = await import('../src/lineage/export-csv.js');
|
|
3581
|
+
data = exportFlowsCSV(graph);
|
|
3582
|
+
} else if (format === 'html') {
|
|
3583
|
+
const { generateHtmlReport } = await import('../scripts/generate-html-report.mjs');
|
|
3584
|
+
data = generateHtmlReport(graph, opts);
|
|
3585
|
+
} else if (format === 'dpia') {
|
|
3586
|
+
const { emitGraphDpiaArtifact } = await import('../src/lineage/export-privacy.js');
|
|
3587
|
+
data = emitGraphDpiaArtifact(graph, opts);
|
|
3588
|
+
} else if (format === 'ropa') {
|
|
3589
|
+
const { emitGraphRopaArtifact } = await import('../src/lineage/export-privacy.js');
|
|
3590
|
+
data = emitGraphRopaArtifact(graph, opts);
|
|
3591
|
+
} else if (format === 'briefing') {
|
|
3592
|
+
const { emitDecisionStory } = await import('../src/lineage/export-briefing.js');
|
|
3593
|
+
data = emitDecisionStory(graph, opts).markdown;
|
|
3594
|
+
} else if (format === 'recipients') {
|
|
3595
|
+
data = _renderDataflowRecipientsMarkdown(graph, opts);
|
|
3596
|
+
} else if (format === 'coverage') {
|
|
3597
|
+
data = _renderDataflowCoverageMarkdown(graph, opts);
|
|
3598
|
+
}
|
|
3599
|
+
} catch (e) {
|
|
3600
|
+
// dpia/ropa reach frontend/src/views/privacy-view.js via a relative
|
|
3601
|
+
// import that lives OUTSIDE the published scanner/ package directory
|
|
3602
|
+
// (`../../../frontend/...`, a repo-root sibling of scanner/) — real
|
|
3603
|
+
// for the `agentic-security`/`as` commands (dist/agentic-security.mjs
|
|
3604
|
+
// is ncc-bundled, so that content is already inlined and this path
|
|
3605
|
+
// never triggers there), but a genuine ERR_MODULE_NOT_FOUND when
|
|
3606
|
+
// someone runs THIS raw, unbundled file directly out of an installed
|
|
3607
|
+
// package's node_modules, reproduced live via a real `npm pack` +
|
|
3608
|
+
// install into a fresh consumer project. Give that one narrow case an
|
|
3609
|
+
// actionable message instead of a bare "Cannot find package
|
|
3610
|
+
// '@clear-capabilities/frontend'" that names a package the user has
|
|
3611
|
+
// never heard of. Scoped re-review finding (fixed): checking only
|
|
3612
|
+
// e.code === 'ERR_MODULE_NOT_FOUND' is too broad — it would also
|
|
3613
|
+
// catch and mislabel an UNRELATED module-resolution failure (a real
|
|
3614
|
+
// future bug, a genuinely broken install) with this same "you ran
|
|
3615
|
+
// the raw bin file" message, masking it. Requiring the message to
|
|
3616
|
+
// actually name the frontend import keeps this narrow to the one
|
|
3617
|
+
// real case it was written for.
|
|
3618
|
+
if ((format === 'dpia' || format === 'ropa') && e && e.code === 'ERR_MODULE_NOT_FOUND' && /privacy-view\.js/.test(e.message)) {
|
|
3619
|
+
process.stderr.write(`agentic-security dataflow export: export failed: --format ${format} could not load its frontend module (${e.message}). This usually means you ran the raw bin/agentic-security.js file directly out of an installed package instead of using the published \`agentic-security\`/\`as\` command (which is fully self-contained). Use the published command, or run from a full source checkout with frontend/ present alongside scanner/.\n`);
|
|
3620
|
+
return 2;
|
|
3621
|
+
}
|
|
3622
|
+
process.stderr.write(`agentic-security dataflow export: export failed: ${e && e.message ? e.message : e}\n`);
|
|
3623
|
+
return 2;
|
|
3624
|
+
}
|
|
3625
|
+
|
|
3626
|
+
const outAbs = path.resolve(outputPath);
|
|
3627
|
+
try {
|
|
3628
|
+
await fsp.mkdir(path.dirname(outAbs), { recursive: true });
|
|
3629
|
+
await fsp.writeFile(outAbs, data);
|
|
3630
|
+
} catch (e) {
|
|
3631
|
+
// Found by Task 1's own review, reproduced live (--output pointing at
|
|
3632
|
+
// an existing directory: EISDIR): left unguarded, this escaped to
|
|
3633
|
+
// main()'s generic top-level catch, printing a raw stack trace and
|
|
3634
|
+
// exiting 4 instead of the documented clean 0/1/2 contract.
|
|
3635
|
+
process.stderr.write(`agentic-security dataflow export: could not write --output "${outputPath}": ${e && e.message ? e.message : e}\n`);
|
|
3636
|
+
return 2;
|
|
3637
|
+
}
|
|
3638
|
+
process.stdout.write(`agentic-security dataflow export: wrote ${format} to ${outAbs}\n`);
|
|
3639
|
+
return 0;
|
|
3640
|
+
}
|
|
3641
|
+
|
|
3642
|
+
// Local Markdown-escaping helpers for `--format recipients` — byte-identical
|
|
3643
|
+
// to export-briefing.js's/export-privacy.js's/`_dfDiffMd*`'s own
|
|
3644
|
+
// _mdInline/_mdCell/_mdCode, reimplemented locally per this codebase's
|
|
3645
|
+
// established per-module-owns-its-own-escaping-helpers convention (see
|
|
3646
|
+
// export-briefing.js's own header comment for why these are never imported
|
|
3647
|
+
// across modules). Applied to every graph-derived or operator-recipient-
|
|
3648
|
+
// config-derived string (provider/legalEntity/dpaStatus/etc.) interpolated
|
|
3649
|
+
// into the Markdown report below.
|
|
3650
|
+
function _dfRecipientsMdInline(value) {
|
|
3651
|
+
return String(value).replace(/\r\n|\r|\n/g, ' ');
|
|
3652
|
+
}
|
|
3653
|
+
function _dfRecipientsMdCell(value) {
|
|
3654
|
+
return _dfRecipientsMdInline(value).replace(/\\/g, '\\\\').replace(/\|/g, '\\|');
|
|
3655
|
+
}
|
|
3656
|
+
function _dfRecipientsMdCode(value) {
|
|
3657
|
+
const s = _dfRecipientsMdInline(value);
|
|
3658
|
+
const runs = s.match(/`+/g);
|
|
3659
|
+
const maxRun = runs ? Math.max(...runs.map((r) => r.length)) : 0;
|
|
3660
|
+
if (maxRun === 0) return `\`${s}\``;
|
|
3661
|
+
const fence = '`'.repeat(maxRun + 1);
|
|
3662
|
+
return `${fence} ${s} ${fence}`;
|
|
3663
|
+
}
|
|
3664
|
+
|
|
3665
|
+
// `--filter`'s scoping rule for `--format recipients` (a real design
|
|
3666
|
+
// decision the task brief left open, disclosed here): a RecipientProfile
|
|
3667
|
+
// has no node/edge of its own — it is a §10.10 extension record keyed by
|
|
3668
|
+
// `recipientKey`, associated with the graph only via `contributingGraphIds`
|
|
3669
|
+
// (the sink NODE ids that resolved to it, per `graph-builder.js`'s own
|
|
3670
|
+
// `opts.buildRecipientProfile` hook). So this format narrows
|
|
3671
|
+
// `graph.recipientProfiles` by whether ANY of a profile's
|
|
3672
|
+
// `contributingGraphIds` survive `filter.nodeIds` — mirroring the same
|
|
3673
|
+
// "narrow by referential soundness against the filtered node set" spirit
|
|
3674
|
+
// `export-json.js`'s own `_filterGraph` uses for flows, applied to this
|
|
3675
|
+
// format's own, different entity shape. `filter.edgeIds` has no effect on
|
|
3676
|
+
// this format (a recipient profile carries no edge reference at all) — a
|
|
3677
|
+
// filter file supplying only `edgeIds` therefore narrows this format to
|
|
3678
|
+
// nothing, an honest (not silently-wrong) consequence of the format having
|
|
3679
|
+
// no edge concept, not a bug.
|
|
3680
|
+
function _filterRecipientProfiles(recipientProfiles, filter) {
|
|
3681
|
+
if (!filter) return recipientProfiles;
|
|
3682
|
+
const nodeIds = new Set(filter.nodeIds ?? []);
|
|
3683
|
+
return recipientProfiles.filter((p) => (p.contributingGraphIds ?? []).some((id) => nodeIds.has(id)));
|
|
3684
|
+
}
|
|
3685
|
+
|
|
3686
|
+
// The table columns named by the task brief — a deliberate SUBSET of
|
|
3687
|
+
// `RecipientProfile`'s full `RECIPIENT_FACT_FIELDS` (14 fields; see
|
|
3688
|
+
// recipient-profile.js), not all of them: technicalEndpoint/servicePurpose/
|
|
3689
|
+
// subprocessorChain/dataResidencyCommitment/observedRegion/
|
|
3690
|
+
// transferMechanism/transferImpactReviewStatus/retentionCommitment are
|
|
3691
|
+
// real fields but not part of the brief's own named column list, so they
|
|
3692
|
+
// are omitted from the table (a future format revision could add them).
|
|
3693
|
+
// `processingCountries` is rendered under the "Jurisdiction(s)" header per
|
|
3694
|
+
// the brief's own wording. `confidence` is record-level metadata (no
|
|
3695
|
+
// `fieldEvidence` entry of its own — see recipient-profile.js's own header
|
|
3696
|
+
// comment on why), so it is shown in the table but excluded from the
|
|
3697
|
+
// per-row evidence-disclosure footer below.
|
|
3698
|
+
const _DATAFLOW_RECIPIENTS_FACT_COLUMNS = [
|
|
3699
|
+
['provider', 'Provider'],
|
|
3700
|
+
['serviceType', 'Service Type'],
|
|
3701
|
+
['legalEntity', 'Legal Entity'],
|
|
3702
|
+
['processorRole', 'Processor Role'],
|
|
3703
|
+
['processingCountries', 'Jurisdiction(s)'],
|
|
3704
|
+
['dpaStatus', 'DPA Status'],
|
|
3705
|
+
];
|
|
3706
|
+
|
|
3707
|
+
function _dataflowRecipientsCellValue(profile, field) {
|
|
3708
|
+
const value = profile[field];
|
|
3709
|
+
if (field === 'processingCountries') {
|
|
3710
|
+
return Array.isArray(value) && value.length > 0 ? value.join(', ') : '—';
|
|
3711
|
+
}
|
|
3712
|
+
return value === null || value === undefined || value === '' ? '—' : String(value);
|
|
3713
|
+
}
|
|
3714
|
+
|
|
3715
|
+
// `agentic-security dataflow export --format recipients` (Milestone 4,
|
|
3716
|
+
// FR-506, Task 3) — one Markdown table row per `graph.recipientProfiles[]`
|
|
3717
|
+
// entry, plus a footer disclosing which fields are `code_inferred` vs
|
|
3718
|
+
// `declared` vs absent for each row (never fabricated — a field with no
|
|
3719
|
+
// `fieldEvidence` entry is disclosed as `absent`, not silently omitted).
|
|
3720
|
+
function _renderDataflowRecipientsMarkdown(graph, opts = {}) {
|
|
3721
|
+
const profiles = _filterRecipientProfiles(graph.recipientProfiles ?? [], opts.filter);
|
|
3722
|
+
const lines = [];
|
|
3723
|
+
lines.push('# Third-Party and Cross-Border Recipient Intelligence');
|
|
3724
|
+
lines.push('');
|
|
3725
|
+
lines.push(`**Graph:** ${_dfRecipientsMdCode(graph.graphId ?? '(no graphId)')}`);
|
|
3726
|
+
lines.push(`**Generated:** ${_dfRecipientsMdInline(opts.generatedAt ?? graph.generatedAt ?? '')}`);
|
|
3727
|
+
if (opts.filter) lines.push('**Scope:** filtered to a subset of the graph (`--filter`).');
|
|
3728
|
+
lines.push('');
|
|
3729
|
+
|
|
3730
|
+
if (profiles.length === 0) {
|
|
3731
|
+
lines.push(opts.filter
|
|
3732
|
+
? 'No recipients survive the given `--filter` scope.'
|
|
3733
|
+
: 'No recipients resolved for this graph — no sink site matched a known technical-provider catalog entry or an operator-declared `recipient-profiles.json` entry.');
|
|
3734
|
+
return `${lines.join('\n')}\n`;
|
|
3735
|
+
}
|
|
3736
|
+
|
|
3737
|
+
lines.push(`| ${_DATAFLOW_RECIPIENTS_FACT_COLUMNS.map(([, header]) => header).join(' | ')} | Confidence |`);
|
|
3738
|
+
lines.push(`|${_DATAFLOW_RECIPIENTS_FACT_COLUMNS.map(() => '---').join('|')}|---|`);
|
|
3739
|
+
for (const p of profiles) {
|
|
3740
|
+
const cells = _DATAFLOW_RECIPIENTS_FACT_COLUMNS.map(([field]) => _dfRecipientsMdCell(_dataflowRecipientsCellValue(p, field)));
|
|
3741
|
+
cells.push(_dfRecipientsMdCell(p.confidence ?? '—'));
|
|
3742
|
+
lines.push(`| ${cells.join(' | ')} |`);
|
|
3743
|
+
}
|
|
3744
|
+
|
|
3745
|
+
lines.push('');
|
|
3746
|
+
lines.push('## Field evidence');
|
|
3747
|
+
lines.push('');
|
|
3748
|
+
lines.push('For each recipient above, whether each shown fact is code-inferred (from a real call site matching this codebase\'s technical-provider catalog), declared (from operator-supplied `recipient-profiles.json`), or absent (no source at all — never fabricated):');
|
|
3749
|
+
lines.push('');
|
|
3750
|
+
for (const p of profiles) {
|
|
3751
|
+
lines.push(`### ${_dfRecipientsMdInline(p.provider ?? p.recipientKey ?? '(unknown recipient)')}`);
|
|
3752
|
+
for (const [field, header] of _DATAFLOW_RECIPIENTS_FACT_COLUMNS) {
|
|
3753
|
+
const ev = p.fieldEvidence ? p.fieldEvidence[field] : undefined;
|
|
3754
|
+
const status = ev ? ev.factType : 'absent';
|
|
3755
|
+
const sourceNote = ev && ev.source ? ` (source: ${_dfRecipientsMdInline(ev.source)})` : '';
|
|
3756
|
+
lines.push(`- ${_dfRecipientsMdInline(header)}: ${_dfRecipientsMdInline(status)}${sourceNote}`);
|
|
3757
|
+
}
|
|
3758
|
+
lines.push('');
|
|
3759
|
+
}
|
|
3760
|
+
|
|
3761
|
+
return `${lines.join('\n')}\n`;
|
|
3762
|
+
}
|
|
3763
|
+
|
|
3764
|
+
// Local Markdown-escaping helpers for `--format coverage` — byte-identical
|
|
3765
|
+
// to _dfRecipientsMdInline/_dfRecipientsMdCell/_dfRecipientsMdCode above,
|
|
3766
|
+
// reimplemented locally per this codebase's established
|
|
3767
|
+
// per-module-owns-its-own-escaping-helpers convention.
|
|
3768
|
+
function _dfCoverageMdInline(value) {
|
|
3769
|
+
return String(value).replace(/\r\n|\r|\n/g, ' ');
|
|
3770
|
+
}
|
|
3771
|
+
function _dfCoverageMdCell(value) {
|
|
3772
|
+
return _dfCoverageMdInline(value).replace(/\\/g, '\\\\').replace(/\|/g, '\\|');
|
|
3773
|
+
}
|
|
3774
|
+
|
|
3775
|
+
// Milestone 5, language coverage-tier disclosure. Renders graph.coverage.
|
|
3776
|
+
// languages[] (Task 1's own additive fields: tier/irTaintRecallPct/
|
|
3777
|
+
// measuredAt/source, alongside the pre-existing filesExpected/filesAnalyzed)
|
|
3778
|
+
// as a Markdown table, with an explicit disclosure paragraph distinguishing
|
|
3779
|
+
// the two different KINDS of fact in this report: real, per-repo file
|
|
3780
|
+
// counts (filesAnalyzed/filesExpected, computed fresh on every scan) versus
|
|
3781
|
+
// a curated, product-level recall estimate (irTaintRecallPct/measuredAt/
|
|
3782
|
+
// source, unchanged since docs/METRICS.md was last measured) — never
|
|
3783
|
+
// presented as one number, since conflating them would imply this scan
|
|
3784
|
+
// itself measured its own recall, which it did not.
|
|
3785
|
+
function _renderDataflowCoverageMarkdown(graph, opts = {}) {
|
|
3786
|
+
const lines = [];
|
|
3787
|
+
lines.push('# Language Coverage');
|
|
3788
|
+
lines.push('');
|
|
3789
|
+
lines.push(`**Graph:** \`${_dfCoverageMdInline(graph.graphId ?? '(no graphId)')}\``);
|
|
3790
|
+
lines.push(`**Generated:** ${_dfCoverageMdInline(opts.generatedAt ?? graph.generatedAt ?? '')}`);
|
|
3791
|
+
lines.push('');
|
|
3792
|
+
lines.push('`Files Analyzed`/`Files Expected` are real counts from THIS scan. `Tier` and `Recall (docs/METRICS.md)` are a curated, product-level estimate — unchanged since the date shown, not measured on this repository. The two are never the same kind of fact.');
|
|
3793
|
+
lines.push('');
|
|
3794
|
+
const languages = Array.isArray(graph.coverage?.languages) ? graph.coverage.languages : [];
|
|
3795
|
+
if (languages.length === 0) {
|
|
3796
|
+
lines.push('_No language coverage data available for this scan._');
|
|
3797
|
+
} else {
|
|
3798
|
+
lines.push('| Language | Files Analyzed | Files Expected | Tier | Recall (docs/METRICS.md) |');
|
|
3799
|
+
lines.push('|---|---|---|---|---|');
|
|
3800
|
+
for (const l of languages) {
|
|
3801
|
+
const recall = typeof l.irTaintRecallPct === 'number'
|
|
3802
|
+
? `${l.irTaintRecallPct}% (as of ${_dfCoverageMdCell(l.measuredAt ?? '?')})`
|
|
3803
|
+
: '—';
|
|
3804
|
+
lines.push(`| ${_dfCoverageMdCell(l.language)} | ${_dfCoverageMdCell(l.filesAnalyzed)} | ${_dfCoverageMdCell(l.filesExpected)} | ${_dfCoverageMdCell(l.tier)} | ${recall} |`);
|
|
3805
|
+
}
|
|
3806
|
+
}
|
|
3807
|
+
lines.push('');
|
|
3808
|
+
return lines.join('\n');
|
|
3809
|
+
}
|
|
3810
|
+
|
|
3811
|
+
// `agentic-security dataflow diff [path] [--against <commit>]
|
|
3812
|
+
// [--drift-policy <path>] --output <file> [--format json|markdown]
|
|
3813
|
+
// [--fail-on-drift]` — Milestone 4, FR-503 sub-project 8b, Task 3. Reads
|
|
3814
|
+
// two persisted GraphSnapshot records (src/lineage/graph-snapshot.js),
|
|
3815
|
+
// computes a GraphDiff (src/lineage/graph-diff.js's computeGraphDiff, which
|
|
3816
|
+
// THROWS on an incomparable pair rather than returning an error object —
|
|
3817
|
+
// caught below and converted to the same clean-message-then-return-2
|
|
3818
|
+
// discipline cmdDataflowExport's own try/catch already established),
|
|
3819
|
+
// optionally evaluates operator drift policies against it
|
|
3820
|
+
// (src/lineage/drift-policy.js), and writes a JSON or Markdown report.
|
|
3821
|
+
//
|
|
3822
|
+
// Snapshot resolution — a real design decision the task brief itself left
|
|
3823
|
+
// open, disclosed here rather than in a comment only cmdDataflowExport's
|
|
3824
|
+
// own precedent would lead a reader to expect: graph-snapshot.js's own
|
|
3825
|
+
// git-HEAD resolver (`_gitHead`) is private and unexported, so this
|
|
3826
|
+
// function cannot independently ask "what is the current commit" the way
|
|
3827
|
+
// the scan-time persistence call site can. Instead, the CLI's own "AFTER"
|
|
3828
|
+
// snapshot is always `loadSnapshots(scanRoot)[0]` — the newest persisted
|
|
3829
|
+
// snapshot by file mtime — which is consistent with how a scan always
|
|
3830
|
+
// persists a snapshot for its own commit before `dataflow diff` would ever
|
|
3831
|
+
// run against that scanRoot (see test/cli/lineage-snapshot-persist.test.js
|
|
3832
|
+
// for the real, already-shipped precedent this assumption rests on).
|
|
3833
|
+
// `loadSnapshots(scanRoot)` returning `[]` is this function's "no snapshot
|
|
3834
|
+
// to compare against" exit-2 case. Without an explicit `--against`, the
|
|
3835
|
+
// "BEFORE" snapshot defaults to `mostRecentPriorSnapshot(scanRoot,
|
|
3836
|
+
// afterSnapshot.commit)` — also exit-2 if that is `null` (only one
|
|
3837
|
+
// snapshot exists, nothing to diff against yet). An explicit
|
|
3838
|
+
// `--against <commit>` resolves via `loadSnapshot(scanRoot, commit)` —
|
|
3839
|
+
// exit-2 with a clear message if that commit has no persisted snapshot.
|
|
3840
|
+
//
|
|
3841
|
+
// --format: the task brief's own invocation-signature line brackets
|
|
3842
|
+
// `[--format json|markdown]` as optional, but its own exit-code contract
|
|
3843
|
+
// explicitly lists "missing/invalid --format" as one of the enumerated
|
|
3844
|
+
// exit-2 cases — a real conflict between the two, resolved here (disclosed,
|
|
3845
|
+
// not silently picked) in favor of the more precisely-worded exit-code
|
|
3846
|
+
// contract: --format is REQUIRED, exactly like --output and exactly like
|
|
3847
|
+
// cmdDataflowExport's own --format above, never defaulted.
|
|
3848
|
+
//
|
|
3849
|
+
// --format json emits the raw GraphDiff record plus a `violations` array
|
|
3850
|
+
// (from evaluateDriftPolicies when --drift-policy was supplied). The task
|
|
3851
|
+
// brief leaves it as this implementation's own call whether to omit
|
|
3852
|
+
// `violations` or emit an empty array when no --drift-policy was given —
|
|
3853
|
+
// this always emits `violations: []` so the JSON shape is uniform across
|
|
3854
|
+
// both cases (a caller/test can always read `.violations` without an
|
|
3855
|
+
// `in`/`?.` check), disclosed here per the brief's own request.
|
|
3856
|
+
//
|
|
3857
|
+
// Exit codes, matching cmdDataflowExport's own documented contract shape:
|
|
3858
|
+
// 0 success (no violations, or violations found but --fail-on-drift not
|
|
3859
|
+
// passed); 2 usage/argument error (missing --output, missing/invalid
|
|
3860
|
+
// --format, no snapshot to compare against, an incomparable pair, a
|
|
3861
|
+
// malformed --drift-policy file); 1 if --drift-policy violations were
|
|
3862
|
+
// found AND --fail-on-drift was passed — the report is still written to
|
|
3863
|
+
// --output in that case, exactly like a passing run, so a CI caller gets
|
|
3864
|
+
// both the gate signal AND the artifact in one invocation.
|
|
3865
|
+
const DATAFLOW_DIFF_FORMATS = new Set(['json', 'markdown']);
|
|
3866
|
+
|
|
3867
|
+
/** Resolves a flow id against a real DataFlowGraph v1 document for
|
|
3868
|
+
* human-readable Markdown rendering — a GraphDiff's own added/removed/
|
|
3869
|
+
* changed entries carry only {id, causeClassification, ...} (see
|
|
3870
|
+
* graph-diff.js's own header), never dataElement/sink names directly.
|
|
3871
|
+
* Mirrors drift-policy.js's own private _resolveFlowContext in spirit
|
|
3872
|
+
* (same id-chasing logic), reimplemented locally here since that helper
|
|
3873
|
+
* is not exported and this CLI's own rendering needs are narrower (labels
|
|
3874
|
+
* only, no dataClasses/sinkCategory matching). Returns raw, UNESCAPED
|
|
3875
|
+
* strings — callers interpolating into Markdown MUST pass every returned
|
|
3876
|
+
* value through _mdInline/_mdCell/_mdCode below, never this function's
|
|
3877
|
+
* output directly. */
|
|
3878
|
+
function _dataflowDiffFlowLabel(flowId, graph) {
|
|
3879
|
+
const flow = (graph?.flows ?? []).find((f) => f.id === flowId);
|
|
3880
|
+
if (!flow) return { dataElementNames: [], sinkLabel: null };
|
|
3881
|
+
const dataElementNames = (flow.dataElementIds ?? [])
|
|
3882
|
+
.map((id) => (graph.dataElements ?? []).find((d) => d.id === id)?.name)
|
|
3883
|
+
.filter((n) => typeof n === 'string' && n.length > 0);
|
|
3884
|
+
const sinkNode = (graph.nodes ?? []).find((n) => n.id === flow.sink) ?? null;
|
|
3885
|
+
const sinkLabel = sinkNode?.label ?? sinkNode?.subtype ?? sinkNode?.kind ?? flow.sink ?? null;
|
|
3886
|
+
return { dataElementNames, sinkLabel };
|
|
3887
|
+
}
|
|
3888
|
+
|
|
3889
|
+
// Local Markdown-escaping helpers — byte-identical to export-briefing.js's/
|
|
3890
|
+
// export-privacy.js's own _mdInline/_mdCell/_mdCode, reimplemented locally
|
|
3891
|
+
// per this codebase's established per-module-owns-its-own-escaping-helpers
|
|
3892
|
+
// convention (see export-briefing.js's own header comment for why these
|
|
3893
|
+
// are never imported across modules). Applied to every graph-derived or
|
|
3894
|
+
// operator-drift-policy-derived string (flow ids, data element names, sink
|
|
3895
|
+
// labels, rule `reason` text) interpolated into the Markdown report below.
|
|
3896
|
+
function _dfDiffMdInline(value) {
|
|
3897
|
+
return String(value).replace(/\r\n|\r|\n/g, ' ');
|
|
3898
|
+
}
|
|
3899
|
+
function _dfDiffMdCell(value) {
|
|
3900
|
+
return _dfDiffMdInline(value).replace(/\\/g, '\\\\').replace(/\|/g, '\\|');
|
|
3901
|
+
}
|
|
3902
|
+
function _dfDiffMdCode(value) {
|
|
3903
|
+
const s = _dfDiffMdInline(value);
|
|
3904
|
+
const runs = s.match(/`+/g);
|
|
3905
|
+
const maxRun = runs ? Math.max(...runs.map((r) => r.length)) : 0;
|
|
3906
|
+
if (maxRun === 0) return `\`${s}\``;
|
|
3907
|
+
const fence = '`'.repeat(maxRun + 1);
|
|
3908
|
+
return `${fence} ${s} ${fence}`;
|
|
3909
|
+
}
|
|
3910
|
+
|
|
3911
|
+
function _renderDataflowDiffMarkdown(diff, violations, beforeSnapshot, afterSnapshot, driftPolicyProvided) {
|
|
3912
|
+
const graphAfter = afterSnapshot.graph ?? {};
|
|
3913
|
+
const graphBefore = beforeSnapshot.graph ?? {};
|
|
3914
|
+
const lines = [];
|
|
3915
|
+
|
|
3916
|
+
lines.push('# Data Flow Explorer — Graph Diff');
|
|
3917
|
+
lines.push('');
|
|
3918
|
+
lines.push(`**Before:** commit ${_dfDiffMdCode(beforeSnapshot.commit)} — captured ${_dfDiffMdInline(beforeSnapshot.capturedAt)}`);
|
|
3919
|
+
lines.push(`**After:** commit ${_dfDiffMdCode(afterSnapshot.commit)} — captured ${_dfDiffMdInline(afterSnapshot.capturedAt)}`);
|
|
3920
|
+
lines.push(`**Generated:** ${_dfDiffMdInline(diff.generatedAt)}`);
|
|
3921
|
+
lines.push('');
|
|
3922
|
+
|
|
3923
|
+
// Drift-policy violations get their own top section — flagged
|
|
3924
|
+
// prominently, never buried under the added/removed/changed detail, per
|
|
3925
|
+
// the task brief's own explicit instruction.
|
|
3926
|
+
lines.push('## Drift Policy Violations');
|
|
3927
|
+
lines.push('');
|
|
3928
|
+
if (!driftPolicyProvided) {
|
|
3929
|
+
lines.push('_No `--drift-policy` supplied — policy evaluation was skipped._');
|
|
3930
|
+
} else if (violations.length === 0) {
|
|
3931
|
+
lines.push('No drift-policy violations detected.');
|
|
3932
|
+
} else {
|
|
3933
|
+
lines.push('| Trigger | Flow | Data Elements | Sink | Reason |');
|
|
3934
|
+
lines.push('|---|---|---|---|---|');
|
|
3935
|
+
for (const v of violations) {
|
|
3936
|
+
const cells = [
|
|
3937
|
+
v.trigger,
|
|
3938
|
+
v.flowId,
|
|
3939
|
+
(v.dataElementNames ?? []).join(', ') || '(none)',
|
|
3940
|
+
v.sinkCategory ?? v.sinkNodeId ?? 'unknown',
|
|
3941
|
+
v.reason,
|
|
3942
|
+
];
|
|
3943
|
+
lines.push(`| ${cells.map(_dfDiffMdCell).join(' | ')} |`);
|
|
3944
|
+
}
|
|
3945
|
+
}
|
|
3946
|
+
lines.push('');
|
|
3947
|
+
|
|
3948
|
+
lines.push('## Summary');
|
|
3949
|
+
lines.push('');
|
|
3950
|
+
lines.push(`- Nodes: +${diff.added.nodes.length} / -${diff.removed.nodes.length}`);
|
|
3951
|
+
lines.push(`- Edges: +${diff.added.edges.length} / -${diff.removed.edges.length}`);
|
|
3952
|
+
lines.push(`- Data elements: +${diff.added.dataElements.length} / -${diff.removed.dataElements.length}`);
|
|
3953
|
+
lines.push(`- Flows: +${diff.added.flows.length} / -${diff.removed.flows.length} / ~${diff.changed.flows.length} changed`);
|
|
3954
|
+
lines.push('');
|
|
3955
|
+
|
|
3956
|
+
const _entityList = (entries) => entries.map((e) => `- ${_dfDiffMdCode(e.id)} (${_dfDiffMdInline(e.causeClassification)})`);
|
|
3957
|
+
|
|
3958
|
+
lines.push('## Added');
|
|
3959
|
+
lines.push('');
|
|
3960
|
+
lines.push('### Flows');
|
|
3961
|
+
if (diff.added.flows.length === 0) {
|
|
3962
|
+
lines.push('_None._');
|
|
3963
|
+
} else {
|
|
3964
|
+
for (const e of diff.added.flows) {
|
|
3965
|
+
const { dataElementNames, sinkLabel } = _dataflowDiffFlowLabel(e.id, graphAfter);
|
|
3966
|
+
const de = dataElementNames.length ? dataElementNames.map(_dfDiffMdCode).join(', ') : 'unclassified data';
|
|
3967
|
+
// Reidentification (fix round 1, Important 1 — see graph-diff.js's
|
|
3968
|
+
// own judgment call #4): this is the SAME real-world flow as
|
|
3969
|
+
// e.reidentifiedFrom, not a genuinely new code-level flow — only
|
|
3970
|
+
// engine confidence/shape (evidenceGrade/transformationIds) changed.
|
|
3971
|
+
// Must be surfaced here, never hidden, or a Markdown-only reader has
|
|
3972
|
+
// no way to see the distinction the JSON report carries.
|
|
3973
|
+
const flag = e.causeClassification === 'reidentified'
|
|
3974
|
+
? ` **(reidentified — same flow as ${_dfDiffMdCode(e.reidentifiedFrom)}, not a new code-level flow; only engine confidence/shape changed)**`
|
|
3975
|
+
: '';
|
|
3976
|
+
lines.push(`- ${_dfDiffMdCode(e.id)} — ${de} → ${_dfDiffMdCode(sinkLabel ?? 'unknown sink')} (first seen ${_dfDiffMdInline(e.firstSeen?.commit ?? '')})${flag}`);
|
|
3977
|
+
}
|
|
3978
|
+
}
|
|
3979
|
+
lines.push('');
|
|
3980
|
+
for (const [heading, key] of [['Nodes', 'nodes'], ['Edges', 'edges'], ['Data Elements', 'dataElements']]) {
|
|
3981
|
+
lines.push(`### ${heading}`);
|
|
3982
|
+
lines.push(diff.added[key].length === 0 ? '_None._' : _entityList(diff.added[key]).join('\n'));
|
|
3983
|
+
lines.push('');
|
|
3984
|
+
}
|
|
3985
|
+
|
|
3986
|
+
lines.push('## Removed');
|
|
3987
|
+
lines.push('');
|
|
3988
|
+
lines.push('### Flows');
|
|
3989
|
+
if (diff.removed.flows.length === 0) {
|
|
3990
|
+
lines.push('_None._');
|
|
3991
|
+
} else {
|
|
3992
|
+
for (const e of diff.removed.flows) {
|
|
3993
|
+
const { dataElementNames, sinkLabel } = _dataflowDiffFlowLabel(e.id, graphBefore);
|
|
3994
|
+
const de = dataElementNames.length ? dataElementNames.map(_dfDiffMdCode).join(', ') : 'unclassified data';
|
|
3995
|
+
// Minor 1: inline the real reasons — the old text pointed at
|
|
3996
|
+
// coverageRegressionReasons without ever rendering it, so a
|
|
3997
|
+
// Markdown-only reader had no way to see the field at all.
|
|
3998
|
+
// Reidentification flag mirrors the ## Added / ### Flows loop above
|
|
3999
|
+
// (fix round 1, Important 1).
|
|
4000
|
+
let flag = '';
|
|
4001
|
+
if (e.causeClassification === 'possible_coverage_regression') {
|
|
4002
|
+
flag = ` **(possible coverage regression: ${_dfDiffMdInline((e.coverageRegressionReasons ?? []).join('; '))})**`;
|
|
4003
|
+
} else if (e.causeClassification === 'reidentified') {
|
|
4004
|
+
flag = ` **(reidentified — same flow as ${_dfDiffMdCode(e.reidentifiedTo)}, not a real removal; only engine confidence/shape changed)**`;
|
|
3041
4005
|
}
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
4006
|
+
lines.push(`- ${_dfDiffMdCode(e.id)} — ${de} → ${_dfDiffMdCode(sinkLabel ?? 'unknown sink')} (last seen ${_dfDiffMdInline(e.lastSeen?.commit ?? '')})${flag}`);
|
|
4007
|
+
}
|
|
4008
|
+
}
|
|
4009
|
+
lines.push('');
|
|
4010
|
+
for (const [heading, key] of [['Nodes', 'nodes'], ['Edges', 'edges'], ['Data Elements', 'dataElements']]) {
|
|
4011
|
+
lines.push(`### ${heading}`);
|
|
4012
|
+
lines.push(diff.removed[key].length === 0 ? '_None._' : _entityList(diff.removed[key]).join('\n'));
|
|
4013
|
+
lines.push('');
|
|
4014
|
+
}
|
|
4015
|
+
|
|
4016
|
+
lines.push('## Changed');
|
|
4017
|
+
lines.push('');
|
|
4018
|
+
lines.push('### Flows');
|
|
4019
|
+
if (diff.changed.flows.length === 0) {
|
|
4020
|
+
lines.push('_None._');
|
|
4021
|
+
} else {
|
|
4022
|
+
for (const e of diff.changed.flows) {
|
|
4023
|
+
const changeText = e.changes.map((c) => `${_dfDiffMdInline(c.field)}: ${_dfDiffMdCode(JSON.stringify(c.before))} -> ${_dfDiffMdCode(JSON.stringify(c.after))}`).join('; ');
|
|
4024
|
+
lines.push(`- ${_dfDiffMdCode(e.id)} — ${changeText}`);
|
|
4025
|
+
}
|
|
4026
|
+
}
|
|
4027
|
+
lines.push('');
|
|
4028
|
+
|
|
4029
|
+
return lines.join('\n');
|
|
4030
|
+
}
|
|
4031
|
+
|
|
4032
|
+
// Shared between `dataflow diff` and `dataflow watch` — parse-then-shape-
|
|
4033
|
+
// check a --drift-policy file BEFORE ever calling loadDriftPolicies.
|
|
4034
|
+
// loadDriftPolicies (src/lineage/drift-policy.js) never throws — a
|
|
4035
|
+
// malformed file degrades to {policies: []} with only a console.error
|
|
4036
|
+
// warning, matching loadPrivacySinkPolicy's own precedent. That is the
|
|
4037
|
+
// right default for a LOADER with no caller-facing error channel, but both
|
|
4038
|
+
// CLI commands' own exit-code contracts explicitly promise exit 2 for "a
|
|
4039
|
+
// malformed --drift-policy file" — so the malformed-JSON case, AND the
|
|
4040
|
+
// wrong-top-level-shape case (syntactically valid JSON, wrong shape, which
|
|
4041
|
+
// previously loaded zero rules with zero warning — sub-project 8b's own fix
|
|
4042
|
+
// round, Important 2), are both detected independently, here, before
|
|
4043
|
+
// delegating to the real loader for the actual (identically-parsed) policy
|
|
4044
|
+
// content. `cmdLabel` names the subcommand ("diff"/"watch") in the error
|
|
4045
|
+
// text. Returns `{ok:true}` or `{ok:false, message}` — never throws.
|
|
4046
|
+
function _validateDriftPolicyFile(driftPolicyAbs, driftPolicyFlag, cmdLabel) {
|
|
4047
|
+
if (!fs.existsSync(driftPolicyAbs)) {
|
|
4048
|
+
return { ok: false, message: `agentic-security dataflow ${cmdLabel}: --drift-policy file "${driftPolicyFlag}" does not exist.\n` };
|
|
4049
|
+
}
|
|
4050
|
+
let parsedDriftPolicy;
|
|
4051
|
+
try {
|
|
4052
|
+
parsedDriftPolicy = JSON.parse(fs.readFileSync(driftPolicyAbs, 'utf8'));
|
|
4053
|
+
} catch (e) {
|
|
4054
|
+
return { ok: false, message: `agentic-security dataflow ${cmdLabel}: could not parse --drift-policy file "${driftPolicyFlag}": ${e.message}\n` };
|
|
4055
|
+
}
|
|
4056
|
+
if (!parsedDriftPolicy || typeof parsedDriftPolicy !== 'object' || Array.isArray(parsedDriftPolicy) || !Array.isArray(parsedDriftPolicy.policies)) {
|
|
4057
|
+
return { ok: false, message: `agentic-security dataflow ${cmdLabel}: --drift-policy file "${driftPolicyFlag}" must be a JSON object of the form {"policies":[...]} (got a different shape).\n` };
|
|
4058
|
+
}
|
|
4059
|
+
return { ok: true };
|
|
4060
|
+
}
|
|
4061
|
+
|
|
4062
|
+
async function cmdDataflowDiff(args) {
|
|
4063
|
+
const target = args._[2] || '.';
|
|
4064
|
+
const targetAbs = path.resolve(target);
|
|
4065
|
+
|
|
4066
|
+
const outputPath = args.flags.output;
|
|
4067
|
+
if (!outputPath || typeof outputPath !== 'string') {
|
|
4068
|
+
process.stderr.write('agentic-security dataflow diff: --output <file> is required.\n');
|
|
4069
|
+
return 2;
|
|
4070
|
+
}
|
|
4071
|
+
|
|
4072
|
+
const format = args.flags.format;
|
|
4073
|
+
if (!format || !DATAFLOW_DIFF_FORMATS.has(format)) {
|
|
4074
|
+
process.stderr.write(`agentic-security dataflow diff: --format must be one of ${[...DATAFLOW_DIFF_FORMATS].join('|')} (got ${JSON.stringify(format)}).\n`);
|
|
4075
|
+
return 2;
|
|
4076
|
+
}
|
|
4077
|
+
|
|
4078
|
+
const againstFlag = args.flags.against;
|
|
4079
|
+
if (againstFlag !== undefined && (typeof againstFlag !== 'string' || !againstFlag)) {
|
|
4080
|
+
process.stderr.write('agentic-security dataflow diff: --against requires a commit value.\n');
|
|
4081
|
+
return 2;
|
|
4082
|
+
}
|
|
4083
|
+
|
|
4084
|
+
const driftPolicyFlag = args.flags['drift-policy'];
|
|
4085
|
+
if (driftPolicyFlag !== undefined && (typeof driftPolicyFlag !== 'string' || !driftPolicyFlag)) {
|
|
4086
|
+
process.stderr.write('agentic-security dataflow diff: --drift-policy requires a file path.\n');
|
|
4087
|
+
return 2;
|
|
4088
|
+
}
|
|
4089
|
+
|
|
4090
|
+
const failOnDrift = !!args.flags['fail-on-drift'];
|
|
4091
|
+
|
|
4092
|
+
// Snapshot resolution — see this function's own header comment above for
|
|
4093
|
+
// the full disclosed reasoning (no unexported git-HEAD resolver
|
|
4094
|
+
// available to this CLI, so "AFTER" is always the newest persisted
|
|
4095
|
+
// snapshot for scanRoot).
|
|
4096
|
+
const snapshots = loadSnapshots(targetAbs);
|
|
4097
|
+
if (snapshots.length === 0) {
|
|
4098
|
+
process.stderr.write(`agentic-security dataflow diff: no persisted GraphSnapshot found for "${targetAbs}" — run a scan with AGENTIC_SECURITY_LINEAGE_DEEP=1 first.\n`);
|
|
4099
|
+
return 2;
|
|
4100
|
+
}
|
|
4101
|
+
const afterSnapshot = snapshots[0];
|
|
4102
|
+
|
|
4103
|
+
let beforeSnapshot;
|
|
4104
|
+
if (againstFlag !== undefined) {
|
|
4105
|
+
beforeSnapshot = loadSnapshot(targetAbs, againstFlag);
|
|
4106
|
+
if (!beforeSnapshot) {
|
|
4107
|
+
process.stderr.write(`agentic-security dataflow diff: no persisted GraphSnapshot found for commit "${againstFlag}" — pass a commit that was actually scanned.\n`);
|
|
4108
|
+
return 2;
|
|
4109
|
+
}
|
|
4110
|
+
} else {
|
|
4111
|
+
beforeSnapshot = mostRecentPriorSnapshot(targetAbs, afterSnapshot.commit);
|
|
4112
|
+
if (!beforeSnapshot) {
|
|
4113
|
+
process.stderr.write(`agentic-security dataflow diff: only one persisted GraphSnapshot exists (commit "${afterSnapshot.commit}") — nothing to compare against yet. Scan again after a code change, or pass --against <commit>.\n`);
|
|
4114
|
+
return 2;
|
|
4115
|
+
}
|
|
4116
|
+
}
|
|
4117
|
+
|
|
4118
|
+
// Fix round 1, Minor 2: --against resolving to the SAME commit as the
|
|
4119
|
+
// current ("AFTER") snapshot is a plausible CI mistake that would
|
|
4120
|
+
// otherwise silently resolve an all-empty diff and pass --fail-on-drift.
|
|
4121
|
+
// The default (--against omitted) path can't hit this on its own —
|
|
4122
|
+
// mostRecentPriorSnapshot explicitly excludes afterSnapshot.commit — but
|
|
4123
|
+
// checking here, after both branches converge, is simpler and correct
|
|
4124
|
+
// either way.
|
|
4125
|
+
if (beforeSnapshot.commit === afterSnapshot.commit) {
|
|
4126
|
+
process.stderr.write(`agentic-security dataflow diff: refusing a self-diff — --against resolved to the same commit as the current snapshot ("${afterSnapshot.commit}"). Pass a different --against, or scan again after a code change.\n`);
|
|
4127
|
+
return 2;
|
|
4128
|
+
}
|
|
4129
|
+
|
|
4130
|
+
const { computeGraphDiff } = await import('../src/lineage/graph-diff.js');
|
|
4131
|
+
let diff;
|
|
4132
|
+
try {
|
|
4133
|
+
diff = computeGraphDiff(beforeSnapshot, afterSnapshot);
|
|
4134
|
+
} catch (e) {
|
|
4135
|
+
// computeGraphDiff THROWS (never returns an error object) on an
|
|
4136
|
+
// incomparable pair — converted to the same clean-message-then-
|
|
4137
|
+
// return-2 discipline cmdDataflowExport's own try/catch establishes.
|
|
4138
|
+
process.stderr.write(`agentic-security dataflow diff: ${e && e.message ? e.message : e}\n`);
|
|
4139
|
+
return 2;
|
|
4140
|
+
}
|
|
4141
|
+
|
|
4142
|
+
let violations = [];
|
|
4143
|
+
const driftPolicyProvided = driftPolicyFlag !== undefined;
|
|
4144
|
+
if (driftPolicyProvided) {
|
|
4145
|
+
const driftPolicyAbs = path.resolve(driftPolicyFlag);
|
|
4146
|
+
const check = _validateDriftPolicyFile(driftPolicyAbs, driftPolicyFlag, 'diff');
|
|
4147
|
+
if (!check.ok) {
|
|
4148
|
+
process.stderr.write(check.message);
|
|
4149
|
+
return 2;
|
|
4150
|
+
}
|
|
4151
|
+
|
|
4152
|
+
const { loadDriftPolicies, evaluateDriftPolicies } = await import('../src/lineage/drift-policy.js');
|
|
4153
|
+
const policies = loadDriftPolicies(driftPolicyAbs);
|
|
4154
|
+
violations = evaluateDriftPolicies(diff, policies, afterSnapshot.graph).violations;
|
|
4155
|
+
}
|
|
4156
|
+
|
|
4157
|
+
let data;
|
|
4158
|
+
if (format === 'json') {
|
|
4159
|
+
data = JSON.stringify({ ...diff, violations }, null, 2);
|
|
4160
|
+
} else {
|
|
4161
|
+
data = _renderDataflowDiffMarkdown(diff, violations, beforeSnapshot, afterSnapshot, driftPolicyProvided);
|
|
4162
|
+
}
|
|
4163
|
+
|
|
4164
|
+
const outAbs = path.resolve(outputPath);
|
|
4165
|
+
try {
|
|
4166
|
+
await fsp.mkdir(path.dirname(outAbs), { recursive: true });
|
|
4167
|
+
await fsp.writeFile(outAbs, data);
|
|
4168
|
+
} catch (e) {
|
|
4169
|
+
process.stderr.write(`agentic-security dataflow diff: could not write --output "${outputPath}": ${e && e.message ? e.message : e}\n`);
|
|
4170
|
+
return 2;
|
|
4171
|
+
}
|
|
4172
|
+
|
|
4173
|
+
process.stdout.write(`agentic-security dataflow diff: wrote ${format} to ${outAbs} (${violations.length} drift-policy violation(s))\n`);
|
|
4174
|
+
|
|
4175
|
+
if (failOnDrift && violations.length > 0) return 1;
|
|
4176
|
+
return 0;
|
|
4177
|
+
}
|
|
4178
|
+
|
|
4179
|
+
// agentic-security dataflow scenario apply [path] --operations <file>
|
|
4180
|
+
// --output <file> [--format json|markdown] [--privacy-sink-policy <file>]
|
|
4181
|
+
// [--environment <name>] — M5 deliverable #3a (FR-502). Loads the
|
|
4182
|
+
// already-scanned, already-signed graph via loadSignedGraph (same
|
|
4183
|
+
// loader/error-message contract as cmdDataflowExport/cmdDataflowDiff),
|
|
4184
|
+
// applies the Scenario in --operations via applyScenario, diffs the
|
|
4185
|
+
// result against the base graph via diffScenarioGraph, writes the
|
|
4186
|
+
// report to --output. Exit codes: 0 success, 1 graph-load failure
|
|
4187
|
+
// (loadSignedGraph's own 4 messages), 2 argument/operations-file
|
|
4188
|
+
// problem.
|
|
4189
|
+
async function cmdDataflowScenarioApply(args) {
|
|
4190
|
+
const target = args._[3] || '.'; // args._ = ['dataflow', 'scenario', 'apply', <path>?]
|
|
4191
|
+
const targetAbs = path.resolve(target);
|
|
4192
|
+
|
|
4193
|
+
const operationsFlag = args.flags.operations;
|
|
4194
|
+
if (!operationsFlag || typeof operationsFlag !== 'string') {
|
|
4195
|
+
process.stderr.write('agentic-security dataflow scenario apply: --operations <file> is required.\n');
|
|
4196
|
+
return 2;
|
|
4197
|
+
}
|
|
4198
|
+
const outputPath = args.flags.output;
|
|
4199
|
+
if (!outputPath || typeof outputPath !== 'string') {
|
|
4200
|
+
process.stderr.write('agentic-security dataflow scenario apply: --output <file> is required.\n');
|
|
4201
|
+
return 2;
|
|
4202
|
+
}
|
|
4203
|
+
const format = args.flags.format ?? 'json';
|
|
4204
|
+
if (format !== 'json' && format !== 'markdown') {
|
|
4205
|
+
process.stderr.write(`agentic-security dataflow scenario apply: --format must be one of json|markdown (got ${JSON.stringify(format)}).\n`);
|
|
4206
|
+
return 2;
|
|
4207
|
+
}
|
|
4208
|
+
|
|
4209
|
+
let opsInput;
|
|
4210
|
+
try {
|
|
4211
|
+
opsInput = JSON.parse(fs.readFileSync(path.resolve(operationsFlag), 'utf8'));
|
|
4212
|
+
} catch (e) {
|
|
4213
|
+
process.stderr.write(`agentic-security dataflow scenario apply: could not read/parse --operations file "${operationsFlag}": ${e.message}\n`);
|
|
4214
|
+
return 2;
|
|
4215
|
+
}
|
|
4216
|
+
|
|
4217
|
+
const { loadSignedGraph } = await import('../src/server/graph-loader.js');
|
|
4218
|
+
const loaded = loadSignedGraph(targetAbs);
|
|
4219
|
+
if (!loaded.ok) {
|
|
4220
|
+
process.stderr.write(`agentic-security dataflow scenario apply: ${loaded.message}\n`);
|
|
4221
|
+
return 1;
|
|
4222
|
+
}
|
|
4223
|
+
const baseGraph = loaded.graph;
|
|
4224
|
+
|
|
4225
|
+
const { validateScenario } = await import('../src/lineage/scenario.js');
|
|
4226
|
+
const { computeGraphDigest } = await import('../src/lineage/export-json.js');
|
|
4227
|
+
const { scenarioId } = await import('../src/lineage/ids.js');
|
|
4228
|
+
const baseGraphDigest = computeGraphDigest(baseGraph);
|
|
4229
|
+
const cliAuthor = opsInput.author ?? 'cli';
|
|
4230
|
+
const cliCreatedAt = new Date().toISOString();
|
|
4231
|
+
const scenarioDraft = {
|
|
4232
|
+
id: scenarioId({ graphId: baseGraph.graphId, graphDigest: baseGraphDigest }, [cliAuthor, cliCreatedAt]),
|
|
4233
|
+
version: '1.0.0',
|
|
4234
|
+
baseGraphId: baseGraph.graphId, baseGraphDigest,
|
|
4235
|
+
operations: opsInput.operations ?? [],
|
|
4236
|
+
assumptions: opsInput.assumptions ?? [], author: cliAuthor,
|
|
4237
|
+
createdAt: cliCreatedAt, expiration: null,
|
|
4238
|
+
simulatedDelta: null, verificationRequirements: opsInput.verificationRequirements ?? [],
|
|
4239
|
+
};
|
|
4240
|
+
const { valid, errors } = validateScenario(scenarioDraft);
|
|
4241
|
+
if (!valid) {
|
|
4242
|
+
process.stderr.write(`agentic-security dataflow scenario apply: --operations file failed validation:\n${errors.map((e) => ` ${e.path}: ${e.message}`).join('\n')}\n`);
|
|
4243
|
+
return 2;
|
|
4244
|
+
}
|
|
4245
|
+
|
|
4246
|
+
let privacySinkPolicy;
|
|
4247
|
+
const policyFlag = args.flags['privacy-sink-policy'];
|
|
4248
|
+
if (policyFlag !== undefined) {
|
|
4249
|
+
try {
|
|
4250
|
+
privacySinkPolicy = JSON.parse(fs.readFileSync(path.resolve(policyFlag), 'utf8'));
|
|
4251
|
+
} catch (e) {
|
|
4252
|
+
process.stderr.write(`agentic-security dataflow scenario apply: could not read/parse --privacy-sink-policy file "${policyFlag}": ${e.message}\n`);
|
|
4253
|
+
return 2;
|
|
4254
|
+
}
|
|
4255
|
+
}
|
|
4256
|
+
|
|
4257
|
+
const { applyScenario } = await import('../src/lineage/scenario-engine.js');
|
|
4258
|
+
const { diffScenarioGraph } = await import('../src/lineage/scenario-diff.js');
|
|
4259
|
+
const opts = { privacySinkPolicy, environment: args.flags.environment };
|
|
4260
|
+
const { graph: scenarioGraph, appliedOperations, skippedOperations } = applyScenario(baseGraph, scenarioDraft, opts);
|
|
4261
|
+
const { changedEntities, removedEntityIds } = diffScenarioGraph(baseGraph, scenarioGraph);
|
|
4262
|
+
|
|
4263
|
+
const report = { scenarioId: scenarioDraft.id, appliedOperations, skippedOperations, changedEntities, removedEntityIds, generatedAt: new Date().toISOString() };
|
|
4264
|
+
let data;
|
|
4265
|
+
if (format === 'json') {
|
|
4266
|
+
data = JSON.stringify(report, null, 2);
|
|
4267
|
+
} else {
|
|
4268
|
+
const lines = [`# Scenario delta`, '', `Applied ${appliedOperations.length} operation(s), skipped ${skippedOperations.length}.`, ''];
|
|
4269
|
+
if (skippedOperations.length) {
|
|
4270
|
+
lines.push('## Skipped operations', '');
|
|
4271
|
+
for (const s of skippedOperations) lines.push(`- \`${s.operation.kind}\`: ${s.reason}`);
|
|
4272
|
+
lines.push('');
|
|
4273
|
+
}
|
|
4274
|
+
lines.push('## Changed entities', '');
|
|
4275
|
+
for (const c of changedEntities) {
|
|
4276
|
+
lines.push(`- **${c.kind} ${c.id}**${c.label ? ` — **${c.label}**` : ''}`);
|
|
4277
|
+
for (const f of c.changedFields) lines.push(` - \`${f.field}\`: ${JSON.stringify(f.before)} → ${JSON.stringify(f.after)}`);
|
|
4278
|
+
}
|
|
4279
|
+
if (removedEntityIds.length) {
|
|
4280
|
+
lines.push('', '## Removed entities', '');
|
|
4281
|
+
for (const id of removedEntityIds) lines.push(`- ${id}`);
|
|
4282
|
+
}
|
|
4283
|
+
data = lines.join('\n') + '\n';
|
|
4284
|
+
}
|
|
4285
|
+
try {
|
|
4286
|
+
await fsp.mkdir(path.dirname(path.resolve(outputPath)), { recursive: true });
|
|
4287
|
+
await fsp.writeFile(path.resolve(outputPath), data);
|
|
4288
|
+
} catch (e) {
|
|
4289
|
+
process.stderr.write(`agentic-security dataflow scenario apply: could not write --output "${outputPath}": ${e && e.message ? e.message : e}\n`);
|
|
4290
|
+
return 2;
|
|
4291
|
+
}
|
|
4292
|
+
return 0;
|
|
4293
|
+
}
|
|
4294
|
+
|
|
4295
|
+
// agentic-security dataflow impact assess [path] --target <canonical-id>
|
|
4296
|
+
// --output <file> [--format json|markdown] — M5 deliverable #4
|
|
4297
|
+
// (FR-507). Loads the already-scanned, already-signed graph via
|
|
4298
|
+
// loadSignedGraph (same loader/error-message contract as every other
|
|
4299
|
+
// dataflow subcommand), computes an ImpactAssessment via
|
|
4300
|
+
// computeImpactAssessment, writes it to --output. Exit codes: 0
|
|
4301
|
+
// success; 1 graph-load failure (loadSignedGraph's own 4 messages) OR
|
|
4302
|
+
// a structurally malformed graph reaching computeImpactAssessment
|
|
4303
|
+
// (loadSignedGraph performs no schema validation — a graph-CONTENT
|
|
4304
|
+
// problem, not a CLI argument problem); 2 a genuine CLI argument
|
|
4305
|
+
// problem (missing --target/--output, or a --target with no
|
|
4306
|
+
// recognized canonical-id prefix).
|
|
4307
|
+
async function cmdDataflowImpactAssess(args) {
|
|
4308
|
+
const target = args._[3] || '.'; // args._ = ['dataflow', 'impact', 'assess', <path>?]
|
|
4309
|
+
const targetAbs = path.resolve(target);
|
|
4310
|
+
|
|
4311
|
+
const targetIdFlag = args.flags.target;
|
|
4312
|
+
if (!targetIdFlag || typeof targetIdFlag !== 'string') {
|
|
4313
|
+
process.stderr.write('agentic-security dataflow impact assess: --target <canonical-id> is required.\n');
|
|
4314
|
+
return 2;
|
|
4315
|
+
}
|
|
4316
|
+
const outputPath = args.flags.output;
|
|
4317
|
+
if (!outputPath || typeof outputPath !== 'string') {
|
|
4318
|
+
process.stderr.write('agentic-security dataflow impact assess: --output <file> is required.\n');
|
|
4319
|
+
return 2;
|
|
4320
|
+
}
|
|
4321
|
+
const format = args.flags.format ?? 'json';
|
|
4322
|
+
if (format !== 'json' && format !== 'markdown') {
|
|
4323
|
+
process.stderr.write(`agentic-security dataflow impact assess: --format must be one of json|markdown (got ${JSON.stringify(format)}).\n`);
|
|
4324
|
+
return 2;
|
|
4325
|
+
}
|
|
4326
|
+
|
|
4327
|
+
const { loadSignedGraph } = await import('../src/server/graph-loader.js');
|
|
4328
|
+
const loaded = loadSignedGraph(targetAbs);
|
|
4329
|
+
if (!loaded.ok) {
|
|
4330
|
+
process.stderr.write(`agentic-security dataflow impact assess: ${loaded.message}\n`);
|
|
4331
|
+
return 1;
|
|
4332
|
+
}
|
|
4333
|
+
|
|
4334
|
+
const { computeImpactAssessment } = await import('../src/lineage/impact-engine.js');
|
|
4335
|
+
let record;
|
|
4336
|
+
try {
|
|
4337
|
+
record = computeImpactAssessment(loaded.graph, targetIdFlag);
|
|
4338
|
+
} catch (e) {
|
|
4339
|
+
const message = e && e.message ? e.message : String(e);
|
|
4340
|
+
process.stderr.write(`agentic-security dataflow impact assess: ${message}\n`);
|
|
4341
|
+
// A malformed graph (e.g. a signed-but-structurally-broken document —
|
|
4342
|
+
// loadSignedGraph performs no schema validation, only signature +
|
|
4343
|
+
// JSON.parse) is a graph-CONTENT problem, not a CLI argument
|
|
4344
|
+
// problem — exit 1, matching loadSignedGraph's own failure exit
|
|
4345
|
+
// code above, not the exit-2 "bad --target/--output" case below.
|
|
4346
|
+
return message.startsWith('computeImpactAssessment: malformed graph') ? 1 : 2;
|
|
4347
|
+
}
|
|
4348
|
+
|
|
4349
|
+
let data;
|
|
4350
|
+
if (format === 'json') {
|
|
4351
|
+
data = JSON.stringify(record, null, 2);
|
|
4352
|
+
} else {
|
|
4353
|
+
const lines = [
|
|
4354
|
+
`# Impact assessment`, '',
|
|
4355
|
+
`id: \`${record.id}\``,
|
|
4356
|
+
`graphId: \`${record.graphId}\``,
|
|
4357
|
+
`graphDigest: \`${record.graphDigest}\``,
|
|
4358
|
+
`generatedAt: ${record.generatedAt}`, '',
|
|
4359
|
+
`Target: \`${record.targetId}\` (${record.targetKind})`,
|
|
4360
|
+
`Scope: ${record.scope} (traceKind: ${record.traceKind})`, '',
|
|
4361
|
+
`## Affected nodes (${record.affectedNodeIds.length})`, '',
|
|
4362
|
+
...record.affectedNodeIds.map((id) => `- ${id}`), '',
|
|
4363
|
+
`## Affected edges (${record.affectedEdgeIds.length})`, '',
|
|
4364
|
+
...record.affectedEdgeIds.map((id) => `- ${id}`), '',
|
|
4365
|
+
`## Affected data classes`, '',
|
|
4366
|
+
record.affectedDataClasses.length ? record.affectedDataClasses.map((c) => `- ${c}`).join('\n') : '_none_', '',
|
|
4367
|
+
`## Affected recipients`, '',
|
|
4368
|
+
record.affectedRecipientProfileIds.length ? record.affectedRecipientProfileIds.map((id) => `- ${id}`).join('\n') : '_none_', '',
|
|
4369
|
+
];
|
|
4370
|
+
if (record.coverageLimitations.length) {
|
|
4371
|
+
lines.push('## Coverage limitations', '', ...record.coverageLimitations.map((s) => `- ${s}`), '');
|
|
4372
|
+
}
|
|
4373
|
+
data = lines.join('\n') + '\n';
|
|
4374
|
+
}
|
|
4375
|
+
try {
|
|
4376
|
+
await fsp.mkdir(path.dirname(path.resolve(outputPath)), { recursive: true });
|
|
4377
|
+
await fsp.writeFile(path.resolve(outputPath), data);
|
|
4378
|
+
} catch (e) {
|
|
4379
|
+
process.stderr.write(`agentic-security dataflow impact assess: could not write --output "${outputPath}": ${e && e.message ? e.message : e}\n`);
|
|
4380
|
+
return 2;
|
|
4381
|
+
}
|
|
4382
|
+
return 0;
|
|
4383
|
+
}
|
|
4384
|
+
|
|
4385
|
+
// ── dataflow observations import/list + dataflow twin — M5 deliverable #7
|
|
4386
|
+
// (Runtime-Corroborated Digital Twin, "7b" — runtime-observed only; FR-505,
|
|
4387
|
+
// AC-29). This is the AC-29 proof surface: the first place a human operator
|
|
4388
|
+
// can actually see and act on the whole deliverable.
|
|
4389
|
+
//
|
|
4390
|
+
// `observations import` is the one mutating verb in this trio, and it is
|
|
4391
|
+
// dry-run-by-default exactly like `governance propose-edit`/`remediation
|
|
4392
|
+
// open`: without --yes it computes and prints exactly what WOULD be
|
|
4393
|
+
// imported and writes nothing; with --yes it writes through
|
|
4394
|
+
// `persistObservationImport` (the store's own closed-world validation is
|
|
4395
|
+
// the authoritative gate — this command's own `validateRuntimeObservation`
|
|
4396
|
+
// call below is a deliberate PREVIEW, so an operator sees a rejection
|
|
4397
|
+
// before ever passing --yes) and appends a real `auditCall` entry, never
|
|
4398
|
+
// on a dry run and never on a rejected import.
|
|
4399
|
+
//
|
|
4400
|
+
// Exit-code contract, identical to `dataflow impact assess`'s plus an
|
|
4401
|
+
// explicit 4: 0 success (preview or real write); 1 a validation failure (a
|
|
4402
|
+
// rejected record, a malformed adapter input, a graph-load failure via
|
|
4403
|
+
// `loadSignedGraph`'s own four messages); 2 a usage/argument error or an
|
|
4404
|
+
// `isSafeStateDir` refusal; 4 an unexpected I/O error during the write
|
|
4405
|
+
// itself — nothing was written and no audit event was recorded.
|
|
4406
|
+
//
|
|
4407
|
+
// Refuse the WHOLE import, never a partial one (AC-29 clause 5): every
|
|
4408
|
+
// adapter parse error AND every per-record `validateRuntimeObservation`
|
|
4409
|
+
// failure is collected across the whole file before any exit decision is
|
|
4410
|
+
// made, so a payload-shaped record caught at the wire layer (an unknown
|
|
4411
|
+
// top-level key) and a payload-shaped record caught one layer up (an
|
|
4412
|
+
// unapproved attribute key) are both named in the SAME refusal — silently
|
|
4413
|
+
// dropping just the ones the adapter caught and importing the rest would
|
|
4414
|
+
// be exactly the partial-import failure this deliverable exists to
|
|
4415
|
+
// prevent.
|
|
4416
|
+
// M5 (final review): --source is a second unconstrained, ≤512-char,
|
|
4417
|
+
// verbatim-persisted, verbatim-echoed-by-`observations list` value channel
|
|
4418
|
+
// — smaller than B1's own attribute-value hole but the same shape. Mirrors
|
|
4419
|
+
// runtime-observation.js's own `_isIdentifierValue` character class
|
|
4420
|
+
// locally rather than importing it — that module's own constant caps at
|
|
4421
|
+
// 128 characters (the attribute-value cap), while `source` is validated up
|
|
4422
|
+
// to 512 characters (`validateRuntimeObservation`'s own `$.source` check),
|
|
4423
|
+
// so reusing the 128-char-capped version verbatim would reject a
|
|
4424
|
+
// legitimate long-but-real source name the schema itself still permits.
|
|
4425
|
+
// A real filename basename (this flag's own default) already satisfies
|
|
4426
|
+
// this grammar.
|
|
4427
|
+
const _SOURCE_IDENTIFIER_RE = /^[A-Za-z0-9._:-]{1,512}$/;
|
|
4428
|
+
|
|
4429
|
+
async function cmdDataflowObservationsImport(args) {
|
|
4430
|
+
const target = args._[3] || '.'; // args._ = ['dataflow', 'observations', 'import', <path>?]
|
|
4431
|
+
const targetAbs = path.resolve(target);
|
|
4432
|
+
|
|
4433
|
+
const { adapterFor } = await import('../src/lineage/observation-adapters.js');
|
|
4434
|
+
const adapterFlag = args.flags.adapter;
|
|
4435
|
+
if (!adapterFlag || typeof adapterFlag !== 'string') {
|
|
4436
|
+
process.stderr.write('agentic-security dataflow observations import: --adapter <name> is required (currently only "native-jsonl" is supported).\n');
|
|
4437
|
+
return 2;
|
|
4438
|
+
}
|
|
4439
|
+
const adapterImpl = adapterFor(adapterFlag);
|
|
4440
|
+
if (!adapterImpl) {
|
|
4441
|
+
process.stderr.write(`agentic-security dataflow observations import: unknown --adapter "${adapterFlag}" — only "native-jsonl" is supported.\n`);
|
|
4442
|
+
return 2;
|
|
4443
|
+
}
|
|
4444
|
+
|
|
4445
|
+
const inputFlag = args.flags.input;
|
|
4446
|
+
if (!inputFlag || typeof inputFlag !== 'string') {
|
|
4447
|
+
process.stderr.write('agentic-security dataflow observations import: --input <file> is required.\n');
|
|
4448
|
+
return 2;
|
|
4449
|
+
}
|
|
4450
|
+
const inputPath = path.resolve(inputFlag);
|
|
4451
|
+
if (!fs.existsSync(inputPath)) {
|
|
4452
|
+
process.stderr.write(`agentic-security dataflow observations import: --input file not found: "${inputFlag}".\n`);
|
|
4453
|
+
return 2;
|
|
4454
|
+
}
|
|
4455
|
+
|
|
4456
|
+
const _isoOk = (v) => typeof v === 'string' && v.length > 0 && Number.isFinite(Date.parse(v));
|
|
4457
|
+
const windowStart = args.flags['window-start'];
|
|
4458
|
+
const windowEnd = args.flags['window-end'];
|
|
4459
|
+
if (!_isoOk(windowStart) || !_isoOk(windowEnd)) {
|
|
4460
|
+
process.stderr.write('agentic-security dataflow observations import: --window-start and --window-end are both required and must be parseable ISO-8601 date-times.\n');
|
|
4461
|
+
return 2;
|
|
4462
|
+
}
|
|
4463
|
+
if (Date.parse(windowStart) > Date.parse(windowEnd)) {
|
|
4464
|
+
process.stderr.write('agentic-security dataflow observations import: --window-start must not be after --window-end.\n');
|
|
4465
|
+
return 2;
|
|
4466
|
+
}
|
|
4467
|
+
|
|
4468
|
+
// M1 (final review): --retain-until is recorded as retention.expiresAt
|
|
4469
|
+
// and NOTHING ELSE — it is not currently enforced in either direction.
|
|
4470
|
+
// An import past its declared expiresAt is not automatically deleted
|
|
4471
|
+
// (grep confirms zero consumers of retention.expiresAt outside this
|
|
4472
|
+
// store's own validator and `observations list`'s display column), and
|
|
4473
|
+
// `reset --yes` sweeps the WHOLE store regardless of any individual
|
|
4474
|
+
// import's own expiresAt. Real enforcement is separate, larger scope
|
|
4475
|
+
// (touching `reset`/`retention-policy.js`) — this comment (and
|
|
4476
|
+
// `commands/dataflow.md`'s own matching wording) exists so this flag's
|
|
4477
|
+
// NAME never implies protection the implementation does not provide.
|
|
4478
|
+
const retainUntilFlag = args.flags['retain-until'];
|
|
4479
|
+
let retainUntil = null;
|
|
4480
|
+
if (retainUntilFlag !== undefined) {
|
|
4481
|
+
if (!_isoOk(retainUntilFlag)) {
|
|
4482
|
+
process.stderr.write('agentic-security dataflow observations import: --retain-until must be a parseable ISO-8601 date-time. Note: this value is recorded as retention.expiresAt but is NOT currently enforced by anything — an import past its declared expiresAt is not auto-deleted, and `reset --yes` sweeps the whole store regardless.\n');
|
|
4483
|
+
return 2;
|
|
4484
|
+
}
|
|
4485
|
+
retainUntil = retainUntilFlag;
|
|
4486
|
+
}
|
|
4487
|
+
|
|
4488
|
+
const { loadSignedGraph } = await import('../src/server/graph-loader.js');
|
|
4489
|
+
const loaded = loadSignedGraph(targetAbs);
|
|
4490
|
+
if (!loaded.ok) {
|
|
4491
|
+
process.stderr.write(`agentic-security dataflow observations import: ${loaded.message}\n`);
|
|
4492
|
+
return 1;
|
|
4493
|
+
}
|
|
4494
|
+
|
|
4495
|
+
const adapter = adapterFlag;
|
|
4496
|
+
const source = (typeof args.flags.source === 'string' && args.flags.source) || path.basename(inputPath);
|
|
4497
|
+
const environment = (typeof args.flags.environment === 'string' && args.flags.environment) || 'unspecified';
|
|
4498
|
+
|
|
4499
|
+
// M5 (final review): refuse a --source value that doesn't look like an
|
|
4500
|
+
// identifier/filename-shaped token, BEFORE reading the --input file's
|
|
4501
|
+
// own content — the same value-axis smuggling shape B1 closed on
|
|
4502
|
+
// attribute values, applied here since --source is persisted verbatim
|
|
4503
|
+
// and echoed by `observations list`.
|
|
4504
|
+
if (!_SOURCE_IDENTIFIER_RE.test(source)) {
|
|
4505
|
+
process.stderr.write(`agentic-security dataflow observations import: --source must look like an identifier/filename (letters, digits, '.', '_', ':', '-', 512 characters max) — a value containing whitespace, quotes, or punctuation is how a payload arrives disguised as metadata (got ${JSON.stringify(source)}).\n`);
|
|
4506
|
+
return 2;
|
|
4507
|
+
}
|
|
4508
|
+
|
|
4509
|
+
let text;
|
|
4510
|
+
try {
|
|
4511
|
+
text = fs.readFileSync(inputPath, 'utf8');
|
|
4512
|
+
} catch (e) {
|
|
4513
|
+
process.stderr.write(`agentic-security dataflow observations import: could not read --input "${inputFlag}": ${e && e.message ? e.message : e}\n`);
|
|
4514
|
+
return 2;
|
|
4515
|
+
}
|
|
4516
|
+
|
|
4517
|
+
const { RUNTIME_OBSERVATION_VERSION, validateRuntimeObservation } = await import('../src/lineage/runtime-observation.js');
|
|
4518
|
+
const importedAt = new Date().toISOString();
|
|
4519
|
+
const context = {
|
|
4520
|
+
adapter, source, environment, windowStart, windowEnd, importedAt,
|
|
4521
|
+
retention: { expiresAt: retainUntil },
|
|
4522
|
+
version: RUNTIME_OBSERVATION_VERSION,
|
|
4523
|
+
};
|
|
4524
|
+
const { drafts, errors: parseErrors } = adapterImpl.parse(text, context);
|
|
4525
|
+
|
|
4526
|
+
const { matchObservationToGraph } = await import('../src/lineage/observation-correlation.js');
|
|
4527
|
+
const { observationId, observationImportId } = await import('../src/lineage/ids.js');
|
|
4528
|
+
|
|
4529
|
+
// Never print an attribute VALUE anywhere in this function (AC-29
|
|
4530
|
+
// clause 5's own "no captured payload... exists in the observation
|
|
4531
|
+
// artifact" extends to this preview, which is not the artifact itself
|
|
4532
|
+
// but must not become a second leak channel for the same values).
|
|
4533
|
+
const records = [];
|
|
4534
|
+
const recordErrors = [];
|
|
4535
|
+
let matchedCount = 0;
|
|
4536
|
+
let unmatchedCount = 0;
|
|
4537
|
+
drafts.forEach((draft, i) => {
|
|
4538
|
+
const match = matchObservationToGraph(loaded.graph, draft);
|
|
4539
|
+
const fingerprint = Object.entries(draft.attributes ?? {})
|
|
4540
|
+
.sort()
|
|
4541
|
+
.map(([k, v]) => `${k}=${JSON.stringify(v)}`)
|
|
4542
|
+
.join('&');
|
|
4543
|
+
const id = observationId(
|
|
4544
|
+
{ adapter, environment: draft.environment, windowStart: draft.windowStart, windowEnd: draft.windowEnd },
|
|
4545
|
+
[fingerprint],
|
|
4546
|
+
);
|
|
4547
|
+
const record = {
|
|
4548
|
+
id, version: draft.version, adapter: draft.adapter, source: draft.source,
|
|
4549
|
+
environment: draft.environment, windowStart: draft.windowStart, windowEnd: draft.windowEnd,
|
|
4550
|
+
matchedNodeIds: match.matchedNodeIds, matchedEdgeIds: match.matchedEdgeIds, matchedFlowIds: match.matchedFlowIds,
|
|
4551
|
+
attributes: draft.attributes, eventCountBand: draft.eventCountBand,
|
|
4552
|
+
firstObservedAt: draft.firstObservedAt, lastObservedAt: draft.lastObservedAt,
|
|
4553
|
+
matchMethod: match.matchMethod, matchConfidence: match.matchConfidence,
|
|
4554
|
+
retention: draft.retention, importedAt: draft.importedAt,
|
|
4555
|
+
};
|
|
4556
|
+
const { valid, errors } = validateRuntimeObservation(record);
|
|
4557
|
+
if (!valid) {
|
|
4558
|
+
recordErrors.push({ index: i + 1, errors });
|
|
4559
|
+
} else {
|
|
4560
|
+
records.push(record);
|
|
4561
|
+
if (match.matchMethod === 'unmatched') unmatchedCount++; else matchedCount++;
|
|
4562
|
+
}
|
|
4563
|
+
});
|
|
4564
|
+
|
|
4565
|
+
// Refuse the WHOLE import — deliberately, so a partial import can never
|
|
4566
|
+
// silently drop the offending record (AC-29 clause 5). Both error
|
|
4567
|
+
// sources (the adapter's own wire-shape rejections AND this command's
|
|
4568
|
+
// preview validateRuntimeObservation rejections) are combined into one
|
|
4569
|
+
// report before any exit decision is made.
|
|
4570
|
+
if (parseErrors.length > 0 || recordErrors.length > 0) {
|
|
4571
|
+
process.stderr.write('agentic-security dataflow observations import: refusing the WHOLE import — a partial import that silently drops an offending record would misrepresent what the operator believes the artifact holds (AC-29 clause 5). Nothing was written. Every offending record:\n');
|
|
4572
|
+
for (const e of parseErrors) {
|
|
4573
|
+
process.stderr.write(` line ${e.line}: ${e.message}\n`);
|
|
4574
|
+
}
|
|
4575
|
+
for (const re of recordErrors) {
|
|
4576
|
+
for (const e of re.errors) {
|
|
4577
|
+
process.stderr.write(` record ${re.index}: ${e.path}: ${e.message}\n`);
|
|
4578
|
+
}
|
|
4579
|
+
}
|
|
4580
|
+
return 1;
|
|
4581
|
+
}
|
|
4582
|
+
|
|
4583
|
+
const { OBSERVATION_IMPORT_VERSION } = await import('../src/lineage/observation-store.js');
|
|
4584
|
+
// B2 (final review): importedAt alone is millisecond-resolution, so two
|
|
4585
|
+
// concurrent imports sharing adapter/source/environment/window CAN land
|
|
4586
|
+
// in the same millisecond and mint the identical import id — the same
|
|
4587
|
+
// file name, one silently clobbering the other while both report
|
|
4588
|
+
// success. A fresh random discriminator makes that collision impossible
|
|
4589
|
+
// regardless of timing, mirroring this package's own established
|
|
4590
|
+
// discriminatorParts convention (pathId/scenarioId/etc., ids.js).
|
|
4591
|
+
const importId = observationImportId(
|
|
4592
|
+
{ adapter, source, environment, windowStart, windowEnd, importedAt },
|
|
4593
|
+
[crypto.randomBytes(4).toString('hex')],
|
|
4594
|
+
);
|
|
4595
|
+
const importRecord = {
|
|
4596
|
+
id: importId, version: OBSERVATION_IMPORT_VERSION, adapter, source, environment,
|
|
4597
|
+
windowStart, windowEnd, importedAt, retention: { expiresAt: retainUntil },
|
|
4598
|
+
observations: records,
|
|
4599
|
+
};
|
|
4600
|
+
|
|
4601
|
+
const preview = {
|
|
4602
|
+
adapter, source, environment, windowStart, windowEnd,
|
|
4603
|
+
recordCount: records.length, matched: matchedCount, unmatched: unmatchedCount,
|
|
4604
|
+
retention: { expiresAt: retainUntil }, importId, written: false,
|
|
4605
|
+
};
|
|
4606
|
+
|
|
4607
|
+
const yes = !!args.flags.yes;
|
|
4608
|
+
if (!yes) {
|
|
4609
|
+
process.stdout.write(JSON.stringify(preview, null, 2) + '\n');
|
|
4610
|
+
return 0;
|
|
4611
|
+
}
|
|
4612
|
+
|
|
4613
|
+
// `isSafeStateDir(statePath(targetAbs, 'runtime-observations'))` —
|
|
4614
|
+
// string literal, third registry-guard call site (the other two are
|
|
4615
|
+
// `observation-store.js` and `index.js`) — before any mkdirSync/write.
|
|
4616
|
+
const dir = statePath(targetAbs, 'runtime-observations');
|
|
4617
|
+
const { isSafeStateDir } = await import('../src/posture/state-dir.js');
|
|
4618
|
+
if (!isSafeStateDir(dir)) {
|
|
4619
|
+
process.stderr.write(`agentic-security dataflow observations import: refusing to write — "${targetAbs}" does not look like a project directory.\n`);
|
|
4620
|
+
return 2;
|
|
4621
|
+
}
|
|
4622
|
+
|
|
4623
|
+
const { persistObservationImport } = await import('../src/lineage/observation-store.js');
|
|
4624
|
+
let result;
|
|
4625
|
+
try {
|
|
4626
|
+
result = persistObservationImport(targetAbs, importRecord);
|
|
4627
|
+
} catch (e) {
|
|
4628
|
+
process.stderr.write(`agentic-security dataflow observations import: unexpected error writing the store: ${e && e.message ? e.message : e}\n`);
|
|
4629
|
+
return 4;
|
|
4630
|
+
}
|
|
4631
|
+
if (!result.ok) {
|
|
4632
|
+
process.stderr.write(`agentic-security dataflow observations import: ${result.reason}\n`);
|
|
4633
|
+
return result.reason && result.reason.startsWith('invalid ObservationImport record') ? 1 : 4;
|
|
4634
|
+
}
|
|
4635
|
+
|
|
4636
|
+
const { auditCall } = await import('../src/mcp/audit.js');
|
|
4637
|
+
auditCall({
|
|
4638
|
+
sessionRoot: targetAbs,
|
|
4639
|
+
tool: 'dataflow_observations_import',
|
|
4640
|
+
args: { adapter, source, environment, windowStart, windowEnd, observations: records.length, matched: matchedCount, importId },
|
|
4641
|
+
outcome: 'ok',
|
|
4642
|
+
});
|
|
4643
|
+
|
|
4644
|
+
preview.written = true;
|
|
4645
|
+
process.stdout.write(JSON.stringify(preview, null, 2) + '\n');
|
|
4646
|
+
return 0;
|
|
4647
|
+
}
|
|
4648
|
+
|
|
4649
|
+
// agentic-security dataflow observations list [path] [--json] — read-only,
|
|
4650
|
+
// never writes, exit 0 always (an empty store is not an error). Never
|
|
4651
|
+
// prints an attribute key or value anywhere (CLI/list-2) — every row is
|
|
4652
|
+
// built from ONLY the import-level metadata fields below; no per-
|
|
4653
|
+
// observation `.attributes` object is ever touched by this function.
|
|
4654
|
+
async function cmdDataflowObservationsList(args) {
|
|
4655
|
+
const target = args._[3] || '.'; // args._ = ['dataflow', 'observations', 'list', <path>?]
|
|
4656
|
+
const targetAbs = path.resolve(target);
|
|
4657
|
+
|
|
4658
|
+
const { loadObservationImports } = await import('../src/lineage/observation-store.js');
|
|
4659
|
+
const imports = loadObservationImports(targetAbs);
|
|
4660
|
+
|
|
4661
|
+
const rows = imports.map((imp) => ({
|
|
4662
|
+
importId: imp.id,
|
|
4663
|
+
adapter: imp.adapter,
|
|
4664
|
+
source: imp.source,
|
|
4665
|
+
environment: imp.environment,
|
|
4666
|
+
windowStart: imp.windowStart,
|
|
4667
|
+
windowEnd: imp.windowEnd,
|
|
4668
|
+
observations: Array.isArray(imp.observations) ? imp.observations.length : 0,
|
|
4669
|
+
importedAt: imp.importedAt,
|
|
4670
|
+
expiresAt: imp.retention?.expiresAt ?? 'no expiry declared',
|
|
4671
|
+
}));
|
|
4672
|
+
|
|
4673
|
+
if (args.flags.json) {
|
|
4674
|
+
process.stdout.write(JSON.stringify(rows, null, 2) + '\n');
|
|
4675
|
+
return 0;
|
|
4676
|
+
}
|
|
4677
|
+
|
|
4678
|
+
if (rows.length === 0) {
|
|
4679
|
+
process.stdout.write('No runtime observation imports found.\n');
|
|
4680
|
+
return 0;
|
|
4681
|
+
}
|
|
4682
|
+
|
|
4683
|
+
for (const r of rows) {
|
|
4684
|
+
process.stdout.write(
|
|
4685
|
+
`${r.importId} adapter=${r.adapter} source=${r.source} environment=${r.environment} `
|
|
4686
|
+
+ `window=${r.windowStart}..${r.windowEnd} observations=${r.observations} `
|
|
4687
|
+
+ `importedAt=${r.importedAt} expiresAt=${r.expiresAt}\n`,
|
|
4688
|
+
);
|
|
4689
|
+
}
|
|
4690
|
+
return 0;
|
|
4691
|
+
}
|
|
4692
|
+
|
|
4693
|
+
// Local Markdown-escaping helpers for `dataflow twin --format markdown` —
|
|
4694
|
+
// byte-identical to _dfCoverageMdInline/_dfCoverageMdCell above,
|
|
4695
|
+
// reimplemented locally per this codebase's established
|
|
4696
|
+
// per-module-owns-its-own-escaping-helpers convention.
|
|
4697
|
+
function _dfTwinMdInline(value) {
|
|
4698
|
+
return String(value).replace(/\r\n|\r|\n/g, ' ');
|
|
4699
|
+
}
|
|
4700
|
+
function _dfTwinMdCell(value) {
|
|
4701
|
+
return _dfTwinMdInline(value).replace(/\\/g, '\\\\').replace(/\|/g, '\\|');
|
|
4702
|
+
}
|
|
4703
|
+
|
|
4704
|
+
const _TWIN_LAYER_DISPLAY = Object.freeze({
|
|
4705
|
+
runtime_observed: 'RUNTIME OBSERVED',
|
|
4706
|
+
not_observed_in_window: 'not_observed_in_window',
|
|
4707
|
+
not_evaluated: 'not_evaluated',
|
|
4708
|
+
});
|
|
4709
|
+
|
|
4710
|
+
// agentic-security dataflow twin [path] --output <file> [--format
|
|
4711
|
+
// json|markdown] [--environment <name>] [--window-start <iso>]
|
|
4712
|
+
// [--window-end <iso>] — the AC-29 Runtime Digital Twin proof surface.
|
|
4713
|
+
// Read-only: never writes into .agentic-security/ (CLI/twin-7). `null`
|
|
4714
|
+
// (never `[]`) observations means "no store was consulted" — the
|
|
4715
|
+
// not_evaluated signal correlateObservations relies on to keep the
|
|
4716
|
+
// three-valued layer honest (AC-29 clause 2).
|
|
4717
|
+
async function cmdDataflowTwin(args) {
|
|
4718
|
+
const target = args._[2] || '.'; // args._ = ['dataflow', 'twin', <path>?]
|
|
4719
|
+
const targetAbs = path.resolve(target);
|
|
4720
|
+
|
|
4721
|
+
const outputPath = args.flags.output;
|
|
4722
|
+
if (!outputPath || typeof outputPath !== 'string') {
|
|
4723
|
+
process.stderr.write('agentic-security dataflow twin: --output <file> is required.\n');
|
|
4724
|
+
return 2;
|
|
4725
|
+
}
|
|
4726
|
+
const format = args.flags.format ?? 'json';
|
|
4727
|
+
if (format !== 'json' && format !== 'markdown') {
|
|
4728
|
+
process.stderr.write(`agentic-security dataflow twin: --format must be one of json|markdown (got ${JSON.stringify(format)}).\n`);
|
|
4729
|
+
return 2;
|
|
4730
|
+
}
|
|
4731
|
+
|
|
4732
|
+
const { loadSignedGraph } = await import('../src/server/graph-loader.js');
|
|
4733
|
+
const loaded = loadSignedGraph(targetAbs);
|
|
4734
|
+
if (!loaded.ok) {
|
|
4735
|
+
process.stderr.write(`agentic-security dataflow twin: ${loaded.message}\n`);
|
|
4736
|
+
return 1;
|
|
4737
|
+
}
|
|
4738
|
+
|
|
4739
|
+
const environment = typeof args.flags.environment === 'string' ? args.flags.environment : null;
|
|
4740
|
+
const windowStart = typeof args.flags['window-start'] === 'string' ? args.flags['window-start'] : null;
|
|
4741
|
+
const windowEnd = typeof args.flags['window-end'] === 'string' ? args.flags['window-end'] : null;
|
|
4742
|
+
|
|
4743
|
+
// Fourth registry-guard call site — string literal, matching
|
|
4744
|
+
// observation-store.js/index.js/cmdDataflowObservationsImport above.
|
|
4745
|
+
const dir = statePath(targetAbs, 'runtime-observations');
|
|
4746
|
+
const observations = fs.existsSync(dir)
|
|
4747
|
+
? (await import('../src/lineage/observation-store.js')).loadObservations(targetAbs)
|
|
4748
|
+
: null;
|
|
4749
|
+
|
|
4750
|
+
const { correlateObservations } = await import('../src/lineage/observation-correlation.js');
|
|
4751
|
+
const result = correlateObservations(loaded.graph, observations, { environment, windowStart, windowEnd });
|
|
4752
|
+
|
|
4753
|
+
let data;
|
|
4754
|
+
if (format === 'json') {
|
|
4755
|
+
data = JSON.stringify(result, null, 2);
|
|
4756
|
+
} else {
|
|
4757
|
+
const { computeGraphDigest } = await import('../src/lineage/export-json.js');
|
|
4758
|
+
const nodesById = new Map(loaded.graph.nodes.map((n) => [n.id, n]));
|
|
4759
|
+
const flowsById = new Map(loaded.graph.flows.map((f) => [f.id, f]));
|
|
4760
|
+
const lines = [];
|
|
4761
|
+
lines.push('# Runtime Digital Twin', '');
|
|
4762
|
+
lines.push(`**Graph:** \`${_dfTwinMdInline(loaded.graph.graphId ?? '(no graphId)')}\``);
|
|
4763
|
+
lines.push(`**Graph digest:** \`${_dfTwinMdInline(computeGraphDigest(loaded.graph))}\``);
|
|
4764
|
+
lines.push(`**Environment filter:** ${_dfTwinMdInline(result.environment ?? '(none)')}`);
|
|
4765
|
+
lines.push(`**Window:** ${_dfTwinMdInline(result.windowStart ?? '(open)')} .. ${_dfTwinMdInline(result.windowEnd ?? '(open)')}`, '');
|
|
4766
|
+
lines.push('## Layers', '');
|
|
4767
|
+
lines.push('| Flow | Source | Sink | Layer |');
|
|
4768
|
+
lines.push('|---|---|---|---|');
|
|
4769
|
+
const flowIds = Object.keys(result.byFlow).sort();
|
|
4770
|
+
for (const fid of flowIds) {
|
|
4771
|
+
const flow = flowsById.get(fid);
|
|
4772
|
+
const srcLabel = flow ? (nodesById.get(flow.source)?.label ?? flow.source) : '(unknown)';
|
|
4773
|
+
const snkLabel = flow ? (nodesById.get(flow.sink)?.label ?? flow.sink) : '(unknown)';
|
|
4774
|
+
const entry = result.byFlow[fid];
|
|
4775
|
+
const layerDisplay = _TWIN_LAYER_DISPLAY[entry.layer] ?? entry.layer;
|
|
4776
|
+
lines.push(`| ${_dfTwinMdCell(fid)} | ${_dfTwinMdCell(srcLabel)} | ${_dfTwinMdCell(snkLabel)} | ${_dfTwinMdCell(layerDisplay)} |`);
|
|
4777
|
+
}
|
|
4778
|
+
lines.push('');
|
|
4779
|
+
|
|
4780
|
+
const observedFlowIdsSorted = flowIds.filter((fid) => result.byFlow[fid].layer === 'runtime_observed');
|
|
4781
|
+
if (observedFlowIdsSorted.length > 0) {
|
|
4782
|
+
lines.push('## Runtime-observed flow detail', '');
|
|
4783
|
+
for (const fid of observedFlowIdsSorted) {
|
|
4784
|
+
const entry = result.byFlow[fid];
|
|
4785
|
+
lines.push(`### ${_dfTwinMdInline(fid)}`, '');
|
|
4786
|
+
lines.push(`- Match method: ${_dfTwinMdInline(entry.matchMethod ?? '(none)')}`);
|
|
4787
|
+
lines.push(`- Match confidence: ${_dfTwinMdInline(entry.matchConfidence ?? '(none)')}`);
|
|
4788
|
+
lines.push(`- Environment: ${_dfTwinMdInline(entry.environment ?? '(none)')}`);
|
|
4789
|
+
lines.push(`- Window: ${_dfTwinMdInline(entry.windowStart ?? '(none)')} .. ${_dfTwinMdInline(entry.windowEnd ?? '(none)')}`);
|
|
4790
|
+
lines.push(`- First observed: ${_dfTwinMdInline(entry.firstObservedAt ?? '(none)')}`);
|
|
4791
|
+
lines.push(`- Last observed: ${_dfTwinMdInline(entry.lastObservedAt ?? '(none)')}`);
|
|
4792
|
+
lines.push(`- Event count band: ${_dfTwinMdInline(entry.eventCountBand ?? '(none)')}`);
|
|
4793
|
+
if (Array.isArray(entry.contributingEnvironments) && entry.contributingEnvironments.length > 1) {
|
|
4794
|
+
lines.push(`- Contributing environments: ${_dfTwinMdInline(entry.contributingEnvironments.join(', '))} — the fields above describe only the representative (strongest-confidence) environment (I2).`);
|
|
4795
|
+
}
|
|
4796
|
+
if (entry.siblingFlowCount) {
|
|
4797
|
+
lines.push(`- Sibling flow count: ${_dfTwinMdInline(entry.siblingFlowCount)} — this observation corroborates the destination NODE, never which sibling flow produced the traffic, which is why matchConfidence reads 'ambiguous' here.`);
|
|
4798
|
+
}
|
|
4799
|
+
lines.push('');
|
|
4800
|
+
}
|
|
4801
|
+
}
|
|
4802
|
+
|
|
4803
|
+
lines.push('## Limitations', '');
|
|
4804
|
+
for (const l of result.limitations ?? []) {
|
|
4805
|
+
lines.push(`- ${_dfTwinMdInline(l)}`);
|
|
4806
|
+
}
|
|
4807
|
+
lines.push("- `not_observed_in_window` means the flow was not observed in the selected environment/window — it does NOT mean the flow does not occur (PRD line 2098).");
|
|
4808
|
+
lines.push('- Runtime observation increases corroboration confidence but cannot prove field-level identity — observations correlate to node/edge/flow ids only, never a specific data element instance (FR-505).');
|
|
4809
|
+
lines.push('- Every statically possible path in this graph is listed above regardless of layer; nothing was filtered out (AC-29 clause 3).');
|
|
4810
|
+
lines.push('');
|
|
4811
|
+
data = lines.join('\n');
|
|
4812
|
+
}
|
|
4813
|
+
|
|
4814
|
+
try {
|
|
4815
|
+
await fsp.mkdir(path.dirname(path.resolve(outputPath)), { recursive: true });
|
|
4816
|
+
await fsp.writeFile(path.resolve(outputPath), data);
|
|
4817
|
+
} catch (e) {
|
|
4818
|
+
process.stderr.write(`agentic-security dataflow twin: could not write --output "${outputPath}": ${e && e.message ? e.message : e}\n`);
|
|
4819
|
+
return 2;
|
|
4820
|
+
}
|
|
4821
|
+
return 0;
|
|
4822
|
+
}
|
|
4823
|
+
|
|
4824
|
+
// Mirrors posture/fix-history.js's own _writeAtomicAndSync (temp file in
|
|
4825
|
+
// the same directory, fsync, then rename over the target) — a crash
|
|
4826
|
+
// mid-write must never leave recipient-profiles.json as invalid JSON,
|
|
4827
|
+
// since loadRecipientConfig silently degrades a malformed file to "no
|
|
4828
|
+
// recipients" with only a console warning, not a hard failure.
|
|
4829
|
+
async function _writeConfigAtomic(fp, content) {
|
|
4830
|
+
const dir = path.dirname(fp);
|
|
4831
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
4832
|
+
const tmp = path.join(dir, `.${path.basename(fp)}.tmp-${process.pid}-${crypto.randomBytes(4).toString('hex')}`);
|
|
4833
|
+
try {
|
|
4834
|
+
const handle = await fsp.open(tmp, 'w');
|
|
4835
|
+
try {
|
|
4836
|
+
await handle.writeFile(content);
|
|
4837
|
+
if (typeof handle.sync === 'function') await handle.sync();
|
|
4838
|
+
} finally {
|
|
4839
|
+
await handle.close();
|
|
4840
|
+
}
|
|
4841
|
+
// Preserve the target's existing permissions (if any) — this file
|
|
4842
|
+
// may hold sensitive governance data (DPA/jurisdiction/transfer
|
|
4843
|
+
// facts) an operator deliberately restricted; a fresh temp file
|
|
4844
|
+
// would otherwise silently widen it to the process umask (real bug
|
|
4845
|
+
// found live by the final review: chmod 600 -> 0644 after a write).
|
|
4846
|
+
try {
|
|
4847
|
+
const { mode } = await fsp.stat(fp);
|
|
4848
|
+
await fsp.chmod(tmp, mode & 0o777);
|
|
4849
|
+
} catch { /* target doesn't exist yet — nothing to preserve */ }
|
|
4850
|
+
await fsp.rename(tmp, fp);
|
|
4851
|
+
} catch (e) {
|
|
4852
|
+
try { await fsp.unlink(tmp); } catch { /* never existed, or already gone — fine either way */ }
|
|
4853
|
+
throw e;
|
|
4854
|
+
}
|
|
4855
|
+
}
|
|
4856
|
+
|
|
4857
|
+
// agentic-security governance propose-edit [path] --patch <file>
|
|
4858
|
+
// [--output <file>] [--yes] [--base-digest <hex>] — M5 deliverable #5.
|
|
4859
|
+
// Proposes a validated, reviewable edit to recipient-profiles.json.
|
|
4860
|
+
// Without --yes: computes and previews the diff, writes nothing.
|
|
4861
|
+
// With --yes: re-validates, checks the version guard, backs up the
|
|
4862
|
+
// current file, writes the new content atomically, and appends a real
|
|
4863
|
+
// audit event via auditCall. Exit codes: 0 success (preview or real
|
|
4864
|
+
// write), 1 validation failure, 2 argument/version-guard problem.
|
|
4865
|
+
//
|
|
4866
|
+
// NOT a `dataflow` subcommand — this edits operator config
|
|
4867
|
+
// (recipient-profiles.json), never the scanned graph, so it is its own
|
|
4868
|
+
// top-level `governance` command (see `case 'governance':` in main()).
|
|
4869
|
+
async function cmdGovernancePropose(args) {
|
|
4870
|
+
const target = args._[2] || '.'; // args._ = ['governance', 'propose-edit', <path>?]
|
|
4871
|
+
const targetAbs = path.resolve(target);
|
|
4872
|
+
|
|
4873
|
+
const patchFlag = args.flags.patch;
|
|
4874
|
+
if (!patchFlag || typeof patchFlag !== 'string') {
|
|
4875
|
+
process.stderr.write('agentic-security governance propose-edit: --patch <file> is required.\n');
|
|
4876
|
+
return 2;
|
|
4877
|
+
}
|
|
4878
|
+
let patch;
|
|
4879
|
+
try {
|
|
4880
|
+
patch = JSON.parse(fs.readFileSync(path.resolve(patchFlag), 'utf8'));
|
|
4881
|
+
} catch (e) {
|
|
4882
|
+
process.stderr.write(`agentic-security governance propose-edit: could not read/parse --patch file "${patchFlag}": ${e.message}\n`);
|
|
4883
|
+
return 2;
|
|
4884
|
+
}
|
|
4885
|
+
|
|
4886
|
+
const { RECIPIENT_CONFIG_FILENAME } = await import('../src/lineage/recipient-registry.js');
|
|
4887
|
+
const { statePath } = await import('../src/posture/state-dir.js');
|
|
4888
|
+
const configPath = statePath(targetAbs, RECIPIENT_CONFIG_FILENAME);
|
|
4889
|
+
const currentRaw = fs.existsSync(configPath) ? fs.readFileSync(configPath, 'utf8') : '{"recipients":{}}';
|
|
4890
|
+
const currentDigest = crypto.createHash('sha256').update(currentRaw).digest('hex');
|
|
4891
|
+
// The merge base is the file's REAL bytes, parsed directly — never
|
|
4892
|
+
// loadRecipientConfig's sanitized view, which silently drops any
|
|
4893
|
+
// entry failing isValidRecipientConfigEntry, any non-string/empty
|
|
4894
|
+
// key, and any top-level key other than `recipients`. Merging against
|
|
4895
|
+
// that sanitized object and writing it back permanently deletes
|
|
4896
|
+
// whatever it dropped (B1, the final review's Blocking finding), with
|
|
4897
|
+
// the preview and audit event both falsely reporting nothing removed.
|
|
4898
|
+
// This single JSON.parse is now the ONLY read of the current file, so
|
|
4899
|
+
// currentDigest and the merge base provably describe the same bytes
|
|
4900
|
+
// (also closes M3).
|
|
4901
|
+
let currentConfig;
|
|
4902
|
+
try {
|
|
4903
|
+
currentConfig = JSON.parse(currentRaw);
|
|
4904
|
+
} catch (e) {
|
|
4905
|
+
process.stderr.write(`agentic-security governance propose-edit: the current config file is not valid JSON: ${e.message}\n`);
|
|
4906
|
+
return 2;
|
|
4907
|
+
}
|
|
4908
|
+
|
|
4909
|
+
// Version guard runs BEFORE validation and BEFORE any write — a
|
|
4910
|
+
// concurrent-edit rejection must never partially validate or
|
|
4911
|
+
// partially write first.
|
|
4912
|
+
const baseDigestFlag = args.flags['base-digest'];
|
|
4913
|
+
if (baseDigestFlag && baseDigestFlag !== currentDigest) {
|
|
4914
|
+
process.stderr.write(`agentic-security governance propose-edit: the config file changed since --base-digest was computed (a concurrent edit) — refusing to write. Re-read the current file and recompute your patch.\n`);
|
|
4915
|
+
return 2;
|
|
4916
|
+
}
|
|
4917
|
+
|
|
4918
|
+
const { proposeGovernanceEdit } = await import('../src/lineage/governance-edit.js');
|
|
4919
|
+
const { valid, errors, diff, merged } = proposeGovernanceEdit(currentConfig, patch);
|
|
4920
|
+
if (!valid) {
|
|
4921
|
+
process.stderr.write(`agentic-security governance propose-edit: --patch file failed validation:\n${errors.map((e) => ` ${e.key}: ${e.message}`).join('\n')}\n`);
|
|
4922
|
+
return 1;
|
|
4923
|
+
}
|
|
4924
|
+
|
|
4925
|
+
const yes = !!args.flags.yes;
|
|
4926
|
+
let written = false;
|
|
4927
|
+
let backupPath = null;
|
|
4928
|
+
if (yes) {
|
|
4929
|
+
// Refuse before touching disk if the target doesn't look like a real
|
|
4930
|
+
// project directory — the same guard every other write path in this
|
|
4931
|
+
// file uses, applied here before any mkdirSync/backup/write (M5).
|
|
4932
|
+
const { isSafeStateDir } = await import('../src/posture/state-dir.js');
|
|
4933
|
+
if (!isSafeStateDir(path.dirname(configPath))) {
|
|
4934
|
+
process.stderr.write(`agentic-security governance propose-edit: refusing to write — "${targetAbs}" does not look like a project directory.\n`);
|
|
4935
|
+
return 2;
|
|
4936
|
+
}
|
|
4937
|
+
// Backup BEFORE the new content is written — a failed write below
|
|
4938
|
+
// this point leaves the backup intact and the original untouched.
|
|
4939
|
+
// Only recorded when a backup actually happened (i.e. a prior file
|
|
4940
|
+
// existed) — reporting a backup path that was never written would
|
|
4941
|
+
// mislead a consumer trusting the report. Backups live in their own
|
|
4942
|
+
// dedicated subdirectory (mirroring posture/fix-history.js's own
|
|
4943
|
+
// `fix-history/` precedent), never as a sibling `.bak-*` file next
|
|
4944
|
+
// to the config — that's what lets artifact-registry.js register the
|
|
4945
|
+
// WHOLE DIRECTORY as one entry (I5) so `reset` can sweep it, since
|
|
4946
|
+
// the registry only supports exact-name matches, never a per-file
|
|
4947
|
+
// timestamped name. The `Date.now()-<random>` naming (mirroring this
|
|
4948
|
+
// file's own temp-file naming convention in `_writeConfigAtomic`)
|
|
4949
|
+
// closes M6 — two writes in the same millisecond no longer collide.
|
|
4950
|
+
const backupDir = statePath(targetAbs, 'recipient-profiles-backups');
|
|
4951
|
+
const candidateBackupPath = path.join(backupDir, `${Date.now()}-${crypto.randomBytes(4).toString('hex')}.bak`);
|
|
4952
|
+
if (fs.existsSync(configPath)) {
|
|
4953
|
+
fs.mkdirSync(backupDir, { recursive: true });
|
|
4954
|
+
fs.copyFileSync(configPath, candidateBackupPath);
|
|
4955
|
+
backupPath = candidateBackupPath;
|
|
4956
|
+
}
|
|
4957
|
+
// The write is the MERGE RESULT (`merged`, per proposeGovernanceEdit's
|
|
4958
|
+
// merge-patch semantics), never the raw patch — writing the patch
|
|
4959
|
+
// verbatim would silently delete every recipient it doesn't name.
|
|
4960
|
+
// `merged` is never null here — a null merge base only happens on a
|
|
4961
|
+
// container-shape validation failure, which already returned 1 above.
|
|
4962
|
+
await _writeConfigAtomic(configPath, JSON.stringify(merged, null, 2));
|
|
4963
|
+
written = true;
|
|
4964
|
+
// Audited ONLY on the real write path — --yes supplied AND
|
|
4965
|
+
// validation passed AND the version guard passed. Never on a
|
|
4966
|
+
// dry-run preview, never on a validation failure. Carries the
|
|
4967
|
+
// pre-write digest and the backup path so a later auditor can tell
|
|
4968
|
+
// which bytes this event produced (M2).
|
|
4969
|
+
const { auditCall } = await import('../src/mcp/audit.js');
|
|
4970
|
+
auditCall({
|
|
4971
|
+
sessionRoot: targetAbs, tool: 'governance_propose_edit',
|
|
4972
|
+
args: {
|
|
4973
|
+
file: RECIPIENT_CONFIG_FILENAME, added: diff.added, removed: diff.removed,
|
|
4974
|
+
changedKeys: diff.changed.map((c) => c.key), beforeDigest: currentDigest, backupPath,
|
|
4975
|
+
},
|
|
4976
|
+
outcome: 'ok',
|
|
4977
|
+
});
|
|
4978
|
+
}
|
|
4979
|
+
|
|
4980
|
+
const report = { currentDigest, diff, written, backupPath };
|
|
4981
|
+
const outputPath = args.flags.output;
|
|
4982
|
+
if (outputPath) {
|
|
4983
|
+
fs.writeFileSync(path.resolve(outputPath), JSON.stringify(report, null, 2));
|
|
4984
|
+
} else {
|
|
4985
|
+
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
|
|
4986
|
+
}
|
|
4987
|
+
return 0;
|
|
4988
|
+
}
|
|
4989
|
+
|
|
4990
|
+
// agentic-security federate declare [path] --local-node <node-id>
|
|
4991
|
+
// --remote-graph <file> --remote-node <node-id> [--repository <label>]
|
|
4992
|
+
// [--relationship data_flow] [--rationale <text>] [--output <file>]
|
|
4993
|
+
// [--yes] [--base-digest <hex>] — M5 deliverable #8 (FR-304's "declared"
|
|
4994
|
+
// half). Declares a CrossRepoLink between a node in the CURRENT
|
|
4995
|
+
// locally-scanned graph and a node in a REMOTE graph export
|
|
4996
|
+
// (`dataflow export --format json`'s own artifact, loaded via
|
|
4997
|
+
// federation-loader.js's loadRemoteGraphExport — never loadSignedGraph,
|
|
4998
|
+
// which authenticates against a per-install HMAC key, the wrong trust
|
|
4999
|
+
// model for a file that crossed a repo/machine boundary).
|
|
5000
|
+
//
|
|
5001
|
+
// Reuses cmdGovernancePropose's exact write contract: (1) version guard
|
|
5002
|
+
// on cross-repo-links.json BEFORE any read of the remote file or any
|
|
5003
|
+
// validation; (2) loads+validates the remote export (a digest-mismatch
|
|
5004
|
+
// is a printed warning, never silently swallowed, and never blocks
|
|
5005
|
+
// --yes — the operator is explicitly asserting this file); (3) confirms
|
|
5006
|
+
// --local-node exists in the CURRENT locally-scanned graph and
|
|
5007
|
+
// --remote-node exists in the loaded remote export's own nodes[];
|
|
5008
|
+
// (4) on --yes: backup, atomic write (via the already-shipped
|
|
5009
|
+
// _writeConfigAtomic), a real hash-chained audit event. Exit codes
|
|
5010
|
+
// mirror cmdGovernancePropose's own scheme: 0 success (incl. preview),
|
|
5011
|
+
// 1 validation failure, 2 usage/version-guard/node-not-found, 4 an
|
|
5012
|
+
// unexpected I/O error during the write itself — uncaught, falling
|
|
5013
|
+
// through to main()'s own outer catch/process.exit(4), the identical,
|
|
5014
|
+
// deliberate non-pattern cmdGovernancePropose itself relies on (no local
|
|
5015
|
+
// try/catch here either).
|
|
5016
|
+
async function cmdFederateDeclare(args) {
|
|
5017
|
+
const target = args._[2] || '.'; // args._ = ['federate', 'declare', <path>?]
|
|
5018
|
+
const targetAbs = path.resolve(target);
|
|
5019
|
+
|
|
5020
|
+
const localNodeFlag = args.flags['local-node'];
|
|
5021
|
+
const remoteGraphFlag = args.flags['remote-graph'];
|
|
5022
|
+
const remoteNodeFlag = args.flags['remote-node'];
|
|
5023
|
+
if (!localNodeFlag || typeof localNodeFlag !== 'string') {
|
|
5024
|
+
process.stderr.write('agentic-security federate declare: --local-node <node-id> is required.\n');
|
|
5025
|
+
return 2;
|
|
5026
|
+
}
|
|
5027
|
+
if (!remoteGraphFlag || typeof remoteGraphFlag !== 'string') {
|
|
5028
|
+
process.stderr.write('agentic-security federate declare: --remote-graph <file> is required.\n');
|
|
5029
|
+
return 2;
|
|
5030
|
+
}
|
|
5031
|
+
if (!remoteNodeFlag || typeof remoteNodeFlag !== 'string') {
|
|
5032
|
+
process.stderr.write('agentic-security federate declare: --remote-node <node-id> is required.\n');
|
|
5033
|
+
return 2;
|
|
5034
|
+
}
|
|
5035
|
+
|
|
5036
|
+
const { CROSS_REPO_LINK_VERSION, CROSS_REPO_LINKS_FILENAME, CROSS_REPO_LINK_RELATIONSHIP, validateCrossRepoLink } = await import('../src/lineage/cross-repo-link.js');
|
|
5037
|
+
|
|
5038
|
+
const relationshipFlag = args.flags.relationship ?? CROSS_REPO_LINK_RELATIONSHIP;
|
|
5039
|
+
if (relationshipFlag !== CROSS_REPO_LINK_RELATIONSHIP) {
|
|
5040
|
+
process.stderr.write(`agentic-security federate declare: --relationship must be "${CROSS_REPO_LINK_RELATIONSHIP}" (got "${relationshipFlag}") — no other relationship value is defined.\n`);
|
|
5041
|
+
return 2;
|
|
5042
|
+
}
|
|
5043
|
+
|
|
5044
|
+
const { statePath, isSafeStateDir } = await import('../src/posture/state-dir.js');
|
|
5045
|
+
const { crossRepoLinkId } = await import('../src/lineage/ids.js');
|
|
5046
|
+
const { loadRemoteGraphExport } = await import('../src/lineage/federation-loader.js');
|
|
5047
|
+
const { loadSignedGraph } = await import('../src/server/graph-loader.js');
|
|
5048
|
+
const { computeGraphDigest } = await import('../src/lineage/export-json.js');
|
|
5049
|
+
|
|
5050
|
+
const configPath = statePath(targetAbs, CROSS_REPO_LINKS_FILENAME);
|
|
5051
|
+
const currentRaw = fs.existsSync(configPath) ? fs.readFileSync(configPath, 'utf8') : '{"links":[]}';
|
|
5052
|
+
const currentDigest = crypto.createHash('sha256').update(currentRaw).digest('hex');
|
|
5053
|
+
|
|
5054
|
+
// Version guard runs BEFORE any read of the remote file or any
|
|
5055
|
+
// validation — mirrors cmdGovernancePropose's own ordering exactly.
|
|
5056
|
+
const baseDigestFlag = args.flags['base-digest'];
|
|
5057
|
+
if (baseDigestFlag && baseDigestFlag !== currentDigest) {
|
|
5058
|
+
process.stderr.write('agentic-security federate declare: the cross-repo-links file changed since --base-digest was computed (a concurrent edit) — refusing to write. Re-read the current file and recompute your declaration.\n');
|
|
5059
|
+
return 2;
|
|
5060
|
+
}
|
|
5061
|
+
|
|
5062
|
+
let currentDoc;
|
|
5063
|
+
try {
|
|
5064
|
+
currentDoc = JSON.parse(currentRaw);
|
|
5065
|
+
} catch (e) {
|
|
5066
|
+
process.stderr.write(`agentic-security federate declare: the current cross-repo-links file is not valid JSON: ${e.message}\n`);
|
|
5067
|
+
return 2;
|
|
5068
|
+
}
|
|
5069
|
+
if (!currentDoc || typeof currentDoc !== 'object' || Array.isArray(currentDoc) || !Array.isArray(currentDoc.links)) {
|
|
5070
|
+
process.stderr.write('agentic-security federate declare: the current cross-repo-links file has no "links" array (expected {"links": [...]}).\n');
|
|
5071
|
+
return 2;
|
|
5072
|
+
}
|
|
5073
|
+
|
|
5074
|
+
// Step 2: load and validate the remote export. A digest-mismatch is a
|
|
5075
|
+
// printed WARNING, never a blocking failure — the operator is
|
|
5076
|
+
// explicitly asserting this file.
|
|
5077
|
+
const remote = loadRemoteGraphExport(path.resolve(remoteGraphFlag));
|
|
5078
|
+
if (!remote.ok) {
|
|
5079
|
+
process.stderr.write(`agentic-security federate declare: could not load --remote-graph "${remoteGraphFlag}": ${remote.message}\n`);
|
|
5080
|
+
return 2;
|
|
5081
|
+
}
|
|
5082
|
+
if (!remote.digestMatches) {
|
|
5083
|
+
process.stderr.write(`agentic-security federate declare: WARNING — ${remote.message}\n`);
|
|
5084
|
+
}
|
|
5085
|
+
const remoteNode = (remote.graph.nodes ?? []).find((n) => n.id === remoteNodeFlag);
|
|
5086
|
+
if (!remoteNode) {
|
|
5087
|
+
process.stderr.write(`agentic-security federate declare: --remote-node "${remoteNodeFlag}" was not found in the remote export's own nodes.\n`);
|
|
5088
|
+
return 2;
|
|
5089
|
+
}
|
|
5090
|
+
|
|
5091
|
+
// Step 3: confirm --local-node exists in the CURRENT locally-scanned
|
|
5092
|
+
// graph — loadSignedGraph is the correct mechanism here (the LOCAL
|
|
5093
|
+
// side, same install, same machine).
|
|
5094
|
+
const local = loadSignedGraph(targetAbs);
|
|
5095
|
+
if (!local.ok) {
|
|
5096
|
+
process.stderr.write(`agentic-security federate declare: could not load the local scanned graph: ${local.message}\n`);
|
|
5097
|
+
return 1;
|
|
5098
|
+
}
|
|
5099
|
+
const localNode = (local.graph.nodes ?? []).find((n) => n.id === localNodeFlag);
|
|
5100
|
+
if (!localNode) {
|
|
5101
|
+
process.stderr.write(`agentic-security federate declare: --local-node "${localNodeFlag}" was not found in the current locally-scanned graph.\n`);
|
|
5102
|
+
return 2;
|
|
5103
|
+
}
|
|
5104
|
+
|
|
5105
|
+
// Strip crossRepoLinks before hashing — final whole-branch review,
|
|
5106
|
+
// M5 deliverable #8, B3: computeGraphDigest has no exclusion for this
|
|
5107
|
+
// field, so a graph's own digest otherwise depends on what
|
|
5108
|
+
// cross-repo links have ALREADY been declared and re-attached by a
|
|
5109
|
+
// later scan (graph-builder.js's own opts.crossRepoLinks hook). Left
|
|
5110
|
+
// unfixed, every declare-then-rescan cycle changes the digest that
|
|
5111
|
+
// the NEXT declaration's own id is derived from, minting a new,
|
|
5112
|
+
// unrelated id for the identical real-world fact. Using an object
|
|
5113
|
+
// without the key (not a key set to undefined) guarantees this is
|
|
5114
|
+
// hash-equivalent to a graph that never had the field at all,
|
|
5115
|
+
// regardless of how the hashing internals treat undefined values.
|
|
5116
|
+
const { crossRepoLinks: _omitForDigest, ...localGraphForDigest } = local.graph;
|
|
5117
|
+
const localGraphDigest = computeGraphDigest(localGraphForDigest);
|
|
5118
|
+
const idInputs = {
|
|
5119
|
+
localGraphId: local.graph.graphId, localGraphDigest, localNodeId: localNodeFlag,
|
|
5120
|
+
remoteGraphId: remote.graph.graphId, remoteGraphDigest: remote.digest, remoteNodeId: remoteNodeFlag,
|
|
5121
|
+
relationship: relationshipFlag,
|
|
5122
|
+
};
|
|
5123
|
+
const record = {
|
|
5124
|
+
id: crossRepoLinkId(idInputs),
|
|
5125
|
+
version: CROSS_REPO_LINK_VERSION,
|
|
5126
|
+
provenance: 'manual',
|
|
5127
|
+
relationship: relationshipFlag,
|
|
5128
|
+
local: { graphId: local.graph.graphId, graphDigest: localGraphDigest, nodeId: localNodeFlag },
|
|
5129
|
+
remote: {
|
|
5130
|
+
// Honest placeholder literal when the operator supplied none — no
|
|
5131
|
+
// code-derived signal exists to name "which repo" a bare exported
|
|
5132
|
+
// JSON file came from (mirrors recipient-registry.js's own
|
|
5133
|
+
// `graphId ?? '(no graph)'` precedent).
|
|
5134
|
+
repository: args.flags.repository ?? '(unspecified)',
|
|
5135
|
+
sourceFile: path.resolve(remoteGraphFlag),
|
|
5136
|
+
graphId: remote.graph.graphId, graphDigest: remote.digest, nodeId: remoteNodeFlag,
|
|
5137
|
+
},
|
|
5138
|
+
rationale: args.flags.rationale ?? null,
|
|
5139
|
+
declaredBy: process.env.USER || process.env.USERNAME || '(unspecified)',
|
|
5140
|
+
declaredAt: new Date().toISOString(),
|
|
5141
|
+
};
|
|
5142
|
+
|
|
5143
|
+
const { valid, errors } = validateCrossRepoLink(record);
|
|
5144
|
+
if (!valid) {
|
|
5145
|
+
process.stderr.write(`agentic-security federate declare: constructed record failed validation:\n${errors.map((e) => ` ${e.path}: ${e.message}`).join('\n')}\n`);
|
|
5146
|
+
return 1;
|
|
5147
|
+
}
|
|
5148
|
+
|
|
5149
|
+
const yes = !!args.flags.yes;
|
|
5150
|
+
let written = false;
|
|
5151
|
+
let backupPath = null;
|
|
5152
|
+
if (yes) {
|
|
5153
|
+
if (!isSafeStateDir(path.dirname(configPath))) {
|
|
5154
|
+
process.stderr.write(`agentic-security federate declare: refusing to write — "${targetAbs}" does not look like a project directory.\n`);
|
|
5155
|
+
return 2;
|
|
5156
|
+
}
|
|
5157
|
+
const backupDir = statePath(targetAbs, 'cross-repo-links-backups');
|
|
5158
|
+
const candidateBackupPath = path.join(backupDir, `${Date.now()}-${crypto.randomBytes(4).toString('hex')}.bak`);
|
|
5159
|
+
if (fs.existsSync(configPath)) {
|
|
5160
|
+
fs.mkdirSync(backupDir, { recursive: true });
|
|
5161
|
+
fs.copyFileSync(configPath, candidateBackupPath);
|
|
5162
|
+
backupPath = candidateBackupPath;
|
|
5163
|
+
}
|
|
5164
|
+
// Dedupe by id, IN PLACE — final whole-branch review, M5 deliverable
|
|
5165
|
+
// #8, B3, refined in round 2 per the re-review's own Minor finding:
|
|
5166
|
+
// a redeclare of the identical fact now replaces the existing entry
|
|
5167
|
+
// at its ORIGINAL array position, rather than moving it to the end
|
|
5168
|
+
// (round 1's filter-then-append did the latter — harmless, but an
|
|
5169
|
+
// unnecessary, undocumented ordering change `federate list`'s own
|
|
5170
|
+
// output would otherwise silently exhibit on every redeclare).
|
|
5171
|
+
const existingIndex = currentDoc.links.findIndex((l) => l && l.id === record.id);
|
|
5172
|
+
const newLinks = existingIndex === -1
|
|
5173
|
+
? [...currentDoc.links, record]
|
|
5174
|
+
: currentDoc.links.map((l, i) => (i === existingIndex ? record : l));
|
|
5175
|
+
const merged = { ...currentDoc, links: newLinks };
|
|
5176
|
+
await _writeConfigAtomic(configPath, JSON.stringify(merged, null, 2));
|
|
5177
|
+
written = true;
|
|
5178
|
+
const { auditCall } = await import('../src/mcp/audit.js');
|
|
5179
|
+
auditCall({
|
|
5180
|
+
sessionRoot: targetAbs, tool: 'federate_declare',
|
|
5181
|
+
args: {
|
|
5182
|
+
file: CROSS_REPO_LINKS_FILENAME, id: record.id, localNodeId: localNodeFlag, remoteNodeId: remoteNodeFlag,
|
|
5183
|
+
digestMatches: remote.digestMatches, beforeDigest: currentDigest, backupPath,
|
|
5184
|
+
},
|
|
5185
|
+
outcome: 'ok',
|
|
5186
|
+
});
|
|
5187
|
+
}
|
|
5188
|
+
|
|
5189
|
+
const report = { currentDigest, record, digestMatches: remote.digestMatches, written, backupPath };
|
|
5190
|
+
const outputPath = args.flags.output;
|
|
5191
|
+
if (outputPath) {
|
|
5192
|
+
fs.writeFileSync(path.resolve(outputPath), JSON.stringify(report, null, 2));
|
|
5193
|
+
} else {
|
|
5194
|
+
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
|
|
5195
|
+
}
|
|
5196
|
+
return 0;
|
|
5197
|
+
}
|
|
5198
|
+
|
|
5199
|
+
// agentic-security federate list [path] [--output <file>] — M5
|
|
5200
|
+
// deliverable #8. Read-only. Reads cross-repo-links.json and, for each
|
|
5201
|
+
// entry, reports whether local.nodeId still resolves against the
|
|
5202
|
+
// current loadSignedGraph output and whether remote.sourceFile still
|
|
5203
|
+
// exists/parses/digest-matches/still names the declared remote node —
|
|
5204
|
+
// never fabricates "still valid" when it cannot check (mirrors
|
|
5205
|
+
// `dataflow observations list`'s own precedent). Exit codes: 0 success
|
|
5206
|
+
// (including an empty list), 2 a malformed cross-repo-links.json.
|
|
5207
|
+
async function cmdFederateList(args) {
|
|
5208
|
+
const target = args._[2] || '.'; // args._ = ['federate', 'list', <path>?]
|
|
5209
|
+
const targetAbs = path.resolve(target);
|
|
5210
|
+
|
|
5211
|
+
const { statePath } = await import('../src/posture/state-dir.js');
|
|
5212
|
+
const { CROSS_REPO_LINKS_FILENAME } = await import('../src/lineage/cross-repo-link.js');
|
|
5213
|
+
const { loadRemoteGraphExport } = await import('../src/lineage/federation-loader.js');
|
|
5214
|
+
const { loadSignedGraph } = await import('../src/server/graph-loader.js');
|
|
5215
|
+
|
|
5216
|
+
const configPath = statePath(targetAbs, CROSS_REPO_LINKS_FILENAME);
|
|
5217
|
+
let links = [];
|
|
5218
|
+
if (fs.existsSync(configPath)) {
|
|
5219
|
+
let doc;
|
|
5220
|
+
try {
|
|
5221
|
+
doc = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
5222
|
+
} catch (e) {
|
|
5223
|
+
process.stderr.write(`agentic-security federate list: ${configPath} is not valid JSON: ${e.message}\n`);
|
|
5224
|
+
return 2;
|
|
5225
|
+
}
|
|
5226
|
+
links = Array.isArray(doc?.links) ? doc.links : [];
|
|
5227
|
+
}
|
|
5228
|
+
|
|
5229
|
+
const local = loadSignedGraph(targetAbs);
|
|
5230
|
+
const localNodeIds = local.ok ? new Set((local.graph.nodes ?? []).map((n) => n.id)) : null;
|
|
5231
|
+
|
|
5232
|
+
const results = links.map((record) => {
|
|
5233
|
+
// `stillValid` is null — "could not check" — whenever there is no
|
|
5234
|
+
// current local graph to check against, never fabricated as true or
|
|
5235
|
+
// false.
|
|
5236
|
+
const localStillValid = localNodeIds ? localNodeIds.has(record?.local?.nodeId) : null;
|
|
5237
|
+
|
|
5238
|
+
let remoteStatus;
|
|
5239
|
+
const sourceFile = record?.remote?.sourceFile;
|
|
5240
|
+
if (typeof sourceFile !== 'string' || !sourceFile) {
|
|
5241
|
+
remoteStatus = { checked: false, reason: 'no sourceFile recorded on this record' };
|
|
5242
|
+
} else {
|
|
5243
|
+
const remote = loadRemoteGraphExport(sourceFile);
|
|
5244
|
+
if (!remote.ok) {
|
|
5245
|
+
remoteStatus = { checked: true, ok: false, reason: remote.reason, message: remote.message };
|
|
5246
|
+
} else {
|
|
5247
|
+
const nodeStillPresent = (remote.graph.nodes ?? []).some((n) => n.id === record?.remote?.nodeId);
|
|
5248
|
+
remoteStatus = { checked: true, ok: true, digestMatches: remote.digestMatches, nodeStillPresent };
|
|
5249
|
+
}
|
|
5250
|
+
}
|
|
5251
|
+
|
|
5252
|
+
return {
|
|
5253
|
+
id: record?.id ?? null,
|
|
5254
|
+
local: { nodeId: record?.local?.nodeId ?? null, stillValid: localStillValid },
|
|
5255
|
+
remote: { sourceFile: sourceFile ?? null, nodeId: record?.remote?.nodeId ?? null, ...remoteStatus },
|
|
5256
|
+
rationale: record?.rationale ?? null,
|
|
5257
|
+
declaredBy: record?.declaredBy ?? null,
|
|
5258
|
+
declaredAt: record?.declaredAt ?? null,
|
|
5259
|
+
};
|
|
5260
|
+
});
|
|
5261
|
+
|
|
5262
|
+
const report = { links: results, localGraphAvailable: local.ok };
|
|
5263
|
+
const outputPath = args.flags.output;
|
|
5264
|
+
if (outputPath) {
|
|
5265
|
+
fs.writeFileSync(path.resolve(outputPath), JSON.stringify(report, null, 2));
|
|
5266
|
+
} else {
|
|
5267
|
+
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
|
|
5268
|
+
}
|
|
5269
|
+
return 0;
|
|
5270
|
+
}
|
|
5271
|
+
|
|
5272
|
+
// ── remediation open/update/accept-risk/list — M5 deliverable #6
|
|
5273
|
+
// (Blast-Radius: Remediation Command Center, FR-507 + AC-31), Task 3.
|
|
5274
|
+
// The CLI wiring for the non-GraphDiff-dependent half of the `remediation`
|
|
5275
|
+
// dispatcher (Task 4 adds `verify`/`reopen-check`). Same shape as
|
|
5276
|
+
// `cmdGovernancePropose` above: dry-run-by-default, `--yes` to write,
|
|
5277
|
+
// `isSafeStateDir` guard, `auditCall` on every real write, exit codes
|
|
5278
|
+
// 0/1/2/4 — but writing through Task 2's `appendLedgerEvent` (an
|
|
5279
|
+
// append-only JSONL event log) rather than a whole-file rewrite.
|
|
5280
|
+
|
|
5281
|
+
function _emitRemediationReport(report, args) {
|
|
5282
|
+
const data = JSON.stringify(report, null, 2);
|
|
5283
|
+
const outputPath = args.flags.output;
|
|
5284
|
+
if (outputPath) {
|
|
5285
|
+
fs.writeFileSync(path.resolve(outputPath), data);
|
|
5286
|
+
} else {
|
|
5287
|
+
process.stdout.write(data + '\n');
|
|
5288
|
+
}
|
|
5289
|
+
}
|
|
5290
|
+
|
|
5291
|
+
// Shared write path for every mutating `remediation` verb. Order is the
|
|
5292
|
+
// same one cmdGovernancePropose established and is load-bearing:
|
|
5293
|
+
// 1. --base-event optimistic-concurrency guard (BEFORE any validation
|
|
5294
|
+
// or write) — the append-only analogue of #5's whole-file
|
|
5295
|
+
// --base-digest, keyed to the last EVENT hash per the scoping doc's
|
|
5296
|
+
// §3 "reuse this shape, with one genuine adaptation."
|
|
5297
|
+
// 2. isSafeStateDir refusal (--yes path only).
|
|
5298
|
+
// 3. Dry run: fold the CURRENT ledger, report what WOULD happen, write
|
|
5299
|
+
// nothing, exit 0 (or 1 if the proposed event is itself illegal).
|
|
5300
|
+
// 4. --yes: appendLedgerEvent (which is the ONLY caller of
|
|
5301
|
+
// validateTransition), then auditCall — never the reverse, and
|
|
5302
|
+
// never auditCall on a rejected or previewed write.
|
|
5303
|
+
// `extraReport` (Task 4) is an optional object of additional fields merged
|
|
5304
|
+
// into the emitted report, in BOTH the dry-run-preview and the real-write
|
|
5305
|
+
// branches — e.g. cmdRemediationVerify's own beforeSnapshotId/
|
|
5306
|
+
// afterSnapshotId/beforeSnapshotSource/evidenceOutcome. It is never
|
|
5307
|
+
// consulted for validity — only validateTransition (inside
|
|
5308
|
+
// appendLedgerEvent, or this function's own preview call below) decides
|
|
5309
|
+
// that — so a caller cannot use it to smuggle a write past validation.
|
|
5310
|
+
// Returns an exit code; the caller returns it verbatim.
|
|
5311
|
+
async function _remediationWrite(targetAbs, verb, auditTool, eventPayload, args, extraReport = {}) {
|
|
5312
|
+
const { ledgerPaths, latestEventHash, readLedgerEvents, appendLedgerEvent } =
|
|
5313
|
+
await import('../src/posture/remediation-ledger.js');
|
|
5314
|
+
const { foldRemediationLedger, validateTransition } = await import('../src/lineage/remediation.js');
|
|
5315
|
+
|
|
5316
|
+
const { ledgerPath } = ledgerPaths(targetAbs);
|
|
5317
|
+
const itemId = eventPayload.itemId ?? eventPayload.id ?? null;
|
|
5318
|
+
|
|
5319
|
+
// 1. --base-event guard — before any validation or write.
|
|
5320
|
+
const currentBaseEvent = latestEventHash(targetAbs);
|
|
5321
|
+
const baseEventFlag = args.flags['base-event'];
|
|
5322
|
+
if (baseEventFlag !== undefined && baseEventFlag !== currentBaseEvent) {
|
|
5323
|
+
process.stderr.write(`agentic-security remediation ${verb}: the ledger changed since --base-event was computed (a concurrent write) — refusing to append. Re-read the ledger with \`remediation list\` and retry.\n`);
|
|
5324
|
+
return 2;
|
|
5325
|
+
}
|
|
5326
|
+
|
|
5327
|
+
const yes = !!args.flags.yes;
|
|
5328
|
+
|
|
5329
|
+
// 2. isSafeStateDir refusal — --yes path only.
|
|
5330
|
+
if (yes) {
|
|
5331
|
+
const { isSafeStateDir } = await import('../src/posture/state-dir.js');
|
|
5332
|
+
if (!isSafeStateDir(path.dirname(ledgerPath))) {
|
|
5333
|
+
process.stderr.write(`agentic-security remediation ${verb}: refusing to write — "${targetAbs}" does not look like a project directory.\n`);
|
|
5334
|
+
return 2;
|
|
5335
|
+
}
|
|
5336
|
+
}
|
|
5337
|
+
|
|
5338
|
+
// 3. Dry run — a genuine preview of the resulting state, computed by
|
|
5339
|
+
// folding the CURRENT ledger with the proposed event appended in
|
|
5340
|
+
// memory. The proposed event's own legality is checked here too (for
|
|
5341
|
+
// the preview only — the authoritative call remains the one inside
|
|
5342
|
+
// appendLedgerEvent below), so --yes never surprises an operator who
|
|
5343
|
+
// previewed first.
|
|
5344
|
+
if (!yes) {
|
|
5345
|
+
const events = readLedgerEvents(targetAbs);
|
|
5346
|
+
const currentItems = foldRemediationLedger(events);
|
|
5347
|
+
const currentItem = itemId != null ? (currentItems[itemId] ?? null) : null;
|
|
5348
|
+
const wouldBeItems = foldRemediationLedger([...events, eventPayload]);
|
|
5349
|
+
const wouldBe = itemId != null ? (wouldBeItems[itemId] ?? null) : null;
|
|
5350
|
+
const report = { verb, itemId, proposedEvent: eventPayload, baseEvent: currentBaseEvent, written: false, wouldBe, ...extraReport };
|
|
5351
|
+
const { valid, errors } = validateTransition(currentItem, eventPayload);
|
|
5352
|
+
if (!valid) {
|
|
5353
|
+
report.errors = errors;
|
|
5354
|
+
_emitRemediationReport(report, args);
|
|
5355
|
+
for (const e of errors) process.stderr.write(`agentic-security remediation ${verb}: ${e.field}: ${e.message}\n`);
|
|
5356
|
+
return 1;
|
|
5357
|
+
}
|
|
5358
|
+
_emitRemediationReport(report, args);
|
|
5359
|
+
return 0;
|
|
5360
|
+
}
|
|
5361
|
+
|
|
5362
|
+
// 4. --yes: the real write. `expectedBaseHash` (I5) threads the
|
|
5363
|
+
// --base-event guard through to the authoritative, inside-the-lock
|
|
5364
|
+
// check — the pre-lock check above (#1) remains as a cheap early fail,
|
|
5365
|
+
// but this is the one that cannot be raced by a concurrent writer.
|
|
5366
|
+
let result;
|
|
5367
|
+
try {
|
|
5368
|
+
result = await appendLedgerEvent(targetAbs, eventPayload, { expectedBaseHash: baseEventFlag });
|
|
5369
|
+
} catch (e) {
|
|
5370
|
+
process.stderr.write(`agentic-security remediation ${verb}: unexpected error writing the ledger: ${e && e.message ? e.message : e}\n`);
|
|
5371
|
+
return 4;
|
|
5372
|
+
}
|
|
5373
|
+
if (!result.valid) {
|
|
5374
|
+
for (const e of result.errors) process.stderr.write(`agentic-security remediation ${verb}: ${e.field}: ${e.message}\n`);
|
|
5375
|
+
// M10: a state-writes-disabled or unsafe-scanRoot refusal is a
|
|
5376
|
+
// usage/environment condition, not a rejected state transition —
|
|
5377
|
+
// `commands/remediation.md`'s exit-code table reserves 1 for the
|
|
5378
|
+
// latter. Both error fields are environment refusals emitted by
|
|
5379
|
+
// appendLedgerEvent BEFORE any real validation runs; every other
|
|
5380
|
+
// error field is a genuine validation/state-machine rejection.
|
|
5381
|
+
if (result.errors.some((e) => e.field === '(state)' || e.field === '(scanRoot)')) return 2;
|
|
5382
|
+
return 1;
|
|
5383
|
+
}
|
|
5384
|
+
const { auditCall } = await import('../src/mcp/audit.js');
|
|
5385
|
+
auditCall({
|
|
5386
|
+
sessionRoot: targetAbs,
|
|
5387
|
+
tool: auditTool,
|
|
5388
|
+
args: { itemId, eventType: eventPayload.type, baseEvent: currentBaseEvent, eventHash: result.hash },
|
|
5389
|
+
outcome: 'ok',
|
|
5390
|
+
});
|
|
5391
|
+
const report = { verb, itemId, proposedEvent: eventPayload, baseEvent: currentBaseEvent, written: true, eventHash: result.hash, ...extraReport };
|
|
5392
|
+
_emitRemediationReport(report, args);
|
|
5393
|
+
return 0;
|
|
5394
|
+
}
|
|
5395
|
+
|
|
5396
|
+
// agentic-security remediation open [path] --assessment <impact-report.json>
|
|
5397
|
+
// --owner <id> --due <YYYY-MM-DD> --control <text>
|
|
5398
|
+
// --required-evidence <flowId,...> [--id <itemId>] [--snapshot <commit>]
|
|
5399
|
+
// [--allow-manual-attestation] [--output <file>] [--yes]
|
|
5400
|
+
async function cmdRemediationOpen(args) {
|
|
5401
|
+
const target = args._[2] || '.'; // args._ = ['remediation', 'open', <path>?]
|
|
5402
|
+
const targetAbs = path.resolve(target);
|
|
5403
|
+
|
|
5404
|
+
const assessmentFlag = args.flags.assessment;
|
|
5405
|
+
const ownerFlag = args.flags.owner;
|
|
5406
|
+
const dueFlag = args.flags.due;
|
|
5407
|
+
const controlFlag = args.flags.control;
|
|
5408
|
+
const requiredEvidenceFlag = args.flags['required-evidence'];
|
|
5409
|
+
|
|
5410
|
+
for (const [name, val] of [
|
|
5411
|
+
['--assessment', assessmentFlag], ['--owner', ownerFlag], ['--due', dueFlag],
|
|
5412
|
+
['--control', controlFlag], ['--required-evidence', requiredEvidenceFlag],
|
|
5413
|
+
]) {
|
|
5414
|
+
if (!val || typeof val !== 'string') {
|
|
5415
|
+
process.stderr.write(`agentic-security remediation open: ${name} is required.\n`);
|
|
5416
|
+
return 2;
|
|
5417
|
+
}
|
|
5418
|
+
}
|
|
5419
|
+
|
|
5420
|
+
// This is the RAW `dataflow impact assess --format json --output`
|
|
5421
|
+
// report — cmdDataflowImpactAssess writes JSON.stringify(record, null,
|
|
5422
|
+
// 2), so the file on disk IS the record. No hand-extraction needed.
|
|
5423
|
+
let record;
|
|
5424
|
+
try {
|
|
5425
|
+
record = JSON.parse(fs.readFileSync(path.resolve(assessmentFlag), 'utf8'));
|
|
5426
|
+
} catch (e) {
|
|
5427
|
+
process.stderr.write(`agentic-security remediation open: could not read/parse --assessment file "${assessmentFlag}": ${e && e.message ? e.message : e}\n`);
|
|
5428
|
+
return 2;
|
|
5429
|
+
}
|
|
5430
|
+
const { validateImpactAssessment } = await import('../src/lineage/impact-assessment.js');
|
|
5431
|
+
const { valid: assessmentValid, errors: assessmentErrors } = validateImpactAssessment(record);
|
|
5432
|
+
if (!assessmentValid) {
|
|
5433
|
+
process.stderr.write(`agentic-security remediation open: --assessment file failed validation:\n${assessmentErrors.map((e) => ` ${e.path}: ${e.message}`).join('\n')}\n`);
|
|
5434
|
+
return 2;
|
|
5435
|
+
}
|
|
5436
|
+
|
|
5437
|
+
// Resolve the incident snapshot: --snapshot <commit> pins one specific
|
|
5438
|
+
// persisted snapshot; otherwise the newest one is used. Refuses (exit
|
|
5439
|
+
// 2) when no snapshot exists at all — an item with no incident
|
|
5440
|
+
// snapshot can never be verified, so opening one would be a
|
|
5441
|
+
// guaranteed dead end.
|
|
5442
|
+
const snapshotFlag = args.flags.snapshot;
|
|
5443
|
+
let snapshot;
|
|
5444
|
+
if (snapshotFlag !== undefined) {
|
|
5445
|
+
snapshot = loadSnapshot(targetAbs, snapshotFlag);
|
|
5446
|
+
if (!snapshot) {
|
|
5447
|
+
process.stderr.write(`agentic-security remediation open: no persisted GraphSnapshot found for commit "${snapshotFlag}" — pass a commit that was actually scanned.\n`);
|
|
5448
|
+
return 2;
|
|
5449
|
+
}
|
|
5450
|
+
} else {
|
|
5451
|
+
const snapshots = loadSnapshots(targetAbs);
|
|
5452
|
+
if (snapshots.length === 0) {
|
|
5453
|
+
process.stderr.write(`agentic-security remediation open: no persisted GraphSnapshot found for "${targetAbs}" — run a scan with AGENTIC_SECURITY_LINEAGE_DEEP=1 first.\n`);
|
|
5454
|
+
return 2;
|
|
5455
|
+
}
|
|
5456
|
+
snapshot = snapshots[0];
|
|
5457
|
+
}
|
|
5458
|
+
|
|
5459
|
+
const requiredEvidence = String(requiredEvidenceFlag).split(',').map((s) => s.trim()).filter(Boolean);
|
|
5460
|
+
if (requiredEvidence.length === 0) {
|
|
5461
|
+
process.stderr.write('agentic-security remediation open: --required-evidence must name at least one flow id.\n');
|
|
5462
|
+
return 2;
|
|
5463
|
+
}
|
|
5464
|
+
|
|
5465
|
+
const itemId = args.flags.id || `rem-${crypto.randomBytes(6).toString('hex')}`;
|
|
5466
|
+
// affectedFlowIds is DERIVED, not copied — ImpactAssessment carries no
|
|
5467
|
+
// affectedFlowIds field of its own (impact-assessment.js). The sorted,
|
|
5468
|
+
// deduplicated union of --required-evidence and (the target itself,
|
|
5469
|
+
// when it names a flow) is the only real flow-id source available.
|
|
5470
|
+
const affectedFlowIds = [...new Set([
|
|
5471
|
+
...requiredEvidence,
|
|
5472
|
+
...(typeof record.targetId === 'string' && record.targetId.startsWith('flow:') ? [record.targetId] : []),
|
|
5473
|
+
])].sort();
|
|
5474
|
+
|
|
5475
|
+
const payload = {
|
|
5476
|
+
type: 'opened',
|
|
5477
|
+
at: new Date().toISOString(),
|
|
5478
|
+
itemId,
|
|
5479
|
+
id: itemId,
|
|
5480
|
+
owner: ownerFlag,
|
|
5481
|
+
dueDate: dueFlag,
|
|
5482
|
+
recommendedControl: controlFlag,
|
|
5483
|
+
assessment: {
|
|
5484
|
+
assessmentId: record.id,
|
|
5485
|
+
targetId: record.targetId,
|
|
5486
|
+
targetKind: record.targetKind,
|
|
5487
|
+
traceKind: record.traceKind,
|
|
5488
|
+
scope: record.scope,
|
|
5489
|
+
graphId: record.graphId,
|
|
5490
|
+
graphDigest: record.graphDigest,
|
|
5491
|
+
snapshotId: snapshot.id,
|
|
5492
|
+
assessmentPath: path.resolve(assessmentFlag),
|
|
5493
|
+
},
|
|
5494
|
+
affectedFlowIds,
|
|
5495
|
+
affectedNodeIds: record.affectedNodeIds ?? [],
|
|
5496
|
+
affectedEdgeIds: record.affectedEdgeIds ?? [],
|
|
5497
|
+
requiredEvidence,
|
|
5498
|
+
manualAttestationPermitted: !!args.flags['allow-manual-attestation'],
|
|
5499
|
+
};
|
|
5500
|
+
|
|
5501
|
+
const { validateOpenPayload } = await import('../src/lineage/remediation.js');
|
|
5502
|
+
const { valid, errors } = validateOpenPayload(payload);
|
|
5503
|
+
if (!valid) {
|
|
5504
|
+
process.stderr.write(`agentic-security remediation open: ${errors.map((e) => `${e.field}: ${e.message}`).join('\n')}\n`);
|
|
5505
|
+
return 1;
|
|
5506
|
+
}
|
|
5507
|
+
|
|
5508
|
+
return _remediationWrite(targetAbs, 'open', 'remediation_open', payload, args);
|
|
5509
|
+
}
|
|
5510
|
+
|
|
5511
|
+
// agentic-security remediation update [path] --id <itemId>
|
|
5512
|
+
// --state <in_progress|awaiting_verification> [--base-event <hash>]
|
|
5513
|
+
// [--output <file>] [--yes]
|
|
5514
|
+
async function cmdRemediationUpdate(args) {
|
|
5515
|
+
const target = args._[2] || '.'; // args._ = ['remediation', 'update', <path>?]
|
|
5516
|
+
const targetAbs = path.resolve(target);
|
|
5517
|
+
|
|
5518
|
+
const idFlag = args.flags.id;
|
|
5519
|
+
if (!idFlag || typeof idFlag !== 'string') {
|
|
5520
|
+
process.stderr.write('agentic-security remediation update: --id <itemId> is required.\n');
|
|
5521
|
+
return 2;
|
|
5522
|
+
}
|
|
5523
|
+
const stateFlag = args.flags.state;
|
|
5524
|
+
if (!stateFlag || typeof stateFlag !== 'string') {
|
|
5525
|
+
process.stderr.write('agentic-security remediation update: --state <in_progress|awaiting_verification> is required.\n');
|
|
5526
|
+
return 2;
|
|
5527
|
+
}
|
|
5528
|
+
// AC-31 at the CLI boundary: `state_changed` can never reach `verified`
|
|
5529
|
+
// — this is the friendlier of two independent guards for the one rule
|
|
5530
|
+
// that matters most (the ledger's own validateTransition rejection,
|
|
5531
|
+
// enforced inside appendLedgerEvent, is the one that cannot be
|
|
5532
|
+
// bypassed). Checked BEFORE the recognized-values check below so a
|
|
5533
|
+
// caller gets this specific message rather than a generic usage error.
|
|
5534
|
+
if (stateFlag === 'verified') {
|
|
5535
|
+
process.stderr.write('agentic-security remediation update: state_changed can never reach "verified" — run `agentic-security remediation verify` instead.\n');
|
|
5536
|
+
return 1;
|
|
5537
|
+
}
|
|
5538
|
+
if (stateFlag !== 'in_progress' && stateFlag !== 'awaiting_verification') {
|
|
5539
|
+
process.stderr.write(`agentic-security remediation update: --state must be one of in_progress|awaiting_verification (got ${JSON.stringify(stateFlag)}).\n`);
|
|
5540
|
+
return 2;
|
|
5541
|
+
}
|
|
5542
|
+
|
|
5543
|
+
const payload = { type: 'state_changed', at: new Date().toISOString(), itemId: idFlag, state: stateFlag };
|
|
5544
|
+
return _remediationWrite(targetAbs, 'update', 'remediation_update', payload, args);
|
|
5545
|
+
}
|
|
5546
|
+
|
|
5547
|
+
// agentic-security remediation accept-risk [path] --id <itemId>
|
|
5548
|
+
// --approver <id> --reason <text> --scope <text> --expires <YYYY-MM-DD>
|
|
5549
|
+
// [--author <id>] [--base-event <hash>] [--output <file>] [--yes]
|
|
5550
|
+
async function cmdRemediationAcceptRisk(args) {
|
|
5551
|
+
const target = args._[2] || '.'; // args._ = ['remediation', 'accept-risk', <path>?]
|
|
5552
|
+
const targetAbs = path.resolve(target);
|
|
5553
|
+
|
|
5554
|
+
const idFlag = args.flags.id;
|
|
5555
|
+
const approverFlag = args.flags.approver;
|
|
5556
|
+
const reasonFlag = args.flags.reason;
|
|
5557
|
+
const scopeFlag = args.flags.scope;
|
|
5558
|
+
const expiresFlag = args.flags.expires;
|
|
5559
|
+
|
|
5560
|
+
for (const [name, val] of [
|
|
5561
|
+
['--id', idFlag], ['--approver', approverFlag], ['--reason', reasonFlag],
|
|
5562
|
+
['--scope', scopeFlag], ['--expires', expiresFlag],
|
|
5563
|
+
]) {
|
|
5564
|
+
if (!val || typeof val !== 'string') {
|
|
5565
|
+
process.stderr.write(`agentic-security remediation accept-risk: ${name} is required.\n`);
|
|
5566
|
+
return 2;
|
|
5567
|
+
}
|
|
5568
|
+
}
|
|
5569
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(expiresFlag)) {
|
|
5570
|
+
process.stderr.write(`agentic-security remediation accept-risk: --expires must be a YYYY-MM-DD date (got ${JSON.stringify(expiresFlag)}).\n`);
|
|
5571
|
+
return 2;
|
|
5572
|
+
}
|
|
5573
|
+
|
|
5574
|
+
const { loadApproverRegistry, verifyApprover, checkSeparationOfDuties } =
|
|
5575
|
+
await import('../src/fix/approver-registry.js');
|
|
5576
|
+
const registry = loadApproverRegistry(targetAbs);
|
|
5577
|
+
const v = verifyApprover(registry, approverFlag, []);
|
|
5578
|
+
if (!v.verified) {
|
|
5579
|
+
process.stderr.write(`agentic-security remediation accept-risk: ${v.reason}\n`);
|
|
5580
|
+
return 1;
|
|
5581
|
+
}
|
|
5582
|
+
const sod = checkSeparationOfDuties(registry, args.flags.author, approverFlag);
|
|
5583
|
+
if (!sod.ok) {
|
|
5584
|
+
process.stderr.write(`agentic-security remediation accept-risk: ${sod.reason}\n`);
|
|
5585
|
+
return 1;
|
|
5586
|
+
}
|
|
5587
|
+
|
|
5588
|
+
const payload = {
|
|
5589
|
+
type: 'accepted_risk',
|
|
5590
|
+
at: new Date().toISOString(),
|
|
5591
|
+
itemId: idFlag,
|
|
5592
|
+
approver: approverFlag,
|
|
5593
|
+
reason: reasonFlag,
|
|
5594
|
+
scope: scopeFlag,
|
|
5595
|
+
expiration: expiresFlag,
|
|
5596
|
+
};
|
|
5597
|
+
return _remediationWrite(targetAbs, 'accept-risk', 'remediation_accept_risk', payload, args);
|
|
5598
|
+
}
|
|
5599
|
+
|
|
5600
|
+
// Byte-identical in behavior to _dfDiffMdInline/_dfDiffMdCell's own bodies
|
|
5601
|
+
// (bin/agentic-security.js), reimplemented locally per this codebase's
|
|
5602
|
+
// established per-module-owns-its-own-escaping-helpers convention.
|
|
5603
|
+
function _remMdInline(value) {
|
|
5604
|
+
return String(value).replace(/\r\n|\r|\n/g, ' ');
|
|
5605
|
+
}
|
|
5606
|
+
function _remMdCell(value) {
|
|
5607
|
+
return _remMdInline(value).replace(/\\/g, '\\\\').replace(/\|/g, '\\|');
|
|
5608
|
+
}
|
|
5609
|
+
|
|
5610
|
+
function _renderRemediationListMarkdown(items, integrity) {
|
|
5611
|
+
const lines = [];
|
|
5612
|
+
if (integrity && !integrity.ok) {
|
|
5613
|
+
lines.push(`> **WARNING — ledger integrity check failed**: the ledger's real content has ${integrity.totalLines} lines but only ${integrity.verifiedLines} verify; the list below may be missing recent items or reflect a tampered/truncated history. Do not treat it as complete.`);
|
|
5614
|
+
lines.push('');
|
|
5615
|
+
}
|
|
5616
|
+
if (items.length === 0) {
|
|
5617
|
+
lines.push('_no remediation items_');
|
|
5618
|
+
return lines.join('\n') + '\n';
|
|
5619
|
+
}
|
|
5620
|
+
lines.push('| id | state | owner | dueDate | recommendedControl |');
|
|
5621
|
+
lines.push('| --- | --- | --- | --- | --- |');
|
|
5622
|
+
for (const item of items) {
|
|
5623
|
+
const control = String(item.recommendedControl ?? '');
|
|
5624
|
+
const truncated = control.length > 60 ? control.slice(0, 60) + '…' : control;
|
|
5625
|
+
lines.push(`| ${_remMdCell(item.id)} | ${_remMdCell(item.state)} | ${_remMdCell(item.owner)} | ${_remMdCell(item.dueDate)} | ${_remMdCell(truncated)} |`);
|
|
5626
|
+
}
|
|
5627
|
+
return lines.join('\n') + '\n';
|
|
5628
|
+
}
|
|
5629
|
+
|
|
5630
|
+
// agentic-security remediation list [path] [--format json|markdown] [--output <file>]
|
|
5631
|
+
async function cmdRemediationList(args) {
|
|
5632
|
+
const target = args._[2] || '.'; // args._ = ['remediation', 'list', <path>?]
|
|
5633
|
+
const targetAbs = path.resolve(target);
|
|
5634
|
+
|
|
5635
|
+
const format = args.flags.format ?? 'json';
|
|
5636
|
+
if (format !== 'json' && format !== 'markdown') {
|
|
5637
|
+
process.stderr.write(`agentic-security remediation list: --format must be one of json|markdown (got ${JSON.stringify(format)}).\n`);
|
|
5638
|
+
return 2;
|
|
5639
|
+
}
|
|
5640
|
+
|
|
5641
|
+
const { readLedgerEvents, ledgerIntegrity } = await import('../src/posture/remediation-ledger.js');
|
|
5642
|
+
const { foldRemediationLedger } = await import('../src/lineage/remediation.js');
|
|
5643
|
+
const items = Object.values(foldRemediationLedger(readLedgerEvents(targetAbs)))
|
|
5644
|
+
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
5645
|
+
|
|
5646
|
+
// I7: a tampered/truncated ledger otherwise silently presents a
|
|
5647
|
+
// shorter-than-real history with no signal anywhere. `ledgerIntegrity`
|
|
5648
|
+
// is a read-only diagnostic — it never changes `items` itself (already
|
|
5649
|
+
// computed from the same longest-verifying-prefix `readLedgerEvents`
|
|
5650
|
+
// always returned) — it just surfaces the loud warning this command
|
|
5651
|
+
// previously had no way to give.
|
|
5652
|
+
const integrity = ledgerIntegrity(targetAbs);
|
|
5653
|
+
if (!integrity.ok) {
|
|
5654
|
+
process.stderr.write(`agentic-security remediation list: WARNING — the ledger's real content has ${integrity.totalLines} lines but only ${integrity.verifiedLines} verify; the list below may be missing recent items or reflect a tampered/truncated history. Do not treat it as complete.\n`);
|
|
5655
|
+
}
|
|
5656
|
+
|
|
5657
|
+
const data = format === 'json' ? JSON.stringify({ items, integrity }, null, 2) : _renderRemediationListMarkdown(items, integrity);
|
|
5658
|
+
const outputPath = args.flags.output;
|
|
5659
|
+
if (outputPath) {
|
|
5660
|
+
fs.writeFileSync(path.resolve(outputPath), data);
|
|
5661
|
+
} else {
|
|
5662
|
+
process.stdout.write(data.endsWith('\n') ? data : data + '\n');
|
|
5663
|
+
}
|
|
5664
|
+
return 0;
|
|
5665
|
+
}
|
|
5666
|
+
|
|
5667
|
+
// ── remediation verify / reopen-check — M5 deliverable #6, Task 4. The
|
|
5668
|
+
// AC-31-CRITICAL half: nothing in this sub-project satisfies AC-31 until
|
|
5669
|
+
// these two verbs land — they are the only callers of computeGraphDiff/
|
|
5670
|
+
// evaluateVerificationEvidence/drift-policy.js that actually gate the
|
|
5671
|
+
// remediation ledger's own `verified`/`reopened` transitions.
|
|
5672
|
+
|
|
5673
|
+
// agentic-security remediation verify [path] --id <itemId> [--against <commit>]
|
|
5674
|
+
// [--manual-attestation --approver <id> --reason <text> [--author <id>]]
|
|
5675
|
+
// [--base-event <hash>] [--output <file>] [--yes]
|
|
5676
|
+
//
|
|
5677
|
+
// Two branches:
|
|
5678
|
+
//
|
|
5679
|
+
// - `--manual-attestation`: after the same approver/separation-of-duties
|
|
5680
|
+
// gating `accept-risk` uses, builds a manual_attestation payload and
|
|
5681
|
+
// delegates straight to `_remediationWrite` — NEVER computes a diff.
|
|
5682
|
+
// The `manualAttestationPermitted` check is `validateTransition`'s job
|
|
5683
|
+
// (enforced inside `appendLedgerEvent`), not duplicated here — V/7
|
|
5684
|
+
// proves it fires through the ledger, not through this handler.
|
|
5685
|
+
//
|
|
5686
|
+
// - default (scan-verification): resolves the AFTER snapshot exactly as
|
|
5687
|
+
// `cmdDataflowDiff` does (`loadSnapshots(targetAbs)[0]`; `--against
|
|
5688
|
+
// <commit>` is a COMMIT KEY resolved via `loadSnapshot`, never a
|
|
5689
|
+
// snapshot id — same resolution `cmdDataflowDiff` uses). The BEFORE
|
|
5690
|
+
// snapshot is preferentially the item's own incident snapshot
|
|
5691
|
+
// (`item.assessment.snapshotId`, resolved by scanning `loadSnapshots`
|
|
5692
|
+
// for a record whose `.id` matches) — that is what makes AC-31's
|
|
5693
|
+
// "fixed to the incident snapshot" clause true in verification too.
|
|
5694
|
+
// When the incident snapshot is no longer on disk, falls back to the
|
|
5695
|
+
// `cmdDataflowDiff` resolution (`mostRecentPriorSnapshot`), and says so
|
|
5696
|
+
// (`beforeSnapshotSource`) rather than silently substituting a
|
|
5697
|
+
// different baseline. An explicit `--against` always wins over both.
|
|
5698
|
+
// `computeGraphDiff` is wrapped in a try/catch — it THROWS on an
|
|
5699
|
+
// incomparable pair (`graph-diff.js:319`) — converted to an
|
|
5700
|
+
// `incomparable_snapshots` unverifiable outcome, never an abort:
|
|
5701
|
+
// verification was genuinely attempted and genuinely refused, and the
|
|
5702
|
+
// ledger records that.
|
|
5703
|
+
//
|
|
5704
|
+
// A `snapshotsComparable`-passed pair means "same schemaVersion" and
|
|
5705
|
+
// nothing more (`graph-snapshot.js:173`) — two snapshots from genuinely
|
|
5706
|
+
// different analyzer configurations are reported comparable; this is a
|
|
5707
|
+
// disclosed limitation, not papered over (see commands/remediation.md).
|
|
5708
|
+
async function cmdRemediationVerify(args) {
|
|
5709
|
+
const target = args._[2] || '.'; // args._ = ['remediation', 'verify', <path>?]
|
|
5710
|
+
const targetAbs = path.resolve(target);
|
|
5711
|
+
|
|
5712
|
+
const idFlag = args.flags.id;
|
|
5713
|
+
if (!idFlag || typeof idFlag !== 'string') {
|
|
5714
|
+
process.stderr.write('agentic-security remediation verify: --id <itemId> is required.\n');
|
|
5715
|
+
return 2;
|
|
5716
|
+
}
|
|
5717
|
+
|
|
5718
|
+
const { readLedgerEvents } = await import('../src/posture/remediation-ledger.js');
|
|
5719
|
+
const { foldRemediationLedger, evaluateVerificationEvidence } = await import('../src/lineage/remediation.js');
|
|
5720
|
+
const items = foldRemediationLedger(readLedgerEvents(targetAbs));
|
|
5721
|
+
const item = items[idFlag];
|
|
5722
|
+
if (!item) {
|
|
5723
|
+
process.stderr.write(`agentic-security remediation verify: no remediation item found with id "${idFlag}".\n`);
|
|
5724
|
+
return 1;
|
|
5725
|
+
}
|
|
5726
|
+
|
|
5727
|
+
// ── Manual-attestation branch. Never computes a diff. ──────────────────
|
|
5728
|
+
if (args.flags['manual-attestation']) {
|
|
5729
|
+
const approverFlag = args.flags.approver;
|
|
5730
|
+
const reasonFlag = args.flags.reason;
|
|
5731
|
+
if (!approverFlag || typeof approverFlag !== 'string' || !reasonFlag || typeof reasonFlag !== 'string') {
|
|
5732
|
+
process.stderr.write('agentic-security remediation verify: --manual-attestation requires --approver <id> and --reason <text>.\n');
|
|
5733
|
+
return 2;
|
|
5734
|
+
}
|
|
5735
|
+
const { loadApproverRegistry, verifyApprover, checkSeparationOfDuties } =
|
|
5736
|
+
await import('../src/fix/approver-registry.js');
|
|
5737
|
+
const registry = loadApproverRegistry(targetAbs);
|
|
5738
|
+
const v = verifyApprover(registry, approverFlag, []);
|
|
5739
|
+
if (!v.verified) {
|
|
5740
|
+
process.stderr.write(`agentic-security remediation verify: ${v.reason}\n`);
|
|
5741
|
+
return 1;
|
|
5742
|
+
}
|
|
5743
|
+
const sod = checkSeparationOfDuties(registry, args.flags.author, approverFlag);
|
|
5744
|
+
if (!sod.ok) {
|
|
5745
|
+
process.stderr.write(`agentic-security remediation verify: ${sod.reason}\n`);
|
|
5746
|
+
return 1;
|
|
5747
|
+
}
|
|
5748
|
+
if (args.flags.against !== undefined) {
|
|
5749
|
+
// M15: --against has no effect on this branch — it never computes a
|
|
5750
|
+
// diff — but a caller supplying both should be told, not left to
|
|
5751
|
+
// wonder why it was silently ignored.
|
|
5752
|
+
process.stderr.write('agentic-security remediation verify: --against has no effect with --manual-attestation (no scan comparison is performed for a manual attestation).\n');
|
|
5753
|
+
}
|
|
5754
|
+
// Resolves the newest available snapshot (if any exist yet — a manual
|
|
5755
|
+
// attestation before any lineage scan has ever run is legitimate, so
|
|
5756
|
+
// this is never required) and records it as the item's new baseline
|
|
5757
|
+
// (final-review fix round 1, Blocking-3) — `foldRemediationItem`'s
|
|
5758
|
+
// `manual_attestation` case reads `ev.snapshotId` to set
|
|
5759
|
+
// `verificationSnapshotId`, which is what makes the attestation
|
|
5760
|
+
// survive the very next `reopen-check` instead of being immediately
|
|
5761
|
+
// undone by a stale old anchor.
|
|
5762
|
+
const attestationSnapshots = loadSnapshots(targetAbs);
|
|
5763
|
+
const payload = {
|
|
5764
|
+
type: 'manual_attestation',
|
|
5765
|
+
at: new Date().toISOString(),
|
|
5766
|
+
itemId: idFlag,
|
|
5767
|
+
approver: approverFlag,
|
|
5768
|
+
reason: reasonFlag,
|
|
5769
|
+
...(attestationSnapshots.length > 0 ? { snapshotId: attestationSnapshots[0].id } : {}),
|
|
5770
|
+
};
|
|
5771
|
+
return _remediationWrite(targetAbs, 'verify', 'remediation_verify', payload, args);
|
|
5772
|
+
}
|
|
5773
|
+
|
|
5774
|
+
// ── Scan-verification branch (the default). ─────────────────────────────
|
|
5775
|
+
const snapshots = loadSnapshots(targetAbs);
|
|
5776
|
+
if (snapshots.length === 0) {
|
|
5777
|
+
process.stderr.write(`agentic-security remediation verify: no persisted GraphSnapshot found for "${targetAbs}" — run a scan with AGENTIC_SECURITY_LINEAGE_DEEP=1 first.\n`);
|
|
5778
|
+
return 2;
|
|
5779
|
+
}
|
|
5780
|
+
const afterSnapshot = snapshots[0];
|
|
5781
|
+
|
|
5782
|
+
const againstFlag = args.flags.against;
|
|
5783
|
+
if (againstFlag !== undefined && (typeof againstFlag !== 'string' || !againstFlag)) {
|
|
5784
|
+
process.stderr.write('agentic-security remediation verify: --against requires a commit value.\n');
|
|
5785
|
+
return 2;
|
|
5786
|
+
}
|
|
5787
|
+
|
|
5788
|
+
let beforeSnapshot;
|
|
5789
|
+
let beforeSnapshotSource;
|
|
5790
|
+
if (againstFlag !== undefined) {
|
|
5791
|
+
beforeSnapshot = loadSnapshot(targetAbs, againstFlag);
|
|
5792
|
+
if (!beforeSnapshot) {
|
|
5793
|
+
process.stderr.write(`agentic-security remediation verify: no persisted GraphSnapshot found for commit "${againstFlag}" — pass a commit that was actually scanned.\n`);
|
|
5794
|
+
return 2;
|
|
5795
|
+
}
|
|
5796
|
+
beforeSnapshotSource = 'against';
|
|
5797
|
+
} else {
|
|
5798
|
+
const incidentSnapshotId = item.assessment?.snapshotId;
|
|
5799
|
+
const incidentSnapshot = incidentSnapshotId ? snapshots.find((s) => s.id === incidentSnapshotId) : null;
|
|
5800
|
+
if (incidentSnapshot) {
|
|
5801
|
+
beforeSnapshot = incidentSnapshot;
|
|
5802
|
+
beforeSnapshotSource = 'incident';
|
|
5803
|
+
} else {
|
|
5804
|
+
beforeSnapshot = mostRecentPriorSnapshot(targetAbs, afterSnapshot.commit);
|
|
5805
|
+
beforeSnapshotSource = 'most-recent-prior';
|
|
5806
|
+
}
|
|
5807
|
+
}
|
|
5808
|
+
|
|
5809
|
+
if (!beforeSnapshot || beforeSnapshot.commit === afterSnapshot.commit) {
|
|
5810
|
+
process.stderr.write(`agentic-security remediation verify: an item cannot be verified until a second lineage scan exists — only one persisted GraphSnapshot is available (commit "${afterSnapshot.commit}"). Scan again after a code change, or pass --against <commit>.\n`);
|
|
5811
|
+
return 2;
|
|
5812
|
+
}
|
|
5813
|
+
|
|
5814
|
+
let evidenceOutcome;
|
|
5815
|
+
if (afterSnapshot.capturedAt <= beforeSnapshot.capturedAt) {
|
|
5816
|
+
// A real, live-reproduced Blocking bug (final review, B1): a `verified`
|
|
5817
|
+
// outcome must never be granted by comparing backwards. `loadSnapshots`
|
|
5818
|
+
// sorts by file mtime (pre-existing, out of scope to fix here), so
|
|
5819
|
+
// `cp -R`/rsync/a CI cache restore/tar/Docker COPY can silently reorder
|
|
5820
|
+
// "history" — nothing else in this deliverable's own code checks
|
|
5821
|
+
// direction. `capturedAt` is an ISO-8601 string on every real
|
|
5822
|
+
// GraphSnapshot record, so plain string comparison is chronologically
|
|
5823
|
+
// correct here, no `Date` parsing needed.
|
|
5824
|
+
evidenceOutcome = {
|
|
5825
|
+
outcome: 'unverifiable',
|
|
5826
|
+
reason: 'stale_after_snapshot',
|
|
5827
|
+
beforeCapturedAt: beforeSnapshot.capturedAt,
|
|
5828
|
+
afterCapturedAt: afterSnapshot.capturedAt,
|
|
5829
|
+
};
|
|
5830
|
+
} else {
|
|
5831
|
+
const { computeGraphDiff } = await import('../src/lineage/graph-diff.js');
|
|
5832
|
+
try {
|
|
5833
|
+
const diff = computeGraphDiff(beforeSnapshot, afterSnapshot);
|
|
5834
|
+
evidenceOutcome = evaluateVerificationEvidence(diff, item.requiredEvidence);
|
|
5835
|
+
} catch (e) {
|
|
5836
|
+
// computeGraphDiff THROWS on an incomparable pair — this is NOT an
|
|
5837
|
+
// exit-2 abort: verification was genuinely attempted and genuinely
|
|
5838
|
+
// refused, and the ledger must record why.
|
|
5839
|
+
evidenceOutcome = { outcome: 'unverifiable', reason: 'incomparable_snapshots', detail: e && e.message ? e.message : String(e) };
|
|
5840
|
+
}
|
|
5841
|
+
}
|
|
5842
|
+
|
|
5843
|
+
const at = new Date().toISOString();
|
|
5844
|
+
let payload;
|
|
5845
|
+
if (evidenceOutcome.outcome === 'verified') {
|
|
5846
|
+
payload = { type: 'scan_verification', at, itemId: idFlag, outcome: 'verified', snapshotId: afterSnapshot.id };
|
|
5847
|
+
} else {
|
|
5848
|
+
// Carry every detail field through verbatim so the ledger records WHY,
|
|
5849
|
+
// not just THAT — unsatisfiedFlowIds/coverageRegressionReasons/
|
|
5850
|
+
// reidentifiedTo/flowId/detail are all conditionally present depending
|
|
5851
|
+
// on which evaluateVerificationEvidence branch fired.
|
|
5852
|
+
payload = { type: 'scan_verification', at, itemId: idFlag, outcome: 'unverifiable', reason: evidenceOutcome.reason };
|
|
5853
|
+
for (const field of ['unsatisfiedFlowIds', 'coverageRegressionReasons', 'reidentifiedTo', 'flowId', 'detail']) {
|
|
5854
|
+
if (evidenceOutcome[field] !== undefined) payload[field] = evidenceOutcome[field];
|
|
5855
|
+
}
|
|
5856
|
+
}
|
|
5857
|
+
|
|
5858
|
+
const extraReport = {
|
|
5859
|
+
beforeSnapshotId: beforeSnapshot.id,
|
|
5860
|
+
afterSnapshotId: afterSnapshot.id,
|
|
5861
|
+
beforeSnapshotSource,
|
|
5862
|
+
evidenceOutcome,
|
|
5863
|
+
};
|
|
5864
|
+
return _remediationWrite(targetAbs, 'verify', 'remediation_verify', payload, args, extraReport);
|
|
5865
|
+
}
|
|
5866
|
+
|
|
5867
|
+
// agentic-security remediation reopen-check [path] [--drift-policy <file>]
|
|
5868
|
+
// [--against <commit>] [--output <file>] [--yes]
|
|
5869
|
+
//
|
|
5870
|
+
// AC-31's automatic-reopening half. Resolves the AFTER snapshot exactly
|
|
5871
|
+
// like `cmdDataflowDiff`/`cmdRemediationVerify`; `--against <commit>`
|
|
5872
|
+
// pins a DEFAULT/FALLBACK before-commit (never used per-item directly —
|
|
5873
|
+
// see below). Every item currently in state `verified` is checked
|
|
5874
|
+
// against TWO independently-evaluated, separately-labelled mechanisms
|
|
5875
|
+
// (scoping doc §4.5) — never collapsed into one unlabelled "reopened"
|
|
5876
|
+
// reason:
|
|
5877
|
+
//
|
|
5878
|
+
// - mechanism 'drift-policy' (Mechanism A, regression via operator
|
|
5879
|
+
// policy): requires `--drift-policy`; a violation whose own `flowId`
|
|
5880
|
+
// names one of the item's own `affectedFlowIds` is a hit.
|
|
5881
|
+
// - mechanism 'affected-flow-diff' (Mechanism B, control went away, a
|
|
5882
|
+
// direct diff read): any of the item's `affectedFlowIds` (or a flow
|
|
5883
|
+
// `reidentifiedFrom` one of them, I6) REAPPEARING in `diff.added.flows`
|
|
5884
|
+
// is a hit — final-review fix round 1's own Blocking-2 correction: a
|
|
5885
|
+
// scan-verified item's flagged flow was, by construction, ABSENT from
|
|
5886
|
+
// its own before-baseline, so a regression can only ever show up in
|
|
5887
|
+
// `diff.added.flows`, never `diff.removed.flows`/`diff.changed.flows`
|
|
5888
|
+
// (the original code read those two buckets instead, which is dead
|
|
5889
|
+
// for the canonical scan-verified case and fires INVERTED — reopening
|
|
5890
|
+
// an item because its flow successfully disappeared — for a manually-
|
|
5891
|
+
// attested item whose baseline still contained the flow). This exists
|
|
5892
|
+
// because `drift-policy.js`'s trigger vocabulary is exactly
|
|
5893
|
+
// `['new_flow', 'changed_flow']` — there is no `removed_flow` trigger,
|
|
5894
|
+
// so a control that goes away (the flow itself disappears from a
|
|
5895
|
+
// later scan) cannot be expressed as a drift policy at ALL.
|
|
5896
|
+
//
|
|
5897
|
+
// Disclosed limitation, not attempted in this round: a manually-
|
|
5898
|
+
// attested item whose flagged flow was ALREADY present at attestation
|
|
5899
|
+
// time (the typical manual-attestation scenario — a compensating
|
|
5900
|
+
// control, not flow removal) can never trigger Mechanism B this way,
|
|
5901
|
+
// since the flow never left `diff.added.flows`'s scope (it's already
|
|
5902
|
+
// present in both before/after). Such an item can only be reopened via
|
|
5903
|
+
// Mechanism A (`--drift-policy`).
|
|
5904
|
+
//
|
|
5905
|
+
// Per item, the BEFORE snapshot is preferentially the item's own
|
|
5906
|
+
// `verificationSnapshotId` (the snapshot its verification was granted
|
|
5907
|
+
// against — the only defensible "has it regressed since" baseline),
|
|
5908
|
+
// falling back to the CLI-resolved default BEFORE (`--against` /
|
|
5909
|
+
// `mostRecentPriorSnapshot`) when that snapshot is no longer on disk.
|
|
5910
|
+
//
|
|
5911
|
+
// No `--base-event`: this appends N events across N items, so a single
|
|
5912
|
+
// whole-ledger token has no coherent meaning (documented in
|
|
5913
|
+
// commands/remediation.md, not left for a reader to wonder about).
|
|
5914
|
+
async function cmdRemediationReopenCheck(args) {
|
|
5915
|
+
const target = args._[2] || '.'; // args._ = ['remediation', 'reopen-check', <path>?]
|
|
5916
|
+
const targetAbs = path.resolve(target);
|
|
5917
|
+
|
|
5918
|
+
const snapshots = loadSnapshots(targetAbs);
|
|
5919
|
+
if (snapshots.length === 0) {
|
|
5920
|
+
process.stderr.write(`agentic-security remediation reopen-check: no persisted GraphSnapshot found for "${targetAbs}" — run a scan with AGENTIC_SECURITY_LINEAGE_DEEP=1 first.\n`);
|
|
5921
|
+
return 2;
|
|
5922
|
+
}
|
|
5923
|
+
const afterSnapshot = snapshots[0];
|
|
5924
|
+
|
|
5925
|
+
const againstFlag = args.flags.against;
|
|
5926
|
+
if (againstFlag !== undefined && (typeof againstFlag !== 'string' || !againstFlag)) {
|
|
5927
|
+
process.stderr.write('agentic-security remediation reopen-check: --against requires a commit value.\n');
|
|
5928
|
+
return 2;
|
|
5929
|
+
}
|
|
5930
|
+
|
|
5931
|
+
let defaultBeforeSnapshot;
|
|
5932
|
+
if (againstFlag !== undefined) {
|
|
5933
|
+
defaultBeforeSnapshot = loadSnapshot(targetAbs, againstFlag);
|
|
5934
|
+
if (!defaultBeforeSnapshot) {
|
|
5935
|
+
process.stderr.write(`agentic-security remediation reopen-check: no persisted GraphSnapshot found for commit "${againstFlag}" — pass a commit that was actually scanned.\n`);
|
|
5936
|
+
return 2;
|
|
5937
|
+
}
|
|
5938
|
+
} else {
|
|
5939
|
+
defaultBeforeSnapshot = mostRecentPriorSnapshot(targetAbs, afterSnapshot.commit);
|
|
5940
|
+
if (!defaultBeforeSnapshot) {
|
|
5941
|
+
process.stderr.write(`agentic-security remediation reopen-check: only one persisted GraphSnapshot exists (commit "${afterSnapshot.commit}") — nothing to compare against yet. Scan again after a code change, or pass --against <commit>.\n`);
|
|
5942
|
+
return 2;
|
|
5943
|
+
}
|
|
5944
|
+
}
|
|
5945
|
+
|
|
5946
|
+
const driftPolicyFlag = args.flags['drift-policy'];
|
|
5947
|
+
const driftPolicyProvided = driftPolicyFlag !== undefined;
|
|
5948
|
+
let policies = { policies: [] };
|
|
5949
|
+
if (driftPolicyProvided) {
|
|
5950
|
+
if (typeof driftPolicyFlag !== 'string' || !driftPolicyFlag) {
|
|
5951
|
+
process.stderr.write('agentic-security remediation reopen-check: --drift-policy requires a file path.\n');
|
|
5952
|
+
return 2;
|
|
5953
|
+
}
|
|
5954
|
+
const driftPolicyAbs = path.resolve(driftPolicyFlag);
|
|
5955
|
+
const check = _validateDriftPolicyFile(driftPolicyAbs, driftPolicyFlag, 'reopen-check');
|
|
5956
|
+
if (!check.ok) {
|
|
5957
|
+
process.stderr.write(check.message);
|
|
5958
|
+
return 2;
|
|
5959
|
+
}
|
|
5960
|
+
const { loadDriftPolicies } = await import('../src/lineage/drift-policy.js');
|
|
5961
|
+
policies = loadDriftPolicies(driftPolicyAbs);
|
|
5962
|
+
}
|
|
5963
|
+
|
|
5964
|
+
const { readLedgerEvents, appendLedgerEvent } = await import('../src/posture/remediation-ledger.js');
|
|
5965
|
+
const { foldRemediationLedger } = await import('../src/lineage/remediation.js');
|
|
5966
|
+
const { computeGraphDiff } = await import('../src/lineage/graph-diff.js');
|
|
5967
|
+
let evaluateDriftPolicies = null;
|
|
5968
|
+
if (driftPolicyProvided) {
|
|
5969
|
+
({ evaluateDriftPolicies } = await import('../src/lineage/drift-policy.js'));
|
|
5970
|
+
}
|
|
5971
|
+
|
|
5972
|
+
const verifiedItems = Object.values(foldRemediationLedger(readLedgerEvents(targetAbs)))
|
|
5973
|
+
.filter((it) => it.state === 'verified')
|
|
5974
|
+
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
5975
|
+
|
|
5976
|
+
const findings = []; // {itemId, mechanism, reason, beforeSnapshotSource}
|
|
5977
|
+
const skipped = []; // {itemId, reason}
|
|
5978
|
+
|
|
5979
|
+
for (const item of verifiedItems) {
|
|
5980
|
+
let beforeSnapshot;
|
|
5981
|
+
let beforeSnapshotSource;
|
|
5982
|
+
if (item.verificationSnapshotId) {
|
|
5983
|
+
const found = snapshots.find((s) => s.id === item.verificationSnapshotId);
|
|
5984
|
+
if (found) { beforeSnapshot = found; beforeSnapshotSource = 'verification'; }
|
|
5985
|
+
}
|
|
5986
|
+
if (!beforeSnapshot) { beforeSnapshot = defaultBeforeSnapshot; beforeSnapshotSource = 'default'; }
|
|
5987
|
+
|
|
5988
|
+
if (!beforeSnapshot || beforeSnapshot.commit === afterSnapshot.commit) {
|
|
5989
|
+
skipped.push({ itemId: item.id, reason: 'no distinct before-snapshot could be resolved for this item' });
|
|
5990
|
+
continue;
|
|
5991
|
+
}
|
|
5992
|
+
|
|
5993
|
+
let diff;
|
|
5994
|
+
try {
|
|
5995
|
+
diff = computeGraphDiff(beforeSnapshot, afterSnapshot);
|
|
5996
|
+
} catch (e) {
|
|
5997
|
+
// Never a crash and never a silent pass — a diff-error item is
|
|
5998
|
+
// recorded as skipped, with its real reason.
|
|
5999
|
+
skipped.push({ itemId: item.id, reason: `incomparable snapshots: ${e && e.message ? e.message : e}` });
|
|
6000
|
+
continue;
|
|
6001
|
+
}
|
|
6002
|
+
|
|
6003
|
+
const affectedFlowIds = new Set(item.affectedFlowIds ?? []);
|
|
6004
|
+
let hit = null;
|
|
6005
|
+
|
|
6006
|
+
// Mechanism A: drift-policy regression.
|
|
6007
|
+
if (driftPolicyProvided) {
|
|
6008
|
+
const { violations } = evaluateDriftPolicies(diff, policies, afterSnapshot.graph);
|
|
6009
|
+
const violation = violations.find((v) => affectedFlowIds.has(v.flowId));
|
|
6010
|
+
if (violation) {
|
|
6011
|
+
hit = { mechanism: 'drift-policy', reason: `drift-policy rule (${violation.trigger}) matched flow ${violation.flowId}: ${violation.reason}` };
|
|
6012
|
+
}
|
|
6013
|
+
}
|
|
6014
|
+
|
|
6015
|
+
// Mechanism B: the control this item established went away — the
|
|
6016
|
+
// flagged flow REAPPEARED relative to the item's own baseline. This is
|
|
6017
|
+
// the symmetric complement of evaluateVerificationEvidence's own
|
|
6018
|
+
// "gone via diff.removed.flows" check: a regression is the flow coming
|
|
6019
|
+
// BACK, which shows up in diff.added.flows, never diff.removed/changed
|
|
6020
|
+
// (a flow disappearing is a FIX, never a regression — the original
|
|
6021
|
+
// code fired on exactly the wrong signal here, a real Blocking bug
|
|
6022
|
+
// found by the final review, live-reproduced against a successfully
|
|
6023
|
+
// remediated item). Also matches a re-identified reappearance (I6): a
|
|
6024
|
+
// flow whose own discriminator moved on the way back still counts, via
|
|
6025
|
+
// reidentifiedFrom.
|
|
6026
|
+
if (!hit) {
|
|
6027
|
+
const addedHit = (diff.added?.flows ?? []).find((e) =>
|
|
6028
|
+
affectedFlowIds.has(e.id) ||
|
|
6029
|
+
(e.causeClassification === 'reidentified' && affectedFlowIds.has(e.reidentifiedFrom))
|
|
6030
|
+
);
|
|
6031
|
+
if (addedHit) {
|
|
6032
|
+
hit = { mechanism: 'affected-flow-diff', reason: `affected flow reappeared as ${addedHit.id} in diff.added.flows (causeClassification: ${addedHit.causeClassification}${addedHit.reidentifiedFrom ? `, reidentified from ${addedHit.reidentifiedFrom}` : ''})` };
|
|
6033
|
+
}
|
|
6034
|
+
}
|
|
6035
|
+
|
|
6036
|
+
if (hit) {
|
|
6037
|
+
findings.push({ itemId: item.id, mechanism: hit.mechanism, reason: hit.reason, beforeSnapshotSource });
|
|
6038
|
+
}
|
|
6039
|
+
}
|
|
6040
|
+
|
|
6041
|
+
const yes = !!args.flags.yes;
|
|
6042
|
+
|
|
6043
|
+
if (!yes) {
|
|
6044
|
+
const report = {
|
|
6045
|
+
verb: 'reopen-check', afterSnapshotId: afterSnapshot.id, driftPolicyProvided,
|
|
6046
|
+
written: false, wouldReopen: findings, skipped,
|
|
6047
|
+
};
|
|
6048
|
+
_emitRemediationReport(report, args);
|
|
6049
|
+
return 0;
|
|
6050
|
+
}
|
|
6051
|
+
|
|
6052
|
+
if (findings.length > 0) {
|
|
6053
|
+
const { isSafeStateDir } = await import('../src/posture/state-dir.js');
|
|
6054
|
+
const { ledgerPaths } = await import('../src/posture/remediation-ledger.js');
|
|
6055
|
+
const { ledgerPath } = ledgerPaths(targetAbs);
|
|
6056
|
+
if (!isSafeStateDir(path.dirname(ledgerPath))) {
|
|
6057
|
+
process.stderr.write(`agentic-security remediation reopen-check: refusing to write — "${targetAbs}" does not look like a project directory.\n`);
|
|
6058
|
+
return 2;
|
|
6059
|
+
}
|
|
6060
|
+
}
|
|
6061
|
+
|
|
6062
|
+
const reopened = [];
|
|
6063
|
+
const failedToReopen = [];
|
|
6064
|
+
try {
|
|
6065
|
+
for (const f of findings) {
|
|
6066
|
+
const payload = { type: 'reopened', at: new Date().toISOString(), itemId: f.itemId, reason: f.reason };
|
|
6067
|
+
// Each event is independently validated by appendLedgerEvent's own
|
|
6068
|
+
// (sole-authoritative) validateTransition call — an item that raced
|
|
6069
|
+
// into a non-`verified` state between the fold above and this append
|
|
6070
|
+
// is correctly rejected here and reported as skipped, never
|
|
6071
|
+
// force-written.
|
|
6072
|
+
const result = await appendLedgerEvent(targetAbs, payload);
|
|
6073
|
+
if (result.valid) {
|
|
6074
|
+
reopened.push({ itemId: f.itemId, mechanism: f.mechanism, reason: f.reason, beforeSnapshotSource: f.beforeSnapshotSource, eventHash: result.hash });
|
|
6075
|
+
} else {
|
|
6076
|
+
failedToReopen.push({ itemId: f.itemId, errors: result.errors });
|
|
6077
|
+
}
|
|
6078
|
+
}
|
|
6079
|
+
} catch (e) {
|
|
6080
|
+
process.stderr.write(`agentic-security remediation reopen-check: unexpected error writing the ledger: ${e && e.message ? e.message : e}\n`);
|
|
6081
|
+
return 4;
|
|
6082
|
+
}
|
|
6083
|
+
|
|
6084
|
+
if (reopened.length > 0) {
|
|
6085
|
+
const { auditCall } = await import('../src/mcp/audit.js');
|
|
6086
|
+
auditCall({
|
|
6087
|
+
sessionRoot: targetAbs,
|
|
6088
|
+
tool: 'remediation_reopen_check',
|
|
6089
|
+
args: { itemIds: reopened.map((r) => r.itemId) },
|
|
6090
|
+
outcome: 'ok',
|
|
6091
|
+
});
|
|
6092
|
+
}
|
|
6093
|
+
|
|
6094
|
+
const report = {
|
|
6095
|
+
verb: 'reopen-check', afterSnapshotId: afterSnapshot.id, driftPolicyProvided,
|
|
6096
|
+
written: reopened.length > 0, reopened, failedToReopen, skipped,
|
|
6097
|
+
};
|
|
6098
|
+
_emitRemediationReport(report, args);
|
|
6099
|
+
return 0;
|
|
6100
|
+
}
|
|
6101
|
+
|
|
6102
|
+
// Terse [watch-dataflow] status line — mirrors watch-mode.js's own
|
|
6103
|
+
// renderStatusLine terse style, adapted to a GraphDiff's own
|
|
6104
|
+
// added/removed/changed shape (computeGraphDiff's `changed` bucket is
|
|
6105
|
+
// flows-only, per graph-diff.js's own header).
|
|
6106
|
+
function _renderDataflowWatchStatusLine(diff) {
|
|
6107
|
+
const n = diff.added.nodes.length, N = diff.removed.nodes.length;
|
|
6108
|
+
const e = diff.added.edges.length, E = diff.removed.edges.length;
|
|
6109
|
+
const d = diff.added.dataElements.length, D = diff.removed.dataElements.length;
|
|
6110
|
+
const f = diff.added.flows.length, F = diff.removed.flows.length, C = diff.changed.flows.length;
|
|
6111
|
+
if (n + N + e + E + d + D + f + F + C === 0) return 'no data-flow graph changes';
|
|
6112
|
+
return `+${f}/-${F} flows (~${C} changed), +${n}/-${N} nodes, +${e}/-${E} edges, +${d}/-${D} data elements`;
|
|
6113
|
+
}
|
|
6114
|
+
|
|
6115
|
+
// A louder, clearly-marked, Markdown-free plain-text block naming each
|
|
6116
|
+
// drift-policy violation — a model of _renderDataflowDiffMarkdown's own
|
|
6117
|
+
// violation-table CONTENT (trigger/flow/data-elements/sink/reason), never
|
|
6118
|
+
// its literal Markdown-table code (this command's whole output is a live
|
|
6119
|
+
// stderr stream, not a file). --fail-on-drift has no exit-code effect in a
|
|
6120
|
+
// long-running watch process (there is no single exit code to gate — see
|
|
6121
|
+
// this command's own startup banner / commands/dataflow.md) — it only
|
|
6122
|
+
// changes how loud this block reads.
|
|
6123
|
+
function _renderDataflowWatchViolations(violations, failOnDrift) {
|
|
6124
|
+
const marker = failOnDrift
|
|
6125
|
+
? '[watch-dataflow] !!! DRIFT POLICY VIOLATION !!!'
|
|
6126
|
+
: '[watch-dataflow] *** drift policy violation ***';
|
|
6127
|
+
const lines = [];
|
|
6128
|
+
for (const v of violations) {
|
|
6129
|
+
lines.push(marker);
|
|
6130
|
+
lines.push(`[watch-dataflow] ${v.trigger}: flow ${v.flowId} — ${(v.dataElementNames ?? []).join(', ') || '(unclassified data)'} -> ${v.sinkCategory ?? v.sinkNodeId ?? 'unknown sink'}`);
|
|
6131
|
+
lines.push(`[watch-dataflow] ${v.reason}`);
|
|
6132
|
+
}
|
|
6133
|
+
return lines.length ? lines.join('\n') + '\n' : '';
|
|
6134
|
+
}
|
|
6135
|
+
|
|
6136
|
+
// #9 (M4 deliverable #9, watch-mode graph delta updates) — re-runs a deep
|
|
6137
|
+
// lineage scan on every debounced file-system change and reports the real
|
|
6138
|
+
// GraphDiff, reusing already-shipped Task 1 (buildGraphSnapshot)/8b
|
|
6139
|
+
// (computeGraphDiff/drift-policy) primitives. Mirrors cmdScan's own
|
|
6140
|
+
// `--watch` blocking/Ctrl-C/stderr-status-line UX (bin/agentic-security.js,
|
|
6141
|
+
// `args.flags['watch']`) — see that block's own header comment for the
|
|
6142
|
+
// shared "blocks until Ctrl-C" contract.
|
|
6143
|
+
//
|
|
6144
|
+
// Two disclosed, deliberate scope boundaries (docs/superpowers/plans/
|
|
6145
|
+
// 2026-09-02-data-flow-explorer-m4-watch-mode-scoping.md §2/§3), both named
|
|
6146
|
+
// in this command's own startup banner, not just here:
|
|
6147
|
+
// - NEVER calls persistGraphSnapshot — only buildGraphSnapshot (Task 1's
|
|
6148
|
+
// own pure, zero-disk-I/O builder). Every rescan before a real `git
|
|
6149
|
+
// commit` resolves to the same HEAD, so persisting on every debounced
|
|
6150
|
+
// rescan would silently overwrite real, commit-keyed snapshot history
|
|
6151
|
+
// with transient, mid-edit graph state. The "before" snapshot lives
|
|
6152
|
+
// purely in the closure variable `prevSnapshot` below, exactly like
|
|
6153
|
+
// the existing SAST `--watch`'s own `prevFindings` closure variable.
|
|
6154
|
+
// - Does NOT refresh .agentic-security/lineage-graph.json on any
|
|
6155
|
+
// rescan — an already-running `explore` session has no live-reload
|
|
6156
|
+
// (confirmed directly against src/server/*.js) and would not reflect
|
|
6157
|
+
// these edits regardless; disclosed rather than implied away.
|
|
6158
|
+
async function cmdDataflowWatch(args) {
|
|
6159
|
+
const target = args._[2] || '.';
|
|
6160
|
+
const targetAbs = path.resolve(target);
|
|
6161
|
+
|
|
6162
|
+
const driftPolicyFlag = args.flags['drift-policy'];
|
|
6163
|
+
if (driftPolicyFlag !== undefined && (typeof driftPolicyFlag !== 'string' || !driftPolicyFlag)) {
|
|
6164
|
+
process.stderr.write('agentic-security dataflow watch: --drift-policy requires a file path.\n');
|
|
6165
|
+
return 2;
|
|
6166
|
+
}
|
|
6167
|
+
const failOnDrift = !!args.flags['fail-on-drift'];
|
|
6168
|
+
const driftPolicyProvided = driftPolicyFlag !== undefined;
|
|
6169
|
+
|
|
6170
|
+
let policies = null;
|
|
6171
|
+
let evaluateDriftPolicies = null;
|
|
6172
|
+
if (driftPolicyProvided) {
|
|
6173
|
+
const driftPolicyAbs = path.resolve(driftPolicyFlag);
|
|
6174
|
+
const check = _validateDriftPolicyFile(driftPolicyAbs, driftPolicyFlag, 'watch');
|
|
6175
|
+
if (!check.ok) {
|
|
6176
|
+
process.stderr.write(check.message);
|
|
6177
|
+
return 2;
|
|
6178
|
+
}
|
|
6179
|
+
const driftPolicyModule = await import('../src/lineage/drift-policy.js');
|
|
6180
|
+
policies = driftPolicyModule.loadDriftPolicies(driftPolicyAbs);
|
|
6181
|
+
evaluateDriftPolicies = driftPolicyModule.evaluateDriftPolicies;
|
|
6182
|
+
}
|
|
6183
|
+
|
|
6184
|
+
const { watchProject } = await import('../src/posture/watch-mode.js');
|
|
6185
|
+
const { buildGraphSnapshot } = await import('../src/lineage/graph-snapshot.js');
|
|
6186
|
+
const { computeGraphDiff } = await import('../src/lineage/graph-diff.js');
|
|
6187
|
+
|
|
6188
|
+
// This is what makes runScan attach scan.lineageGraph at all — without
|
|
6189
|
+
// it, every rescan (including the seed) produces no graph and this
|
|
6190
|
+
// command has nothing to diff. Mirrors cmdScan's own `--watch` branch
|
|
6191
|
+
// setting AGENTIC_SECURITY_INCREMENTAL before its own seed scan.
|
|
6192
|
+
process.env.AGENTIC_SECURITY_LINEAGE_DEEP = '1';
|
|
6193
|
+
|
|
6194
|
+
// Deliberately printed BEFORE the (possibly multi-second) seed scan —
|
|
6195
|
+
// real-time feedback that the command is alive, not stuck — but this is
|
|
6196
|
+
// NOT the "the watcher is live" signal; see the banner printed below,
|
|
6197
|
+
// right before `watchProject` is called, for that.
|
|
6198
|
+
process.stderr.write(`[watch-dataflow] running seed scan for ${targetAbs}...\n`);
|
|
6199
|
+
|
|
6200
|
+
const _lineageStatusReason = (ls) => (ls && ls.failure) ? `failure: ${ls.failure}` : (ls && ls.reason) ? `reason: ${ls.reason}` : 'unknown reason';
|
|
6201
|
+
|
|
6202
|
+
// withStateWritesDisabled, for the same reason lsp/server.js's on-save scan
|
|
6203
|
+
// and mcp/tools.js's scan_diff (FR-704) wrap their own repeated runScan
|
|
6204
|
+
// calls. This command reruns a full scan on every debounced file edit for
|
|
6205
|
+
// as long as the watch session stays open, against the user's real project
|
|
6206
|
+
// root, with state writes fully enabled by default — without the wrapper,
|
|
6207
|
+
// every single-edit rescan writes ~11 real files into .agentic-security/
|
|
6208
|
+
// (dpia.md, ropa.md, threat-model.*, privacy-framework.*, data-inventory.json,
|
|
6209
|
+
// sbom-history/, ...), appends to scan-history.json, and — the genuinely
|
|
6210
|
+
// dangerous part — feeds posture/provenance/lifecycle.js's introduce/
|
|
6211
|
+
// remediate/reintroduce ledger. A real, transient mid-edit change (e.g. a
|
|
6212
|
+
// cut-and-paste during a refactor) would get recorded as a FABRICATED
|
|
6213
|
+
// `remediated` event (commit: null, authorDate: null), later undone by a
|
|
6214
|
+
// fabricated `reintroduced` event — permanently corrupting the one
|
|
6215
|
+
// artifact that backs finding provenance, MTTR reporting, and signed
|
|
6216
|
+
// evidence bundles, for a "fix" that never happened. exceptCategories:
|
|
6217
|
+
// ['provenance-cache'] mirrors lsp/server.js's own exact precedent: it
|
|
6218
|
+
// keeps ONLY the provenance disk cache live across repeated rescans in one
|
|
6219
|
+
// watch session (so a slowly-changing project doesn't pay the full
|
|
6220
|
+
// uncached provenance-resolution cost on every edit), while every OTHER
|
|
6221
|
+
// state write named above stays fully suppressed. Do not "simplify" this
|
|
6222
|
+
// back to a bare runScan call.
|
|
6223
|
+
const seed = await withStateWritesDisabled(() => runScan(targetAbs, {}), { exceptCategories: ['provenance-cache'] });
|
|
6224
|
+
if (!seed.scan.lineageGraph) {
|
|
6225
|
+
process.stderr.write(`agentic-security dataflow watch: seed scan produced no data-flow graph (${_lineageStatusReason(seed.scan.lineageStatus)}) — nothing to watch/diff against.\n`);
|
|
6226
|
+
return 1;
|
|
6227
|
+
}
|
|
6228
|
+
let prevSnapshot = buildGraphSnapshot(seed.scan.lineageGraph, targetAbs);
|
|
6229
|
+
|
|
6230
|
+
// watchProject itself checks process.env.AGENTIC_SECURITY_NO_WATCH === '1'
|
|
6231
|
+
// internally and returns immediately without ever subscribing to fs.watch
|
|
6232
|
+
// — so if we printed the "watching..." banner unconditionally below, a
|
|
6233
|
+
// caller with the env var set would see "watching..." immediately followed
|
|
6234
|
+
// by the process exiting, having watched nothing. Check the exact same
|
|
6235
|
+
// condition watchProject checks (do not invent a different one that could
|
|
6236
|
+
// drift from it) and print an honest message instead.
|
|
6237
|
+
if (process.env.AGENTIC_SECURITY_NO_WATCH === '1') {
|
|
6238
|
+
process.stderr.write(`[watch-dataflow] AGENTIC_SECURITY_NO_WATCH=1 — watch mode is disabled; ran the seed scan only, not watching ${targetAbs} for changes.\n`);
|
|
6239
|
+
return 0;
|
|
6240
|
+
}
|
|
6241
|
+
|
|
6242
|
+
// Printed AFTER the seed scan succeeds, immediately before `watchProject`
|
|
6243
|
+
// subscribes — this is the real "the watcher is live" signal a caller
|
|
6244
|
+
// (this command's own CLI test included) can poll stderr for, unlike the
|
|
6245
|
+
// pre-seed-scan message above.
|
|
6246
|
+
process.stderr.write(`[watch-dataflow] watching ${targetAbs} for data-flow graph changes on file change — Ctrl-C to stop.\n`);
|
|
6247
|
+
process.stderr.write('[watch-dataflow] does NOT refresh .agentic-security/lineage-graph.json on any rescan — an already-running `agentic-security explore` session will not reflect these edits live.\n');
|
|
6248
|
+
|
|
6249
|
+
// No in-flight-rescan guard: if a debounced batch fires while a previous
|
|
6250
|
+
// rescan is still running, two concurrent invocations of this callback
|
|
6251
|
+
// can race on `prevSnapshot` (the closure variable below), producing a
|
|
6252
|
+
// diff against a stale baseline or clobbering it out of order on a large
|
|
6253
|
+
// enough project. Not fixed here — watchProject's own design has no
|
|
6254
|
+
// such guard, and the pre-existing `scan --watch` (this file's own
|
|
6255
|
+
// args.flags['watch'] branch) has the identical unguarded shape on its
|
|
6256
|
+
// own `prevFindings` variable; adding one here alone would diverge from
|
|
6257
|
+
// that established UX for no consistent reason. A future increment
|
|
6258
|
+
// could add a real mutex/generation-counter to BOTH commands together.
|
|
6259
|
+
await watchProject(targetAbs, async (batch, watchErr) => {
|
|
6260
|
+
if (watchErr) {
|
|
6261
|
+
process.stderr.write(`[watch-dataflow] file watcher error: ${watchErr && watchErr.message ? watchErr.message : watchErr} — no longer watching for changes.\n`);
|
|
6262
|
+
return;
|
|
6263
|
+
}
|
|
6264
|
+
try {
|
|
6265
|
+
const { scan } = await withStateWritesDisabled(() => runScan(targetAbs, {}), { exceptCategories: ['provenance-cache'] });
|
|
6266
|
+
if (!scan.lineageGraph) {
|
|
6267
|
+
process.stderr.write(`[watch-dataflow] rescan produced no data-flow graph (${_lineageStatusReason(scan.lineageStatus)}) — skipping this change.\n`);
|
|
6268
|
+
return;
|
|
6269
|
+
}
|
|
6270
|
+
const currSnapshot = buildGraphSnapshot(scan.lineageGraph, targetAbs);
|
|
6271
|
+
|
|
6272
|
+
let diff;
|
|
6273
|
+
try {
|
|
6274
|
+
diff = computeGraphDiff(prevSnapshot, currSnapshot);
|
|
6275
|
+
} catch (e) {
|
|
6276
|
+
process.stderr.write(`[watch-dataflow] could not diff: ${e && e.message ? e.message : e}\n`);
|
|
6277
|
+
return;
|
|
6278
|
+
}
|
|
6279
|
+
|
|
6280
|
+
let violations = [];
|
|
6281
|
+
if (driftPolicyProvided) {
|
|
6282
|
+
violations = evaluateDriftPolicies(diff, policies, currSnapshot.graph).violations;
|
|
6283
|
+
}
|
|
6284
|
+
|
|
6285
|
+
process.stderr.write('[watch-dataflow] ' + _renderDataflowWatchStatusLine(diff) + '\n');
|
|
6286
|
+
if (violations.length) {
|
|
6287
|
+
process.stderr.write(_renderDataflowWatchViolations(violations, failOnDrift));
|
|
6288
|
+
}
|
|
6289
|
+
|
|
6290
|
+
prevSnapshot = currSnapshot;
|
|
6291
|
+
} catch (e) {
|
|
6292
|
+
process.stderr.write(`[watch-dataflow] rescan failed: ${e && e.message ? e.message : e}\n`);
|
|
6293
|
+
}
|
|
6294
|
+
});
|
|
6295
|
+
|
|
6296
|
+
return 0; // watchProject itself does not block (see the dispatch site's
|
|
6297
|
+
// own comment, case 'dataflow' -> the `watch` branch, for the full
|
|
6298
|
+
// explanation) — the process stays alive on the live fs.watch handle
|
|
6299
|
+
// watchProject subscribes internally, exiting only via Ctrl-C/SIGTERM.
|
|
6300
|
+
// Do NOT "simplify" the dispatch call around this function back to
|
|
6301
|
+
// `process.exit(await cmdDataflowWatch(args))` — that is the exact
|
|
6302
|
+
// change that makes `scan --watch` exit before it ever watches anything.
|
|
6303
|
+
}
|
|
6304
|
+
|
|
6305
|
+
async function main() {
|
|
6306
|
+
const args = parseArgs(process.argv.slice(2));
|
|
6307
|
+
const cmd = args._[0];
|
|
6308
|
+
try {
|
|
6309
|
+
switch (cmd) {
|
|
6310
|
+
case 'scan': process.exit(await cmdScan(args));
|
|
6311
|
+
case 'ship': process.exit(await cmdShip(args));
|
|
6312
|
+
case 'ci': process.exit(await cmdCi(args));
|
|
6313
|
+
case 'fix': process.exit(await cmdFix(args));
|
|
6314
|
+
case 'undo': process.exit(await cmdUndo(args));
|
|
6315
|
+
case 'accept': process.exit(await cmdAccept(args));
|
|
6316
|
+
case 'profile': process.exit(await cmdProfile(args));
|
|
6317
|
+
case 'triage': process.exit(await cmdTriage(args));
|
|
6318
|
+
case 'org-scan': process.exit(await cmdOrgScan(args));
|
|
6319
|
+
case 'rules': process.exit(await cmdRules(args));
|
|
6320
|
+
case 'rule': process.exit(await cmdRule(args));
|
|
6321
|
+
case 'tickets': process.exit(await cmdTickets(args));
|
|
6322
|
+
case 'secure': process.exit(await cmdSecure(args));
|
|
6323
|
+
case 'packs': process.exit(await cmdPacks(args));
|
|
6324
|
+
case 'validator-cache': process.exit(await cmdValidatorCache(args));
|
|
6325
|
+
case 'verify': process.exit(await cmdVerify(args));
|
|
6326
|
+
case 'reset': process.exit(await cmdReset(args));
|
|
6327
|
+
case 'export': process.exit(await cmdExport(args));
|
|
6328
|
+
case 'legal-hold': process.exit(await cmdLegalHold(args));
|
|
6329
|
+
case 'calibration-feedback': process.exit(await cmdCalibrationFeedback(args));
|
|
6330
|
+
case 'calibration-report': process.exit(await cmdCalibrationReport(args));
|
|
6331
|
+
case 'hunt': process.exit(await cmdHunt(args));
|
|
6332
|
+
case 'compliance': process.exit(await cmdCompliance(args));
|
|
6333
|
+
case 'attest': process.exit(await cmdAttest(args));
|
|
6334
|
+
case 'verify-attestation': process.exit(await cmdVerifyAttestation(args));
|
|
6335
|
+
case 'policy-explain': process.exit(await cmdPolicyExplain(args));
|
|
6336
|
+
case 'policy-sign': process.exit(await cmdPolicySign(args));
|
|
6337
|
+
case 'rule-synth': process.exit(await cmdRuleSynth(args));
|
|
6338
|
+
case 'digest': process.exit(await cmdDigest(args));
|
|
6339
|
+
case 'setup': process.exit(await cmdSetup(args));
|
|
6340
|
+
case 'cache-report': {
|
|
6341
|
+
// Prompt-cache economics for the current session: parse the Claude Code
|
|
6342
|
+
// transcript usage and report cache-hit %, $ saved, and avoidable leaks.
|
|
6343
|
+
// Advisory/read-only — always exits 0.
|
|
6344
|
+
const { analyzeTranscript, formatCacheReport } = await import('../src/posture/cache-economics.js');
|
|
6345
|
+
const projectDir = path.resolve(args.flags.root || process.env.CLAUDE_PROJECT_DIR || process.cwd());
|
|
6346
|
+
const result = analyzeTranscript({ transcriptPath: args.flags.transcript, projectDir });
|
|
6347
|
+
if (args.flags.json) writeStdout(JSON.stringify(result, null, 2) + '\n');
|
|
6348
|
+
else console.log(formatCacheReport(result));
|
|
6349
|
+
process.exit(0);
|
|
6350
|
+
}
|
|
6351
|
+
case 'cache-statusline': {
|
|
6352
|
+
// F6 — one-line cost HUD for a Claude Code statusLine command. Also writes
|
|
6353
|
+
// .agentic-security/cache-telemetry.json for other pollers. Always exits 0.
|
|
6354
|
+
const { analyzeTranscript, renderCacheStatusLine } = await import('../src/posture/cache-economics.js');
|
|
6355
|
+
const projectDir = path.resolve(args.flags.root || process.env.CLAUDE_PROJECT_DIR || process.cwd());
|
|
6356
|
+
const result = analyzeTranscript({ transcriptPath: args.flags.transcript, projectDir });
|
|
6357
|
+
if (result.ok) {
|
|
6358
|
+
try {
|
|
6359
|
+
const dir = stateDir(projectDir);
|
|
6360
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
6361
|
+
fs.writeFileSync(path.join(dir, 'cache-telemetry.json'),
|
|
6362
|
+
JSON.stringify({ updatedAt: new Date().toISOString(), metrics: result.metrics, leaks: result.leaks }, null, 2));
|
|
6363
|
+
} catch { /* best-effort */ }
|
|
6364
|
+
console.log(renderCacheStatusLine(result.metrics));
|
|
6365
|
+
} else {
|
|
6366
|
+
console.log('agentic-security: no session cost yet');
|
|
6367
|
+
}
|
|
6368
|
+
process.exit(0);
|
|
6369
|
+
}
|
|
6370
|
+
case 'mcp': {
|
|
6371
|
+
const { runStdio } = await import('../src/mcp/stdio.js');
|
|
6372
|
+
const root = args.flags.root || process.env.AGENTIC_SECURITY_MCP_ROOT || process.cwd();
|
|
6373
|
+
runStdio({ sessionRoot: path.resolve(root) });
|
|
6374
|
+
return;
|
|
6375
|
+
}
|
|
6376
|
+
case 'explore': process.exit(await cmdExplore(args));
|
|
6377
|
+
case 'dataflow': {
|
|
6378
|
+
const sub = args._[1];
|
|
6379
|
+
if (sub === 'export') { process.exit(await cmdDataflowExport(args)); }
|
|
6380
|
+
else if (sub === 'diff') { process.exit(await cmdDataflowDiff(args)); }
|
|
6381
|
+
else if (sub === 'watch') {
|
|
6382
|
+
// NOT `process.exit(await cmdDataflowWatch(args))` — deliberately.
|
|
6383
|
+
// process.exit() terminates immediately, ignoring any pending
|
|
6384
|
+
// handles/timers; cmdDataflowWatch's own success path (return 0)
|
|
6385
|
+
// resolves almost immediately once watchProject's internal
|
|
6386
|
+
// subscription is set up (watchProject itself doesn't block —
|
|
6387
|
+
// see that function's own header), so wrapping it in
|
|
6388
|
+
// process.exit() here would tear the process down before the
|
|
6389
|
+
// live fs.watch async iterator inside watchProject ever gets a
|
|
6390
|
+
// chance to fire — confirmed empirically (measured exit in
|
|
6391
|
+
// <1s, no rescan ever observed) against the pre-existing SAST
|
|
6392
|
+
// `scan --watch` (bin/agentic-security.js's `args.flags['watch']`
|
|
6393
|
+
// branch), which has this exact same latent bug via its own
|
|
6394
|
+
// identical `process.exit(await cmdScan(args))` dispatch — out
|
|
6395
|
+
// of scope to fix here (watch-mode.js/scan --watch are
|
|
6396
|
+
// deliberately unmodified by this sub-project), but the fix for
|
|
6397
|
+
// THIS new subcommand is to only exit explicitly on a non-zero
|
|
6398
|
+
// (validation-failure / no-lineage-graph) return, which happens
|
|
6399
|
+
// before watchProject is ever called and has nothing keeping
|
|
6400
|
+
// the process alive on its own. On the success path the process
|
|
6401
|
+
// is left to Node's default "still has an active handle" event
|
|
6402
|
+
// loop keep-alive, exiting only via Ctrl-C (SIGINT)'s own
|
|
6403
|
+
// Node-default termination.
|
|
6404
|
+
const code = await cmdDataflowWatch(args);
|
|
6405
|
+
if (code !== 0) process.exit(code);
|
|
6406
|
+
break;
|
|
6407
|
+
}
|
|
6408
|
+
else if (sub === 'scenario') {
|
|
6409
|
+
const scenarioSub = args._[2];
|
|
6410
|
+
if (scenarioSub === 'apply') { process.exit(await cmdDataflowScenarioApply(args)); }
|
|
6411
|
+
else {
|
|
6412
|
+
process.stderr.write(`agentic-security dataflow scenario: unrecognized sub-command "${scenarioSub}" — must be "apply".\n`);
|
|
6413
|
+
process.exit(2);
|
|
6414
|
+
}
|
|
6415
|
+
break;
|
|
6416
|
+
}
|
|
6417
|
+
else if (sub === 'impact') {
|
|
6418
|
+
const impactSub = args._[2];
|
|
6419
|
+
if (impactSub === 'assess') { process.exit(await cmdDataflowImpactAssess(args)); }
|
|
6420
|
+
else {
|
|
6421
|
+
process.stderr.write(`agentic-security dataflow impact: unrecognized sub-command "${impactSub}" — must be "assess".\n`);
|
|
6422
|
+
process.exit(2);
|
|
6423
|
+
}
|
|
6424
|
+
break;
|
|
6425
|
+
}
|
|
6426
|
+
else if (sub === 'observations') {
|
|
6427
|
+
const obsSub = args._[2];
|
|
6428
|
+
if (obsSub === 'import') { process.exit(await cmdDataflowObservationsImport(args)); }
|
|
6429
|
+
else if (obsSub === 'list') { process.exit(await cmdDataflowObservationsList(args)); }
|
|
6430
|
+
else {
|
|
6431
|
+
process.stderr.write(`agentic-security dataflow observations: unrecognized sub-command "${obsSub}" — must be "import" or "list".\n`);
|
|
6432
|
+
process.exit(2);
|
|
6433
|
+
}
|
|
6434
|
+
break;
|
|
6435
|
+
}
|
|
6436
|
+
else if (sub === 'twin') { process.exit(await cmdDataflowTwin(args)); }
|
|
6437
|
+
process.stderr.write(`agentic-security dataflow: unknown subcommand "${sub}" — only "export", "diff", "watch", "scenario", "impact", "observations", and "twin" are supported.\n`);
|
|
6438
|
+
process.exit(2);
|
|
6439
|
+
}
|
|
6440
|
+
case 'governance': {
|
|
6441
|
+
// NOT a `dataflow` subcommand — this edits operator config
|
|
6442
|
+
// (recipient-profiles.json), never the scanned graph. See
|
|
6443
|
+
// cmdGovernancePropose's own header comment for the full
|
|
6444
|
+
// exit-code/backup/audit contract.
|
|
6445
|
+
const sub = args._[1];
|
|
6446
|
+
if (sub === 'propose-edit') { process.exit(await cmdGovernancePropose(args)); }
|
|
6447
|
+
else {
|
|
6448
|
+
process.stderr.write(`agentic-security governance: unrecognized sub-command "${sub}" — must be "propose-edit".\n`);
|
|
6449
|
+
process.exit(2);
|
|
6450
|
+
}
|
|
6451
|
+
break;
|
|
6452
|
+
}
|
|
6453
|
+
case 'federate': {
|
|
6454
|
+
// NOT a `dataflow` subcommand — this writes operator-declared
|
|
6455
|
+
// config (cross-repo-links.json), never the scanned graph. Same
|
|
6456
|
+
// distinction that made `governance`/`remediation` their own
|
|
6457
|
+
// dispatchers. See cmdFederateDeclare's own header comment for
|
|
6458
|
+
// the full exit-code/backup/audit contract.
|
|
6459
|
+
const sub = args._[1];
|
|
6460
|
+
if (sub === 'declare') { process.exit(await cmdFederateDeclare(args)); }
|
|
6461
|
+
else if (sub === 'list') { process.exit(await cmdFederateList(args)); }
|
|
6462
|
+
else {
|
|
6463
|
+
process.stderr.write(`agentic-security federate: unrecognized sub-command "${sub}" — must be "declare" or "list".\n`);
|
|
6464
|
+
process.exit(2);
|
|
6465
|
+
}
|
|
6466
|
+
break;
|
|
6467
|
+
}
|
|
6468
|
+
case 'remediation': {
|
|
6469
|
+
// NOT a `dataflow` subcommand — this writes operator/incident
|
|
6470
|
+
// state (the append-only remediation ledger), never the scanned
|
|
6471
|
+
// graph. Same distinction that made `governance` its own
|
|
6472
|
+
// dispatcher. See _remediationWrite's own header for the full
|
|
6473
|
+
// base-event/isSafeStateDir/audit/exit-code contract.
|
|
6474
|
+
const sub = args._[1];
|
|
6475
|
+
if (sub === 'open') { process.exit(await cmdRemediationOpen(args)); }
|
|
6476
|
+
else if (sub === 'update') { process.exit(await cmdRemediationUpdate(args)); }
|
|
6477
|
+
else if (sub === 'verify') { process.exit(await cmdRemediationVerify(args)); }
|
|
6478
|
+
else if (sub === 'accept-risk') { process.exit(await cmdRemediationAcceptRisk(args)); }
|
|
6479
|
+
else if (sub === 'reopen-check') { process.exit(await cmdRemediationReopenCheck(args)); }
|
|
6480
|
+
else if (sub === 'list') { process.exit(await cmdRemediationList(args)); }
|
|
6481
|
+
else {
|
|
6482
|
+
process.stderr.write(`agentic-security remediation: unrecognized sub-command "${sub}" — must be one of open|update|verify|accept-risk|reopen-check|list.\n`);
|
|
6483
|
+
process.exit(2);
|
|
6484
|
+
}
|
|
6485
|
+
break;
|
|
3047
6486
|
}
|
|
3048
6487
|
case 'cve-watch': {
|
|
3049
6488
|
// Continuous CVE-watch daemon (one-shot). Polls OSV for the project's
|