@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
|
@@ -0,0 +1,793 @@
|
|
|
1
|
+
export const id = 2432;
|
|
2
|
+
export const ids = [2432,5561];
|
|
3
|
+
export const modules = {
|
|
4
|
+
|
|
5
|
+
/***/ 5561:
|
|
6
|
+
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
7
|
+
|
|
8
|
+
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
|
9
|
+
/* harmony export */ evaluateVerificationEvidence: () => (/* binding */ evaluateVerificationEvidence),
|
|
10
|
+
/* harmony export */ foldRemediationLedger: () => (/* binding */ foldRemediationLedger),
|
|
11
|
+
/* harmony export */ validateOpenPayload: () => (/* binding */ validateOpenPayload),
|
|
12
|
+
/* harmony export */ validateTransition: () => (/* binding */ validateTransition)
|
|
13
|
+
/* harmony export */ });
|
|
14
|
+
/* unused harmony exports REMEDIATION_STATES, REMEDIATION_EVENT_TYPES, ACCEPTED_RISK_REQUIRED_FIELDS, foldRemediationItem */
|
|
15
|
+
// remediation.js — M5 deliverable #6 (Blast-Radius: Remediation Command
|
|
16
|
+
// Center, FR-507 + AC-31): the pure RemediationItem contract, its event
|
|
17
|
+
// fold, its state machine, and the verification-evidence decision.
|
|
18
|
+
//
|
|
19
|
+
// Pure by contract — zero imports, no fs, no I/O of any kind, never
|
|
20
|
+
// throws. The ledger layer (`posture/remediation-ledger.js`, Task 2 of
|
|
21
|
+
// this same sub-project) owns every side effect: file locking, append,
|
|
22
|
+
// tolerant read, hash chaining. This module mirrors `governance-edit.js`'s
|
|
23
|
+
// own split (that module's own header makes the identical claim for its
|
|
24
|
+
// own CLI-write boundary) and the "pure data + pure functions, boundary-
|
|
25
|
+
// tested" precedent `flow-grade.js`/`obligation-mapping.js` already set
|
|
26
|
+
// in this package.
|
|
27
|
+
//
|
|
28
|
+
// ── The `verified`-unreachable-from-`state_changed` rule (AC-31) ──────
|
|
29
|
+
//
|
|
30
|
+
// AC-31's own `then`-clause requires that marking remediation work
|
|
31
|
+
// "done" can NEVER, by itself, mark a finding verified — only a real
|
|
32
|
+
// scan re-confirming the flow is gone (`scan_verification`) or an
|
|
33
|
+
// explicitly-permitted, explicitly-attributed manual attestation
|
|
34
|
+
// (`manual_attestation`) may transition an item into `verified`. PRD
|
|
35
|
+
// line 171 states the same rule as an explicit non-goal: this deliverable
|
|
36
|
+
// must never let "I fixed it" (a human claim) substitute for "the scanner
|
|
37
|
+
// re-observed the flow is gone" (an evidence-backed claim) without an
|
|
38
|
+
// explicit, auditable manual-attestation opt-in. `validateTransition`
|
|
39
|
+
// enforces this by rejecting `state_changed` with `state: 'verified'`
|
|
40
|
+
// FIRST, before even looking at the item's current state — the rejection
|
|
41
|
+
// is therefore genuinely unconditional, not just unreached from the
|
|
42
|
+
// states this module happens to reach today.
|
|
43
|
+
//
|
|
44
|
+
// ── Deliberate scope narrowing in evaluateVerificationEvidence ────────
|
|
45
|
+
//
|
|
46
|
+
// A required-evidence flow id is treated as SATISFIED only when it
|
|
47
|
+
// appears in `diff.removed.flows` with `causeClassification ===
|
|
48
|
+
// 'application_change'` — i.e. the flow is genuinely gone from the graph
|
|
49
|
+
// and its disappearance is attributable to an application change, not to
|
|
50
|
+
// the scanner simply seeing less than it used to. A flow that is still
|
|
51
|
+
// PRESENT in the graph but has merely become MORE PROTECTED (e.g. its
|
|
52
|
+
// `protectionSummary` moved from `unprotected` to `protected` — visible
|
|
53
|
+
// only as a `changed.flows` entry, never a `removed.flows` entry) is
|
|
54
|
+
// explicitly OUT OF SCOPE for this first cut: recognizing an "improving
|
|
55
|
+
// transition" as evidence of a fix needs its own deliberate, disclosed
|
|
56
|
+
// heuristic (what counts as "protected enough", whether a policy verdict
|
|
57
|
+
// change alone should count, etc.) that this module does not attempt.
|
|
58
|
+
// A future increment could add it; until then, such a flow reports
|
|
59
|
+
// `flows_still_present`, same as a flow with no change at all.
|
|
60
|
+
//
|
|
61
|
+
// `possible_coverage_regression` is computed DIFF-WIDE by
|
|
62
|
+
// `computeGraphDiff` (see `graph-diff.js:328`'s own
|
|
63
|
+
// `coverageRegressionReasons`/`flowRemovalCause` — every removed flow in
|
|
64
|
+
// one diff shares the SAME cause once any completeness signal regressed)
|
|
65
|
+
// — which is why a coverage-regression hit on any of the item's OWN
|
|
66
|
+
// required-evidence flows refuses the WHOLE verification immediately (PRD
|
|
67
|
+
// line 1975), rather than letting other, seemingly-clean required-
|
|
68
|
+
// evidence flows in the same diff verify: an incomplete scan cannot be
|
|
69
|
+
// trusted to have honestly seen everything it claims to have not seen.
|
|
70
|
+
// (The loop below only ever inspects `diff.removed.flows` entries for
|
|
71
|
+
// flows actually named in `requiredEvidenceFlowIds` — an unrelated
|
|
72
|
+
// removed flow elsewhere in the same diff is never consulted.)
|
|
73
|
+
|
|
74
|
+
const REMEDIATION_STATES = Object.freeze([
|
|
75
|
+
'open', 'in_progress', 'awaiting_verification', 'verified', 'accepted_risk', 'reopened',
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
const REMEDIATION_EVENT_TYPES = Object.freeze([
|
|
79
|
+
'opened', 'state_changed', 'scan_verification', 'manual_attestation', 'accepted_risk', 'reopened',
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
// PRD line 572's exact four-field list (scoping doc §4.3).
|
|
83
|
+
const ACCEPTED_RISK_REQUIRED_FIELDS = Object.freeze(['approver', 'reason', 'scope', 'expiration']);
|
|
84
|
+
|
|
85
|
+
function _isNonEmptyString(v) {
|
|
86
|
+
return typeof v === 'string' && v.length > 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function _isPlainObject(v) {
|
|
90
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── foldRemediationItem / foldRemediationLedger ────────────────────────
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Folds an ordered event list for ONE remediation item into its current
|
|
97
|
+
* shape, or `null` for an empty/malformed/non-`opened`-first list. Never
|
|
98
|
+
* throws.
|
|
99
|
+
*/
|
|
100
|
+
function foldRemediationItem(events) {
|
|
101
|
+
if (!Array.isArray(events) || events.length === 0) return null;
|
|
102
|
+
const openEvent = events[0];
|
|
103
|
+
if (!openEvent || openEvent.type !== 'opened') return null;
|
|
104
|
+
|
|
105
|
+
const item = {
|
|
106
|
+
id: openEvent.id,
|
|
107
|
+
state: 'open',
|
|
108
|
+
owner: openEvent.owner,
|
|
109
|
+
dueDate: openEvent.dueDate,
|
|
110
|
+
recommendedControl: openEvent.recommendedControl,
|
|
111
|
+
assessment: openEvent.assessment,
|
|
112
|
+
affectedFlowIds: openEvent.affectedFlowIds,
|
|
113
|
+
affectedNodeIds: openEvent.affectedNodeIds,
|
|
114
|
+
affectedEdgeIds: openEvent.affectedEdgeIds,
|
|
115
|
+
requiredEvidence: openEvent.requiredEvidence,
|
|
116
|
+
manualAttestationPermitted: openEvent.manualAttestationPermitted,
|
|
117
|
+
approvals: [],
|
|
118
|
+
exceptions: [],
|
|
119
|
+
verificationSnapshotId: null,
|
|
120
|
+
history: events,
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
for (let i = 1; i < events.length; i++) {
|
|
124
|
+
const ev = events[i];
|
|
125
|
+
if (!ev || typeof ev !== 'object') continue;
|
|
126
|
+
switch (ev.type) {
|
|
127
|
+
case 'state_changed':
|
|
128
|
+
item.state = ev.state;
|
|
129
|
+
break;
|
|
130
|
+
case 'scan_verification':
|
|
131
|
+
if (ev.outcome === 'verified') {
|
|
132
|
+
item.state = 'verified';
|
|
133
|
+
item.verificationSnapshotId = ev.snapshotId;
|
|
134
|
+
}
|
|
135
|
+
// an 'unverifiable' outcome changes neither state nor snapshot.
|
|
136
|
+
break;
|
|
137
|
+
case 'manual_attestation':
|
|
138
|
+
item.state = 'verified';
|
|
139
|
+
item.approvals.push({
|
|
140
|
+
approver: ev.approver, reason: ev.reason, at: ev.at, evidenceKind: 'manual',
|
|
141
|
+
});
|
|
142
|
+
// Records a real baseline (final-review fix round 1, Blocking-3):
|
|
143
|
+
// without this, a manually-attested item keeps whatever STALE
|
|
144
|
+
// `verificationSnapshotId` it happened to carry (or `null`,
|
|
145
|
+
// falling back to an even less defensible baseline) forever, and
|
|
146
|
+
// `reopen-check` keeps diffing from that stale anchor — reopening
|
|
147
|
+
// a just-permitted attestation on the very next run even though
|
|
148
|
+
// nothing changed. `ev.snapshotId` is optional (a manual
|
|
149
|
+
// attestation before any lineage scan has ever run is legitimate).
|
|
150
|
+
if (ev.snapshotId) item.verificationSnapshotId = ev.snapshotId;
|
|
151
|
+
break;
|
|
152
|
+
case 'accepted_risk':
|
|
153
|
+
item.state = 'accepted_risk';
|
|
154
|
+
item.exceptions.push({
|
|
155
|
+
approver: ev.approver, reason: ev.reason, scope: ev.scope, expiration: ev.expiration, at: ev.at,
|
|
156
|
+
});
|
|
157
|
+
break;
|
|
158
|
+
case 'reopened':
|
|
159
|
+
item.state = 'reopened';
|
|
160
|
+
// Retires the stale anchor (final-review fix round 1, Blocking-3)
|
|
161
|
+
// so it cannot outlive the verification it belonged to — the next
|
|
162
|
+
// verification (scan or manual) must establish its own baseline.
|
|
163
|
+
item.verificationSnapshotId = null;
|
|
164
|
+
break;
|
|
165
|
+
default:
|
|
166
|
+
// an unrecognized event type is ignored by the fold — validation
|
|
167
|
+
// of proposed events is validateTransition's job, not this one's.
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return item;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Groups a flat, interleaved event stream by item id (`ev.itemId`,
|
|
177
|
+
* falling back to `ev.id` on an `opened` event that carries only `id`),
|
|
178
|
+
* preserving per-group insertion order, and folds each group. Events with
|
|
179
|
+
* no resolvable item id are skipped. Groups that fold to `null` are
|
|
180
|
+
* dropped. Never throws.
|
|
181
|
+
*/
|
|
182
|
+
function foldRemediationLedger(allEvents) {
|
|
183
|
+
const result = {};
|
|
184
|
+
if (!Array.isArray(allEvents)) return result;
|
|
185
|
+
|
|
186
|
+
const groups = new Map();
|
|
187
|
+
for (const ev of allEvents) {
|
|
188
|
+
if (!ev || typeof ev !== 'object') continue;
|
|
189
|
+
const itemId = ev.itemId ?? (ev.type === 'opened' ? ev.id : undefined);
|
|
190
|
+
if (!_isNonEmptyString(itemId)) continue;
|
|
191
|
+
if (!groups.has(itemId)) groups.set(itemId, []);
|
|
192
|
+
groups.get(itemId).push(ev);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
for (const [itemId, events] of groups) {
|
|
196
|
+
const item = foldRemediationItem(events);
|
|
197
|
+
if (item !== null) result[itemId] = item;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return result;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ── validateOpenPayload ─────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
const _REQUIRED_ASSESSMENT_FIELDS = [
|
|
206
|
+
'assessmentId', 'targetId', 'targetKind', 'traceKind', 'scope', 'graphId', 'graphDigest', 'snapshotId',
|
|
207
|
+
];
|
|
208
|
+
|
|
209
|
+
const _DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Validates an `opened`-event-shaped payload. Returns `{valid, errors}`,
|
|
213
|
+
* `errors` as `[{field, message}]`. Never throws.
|
|
214
|
+
*/
|
|
215
|
+
function validateOpenPayload(payload) {
|
|
216
|
+
const errors = [];
|
|
217
|
+
const err = (field, message) => errors.push({ field, message });
|
|
218
|
+
|
|
219
|
+
if (!_isPlainObject(payload)) {
|
|
220
|
+
err('(payload)', 'payload must be an object');
|
|
221
|
+
return { valid: false, errors };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
for (const field of ['id', 'owner', 'recommendedControl']) {
|
|
225
|
+
if (!_isNonEmptyString(payload[field])) err(field, `${field} is required and must be a non-empty string`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (!_isNonEmptyString(payload.dueDate) || !_DATE_RE.test(payload.dueDate)) {
|
|
229
|
+
err('dueDate', 'dueDate is required and must be a YYYY-MM-DD date');
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (!_isPlainObject(payload.assessment)) {
|
|
233
|
+
err('assessment', 'assessment is required and must be an object');
|
|
234
|
+
} else {
|
|
235
|
+
for (const field of _REQUIRED_ASSESSMENT_FIELDS) {
|
|
236
|
+
if (!_isNonEmptyString(payload.assessment[field])) {
|
|
237
|
+
err(`assessment.${field}`, `assessment.${field} is required and must be a non-empty string`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
// assessmentPath is the one OPTIONAL inline field — no check.
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const evidence = payload.requiredEvidence;
|
|
244
|
+
if (!Array.isArray(evidence) || evidence.length === 0 || !evidence.every(_isNonEmptyString)) {
|
|
245
|
+
err('requiredEvidence', 'requiredEvidence is required and must be a non-empty array of non-empty strings');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return { valid: errors.length === 0, errors };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ── validateTransition — AC-31's own state machine ──────────────────────
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Validates a proposed event against a remediation item's current state
|
|
255
|
+
* (or `null` for no item yet). Returns `{valid, errors}`, `errors` as
|
|
256
|
+
* `[{field, message}]`. Never throws.
|
|
257
|
+
*/
|
|
258
|
+
function validateTransition(item, proposedEvent) {
|
|
259
|
+
const errors = [];
|
|
260
|
+
const err = (field, message) => errors.push({ field, message });
|
|
261
|
+
|
|
262
|
+
if (!_isPlainObject(proposedEvent)) {
|
|
263
|
+
err('(event)', 'proposed event must be an object');
|
|
264
|
+
return { valid: false, errors };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const type = proposedEvent.type;
|
|
268
|
+
|
|
269
|
+
if (type === 'opened') {
|
|
270
|
+
if (item !== null && item !== undefined) {
|
|
271
|
+
err('type', 'a remediation item with this id already exists — opened is only valid when no item exists');
|
|
272
|
+
return { valid: false, errors };
|
|
273
|
+
}
|
|
274
|
+
return { valid: true, errors: [] };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// AC-31's own load-bearing rule: state_changed can never reach
|
|
278
|
+
// `verified`. Checked FIRST, before even the "item must exist" check
|
|
279
|
+
// below, so the rejection is genuinely unconditional — reachable from
|
|
280
|
+
// no state at all, including no item — and cannot be routed around by
|
|
281
|
+
// any current state. See this file's own header.
|
|
282
|
+
if (type === 'state_changed' && proposedEvent.state === 'verified') {
|
|
283
|
+
err('state', 'state_changed can never reach verified — only scan_verification or manual_attestation may');
|
|
284
|
+
return { valid: false, errors };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Every other event type requires an existing item.
|
|
288
|
+
if (!_isPlainObject(item)) {
|
|
289
|
+
err('type', `${type} is not valid against no item — open the item first`);
|
|
290
|
+
return { valid: false, errors };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
switch (type) {
|
|
294
|
+
case 'state_changed': {
|
|
295
|
+
if (proposedEvent.state === 'in_progress') {
|
|
296
|
+
if (item.state !== 'open' && item.state !== 'reopened') {
|
|
297
|
+
err('state', `state_changed to in_progress is only valid from open or reopened, not ${item.state}`);
|
|
298
|
+
}
|
|
299
|
+
} else if (proposedEvent.state === 'awaiting_verification') {
|
|
300
|
+
if (item.state !== 'in_progress') {
|
|
301
|
+
err('state', `state_changed to awaiting_verification is only valid from in_progress, not ${item.state}`);
|
|
302
|
+
}
|
|
303
|
+
} else {
|
|
304
|
+
err('state', `state_changed to ${JSON.stringify(proposedEvent.state)} is not a recognized transition`);
|
|
305
|
+
}
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
case 'scan_verification': {
|
|
310
|
+
if (item.state !== 'awaiting_verification') {
|
|
311
|
+
err('type', `scan_verification is only valid from awaiting_verification, not ${item.state}`);
|
|
312
|
+
break;
|
|
313
|
+
}
|
|
314
|
+
if (proposedEvent.outcome === 'verified') {
|
|
315
|
+
if (!_isNonEmptyString(proposedEvent.snapshotId)) {
|
|
316
|
+
err('snapshotId', 'a verified outcome requires a snapshotId');
|
|
317
|
+
}
|
|
318
|
+
} else if (proposedEvent.outcome === 'unverifiable') {
|
|
319
|
+
// no further requirement beyond the outcome itself.
|
|
320
|
+
} else {
|
|
321
|
+
err('outcome', `outcome ${JSON.stringify(proposedEvent.outcome)} is not a recognized scan_verification outcome`);
|
|
322
|
+
}
|
|
323
|
+
break;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
case 'manual_attestation': {
|
|
327
|
+
if (item.state !== 'awaiting_verification') {
|
|
328
|
+
err('type', `manual_attestation is only valid from awaiting_verification, not ${item.state}`);
|
|
329
|
+
break;
|
|
330
|
+
}
|
|
331
|
+
if (!item.manualAttestationPermitted) {
|
|
332
|
+
err('manualAttestationPermitted', 'manual attestation is not permitted for this item — it must be opened with --allow-manual-attestation to allow one (open a new item if this one predates that need)');
|
|
333
|
+
break;
|
|
334
|
+
}
|
|
335
|
+
if (!_isNonEmptyString(proposedEvent.approver)) err('approver', 'manual_attestation requires a non-empty approver');
|
|
336
|
+
if (!_isNonEmptyString(proposedEvent.reason)) err('reason', 'manual_attestation requires a non-empty reason');
|
|
337
|
+
break;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
case 'accepted_risk': {
|
|
341
|
+
if (item.state === 'verified' || item.state === 'accepted_risk') {
|
|
342
|
+
err('type', `accepted_risk is not valid from ${item.state}`);
|
|
343
|
+
break;
|
|
344
|
+
}
|
|
345
|
+
for (const field of ACCEPTED_RISK_REQUIRED_FIELDS) {
|
|
346
|
+
if (!_isNonEmptyString(proposedEvent[field])) {
|
|
347
|
+
err(field, `accepted_risk requires a non-empty ${field}`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
break;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
case 'reopened': {
|
|
354
|
+
if (item.state !== 'verified') {
|
|
355
|
+
err('type', `reopened is only valid from verified, not ${item.state}`);
|
|
356
|
+
break;
|
|
357
|
+
}
|
|
358
|
+
if (!_isNonEmptyString(proposedEvent.reason)) {
|
|
359
|
+
err('reason', 'reopened must always carry a non-empty reason naming what triggered it');
|
|
360
|
+
}
|
|
361
|
+
break;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
default:
|
|
365
|
+
err('type', `${JSON.stringify(type)} is not a recognized remediation event type`);
|
|
366
|
+
break;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return { valid: errors.length === 0, errors };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ── evaluateVerificationEvidence ────────────────────────────────────────
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Decides whether a GraphDiff's real, checkable evidence satisfies every
|
|
376
|
+
* flow id a remediation item required evidence for. See this file's own
|
|
377
|
+
* header for the deliberate scope narrowing and the coverage-regression
|
|
378
|
+
* whole-verification-refusal rule. Never throws.
|
|
379
|
+
*/
|
|
380
|
+
function evaluateVerificationEvidence(diff, requiredEvidenceFlowIds) {
|
|
381
|
+
if (!Array.isArray(requiredEvidenceFlowIds) || requiredEvidenceFlowIds.length === 0) {
|
|
382
|
+
return { outcome: 'unverifiable', reason: 'no_required_evidence' };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const removedFlows = diff?.removed?.flows;
|
|
386
|
+
const removedById = new Map();
|
|
387
|
+
if (Array.isArray(removedFlows)) {
|
|
388
|
+
for (const entry of removedFlows) {
|
|
389
|
+
if (entry && typeof entry.id === 'string') removedById.set(entry.id, entry);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const unsatisfiedFlowIds = [];
|
|
394
|
+
|
|
395
|
+
for (const flowId of requiredEvidenceFlowIds) {
|
|
396
|
+
const entry = removedById.get(flowId);
|
|
397
|
+
const cause = entry?.causeClassification;
|
|
398
|
+
|
|
399
|
+
if (cause === 'possible_coverage_regression') {
|
|
400
|
+
return {
|
|
401
|
+
outcome: 'unverifiable',
|
|
402
|
+
reason: 'possible_coverage_regression',
|
|
403
|
+
flowId,
|
|
404
|
+
coverageRegressionReasons: entry.coverageRegressionReasons,
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (cause === 'reidentified') {
|
|
409
|
+
return {
|
|
410
|
+
outcome: 'unverifiable',
|
|
411
|
+
reason: 'reidentified',
|
|
412
|
+
flowId,
|
|
413
|
+
reidentifiedTo: entry.reidentifiedTo,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (cause === 'application_change') {
|
|
418
|
+
continue; // satisfied
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Absent from removed.flows entirely, or an unrecognized
|
|
422
|
+
// classification — either way, not satisfied.
|
|
423
|
+
unsatisfiedFlowIds.push(flowId);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (unsatisfiedFlowIds.length > 0) {
|
|
427
|
+
return { outcome: 'unverifiable', reason: 'flows_still_present', unsatisfiedFlowIds };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return { outcome: 'verified' };
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
/***/ }),
|
|
435
|
+
|
|
436
|
+
/***/ 2432:
|
|
437
|
+
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
438
|
+
|
|
439
|
+
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
|
440
|
+
/* harmony export */ appendLedgerEvent: () => (/* binding */ appendLedgerEvent),
|
|
441
|
+
/* harmony export */ latestEventHash: () => (/* binding */ latestEventHash),
|
|
442
|
+
/* harmony export */ ledgerIntegrity: () => (/* binding */ ledgerIntegrity),
|
|
443
|
+
/* harmony export */ ledgerPaths: () => (/* binding */ ledgerPaths),
|
|
444
|
+
/* harmony export */ readLedgerEvents: () => (/* binding */ readLedgerEvents)
|
|
445
|
+
/* harmony export */ });
|
|
446
|
+
/* harmony import */ var node_fs__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(3024);
|
|
447
|
+
/* harmony import */ var node_fs_promises__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(1455);
|
|
448
|
+
/* harmony import */ var node_path__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(6760);
|
|
449
|
+
/* harmony import */ var node_crypto__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(7598);
|
|
450
|
+
/* harmony import */ var _state_dir_js__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(1174);
|
|
451
|
+
/* harmony import */ var _lineage_remediation_js__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(5561);
|
|
452
|
+
// remediation-ledger.js — M5 deliverable #6 (Blast-Radius: Remediation
|
|
453
|
+
// Command Center, FR-507 + AC-31), Task 2 of the sub-project: the IMPURE
|
|
454
|
+
// half. `../lineage/remediation.js` (Task 1) ships the pure RemediationItem
|
|
455
|
+
// contract — zero imports, no fs, never throws. This module owns every side
|
|
456
|
+
// effect that contract needs to become a real, durable, tamper-evident
|
|
457
|
+
// record: file locking, JSONL append, tolerant read, and the hash chain.
|
|
458
|
+
//
|
|
459
|
+
// This is the SECOND `posture/` → `lineage/` import in the codebase
|
|
460
|
+
// (`auditor-walkthrough.js`'s `graph:` branch was the first, per
|
|
461
|
+
// `posture/CLAUDE.md`'s "First `posture/` → `lineage/` import" section) —
|
|
462
|
+
// noted here so the boundary stays a deliberate, documented exception
|
|
463
|
+
// rather than an accident.
|
|
464
|
+
//
|
|
465
|
+
// ── The append-only contract (PRD line 984) ──────────────────────────────
|
|
466
|
+
//
|
|
467
|
+
// A remediation item's history is never rewritten, only appended to. Every
|
|
468
|
+
// decision — owner assignment, a state change, a manual attestation, an
|
|
469
|
+
// accepted-risk exception — is a permanent event, never an edit of a prior
|
|
470
|
+
// one. `foldRemediationItem`/`foldRemediationLedger` (Task 1) derive an
|
|
471
|
+
// item's CURRENT shape by replaying its events forward; nothing here ever
|
|
472
|
+
// mutates a written line.
|
|
473
|
+
//
|
|
474
|
+
// ── Why the physical shape is a JSONL append, not a whole-file rewrite ────
|
|
475
|
+
//
|
|
476
|
+
// `fix-metrics.js:69`'s `recordFixAttempt` is the precedent this module
|
|
477
|
+
// follows: "One writeSync of one newline-terminated line: a concurrent
|
|
478
|
+
// reader sees whole records or nothing, and a torn tail is dropped on
|
|
479
|
+
// read." `provenance/lifecycle.js:195`'s `updateLifecycle`, by contrast,
|
|
480
|
+
// reads the WHOLE store into memory, folds one scan's worth of changes in,
|
|
481
|
+
// and rewrites the WHOLE file — safe there because a lifecycle update is a
|
|
482
|
+
// single scan touching potentially every finding at once. A remediation
|
|
483
|
+
// ledger is the opposite shape: a human, one item at a time, arbitrarily
|
|
484
|
+
// interleaved with edits to every OTHER item. A partial rewrite of a shared
|
|
485
|
+
// document risks losing every OTHER item's history to a crash or a bug
|
|
486
|
+
// mid-rewrite; an append can only ever add a new, independently-readable
|
|
487
|
+
// line.
|
|
488
|
+
//
|
|
489
|
+
// ── Why a lock is still required, despite the append itself being atomic ──
|
|
490
|
+
//
|
|
491
|
+
// `fs.appendFileSync` of one line is atomic at the OS level, but writing an
|
|
492
|
+
// event is not just "append a line" — it is a READ-MODIFY-WRITE: the
|
|
493
|
+
// proposed event must be validated (`validateTransition`) against the
|
|
494
|
+
// item's CURRENT folded state, which requires reading and folding every
|
|
495
|
+
// prior event first. Two concurrent callers each reading the same "before"
|
|
496
|
+
// state and then both appending could both validate against a state that
|
|
497
|
+
// is stale by the time either write lands — e.g. two racing attempts to
|
|
498
|
+
// open the same item, both seeing "no item yet" and both succeeding, when
|
|
499
|
+
// exactly one must win. The lock serializes the whole
|
|
500
|
+
// read-fold-validate-append sequence into one critical section per event.
|
|
501
|
+
//
|
|
502
|
+
// `withLock` below is a faithful local PORT of
|
|
503
|
+
// `provenance/lifecycle.js:36`'s own `withLock` — NOT an import, because
|
|
504
|
+
// that function is not exported (verified directly against the file: it is
|
|
505
|
+
// a bare, module-private `async function withLock`). `isProcessAlive` is
|
|
506
|
+
// ported alongside it for the same reason.
|
|
507
|
+
//
|
|
508
|
+
// ── The hash chain ─────────────────────────────────────────────────────
|
|
509
|
+
//
|
|
510
|
+
// Byte-identical in behavior to `mcp/audit.js`'s own chain (`_sha`,
|
|
511
|
+
// `GENESIS`, `_readLastEntryHash`, `verifyAuditLog`, verified directly
|
|
512
|
+
// against that file before writing this one): each event carries `prev`,
|
|
513
|
+
// the SHA-256 hex digest of the PREVIOUS line's exact serialized JSON text
|
|
514
|
+
// (including that line's own `prev` field). The first event's `prev` is
|
|
515
|
+
// the literal string `'GENESIS'`. `readLedgerEvents`/`latestEventHash`
|
|
516
|
+
// walk forward from GENESIS; a line that fails to `JSON.parse` (a torn
|
|
517
|
+
// tail) or whose `prev` does not match the expected running hash (tamper)
|
|
518
|
+
// STOPS the walk. Both functions therefore return the longest verifying
|
|
519
|
+
// PREFIX of the file — never the full stream when any of it is
|
|
520
|
+
// unverifiable, and never a thrown error. See `_walkLedger` below, which
|
|
521
|
+
// both exported readers share so they cannot silently disagree on what
|
|
522
|
+
// "the last valid line" means.
|
|
523
|
+
//
|
|
524
|
+
// `appendLedgerEvent` is async and is the SINGLE place `validateTransition`
|
|
525
|
+
// is called in this codebase. No CLI command (Task 3) computes validity
|
|
526
|
+
// for itself — every proposed event is validated at this one write
|
|
527
|
+
// boundary, inside the lock, against the real current folded state. As of
|
|
528
|
+
// final-review fix round 1, this is also the single place THREE more
|
|
529
|
+
// things are enforced, all inside the same lock so none of them can race
|
|
530
|
+
// the write they guard: an `opened` event is additionally checked against
|
|
531
|
+
// `validateOpenPayload` (I4/M11 — previously only the CLI validated an
|
|
532
|
+
// `opened` payload's own shape, so a non-CLI caller could append a
|
|
533
|
+
// malformed one); the ledger's on-disk tail is checked for tearing before
|
|
534
|
+
// anything is appended onto it (I4 — appending onto a torn line would
|
|
535
|
+
// merge them into one unparseable line, silently losing the new event,
|
|
536
|
+
// and everything after it, forever); and an optional
|
|
537
|
+
// `opts.expectedBaseHash` optimistic-concurrency check runs against the
|
|
538
|
+
// real `lastHash` computed inside the lock (I5 — previously the CLI's own
|
|
539
|
+
// `--base-event` guard ran OUTSIDE the lock, a real TOCTOU: another
|
|
540
|
+
// process could append in the window between that check and this
|
|
541
|
+
// function's own lock acquisition).
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
const GENESIS = 'GENESIS';
|
|
551
|
+
|
|
552
|
+
function _sha(s) {
|
|
553
|
+
return node_crypto__WEBPACK_IMPORTED_MODULE_3__.createHash('sha256').update(s).digest('hex');
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function ledgerPaths(scanRoot) {
|
|
557
|
+
return {
|
|
558
|
+
ledgerPath: (0,_state_dir_js__WEBPACK_IMPORTED_MODULE_4__.statePath)(scanRoot, 'remediation', 'items.jsonl'),
|
|
559
|
+
lockPath: (0,_state_dir_js__WEBPACK_IMPORTED_MODULE_4__.statePath)(scanRoot, 'remediation', 'items.lock'),
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Shared walk: reads the ledger file (missing → empty), splits on '\n',
|
|
564
|
+
// drops empty lines, and walks forward maintaining `expectedPrev` (starting
|
|
565
|
+
// at GENESIS) exactly as `mcp/audit.js`'s `verifyAuditLog` does. A parse
|
|
566
|
+
// failure or a `prev` mismatch stops the walk without throwing. Returns
|
|
567
|
+
// `{events, lastHash}` so `readLedgerEvents`/`latestEventHash` cannot drift
|
|
568
|
+
// apart on what "the last valid line" means.
|
|
569
|
+
function _walkLedger(scanRoot) {
|
|
570
|
+
const { ledgerPath } = ledgerPaths(scanRoot);
|
|
571
|
+
let raw;
|
|
572
|
+
try {
|
|
573
|
+
raw = node_fs__WEBPACK_IMPORTED_MODULE_0__.readFileSync(ledgerPath, 'utf8');
|
|
574
|
+
} catch {
|
|
575
|
+
return { events: [], lastHash: GENESIS };
|
|
576
|
+
}
|
|
577
|
+
const lines = raw.split('\n').filter(Boolean);
|
|
578
|
+
const events = [];
|
|
579
|
+
let expectedPrev = GENESIS;
|
|
580
|
+
let lastHash = GENESIS;
|
|
581
|
+
for (const line of lines) {
|
|
582
|
+
let event;
|
|
583
|
+
try {
|
|
584
|
+
event = JSON.parse(line);
|
|
585
|
+
} catch {
|
|
586
|
+
break; // torn tail — stop, do not throw
|
|
587
|
+
}
|
|
588
|
+
if (!event || typeof event !== 'object' || Array.isArray(event) || event.prev !== expectedPrev) {
|
|
589
|
+
break; // tamper detected — stop, do not throw
|
|
590
|
+
}
|
|
591
|
+
events.push(event);
|
|
592
|
+
lastHash = _sha(line);
|
|
593
|
+
expectedPrev = lastHash;
|
|
594
|
+
}
|
|
595
|
+
return { events, lastHash };
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Returns the longest verifying PREFIX of the ledger — never the full
|
|
599
|
+
// stream when a tail is torn or a middle line is tampered. Never throws.
|
|
600
|
+
function readLedgerEvents(scanRoot) {
|
|
601
|
+
return _walkLedger(scanRoot).events;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// GENESIS when the ledger is empty/missing, or when nothing in it verifies.
|
|
605
|
+
function latestEventHash(scanRoot) {
|
|
606
|
+
return _walkLedger(scanRoot).lastHash;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Reports whether the ledger's real content on disk has more raw lines than
|
|
610
|
+
// the longest verifying prefix — i.e. a torn tail OR a tampered middle line
|
|
611
|
+
// broke the hash chain partway through. Never throws. This is a read-only
|
|
612
|
+
// diagnostic; it does not change what readLedgerEvents/latestEventHash
|
|
613
|
+
// return (both still return the longest verifying prefix, unconditionally
|
|
614
|
+
// safe by construction) — it exists so a caller (the CLI's `list` command)
|
|
615
|
+
// can surface a loud warning instead of silently presenting a shorter or
|
|
616
|
+
// stale history as if it were the whole truth. (I7, final-review fix
|
|
617
|
+
// round 1.)
|
|
618
|
+
function ledgerIntegrity(scanRoot) {
|
|
619
|
+
const { ledgerPath } = ledgerPaths(scanRoot);
|
|
620
|
+
let raw;
|
|
621
|
+
try {
|
|
622
|
+
raw = node_fs__WEBPACK_IMPORTED_MODULE_0__.readFileSync(ledgerPath, 'utf8');
|
|
623
|
+
} catch {
|
|
624
|
+
return { ok: true, totalLines: 0, verifiedLines: 0 };
|
|
625
|
+
}
|
|
626
|
+
const totalLines = raw.split('\n').filter(Boolean).length;
|
|
627
|
+
const { events } = _walkLedger(scanRoot);
|
|
628
|
+
return { ok: events.length === totalLines, totalLines, verifiedLines: events.length };
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function isProcessAlive(pid) {
|
|
632
|
+
// POSIX: process.kill(pid, 0) probes existence without sending a signal.
|
|
633
|
+
// EPERM also means the process exists; only ESRCH means dead.
|
|
634
|
+
try { process.kill(pid, 0); return true; }
|
|
635
|
+
catch (e) { return e && e.code === 'EPERM'; }
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// Faithful local port of `provenance/lifecycle.js:36-88`'s `withLock` — not
|
|
639
|
+
// an import, since that function is module-private there. Exclusive `wx`
|
|
640
|
+
// open of the lockfile, write the PID, run `fn` in a `try`, `unlink` in
|
|
641
|
+
// `finally`. On EEXIST, a stale lock (holding PID not alive, or lockfile
|
|
642
|
+
// older than 30s) is reaped, re-reading the lockfile before unlinking so a
|
|
643
|
+
// fresh holder taken by another process in the meantime is never raced.
|
|
644
|
+
// 25ms retry; 5s timeout throwing a named error.
|
|
645
|
+
async function withLock(lockPath, fn) {
|
|
646
|
+
node_fs__WEBPACK_IMPORTED_MODULE_0__.mkdirSync(node_path__WEBPACK_IMPORTED_MODULE_2__.dirname(lockPath), { recursive: true });
|
|
647
|
+
const start = Date.now();
|
|
648
|
+
const TIMEOUT_MS = 5000;
|
|
649
|
+
while (true) {
|
|
650
|
+
try {
|
|
651
|
+
const handle = await node_fs_promises__WEBPACK_IMPORTED_MODULE_1__.open(lockPath, 'wx');
|
|
652
|
+
await handle.writeFile(String(process.pid));
|
|
653
|
+
try { await handle.close(); } catch {}
|
|
654
|
+
try {
|
|
655
|
+
return await fn();
|
|
656
|
+
} finally {
|
|
657
|
+
await node_fs_promises__WEBPACK_IMPORTED_MODULE_1__.unlink(lockPath).catch(() => {});
|
|
658
|
+
}
|
|
659
|
+
} catch (e) {
|
|
660
|
+
if (e && e.code === 'EEXIST') {
|
|
661
|
+
try {
|
|
662
|
+
const [st, pidStr] = await Promise.all([
|
|
663
|
+
node_fs_promises__WEBPACK_IMPORTED_MODULE_1__.stat(lockPath),
|
|
664
|
+
node_fs_promises__WEBPACK_IMPORTED_MODULE_1__.readFile(lockPath, 'utf8').catch(() => ''),
|
|
665
|
+
]);
|
|
666
|
+
const pid = parseInt(pidStr.trim(), 10);
|
|
667
|
+
const pidAlive = Number.isFinite(pid) && isProcessAlive(pid);
|
|
668
|
+
const old = Date.now() - st.mtimeMs > 30000;
|
|
669
|
+
if (!pidAlive || old) {
|
|
670
|
+
try {
|
|
671
|
+
// Only unlink if the lockfile still holds the PID we just
|
|
672
|
+
// read, so we don't race the unlink against a fresh lock
|
|
673
|
+
// taken by another process in the meantime.
|
|
674
|
+
const recheck = (await node_fs_promises__WEBPACK_IMPORTED_MODULE_1__.readFile(lockPath, 'utf8').catch(() => '')).trim();
|
|
675
|
+
if (recheck === pidStr.trim()) {
|
|
676
|
+
await node_fs_promises__WEBPACK_IMPORTED_MODULE_1__.unlink(lockPath);
|
|
677
|
+
}
|
|
678
|
+
} catch {}
|
|
679
|
+
continue;
|
|
680
|
+
}
|
|
681
|
+
} catch {}
|
|
682
|
+
if (Date.now() - start > TIMEOUT_MS) throw new Error('remediation-ledger: lock timed out');
|
|
683
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
684
|
+
continue;
|
|
685
|
+
}
|
|
686
|
+
throw e;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// Resolves the itemId a proposed event refers to, mirroring
|
|
692
|
+
// `foldRemediationLedger`'s own grouping fallback exactly (Task 1's
|
|
693
|
+
// `../lineage/remediation.js`: `ev.itemId ?? (ev.type === 'opened' ?
|
|
694
|
+
// ev.id : undefined)`) — an `opened` event carries `id` (per
|
|
695
|
+
// `foldRemediationItem`'s own use of `openEvent.id`), every other event
|
|
696
|
+
// type carries `itemId`. Reusing the identical fallback here, rather than
|
|
697
|
+
// reading `eventPayload.itemId` alone, is what makes item resolution agree
|
|
698
|
+
// with how the ledger will actually be folded on read — an opened event
|
|
699
|
+
// for an id that already exists must resolve to that existing item, not
|
|
700
|
+
// silently miss it because the payload only carries `id`.
|
|
701
|
+
function _resolveItemId(eventPayload) {
|
|
702
|
+
if (!eventPayload || typeof eventPayload !== 'object') return undefined;
|
|
703
|
+
return eventPayload.itemId ?? (eventPayload.type === 'opened' ? eventPayload.id : undefined);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// This function is the single place `validateTransition` is called. No CLI
|
|
707
|
+
// command computes validity for itself. `opts.expectedBaseHash` (I5) is an
|
|
708
|
+
// optional optimistic-concurrency check, compared against the real
|
|
709
|
+
// `lastHash` computed INSIDE the lock — the authoritative half of the
|
|
710
|
+
// `--base-event` guard; `undefined` (the flag was never passed) performs no
|
|
711
|
+
// check.
|
|
712
|
+
async function appendLedgerEvent(scanRoot, eventPayload, opts = {}) {
|
|
713
|
+
const { ledgerPath, lockPath } = ledgerPaths(scanRoot);
|
|
714
|
+
const dir = node_path__WEBPACK_IMPORTED_MODULE_2__.dirname(ledgerPath);
|
|
715
|
+
|
|
716
|
+
// Refused BEFORE the lock is taken, so an unsafe/disabled target never
|
|
717
|
+
// even gets a lockfile written into it.
|
|
718
|
+
if (!(0,_state_dir_js__WEBPACK_IMPORTED_MODULE_4__.isSafeStateDir)(dir)) {
|
|
719
|
+
return {
|
|
720
|
+
valid: false,
|
|
721
|
+
errors: [{ field: '(scanRoot)', message: 'refusing to write — not a recognized project state directory' }],
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
if (!(0,_state_dir_js__WEBPACK_IMPORTED_MODULE_4__.stateWritesEnabled)()) {
|
|
725
|
+
return {
|
|
726
|
+
valid: false,
|
|
727
|
+
errors: [{ field: '(state)', message: 'state writes are disabled (AGENTIC_SECURITY_NO_STATE or setStateWritesEnabled(false))' }],
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
return withLock(lockPath, async () => {
|
|
732
|
+
// I4 (final-review fix round 1): refuse to append onto a torn tail —
|
|
733
|
+
// concatenating a new event onto an unterminated final line would
|
|
734
|
+
// merge them into one unparseable line, silently losing this event
|
|
735
|
+
// (and everything after it) forever while still reporting success. A
|
|
736
|
+
// crash/ENOSPC mid-write leaves exactly this shape. Checked first,
|
|
737
|
+
// inside the lock, before anything else touches the file.
|
|
738
|
+
try {
|
|
739
|
+
const raw = node_fs__WEBPACK_IMPORTED_MODULE_0__.readFileSync(ledgerPath, 'utf8');
|
|
740
|
+
if (raw.length > 0 && !raw.endsWith('\n')) {
|
|
741
|
+
return {
|
|
742
|
+
valid: false,
|
|
743
|
+
errors: [{ field: '(ledger)', message: 'the ledger file has a torn/unterminated final line — refusing to append onto it. Recover the file (restore from backup, or manually truncate to its last complete, newline-terminated line) before retrying.' }],
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
} catch { /* missing file — nothing to check */ }
|
|
747
|
+
|
|
748
|
+
const { events, lastHash } = _walkLedger(scanRoot);
|
|
749
|
+
|
|
750
|
+
// I5 (final-review fix round 1): the authoritative optimistic-
|
|
751
|
+
// concurrency check, run against the real `lastHash` computed inside
|
|
752
|
+
// this same critical section — the CLI's own pre-lock check is still
|
|
753
|
+
// useful as a cheap, early fail, but this is the one that cannot be
|
|
754
|
+
// raced by a concurrent writer.
|
|
755
|
+
if (opts.expectedBaseHash !== undefined && opts.expectedBaseHash !== lastHash) {
|
|
756
|
+
return {
|
|
757
|
+
valid: false,
|
|
758
|
+
errors: [{ field: '(base-event)', message: 'the ledger changed since --base-event was computed (a concurrent write) — refusing to append.' }],
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
const items = (0,_lineage_remediation_js__WEBPACK_IMPORTED_MODULE_5__.foldRemediationLedger)(events);
|
|
763
|
+
const itemId = _resolveItemId(eventPayload);
|
|
764
|
+
const item = itemId != null ? (items[itemId] ?? null) : null;
|
|
765
|
+
|
|
766
|
+
// M11: an `opened` event's own shape is validated here too, not just
|
|
767
|
+
// by the CLI — mirrors "the single place validity is enforced" for
|
|
768
|
+
// the one event type `validateTransition` deliberately does not
|
|
769
|
+
// shape-check (it only checks that no item with this id exists yet).
|
|
770
|
+
if (eventPayload && eventPayload.type === 'opened') {
|
|
771
|
+
const openCheck = (0,_lineage_remediation_js__WEBPACK_IMPORTED_MODULE_5__.validateOpenPayload)(eventPayload);
|
|
772
|
+
if (!openCheck.valid) {
|
|
773
|
+
return { valid: false, errors: openCheck.errors };
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
const { valid, errors } = (0,_lineage_remediation_js__WEBPACK_IMPORTED_MODULE_5__.validateTransition)(item, eventPayload);
|
|
778
|
+
if (!valid) {
|
|
779
|
+
return { valid: false, errors };
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const event = { ...eventPayload, prev: lastHash };
|
|
783
|
+
const line = JSON.stringify(event);
|
|
784
|
+
node_fs__WEBPACK_IMPORTED_MODULE_0__.mkdirSync(dir, { recursive: true });
|
|
785
|
+
node_fs__WEBPACK_IMPORTED_MODULE_0__.appendFileSync(ledgerPath, line + '\n', 'utf8');
|
|
786
|
+
return { valid: true, errors: [], event, hash: _sha(line) };
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
/***/ })
|
|
792
|
+
|
|
793
|
+
};
|