@clear-capabilities/agentic-security-scanner 0.162.0 → 0.163.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 +26 -0
- package/bin/agentic-security.js +87 -0
- package/dist/1340.index.js +1871 -0
- package/dist/3477.index.js +727 -0
- package/dist/3499.index.js +23 -3
- package/dist/4113.index.js +1 -1
- package/dist/5014.index.js +833 -0
- package/dist/5756.index.js +2 -2
- package/dist/6257.index.js +1 -1
- package/dist/6907.index.js +248 -0
- package/dist/6994.index.js +1 -1
- package/dist/7033.index.js +33 -5
- package/dist/7439.index.js +1822 -0
- package/dist/7838.index.js +1 -1
- package/dist/9560.index.js +2318 -23
- package/dist/957.index.js +1 -1
- package/dist/9999.index.js +43 -7
- package/dist/agentic-security.mjs +12 -12
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +33 -6
- package/src/capabilities/CLAUDE.md +114 -0
- package/src/capabilities/address.js +73 -0
- package/src/capabilities/attack-coverage.js +93 -0
- package/src/capabilities/decide.js +256 -0
- package/src/capabilities/delegate.js +69 -0
- package/src/capabilities/egress.js +20 -0
- package/src/capabilities/hook-advice.js +50 -0
- package/src/capabilities/index.js +32 -0
- package/src/capabilities/manifest.js +344 -0
- package/src/capabilities/outbound.js +189 -0
- package/src/capabilities/paths.js +57 -0
- package/src/capabilities/probes.js +211 -0
- package/src/capabilities/proxy.js +182 -0
- package/src/capabilities/reasons.js +67 -0
- package/src/capabilities/receipts.js +356 -0
- package/src/capabilities/records.js +58 -0
- package/src/capabilities/recovery.js +367 -0
- package/src/capabilities/report.js +81 -0
- package/src/capabilities/runner.js +236 -0
- package/src/capabilities/secrets.js +26 -0
- package/src/capabilities/tool-gate.js +119 -0
- package/src/capabilities/tool-registry.js +60 -0
- package/src/dataflow/CLAUDE.md +29 -0
- package/src/dataflow/catalog.js +21 -0
- package/src/dataflow/engine.js +20 -0
- package/src/dataflow/guard-dominance.js +352 -0
- package/src/discovery/CLAUDE.md +9 -0
- package/src/discovery/index.js +15 -1
- package/src/discovery/lenses.js +18 -0
- package/src/engine.js +80 -15
- package/src/integrations/index.js +5 -2
- package/src/ir/CLAUDE.md +14 -0
- package/src/ir/implicit-handler-params.js +74 -0
- package/src/ir/parser-cs.js +20 -0
- package/src/ir/parser-go.js +30 -13
- package/src/ir/parser-java.js +11 -0
- package/src/language/nix-eval-isolation.js +33 -5
- package/src/lineage/CLAUDE.md +31 -0
- package/src/lineage/deployment/adapter-cloud.js +202 -0
- package/src/lineage/deployment/adapter-compose.js +90 -0
- package/src/lineage/deployment/adapter-kit.js +142 -0
- package/src/lineage/deployment/adapter-kubernetes.js +247 -0
- package/src/lineage/deployment/attack-paths.js +443 -0
- package/src/lineage/deployment/boundaries-run.js +223 -0
- package/src/lineage/deployment/boundary-graph.js +567 -0
- package/src/lineage/deployment/cross-repo.js +253 -0
- package/src/lineage/deployment/drift.js +383 -0
- package/src/lineage/deployment/ingest.js +221 -0
- package/src/lineage/deployment/projection.js +244 -0
- package/src/lineage/deployment/trace-correlation.js +541 -0
- package/src/lineage/source-registry.js +19 -0
- package/src/mcp/CLAUDE.md +5 -2
- package/src/mcp/invariant-tools.js +64 -0
- package/src/mcp/portfolio-tools.js +79 -0
- package/src/mcp/redact.js +15 -0
- package/src/mcp/server.js +24 -3
- package/src/mcp/tools.js +13 -1
- package/src/posture/CLAUDE.md +332 -1
- package/src/posture/accuracy-scorecard.js +95 -4
- package/src/posture/assurance/rollback.js +214 -0
- package/src/posture/assurance/rollout.js +200 -0
- package/src/posture/business-logic.js +26 -4
- package/src/posture/evaluation/bakeoff.js +148 -0
- package/src/posture/evaluation/custody.js +200 -0
- package/src/posture/evaluation/deployment-ablation.js +416 -0
- package/src/posture/evaluation/dev-cases.js +65 -0
- package/src/posture/evaluation/economics.js +85 -0
- package/src/posture/evaluation/gates.js +161 -0
- package/src/posture/evaluation/grouping.js +101 -0
- package/src/posture/evaluation/interval.js +132 -0
- package/src/posture/evaluation/invariant-ablation.js +325 -0
- package/src/posture/evaluation/labels.js +200 -0
- package/src/posture/evaluation/protocol.js +308 -0
- package/src/posture/evaluation/release-gate.js +209 -0
- package/src/posture/evaluation/report.js +158 -0
- package/src/posture/evaluation/runner.js +273 -0
- package/src/posture/evaluation/scan-child.js +80 -0
- package/src/posture/evaluation/score.js +174 -0
- package/src/posture/evaluation/synthetic.js +90 -0
- package/src/posture/evaluation/why-missed.js +219 -0
- package/src/posture/fleet.js +11 -2
- package/src/posture/invariants/cli.js +95 -0
- package/src/posture/invariants/coverage.js +164 -0
- package/src/posture/invariants/export.js +178 -0
- package/src/posture/invariants/expressions.js +46 -0
- package/src/posture/invariants/lifecycle.js +289 -0
- package/src/posture/invariants/project-input.js +55 -0
- package/src/posture/invariants/repair.js +340 -0
- package/src/posture/invariants/run.js +45 -0
- package/src/posture/invariants/scenarios.js +311 -0
- package/src/posture/invariants/schema.js +167 -0
- package/src/posture/invariants/shrink.js +407 -0
- package/src/posture/invariants/state-assertions.js +165 -0
- package/src/posture/logic-claims.js +12 -0
- package/src/posture/model-routing.js +40 -0
- package/src/posture/oracles/adapters.js +172 -1
- package/src/posture/oracles/conformance.js +3 -2
- package/src/posture/oracles/oracle.js +23 -3
- package/src/posture/oracles/registry.js +1 -1
- package/src/posture/portfolio/backend.js +279 -0
- package/src/posture/portfolio/bundle.js +314 -0
- package/src/posture/portfolio/cli.js +142 -0
- package/src/posture/portfolio/manifest.js +263 -0
- package/src/posture/portfolio/progress.js +267 -0
- package/src/posture/portfolio/resume.js +130 -0
- package/src/posture/portfolio/retention.js +207 -0
- package/src/posture/portfolio/scheduler.js +488 -0
- package/src/posture/portfolio/signing.js +155 -0
- package/src/posture/portfolio/wording.js +59 -0
- package/src/posture/portfolio/work-units.js +405 -0
- package/src/posture/replay/replay.js +1 -0
- package/src/posture/routing/adapter.js +140 -0
- package/src/posture/routing/calibration.js +278 -0
- package/src/posture/routing/canary.js +141 -0
- package/src/posture/routing/control.js +35 -0
- package/src/posture/routing/decide.js +211 -0
- package/src/posture/routing/drift.js +137 -0
- package/src/posture/routing/economics.js +185 -0
- package/src/posture/routing/feedback.js +207 -0
- package/src/posture/routing/index.js +31 -0
- package/src/posture/routing/outcomes.js +281 -0
- package/src/posture/routing/promotion.js +291 -0
- package/src/posture/routing/receipts.js +59 -0
- package/src/posture/routing/report.js +142 -0
- package/src/posture/routing/shadow.js +53 -0
- package/src/posture/specification-mining.js +26 -0
- package/src/posture/verification/patch-negative.js +16 -4
- package/src/posture/verification/patch-promotion.js +10 -2
- package/src/report/index.js +51 -1
- package/src/sandbox/CLAUDE.md +10 -0
- package/src/sandbox/backend-userspace.js +91 -13
- package/dist/5613.index.js +0 -500
- package/dist/6239.index.js +0 -469
|
@@ -0,0 +1,833 @@
|
|
|
1
|
+
export const id = 5014;
|
|
2
|
+
export const ids = [5014,5144];
|
|
3
|
+
export const modules = {
|
|
4
|
+
|
|
5
|
+
/***/ 45144:
|
|
6
|
+
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
7
|
+
|
|
8
|
+
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
|
9
|
+
/* harmony export */ addLegalHold: () => (/* binding */ addLegalHold),
|
|
10
|
+
/* harmony export */ isUnderHold: () => (/* binding */ isUnderHold),
|
|
11
|
+
/* harmony export */ listLegalHolds: () => (/* binding */ listLegalHolds),
|
|
12
|
+
/* harmony export */ loadLegalHolds: () => (/* binding */ loadLegalHolds),
|
|
13
|
+
/* harmony export */ removeLegalHold: () => (/* binding */ removeLegalHold)
|
|
14
|
+
/* harmony export */ });
|
|
15
|
+
/* unused harmony export LEGAL_HOLD_FILE */
|
|
16
|
+
/* harmony import */ var node_fs__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(73024);
|
|
17
|
+
/* harmony import */ var _state_dir_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(31174);
|
|
18
|
+
/* harmony import */ var _artifact_registry_js__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(70471);
|
|
19
|
+
// FR-707 (assurance-hardening PRD): "Support legal hold and policy-
|
|
20
|
+
// authorized retention exceptions | Legal hold is identity-bound, reasoned,
|
|
21
|
+
// time-bounded where applicable, and auditable."
|
|
22
|
+
//
|
|
23
|
+
// A third instance of the recurring {owner, reason, expires_at} exception
|
|
24
|
+
// shape this codebase already uses twice — `posture/suppressions.js`'s
|
|
25
|
+
// pro-tier exception (scoped to a FINDING) and `posture/compliance-policy.js`'s
|
|
26
|
+
// structured `not-applicable` (scoped to a COMPLIANCE CONTROL). Per D-0025,
|
|
27
|
+
// these are deliberately distinct mechanisms serving different subjects, not
|
|
28
|
+
// one shared module — this file is the third subject: a STATE ARTIFACT.
|
|
29
|
+
//
|
|
30
|
+
// Field naming matches the existing two schemas' snake_case convention
|
|
31
|
+
// (`owner`, `reason`, `expires_at`) rather than inventing a fourth style.
|
|
32
|
+
//
|
|
33
|
+
// identity-bound -> `owner` (required, who placed the hold and is
|
|
34
|
+
// accountable for lifting it)
|
|
35
|
+
// reasoned -> `reason` (required — "we might need this later" is
|
|
36
|
+
// not a reason; same discipline suppressions.js
|
|
37
|
+
// already enforces for its own exceptions)
|
|
38
|
+
// time-bounded
|
|
39
|
+
// where applicable -> `expires_at` is OPTIONAL: a null/absent value is
|
|
40
|
+
// an INDEFINITE hold, which the acceptance
|
|
41
|
+
// criterion's own "where applicable" phrase
|
|
42
|
+
// explicitly allows (a genuine legal matter may have
|
|
43
|
+
// no known end date) — an ISO date value behaves
|
|
44
|
+
// exactly like FR-506/FR-1004's own expiring
|
|
45
|
+
// exceptions: once past, the hold is no longer
|
|
46
|
+
// active and the artifact is exposed to its normal
|
|
47
|
+
// retention TTL again.
|
|
48
|
+
// auditable -> persisted as a single JSON array under
|
|
49
|
+
// `.agentic-security/legal-holds.json` (itself
|
|
50
|
+
// registered as operator-config — an operator/legal
|
|
51
|
+
// team's own input, never scanner-written from scan
|
|
52
|
+
// results), readable via `listLegalHolds`.
|
|
53
|
+
//
|
|
54
|
+
// Consulted from TWO places, not one: `retention-policy.js#findExpiredArtifacts`
|
|
55
|
+
// (defense in depth for any caller reaching it directly) AND `cmdReset`
|
|
56
|
+
// itself for its PLAIN (non-`--expired`) path, which deletes every
|
|
57
|
+
// registered 'generated' artifact unconditionally and would otherwise blow
|
|
58
|
+
// through a hold that only gated TTL expiry.
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
const LEGAL_HOLD_FILE = 'legal-holds.json';
|
|
65
|
+
|
|
66
|
+
function _loadRaw(scanRoot) {
|
|
67
|
+
let fp;
|
|
68
|
+
try { fp = (0,_state_dir_js__WEBPACK_IMPORTED_MODULE_1__.statePath)(scanRoot, LEGAL_HOLD_FILE); } catch { return []; }
|
|
69
|
+
let raw;
|
|
70
|
+
try { raw = node_fs__WEBPACK_IMPORTED_MODULE_0__.readFileSync(fp, 'utf8'); } catch { return []; }
|
|
71
|
+
try {
|
|
72
|
+
const doc = JSON.parse(raw);
|
|
73
|
+
return Array.isArray(doc) ? doc : [];
|
|
74
|
+
} catch { return []; }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* All legal holds ever recorded for this project, malformed entries
|
|
79
|
+
* dropped rather than throwing. Never filters by expiry — see
|
|
80
|
+
* `isUnderHold`/`listLegalHolds` for that.
|
|
81
|
+
*/
|
|
82
|
+
function loadLegalHolds(scanRoot) {
|
|
83
|
+
return _loadRaw(scanRoot).filter(h => h && typeof h === 'object' && typeof h.artifact === 'string' && typeof h.owner === 'string' && typeof h.reason === 'string');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Is `artifactName` currently protected by an active (non-expired) hold?
|
|
88
|
+
* Returns the matching hold record, or null. Multiple holds on the same
|
|
89
|
+
* artifact are permitted (e.g. two independent legal matters); the first
|
|
90
|
+
* still-active one found is returned.
|
|
91
|
+
*/
|
|
92
|
+
function isUnderHold(artifactName, holds, now = Date.now()) {
|
|
93
|
+
for (const h of holds) {
|
|
94
|
+
if (h.artifact !== artifactName) continue;
|
|
95
|
+
if (!h.expires_at) return h; // indefinite hold — always active
|
|
96
|
+
const t = Date.parse(h.expires_at);
|
|
97
|
+
if (!Number.isFinite(t) || t >= now) return h;
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Active (non-expired) holds only, unless `includeExpired`. For
|
|
104
|
+
* `legal-hold list` / auditing.
|
|
105
|
+
*/
|
|
106
|
+
function listLegalHolds(scanRoot, { includeExpired = false, now = Date.now() } = {}) {
|
|
107
|
+
const holds = loadLegalHolds(scanRoot);
|
|
108
|
+
if (includeExpired) return holds;
|
|
109
|
+
return holds.filter(h => {
|
|
110
|
+
if (!h.expires_at) return true;
|
|
111
|
+
const t = Date.parse(h.expires_at);
|
|
112
|
+
return !Number.isFinite(t) || t >= now;
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Add a legal hold. Validates identity-bound + reasoned up front (both
|
|
118
|
+
* required, non-empty) and that `expires_at`, if given, is a parseable
|
|
119
|
+
* date in the future — an already-expired hold would be a hold that
|
|
120
|
+
* protects nothing, which is never a legitimate request. `artifact` must
|
|
121
|
+
* name a real registered artifact (artifact-registry.js) — a hold on an
|
|
122
|
+
* unrecognised name can never protect anything and almost always means a
|
|
123
|
+
* typo. Returns `{ok:true, hold}` or `{ok:false, reason}`; never throws.
|
|
124
|
+
*/
|
|
125
|
+
function addLegalHold(scanRoot, { artifact, owner, reason, expires_at } = {}) {
|
|
126
|
+
if (!artifact || typeof artifact !== 'string') return { ok: false, reason: '--artifact is required' };
|
|
127
|
+
if (!(0,_artifact_registry_js__WEBPACK_IMPORTED_MODULE_2__/* .isRegisteredArtifact */ .Jl)(artifact)) return { ok: false, reason: `"${artifact}" is not a registered state artifact` };
|
|
128
|
+
if (!owner || typeof owner !== 'string') return { ok: false, reason: '--owner is required (identity-bound)' };
|
|
129
|
+
if (!reason || typeof reason !== 'string') return { ok: false, reason: '--reason is required (reasoned)' };
|
|
130
|
+
if (expires_at) {
|
|
131
|
+
const t = Date.parse(expires_at);
|
|
132
|
+
if (!Number.isFinite(t)) return { ok: false, reason: 'expires_at must be a parseable date' };
|
|
133
|
+
if (t < Date.now()) return { ok: false, reason: 'expires_at is in the past — a hold that already expired protects nothing' };
|
|
134
|
+
}
|
|
135
|
+
const hold = { artifact, owner, reason, expires_at: expires_at || null, created_at: new Date().toISOString() };
|
|
136
|
+
const holds = _loadRaw(scanRoot);
|
|
137
|
+
holds.push(hold);
|
|
138
|
+
const fp = (0,_state_dir_js__WEBPACK_IMPORTED_MODULE_1__.statePath)(scanRoot, LEGAL_HOLD_FILE);
|
|
139
|
+
if (!(0,_state_dir_js__WEBPACK_IMPORTED_MODULE_1__/* .safeWriteState */ .Ep)(fp, JSON.stringify(holds, null, 2) + '\n')) {
|
|
140
|
+
return { ok: false, reason: 'state writes are disabled (--no-state) or this is not a safe state directory' };
|
|
141
|
+
}
|
|
142
|
+
return { ok: true, hold };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Remove every hold on `artifact` (lifting a hold, not letting it expire).
|
|
147
|
+
* Returns the number removed. A no-op (0) if none existed — never an error.
|
|
148
|
+
*/
|
|
149
|
+
function removeLegalHold(scanRoot, artifact) {
|
|
150
|
+
const holds = _loadRaw(scanRoot);
|
|
151
|
+
const remaining = holds.filter(h => !(h && h.artifact === artifact));
|
|
152
|
+
const removedCount = holds.length - remaining.length;
|
|
153
|
+
if (removedCount > 0) {
|
|
154
|
+
const fp = (0,_state_dir_js__WEBPACK_IMPORTED_MODULE_1__.statePath)(scanRoot, LEGAL_HOLD_FILE);
|
|
155
|
+
(0,_state_dir_js__WEBPACK_IMPORTED_MODULE_1__/* .safeWriteState */ .Ep)(fp, JSON.stringify(remaining, null, 2) + '\n');
|
|
156
|
+
}
|
|
157
|
+
return removedCount;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
/***/ }),
|
|
162
|
+
|
|
163
|
+
/***/ 25014:
|
|
164
|
+
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
// EXPORTS
|
|
168
|
+
__webpack_require__.d(__webpack_exports__, {
|
|
169
|
+
runPortfolioCommand: () => (/* binding */ runPortfolioCommand)
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// EXTERNAL MODULE: external "node:fs"
|
|
173
|
+
var external_node_fs_ = __webpack_require__(73024);
|
|
174
|
+
// EXTERNAL MODULE: external "node:path"
|
|
175
|
+
var external_node_path_ = __webpack_require__(76760);
|
|
176
|
+
// EXTERNAL MODULE: ./src/posture/assurance/config.js
|
|
177
|
+
var assurance_config = __webpack_require__(90385);
|
|
178
|
+
// EXTERNAL MODULE: ./src/posture/portfolio/wording.js
|
|
179
|
+
var wording = __webpack_require__(9886);
|
|
180
|
+
// EXTERNAL MODULE: ./src/posture/portfolio/work-units.js
|
|
181
|
+
var work_units = __webpack_require__(90987);
|
|
182
|
+
// EXTERNAL MODULE: ./src/posture/portfolio/scheduler.js
|
|
183
|
+
var scheduler = __webpack_require__(34563);
|
|
184
|
+
// EXTERNAL MODULE: ./src/posture/portfolio/progress.js
|
|
185
|
+
var progress = __webpack_require__(67439);
|
|
186
|
+
// EXTERNAL MODULE: external "node:crypto"
|
|
187
|
+
var external_node_crypto_ = __webpack_require__(77598);
|
|
188
|
+
// EXTERNAL MODULE: ./src/posture/legal-hold.js
|
|
189
|
+
var legal_hold = __webpack_require__(45144);
|
|
190
|
+
// EXTERNAL MODULE: ./src/posture/assurance/identity.js
|
|
191
|
+
var identity = __webpack_require__(41877);
|
|
192
|
+
;// CONCATENATED MODULE: ./src/posture/portfolio/retention.js
|
|
193
|
+
// Evidence retention for portfolio artifacts (X-708.AC01).
|
|
194
|
+
//
|
|
195
|
+
// `posture/retention-policy.js` ages the scanner's own registered state files. A portfolio keeps a different population: replay
|
|
196
|
+
// evidence that lets someone re-run a check, metadata that describes a release, traces of model calls, and secrets that must not
|
|
197
|
+
// outlive their use. They differ in how long they should live, so a record here has a CLASS, and the policy sets a retention per class:
|
|
198
|
+
//
|
|
199
|
+
// replay-evidence replay manifests and verification receipts. Kept longest: they are what makes a claim re-checkable.
|
|
200
|
+
// metadata manifests, findings, provenance, toolchain identities.
|
|
201
|
+
// model-trace records of model calls (prompts, responses, routing decisions). Short: they hold the most sensitive content.
|
|
202
|
+
// secret anything credential-like captured in the course of a run. Default 0 days (delete at the next sweep) and a hard
|
|
203
|
+
// ceiling of 7 days. A secret is never exported.
|
|
204
|
+
//
|
|
205
|
+
// `RETENTION_DEFAULTS` are engineering defaults, not a regulatory claim; an operator sets their own per class, and a configured value
|
|
206
|
+
// above the class's `maxDays` is clamped down and the clamp is disclosed (a TTL that can be configured to "never" is not a TTL), the
|
|
207
|
+
// same rule retention-policy.js applies.
|
|
208
|
+
//
|
|
209
|
+
// THE ORDER OF PROTECTION, each tested in both directions:
|
|
210
|
+
// 1. a REQUIRED CURRENT receipt is never deleted, whatever its age and whether or not a hold exists. A record is required when it
|
|
211
|
+
// is named in `currentReceiptIds`, or when a unit listed in its `requiredBy` is `verified` in the store right now. When it is also
|
|
212
|
+
// past its retention the plan says so (`expiredButRequired`): the deletion is blocked loudly, never skipped silently. Once the unit
|
|
213
|
+
// goes stale, is cancelled or is re-planned, the record is no longer required and ages out normally.
|
|
214
|
+
// 2. a LEGAL HOLD (identity-bound, reasoned, optionally expiring: the shape and the check of posture/legal-hold.js, which this reuses)
|
|
215
|
+
// on the record, its class or its repository keeps it. A hold on a `secret` is honoured and flagged, because a hold is a legal act.
|
|
216
|
+
// 3. otherwise a record past its class's retention is deleted; a record in a class the policy does not know is KEPT and reported.
|
|
217
|
+
//
|
|
218
|
+
// DELETION IS LOGGED, AND LOGGED FIRST. Each deletion appends an intent entry to a hash-chained log (record id, class, path, size, content
|
|
219
|
+
// digest, reason, age, policy version, actor) and only then removes the file; an outcome entry follows. If the log cannot be written
|
|
220
|
+
// nothing is deleted. The log records that a deletion happened and what it was, never the content. Paths are confined to the root,
|
|
221
|
+
// symlinks are refused, and a directory record is refused (not recursed into).
|
|
222
|
+
//
|
|
223
|
+
// `planRetention` is pure. `applyRetention` re-plans at the moment of deletion with the live store, so a plan made earlier cannot delete
|
|
224
|
+
// a record that has become required since.
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
const RETENTION_CLASSES = Object.freeze(['replay-evidence', 'metadata', 'model-trace', 'secret']);
|
|
233
|
+
const RETENTION_DEFAULTS = Object.freeze({
|
|
234
|
+
'replay-evidence': Object.freeze({ defaultDays: 365, maxDays: 1095 }),
|
|
235
|
+
metadata: Object.freeze({ defaultDays: 730, maxDays: 1825 }),
|
|
236
|
+
'model-trace': Object.freeze({ defaultDays: 30, maxDays: 90 }),
|
|
237
|
+
secret: Object.freeze({ defaultDays: 0, maxDays: 7 }),
|
|
238
|
+
});
|
|
239
|
+
const RETENTION_LOG_SCHEMA = 'agentic-security/retention-log';
|
|
240
|
+
const DAY = 86_400_000;
|
|
241
|
+
const BUNDLE_ROLE_CLASS = Object.freeze({ 'replay-manifest': 'replay-evidence', receipt: 'replay-evidence', manifest: 'metadata', findings: 'metadata', provenance: 'metadata', toolchain: 'metadata' });
|
|
242
|
+
|
|
243
|
+
/** The retention class of a portfolio bundle role (bundle.js ROLES). An unknown role has no class and is never deleted by this module. */
|
|
244
|
+
const classOfBundleRole = (role) => BUNDLE_ROLE_CLASS[role] ?? null;
|
|
245
|
+
|
|
246
|
+
/** Validate a policy `{ version, classes: { <class>: { retainDays } } }` and return the effective days per class, with any clamp disclosed. */
|
|
247
|
+
function resolveRetentionPolicy(policy) {
|
|
248
|
+
const errors = [];
|
|
249
|
+
if (policy !== undefined && policy !== null && (typeof policy !== 'object' || Array.isArray(policy))) return { ok: false, errors: [{ code: 'BAD_POLICY', message: 'a policy is an object' }] };
|
|
250
|
+
const version = policy?.version ?? 'defaults';
|
|
251
|
+
if (typeof version !== 'string' || !version) errors.push({ code: 'BAD_POLICY', message: 'version must be a non-empty string' });
|
|
252
|
+
const effective = {};
|
|
253
|
+
for (const cls of Object.keys(policy?.classes ?? {})) if (!RETENTION_CLASSES.includes(cls)) errors.push({ code: 'UNKNOWN_CLASS', message: `'${cls}' is not a retention class` });
|
|
254
|
+
for (const cls of RETENTION_CLASSES) {
|
|
255
|
+
const b = RETENTION_DEFAULTS[cls];
|
|
256
|
+
const set = policy?.classes?.[cls]?.retainDays;
|
|
257
|
+
if (set !== undefined && !(typeof set === 'number' && Number.isFinite(set) && set >= 0)) { errors.push({ code: 'BAD_DAYS', message: `${cls}.retainDays must be a non-negative number` }); continue; }
|
|
258
|
+
const days = set === undefined ? b.defaultDays : Math.min(set, b.maxDays);
|
|
259
|
+
effective[cls] = { days, source: set === undefined ? 'default' : 'policy', clamped: set !== undefined && set > b.maxDays, maxDays: b.maxDays };
|
|
260
|
+
}
|
|
261
|
+
return errors.length ? { ok: false, errors } : { ok: true, version, effective };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Validate legal holds. A hold is `{ target, owner, reason, expires_at? }` with `target` one of `{ id }`, `{ class }`, `{ repository }`.
|
|
266
|
+
* Identity-bound (owner), reasoned (reason), and an expiry, when present, must parse. Returns the holds in the shape legal-hold.js reads.
|
|
267
|
+
*/
|
|
268
|
+
function normalizeHolds(holds) {
|
|
269
|
+
const errors = []; const out = [];
|
|
270
|
+
for (const [i, h] of (Array.isArray(holds) ? holds : []).entries()) {
|
|
271
|
+
const t = h?.target;
|
|
272
|
+
const key = t && typeof t === 'object' ? (t.id ? `id:${t.id}` : t.class ? `class:${t.class}` : t.repository ? `repository:${t.repository}` : null) : null;
|
|
273
|
+
if (!key) { errors.push({ code: 'BAD_HOLD', index: i, message: 'a hold targets an id, a class or a repository' }); continue; }
|
|
274
|
+
if (t.class && !RETENTION_CLASSES.includes(t.class)) { errors.push({ code: 'BAD_HOLD', index: i, message: `'${t.class}' is not a retention class` }); continue; }
|
|
275
|
+
if (typeof h.owner !== 'string' || !h.owner) { errors.push({ code: 'BAD_HOLD', index: i, message: 'a hold needs an owner (identity-bound)' }); continue; }
|
|
276
|
+
if (typeof h.reason !== 'string' || !h.reason) { errors.push({ code: 'BAD_HOLD', index: i, message: 'a hold needs a reason' }); continue; }
|
|
277
|
+
if (h.expires_at && !Number.isFinite(Date.parse(h.expires_at))) { errors.push({ code: 'BAD_HOLD', index: i, message: 'expires_at must be a parseable date' }); continue; }
|
|
278
|
+
out.push({ artifact: key, owner: h.owner, reason: h.reason, expires_at: h.expires_at ?? null });
|
|
279
|
+
}
|
|
280
|
+
return { ok: errors.length === 0, errors, holds: out };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const createdMs = (r) => (typeof r.createdAt === 'number' ? r.createdAt : Date.parse(r.createdAt));
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* @param {object} p
|
|
287
|
+
* @param {Array<{id:string, class:string, path:string, createdAt:number|string, repository?:string, requiredBy?:string[]}>} p.records
|
|
288
|
+
* @param {object} [p.policy]
|
|
289
|
+
* @param {Array} [p.holds]
|
|
290
|
+
* @param {number} p.now milliseconds
|
|
291
|
+
* @param {object} [p.store] a portfolio store: a record whose `requiredBy` names a verified unit is a required current receipt
|
|
292
|
+
* @param {Iterable<string>} [p.currentReceiptIds]
|
|
293
|
+
*/
|
|
294
|
+
function planRetention({ records, policy, holds = [], now, store = null, currentReceiptIds = [] } = {}) {
|
|
295
|
+
const pol = resolveRetentionPolicy(policy);
|
|
296
|
+
if (!pol.ok) return { ok: false, errors: pol.errors };
|
|
297
|
+
const h = normalizeHolds(holds);
|
|
298
|
+
if (!h.ok) return { ok: false, errors: h.errors };
|
|
299
|
+
if (!Number.isFinite(now)) return { ok: false, errors: [{ code: 'NO_NOW', message: 'now (milliseconds) is required' }] };
|
|
300
|
+
const required = new Set(currentReceiptIds);
|
|
301
|
+
const decisions = [];
|
|
302
|
+
for (const r of Array.isArray(records) ? records : []) {
|
|
303
|
+
const base = { id: r?.id ?? null, class: r?.class ?? null, path: r?.path ?? null };
|
|
304
|
+
if (!r || typeof r.id !== 'string' || !r.id || typeof r.path !== 'string' || !Number.isFinite(createdMs(r))) { decisions.push({ ...base, action: 'keep', reason: 'malformed-record', detail: 'a record needs an id, a path and a creation time; it is not deleted' }); continue; }
|
|
305
|
+
if (!RETENTION_CLASSES.includes(r.class)) { decisions.push({ ...base, action: 'keep', reason: 'unclassified', detail: `'${r.class}' is not a retention class; unclassified records are never deleted` }); continue; }
|
|
306
|
+
const ageDays = (now - createdMs(r)) / DAY;
|
|
307
|
+
const ttl = pol.effective[r.class].days;
|
|
308
|
+
const expired = ageDays > ttl;
|
|
309
|
+
const requiredNow = required.has(r.id) || (Array.isArray(r.requiredBy) && r.requiredBy.some((uid) => store?.units?.[uid]?.state === 'verified'));
|
|
310
|
+
const d = { ...base, ageDays: Math.round(ageDays * 100) / 100, ttlDays: ttl, expired };
|
|
311
|
+
if (requiredNow) { decisions.push({ ...d, action: 'protect', reason: 'required-current-receipt', expiredButRequired: expired, detail: expired ? 'past its retention, but a current verified result depends on it: deletion is blocked' : 'a current verified result depends on it' }); continue; }
|
|
312
|
+
const names = [`id:${r.id}`, `class:${r.class}`, ...(r.repository ? [`repository:${r.repository}`] : [])];
|
|
313
|
+
const hold = names.map((n) => (0,legal_hold.isUnderHold)(n, h.holds, now)).find(Boolean);
|
|
314
|
+
if (hold) { decisions.push({ ...d, action: 'keep', reason: 'legal-hold', hold: { target: hold.artifact, owner: hold.owner, reason: hold.reason, expires_at: hold.expires_at }, ...(r.class === 'secret' ? { warning: 'a secret is being retained under a legal hold' } : {}) }); continue; }
|
|
315
|
+
decisions.push(expired ? { ...d, action: 'delete', reason: r.class === 'secret' ? 'secret-expired' : 'expired' } : { ...d, action: 'keep', reason: 'within-retention' });
|
|
316
|
+
}
|
|
317
|
+
const count = (a) => decisions.filter((x) => x.action === a).length;
|
|
318
|
+
return {
|
|
319
|
+
ok: true, policyVersion: pol.version, effective: pol.effective, now, decisions,
|
|
320
|
+
summary: { records: decisions.length, delete: count('delete'), keep: count('keep'), protect: count('protect'), expiredButRequired: decisions.filter((x) => x.expiredButRequired).length, heldByLegalHold: decisions.filter((x) => x.reason === 'legal-hold').length },
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ---------------------------------------------------------------- the deletion log
|
|
325
|
+
|
|
326
|
+
const entryDigest = (e) => (0,identity/* digestOf */.ol)(e);
|
|
327
|
+
|
|
328
|
+
function readLog(file) {
|
|
329
|
+
let raw;
|
|
330
|
+
try { raw = external_node_fs_.readFileSync(file, 'utf8'); } catch (e) { if (e.code === 'ENOENT') return []; throw e; }
|
|
331
|
+
return raw.split('\n').filter(Boolean).map((l) => JSON.parse(l));
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Verify the hash chain of a deletion log. Returns `{ ok, entries, errors }`. */
|
|
335
|
+
function verifyRetentionLog(file) {
|
|
336
|
+
let entries;
|
|
337
|
+
try { entries = readLog(file); } catch { return { ok: false, entries: [], errors: [{ code: 'LOG_UNREADABLE', message: 'the deletion log is unreadable' }] }; }
|
|
338
|
+
const errors = []; let prev = null;
|
|
339
|
+
entries.forEach((e, i) => {
|
|
340
|
+
const { digest, ...body } = e;
|
|
341
|
+
if (e.seq !== i + 1 || e.prev !== prev || entryDigest(body) !== digest) errors.push({ code: 'LOG_BROKEN', seq: i + 1, message: `entry ${i + 1} was altered, removed or reordered` });
|
|
342
|
+
prev = digest;
|
|
343
|
+
});
|
|
344
|
+
return { ok: errors.length === 0, entries, errors };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function appendLog(file, entry) {
|
|
348
|
+
const v = verifyRetentionLog(file);
|
|
349
|
+
if (!v.ok) throw Object.assign(new Error('the deletion log fails verification; nothing will be deleted'), { code: 'LOG_BROKEN' });
|
|
350
|
+
const last = v.entries.at(-1);
|
|
351
|
+
const body = { schema: RETENTION_LOG_SCHEMA, seq: v.entries.length + 1, prev: last ? last.digest : null, ...entry };
|
|
352
|
+
external_node_fs_.mkdirSync(external_node_path_.dirname(external_node_path_.resolve(file)), { recursive: true });
|
|
353
|
+
const fd = external_node_fs_.openSync(file, 'a', 0o600);
|
|
354
|
+
try { external_node_fs_.writeSync(fd, `${JSON.stringify({ ...body, digest: entryDigest(body) })}\n`); external_node_fs_.fsyncSync(fd); } finally { external_node_fs_.closeSync(fd); }
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function confined(root, rel) {
|
|
358
|
+
if (external_node_path_.isAbsolute(rel) || rel.split(/[\\/]/).includes('..')) return null;
|
|
359
|
+
const abs = external_node_path_.resolve(root, rel);
|
|
360
|
+
const rootReal = external_node_fs_.realpathSync(root);
|
|
361
|
+
if (abs !== rootReal && !abs.startsWith(rootReal + external_node_path_.sep) && !abs.startsWith(external_node_path_.resolve(root) + external_node_path_.sep)) return null;
|
|
362
|
+
// a directory inside the root that is a link out of it must not carry a deletion out of the root either
|
|
363
|
+
const parentReal = external_node_fs_.realpathSync(external_node_path_.dirname(abs));
|
|
364
|
+
if (parentReal !== rootReal && !parentReal.startsWith(rootReal + external_node_path_.sep)) return null;
|
|
365
|
+
return abs;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Plan and apply, logging every deletion first. `dryRun` plans only. Returns the plan plus what happened.
|
|
370
|
+
* @returns {{ ok: boolean, plan?: object, deleted: object[], failed: object[], blocked: object[], errors?: object[] }}
|
|
371
|
+
*/
|
|
372
|
+
function applyRetention({ root, logFile, actor, dryRun = false, ...planInput } = {}) {
|
|
373
|
+
const plan = planRetention(planInput);
|
|
374
|
+
if (!plan.ok) return { ok: false, errors: plan.errors, deleted: [], failed: [], blocked: [] };
|
|
375
|
+
const blocked = plan.decisions.filter((d) => d.expiredButRequired).map((d) => ({ id: d.id, reason: d.reason, detail: d.detail }));
|
|
376
|
+
if (dryRun) return { ok: true, plan, dryRun: true, deleted: [], failed: [], blocked };
|
|
377
|
+
if (typeof actor !== 'string' || !actor) return { ok: false, errors: [{ code: 'NO_ACTOR', message: 'a deletion is attributed to an actor' }], plan, deleted: [], failed: [], blocked };
|
|
378
|
+
const deleted = []; const failed = [];
|
|
379
|
+
for (const d of plan.decisions.filter((x) => x.action === 'delete')) {
|
|
380
|
+
const fail = (code, message) => failed.push({ id: d.id, code, message });
|
|
381
|
+
let abs;
|
|
382
|
+
try { abs = confined(root, d.path); } catch { abs = null; }
|
|
383
|
+
if (!abs) { fail('PATH_ESCAPES_ROOT', 'the record path is not inside the retention root'); continue; }
|
|
384
|
+
let st;
|
|
385
|
+
try { st = external_node_fs_.lstatSync(abs); } catch (e) { fail(e.code === 'ENOENT' ? 'ALREADY_GONE' : 'STAT_FAILED', 'the file is not there to delete'); continue; }
|
|
386
|
+
if (st.isSymbolicLink()) { fail('SYMLINK_REFUSED', 'a symbolic link is not followed or removed'); continue; }
|
|
387
|
+
if (!st.isFile()) { fail('NOT_A_FILE', 'only files are deleted, never directories'); continue; }
|
|
388
|
+
const digest = `sha256:${external_node_crypto_.createHash('sha256').update(external_node_fs_.readFileSync(abs)).digest('hex')}`;
|
|
389
|
+
const entry = { recordId: d.id, class: d.class, path: d.path, bytes: st.size, contentDigest: digest, reason: d.reason, ageDays: d.ageDays, ttlDays: d.ttlDays, policyVersion: plan.policyVersion, actor, at: new Date(plan.now).toISOString() };
|
|
390
|
+
try { appendLog(logFile, { type: 'delete-intent', ...entry }); } catch (e) { fail(e.code ?? 'LOG_FAILED', 'the deletion could not be logged, so the file was not deleted'); continue; }
|
|
391
|
+
try { external_node_fs_.unlinkSync(abs); } catch (e) {
|
|
392
|
+
try { appendLog(logFile, { type: 'delete-failed', recordId: d.id, code: e.code ?? 'UNLINK_FAILED', at: entry.at }); } catch { /* the intent entry stands */ }
|
|
393
|
+
fail(e.code ?? 'UNLINK_FAILED', 'the file could not be removed'); continue;
|
|
394
|
+
}
|
|
395
|
+
try { appendLog(logFile, { type: 'deleted', recordId: d.id, contentDigest: digest, at: entry.at }); } catch { /* the intent entry already recorded it */ }
|
|
396
|
+
deleted.push({ id: d.id, class: d.class, path: d.path, contentDigest: digest });
|
|
397
|
+
}
|
|
398
|
+
return { ok: true, plan, deleted, failed, blocked };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// EXTERNAL MODULE: external "node:os"
|
|
402
|
+
var external_node_os_ = __webpack_require__(48161);
|
|
403
|
+
// EXTERNAL MODULE: ./src/posture/portfolio/bundle.js + 1 modules
|
|
404
|
+
var bundle = __webpack_require__(33889);
|
|
405
|
+
;// CONCATENATED MODULE: ./src/posture/portfolio/backend.js
|
|
406
|
+
// Pluggable storage backends and offline operation for portfolios (X-708.AC02, X-708.AC03).
|
|
407
|
+
//
|
|
408
|
+
// Everything in the portfolio works with no hosted service: state is plain files, written atomically, readable with the standard
|
|
409
|
+
// library. This module adds the one thing a single store file cannot give, a defined way to run it from SEVERAL workers or hosts that
|
|
410
|
+
// share a directory, and a defined answer when that directory is not there.
|
|
411
|
+
//
|
|
412
|
+
// THE BACKEND INTERFACE (what a replacement implements)
|
|
413
|
+
// kind 'local-fs' | 'shared-dir' (a label that appears in every report)
|
|
414
|
+
// describe() { kind, root, consistency } and what the backend does NOT claim
|
|
415
|
+
// probe() { ok: true } or { ok: false, state: 'blocked', code, reason }; never throws, never creates anything
|
|
416
|
+
// storeFile(name) the path of a named document, for the portfolio store and ledger
|
|
417
|
+
// withExclusive(name, fn) run `fn` while holding a cross-process lock; throws BACKEND_UNAVAILABLE if the backend went away
|
|
418
|
+
// acquireLease / renewLease / releaseLease / readLease
|
|
419
|
+
// a named, expiring, FENCED lease (below)
|
|
420
|
+
//
|
|
421
|
+
// REFERENCE IMPLEMENTATION. `FileLockBackend` uses exclusive file creation (`open(..., 'wx')`) in a directory. `createLocalBackend`
|
|
422
|
+
// makes the directory if needed (single user, durable restart). `createSharedBackend` will NOT: it requires a marker file written by
|
|
423
|
+
// an explicit `initSharedBackend`, so an unmounted path, a mistyped path or an empty mount point is a typed `blocked`, never a quietly
|
|
424
|
+
// fresh store.
|
|
425
|
+
//
|
|
426
|
+
// LEASE SEMANTICS (each tested, including two real processes contending):
|
|
427
|
+
// - mutual exclusion: at most one holder has an unexpired lease on a resource at a time;
|
|
428
|
+
// - fencing: every grant increments a `fence` number that never goes down (a release keeps it). A holder presents its fence on
|
|
429
|
+
// renew and release, so a holder that was replaced after its lease expired is refused and learns it lost the lease (`lost`);
|
|
430
|
+
// - expiry takeover: an expired lease can be taken by anyone; the previous holder's renew then fails;
|
|
431
|
+
// - release by anyone but the holder, or with a stale fence, is refused.
|
|
432
|
+
//
|
|
433
|
+
// WHAT IS NOT CLAIMED. These semantics were verified on one machine's local filesystem, with several processes. Exclusive file
|
|
434
|
+
// creation is atomic on a local POSIX filesystem; whether it is atomic on a particular network filesystem depends on that filesystem
|
|
435
|
+
// and its mount options, and this build has not tested any. A shared directory used from several HOSTS therefore needs the operator to
|
|
436
|
+
// verify exclusive-create atomicity on that mount, and lease expiry compares wall clocks, so hosts' clocks must agree to well within a
|
|
437
|
+
// lease. Stale-lock recovery (a holder that died inside a critical section) has a narrow window in which two recoverers can both
|
|
438
|
+
// believe they broke the lock; critical sections here last milliseconds, and `lockStaleMs` is far above that. `networkFilesystem` is
|
|
439
|
+
// reported `unverified` in `describe()`.
|
|
440
|
+
//
|
|
441
|
+
// UNAVAILABLE BACKEND. Selecting a shared backend that cannot be used returns `{ ok: false, state: 'blocked', code, reason }` from
|
|
442
|
+
// `openBackend`, and there is no fallback argument: nothing here ever "degrades" to a local write. Callers must stop, not substitute.
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
const BACKEND_MARKER = '.agentic-security-backend.json';
|
|
454
|
+
const BACKEND_MARKER_SCHEMA = 'agentic-security/portfolio-backend-marker';
|
|
455
|
+
const STATE_EXPORT_SCHEMA = 'agentic-security/portfolio-state-export';
|
|
456
|
+
const BLOCK_CODES = Object.freeze(['backend-missing', 'backend-not-initialized', 'backend-not-a-directory', 'backend-symlink', 'backend-not-writable', 'backend-marker-invalid']);
|
|
457
|
+
const BACKEND_LIMITS = Object.freeze({ lockStaleMs: 30_000, lockWaitMs: 5000, maxLeaseMs: 6 * 60 * 60_000, maxExportBytes: 64 * 1024 * 1024 });
|
|
458
|
+
const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
459
|
+
|
|
460
|
+
function sleepSync(ms) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); }
|
|
461
|
+
const unavailable = (probe) => (0,work_units/* typedError */.Zz)('BACKEND_UNAVAILABLE', `the portfolio backend is unavailable (${probe.code}): ${probe.reason}`, { state: 'blocked', probe });
|
|
462
|
+
|
|
463
|
+
/** The reference backend: exclusive-create file locks in a directory. */
|
|
464
|
+
class FileLockBackend {
|
|
465
|
+
constructor({ kind, root, requireMarker, lockStaleMs = BACKEND_LIMITS.lockStaleMs, lockWaitMs = BACKEND_LIMITS.lockWaitMs, nodeId = null }) {
|
|
466
|
+
this.kind = kind; this.root = external_node_path_.resolve(root); this.requireMarker = requireMarker;
|
|
467
|
+
this.lockStaleMs = lockStaleMs; this.lockWaitMs = lockWaitMs;
|
|
468
|
+
this.nodeId = nodeId ?? `${external_node_os_.hostname()}:${process.pid}`;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
describe() {
|
|
472
|
+
return {
|
|
473
|
+
kind: this.kind, root: this.root, requiresMarker: this.requireMarker,
|
|
474
|
+
consistency: {
|
|
475
|
+
mutualExclusion: 'verified for processes sharing one local filesystem (two-process contention test)',
|
|
476
|
+
fencing: 'monotonic per resource; a stale holder is refused on renew and release',
|
|
477
|
+
networkFilesystem: 'unverified',
|
|
478
|
+
clocks: 'lease expiry compares wall clocks; hosts sharing a directory must agree to well within a lease',
|
|
479
|
+
staleLockRecovery: `a lock file older than ${this.lockStaleMs} ms is treated as abandoned; the recovery has a narrow race and is not a substitute for short critical sections`,
|
|
480
|
+
},
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
probe() {
|
|
485
|
+
let st;
|
|
486
|
+
try { st = external_node_fs_.lstatSync(this.root); } catch { return { ok: false, state: 'blocked', code: 'backend-missing', reason: `${this.kind} backend directory does not exist or cannot be read` }; }
|
|
487
|
+
if (st.isSymbolicLink()) return { ok: false, state: 'blocked', code: 'backend-symlink', reason: 'the backend directory is a symbolic link; it is not followed' };
|
|
488
|
+
if (!st.isDirectory()) return { ok: false, state: 'blocked', code: 'backend-not-a-directory', reason: 'the backend path is not a directory' };
|
|
489
|
+
if (this.requireMarker) {
|
|
490
|
+
let m;
|
|
491
|
+
try { m = JSON.parse(external_node_fs_.readFileSync(external_node_path_.join(this.root, BACKEND_MARKER), 'utf8')); } catch (e) {
|
|
492
|
+
return { ok: false, state: 'blocked', code: e.code === 'ENOENT' ? 'backend-not-initialized' : 'backend-marker-invalid', reason: e.code === 'ENOENT' ? 'the shared directory has no backend marker: it is not initialized (an unmounted or empty directory is not a store)' : 'the backend marker is unreadable' };
|
|
493
|
+
}
|
|
494
|
+
if (m?.schema !== BACKEND_MARKER_SCHEMA || m.kind !== this.kind) return { ok: false, state: 'blocked', code: 'backend-marker-invalid', reason: 'the backend marker does not describe this kind of backend' };
|
|
495
|
+
}
|
|
496
|
+
try {
|
|
497
|
+
const probeFile = external_node_path_.join(this.root, `.probe-${process.pid}-${external_node_crypto_.randomBytes(4).toString('hex')}`);
|
|
498
|
+
external_node_fs_.writeFileSync(probeFile, '', { flag: 'wx' }); external_node_fs_.unlinkSync(probeFile);
|
|
499
|
+
} catch { return { ok: false, state: 'blocked', code: 'backend-not-writable', reason: 'the backend directory is not writable' }; }
|
|
500
|
+
return { ok: true };
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
storeFile(name = 'portfolio-store.json') {
|
|
504
|
+
if (!NAME_RE.test(name)) throw (0,work_units/* typedError */.Zz)('BAD_NAME', `'${name}' is not an allowed document name`);
|
|
505
|
+
return external_node_path_.join(this.root, name);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
_lockPath(name) {
|
|
509
|
+
if (!NAME_RE.test(name)) throw (0,work_units/* typedError */.Zz)('BAD_NAME', `'${name}' is not an allowed lock name`);
|
|
510
|
+
return external_node_path_.join(this.root, '.locks', `${name}.lock`);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** Run `fn` under the named cross-process lock. The backend is re-probed first: a backend that went away is an error, not an empty store. */
|
|
514
|
+
withExclusive(name, fn, { waitMs = this.lockWaitMs } = {}) {
|
|
515
|
+
const p = this.probe();
|
|
516
|
+
if (!p.ok) throw unavailable(p);
|
|
517
|
+
const lock = this._lockPath(name);
|
|
518
|
+
external_node_fs_.mkdirSync(external_node_path_.dirname(lock), { recursive: true });
|
|
519
|
+
const token = external_node_crypto_.randomBytes(12).toString('hex');
|
|
520
|
+
const body = JSON.stringify({ token, node: this.nodeId, pid: process.pid });
|
|
521
|
+
const start = Date.now();
|
|
522
|
+
for (;;) {
|
|
523
|
+
try { external_node_fs_.writeFileSync(lock, body, { flag: 'wx' }); break; } catch (e) {
|
|
524
|
+
if (e.code !== 'EEXIST') throw e;
|
|
525
|
+
this._breakIfStale(lock);
|
|
526
|
+
if (Date.now() - start > waitMs) throw (0,work_units/* typedError */.Zz)('LOCK_TIMEOUT', `could not take lock '${name}' within ${waitMs} ms`);
|
|
527
|
+
sleepSync(3);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
try { return fn(); } finally {
|
|
531
|
+
// release only OUR lock: if it was recovered as stale and re-taken, the file belongs to someone else now
|
|
532
|
+
try { if (JSON.parse(external_node_fs_.readFileSync(lock, 'utf8')).token === token) external_node_fs_.unlinkSync(lock); } catch { /* already gone */ }
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
_breakIfStale(lock) {
|
|
537
|
+
let st; let seen;
|
|
538
|
+
try { st = external_node_fs_.statSync(lock); seen = JSON.parse(external_node_fs_.readFileSync(lock, 'utf8')).token; } catch { return; }
|
|
539
|
+
if (Date.now() - st.mtimeMs <= this.lockStaleMs) return;
|
|
540
|
+
const grave = `${lock}.broken-${process.pid}-${external_node_crypto_.randomBytes(3).toString('hex')}`;
|
|
541
|
+
try { external_node_fs_.renameSync(lock, grave); } catch { return; }
|
|
542
|
+
let got = null;
|
|
543
|
+
try { got = JSON.parse(external_node_fs_.readFileSync(grave, 'utf8')).token; } catch { /* unreadable: treated as the stale one */ }
|
|
544
|
+
if (got !== null && got !== seen) { try { external_node_fs_.linkSync(grave, lock); } catch { /* the lock was re-taken meanwhile: the documented narrow race */ } }
|
|
545
|
+
try { external_node_fs_.unlinkSync(grave); } catch { /* ignore */ }
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
_leaseFile(resource) {
|
|
549
|
+
if (!NAME_RE.test(resource)) throw (0,work_units/* typedError */.Zz)('BAD_NAME', `'${resource}' is not an allowed lease name`);
|
|
550
|
+
return external_node_path_.join(this.root, 'leases', `${resource}.json`);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
_readLease(resource) {
|
|
554
|
+
try { return JSON.parse(external_node_fs_.readFileSync(this._leaseFile(resource), 'utf8')); } catch (e) { if (e.code === 'ENOENT') return null; throw (0,work_units/* typedError */.Zz)('LEASE_CORRUPT', `lease '${resource}' is unreadable`); }
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
_writeLease(resource, doc) {
|
|
558
|
+
const file = this._leaseFile(resource);
|
|
559
|
+
external_node_fs_.mkdirSync(external_node_path_.dirname(file), { recursive: true });
|
|
560
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
561
|
+
external_node_fs_.writeFileSync(tmp, JSON.stringify(doc), { mode: 0o600 });
|
|
562
|
+
external_node_fs_.renameSync(tmp, file);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
readLease(resource) { return this.withExclusive(`lease-${resource}`, () => this._readLease(resource)); }
|
|
566
|
+
|
|
567
|
+
acquireLease(resource, { holder, ttlMs, now }) {
|
|
568
|
+
if (typeof holder !== 'string' || !holder) throw (0,work_units/* typedError */.Zz)('BAD_HOLDER', 'a lease needs a holder');
|
|
569
|
+
if (!(Number.isFinite(ttlMs) && ttlMs >= 1) || !Number.isFinite(now)) throw (0,work_units/* typedError */.Zz)('BAD_LEASE', 'a lease needs a positive ttlMs and a now');
|
|
570
|
+
const ttl = Math.min(ttlMs, BACKEND_LIMITS.maxLeaseMs);
|
|
571
|
+
return this.withExclusive(`lease-${resource}`, () => {
|
|
572
|
+
const cur = this._readLease(resource);
|
|
573
|
+
if (cur && cur.holder && cur.expiresAt > now) return { ok: false, code: 'held', heldBy: cur.holder, expiresAt: cur.expiresAt, fence: cur.fence };
|
|
574
|
+
const fence = (cur?.fence ?? 0) + 1;
|
|
575
|
+
const doc = { resource, holder, fence, expiresAt: now + ttl, acquiredAt: now, node: this.nodeId };
|
|
576
|
+
this._writeLease(resource, doc);
|
|
577
|
+
return { ok: true, fence, expiresAt: doc.expiresAt, takenOver: !!(cur && cur.holder) };
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
renewLease(resource, { holder, fence, ttlMs, now }) {
|
|
582
|
+
const ttl = Math.min(ttlMs, BACKEND_LIMITS.maxLeaseMs);
|
|
583
|
+
return this.withExclusive(`lease-${resource}`, () => {
|
|
584
|
+
const cur = this._readLease(resource);
|
|
585
|
+
if (!cur || cur.holder !== holder || cur.fence !== fence) return { ok: false, code: 'lost', reason: 'the lease is held by another holder or generation' };
|
|
586
|
+
if (cur.expiresAt <= now) return { ok: false, code: 'expired', reason: 'the lease expired; it must be acquired again' };
|
|
587
|
+
cur.expiresAt = now + ttl;
|
|
588
|
+
this._writeLease(resource, cur);
|
|
589
|
+
return { ok: true, fence, expiresAt: cur.expiresAt };
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
releaseLease(resource, { holder, fence }) {
|
|
594
|
+
return this.withExclusive(`lease-${resource}`, () => {
|
|
595
|
+
const cur = this._readLease(resource);
|
|
596
|
+
if (!cur || cur.holder !== holder || cur.fence !== fence) return { ok: false, code: 'not-holder', reason: 'only the current holder, with its fence, can release' };
|
|
597
|
+
this._writeLease(resource, { resource, holder: null, fence: cur.fence, expiresAt: 0, node: this.nodeId });
|
|
598
|
+
return { ok: true };
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/** Create the marker that makes a directory a shared backend. An explicit operator act; `openBackend` never does it. */
|
|
604
|
+
function initSharedBackend(dir, { id = crypto.randomBytes(8).toString('hex') } = {}) {
|
|
605
|
+
const root = path.resolve(dir);
|
|
606
|
+
fs.mkdirSync(root, { recursive: true });
|
|
607
|
+
const marker = path.join(root, BACKEND_MARKER);
|
|
608
|
+
if (fs.existsSync(marker)) return { ok: true, created: false };
|
|
609
|
+
fs.writeFileSync(marker, JSON.stringify({ schema: BACKEND_MARKER_SCHEMA, kind: 'shared-dir', id }), { flag: 'wx' });
|
|
610
|
+
return { ok: true, created: true };
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const createLocalBackend = (dir, o = {}) => { external_node_fs_.mkdirSync(external_node_path_.resolve(dir), { recursive: true }); return new FileLockBackend({ kind: 'local-fs', root: dir, requireMarker: false, ...o }); };
|
|
614
|
+
const createSharedBackend = (dir, o = {}) => new FileLockBackend({ kind: 'shared-dir', root: dir, requireMarker: true, ...o });
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Select a backend. `mode` is 'local' or 'shared'. A shared backend that is not usable returns `{ ok: false, state: 'blocked', ... }`;
|
|
618
|
+
* there is no fallback to the local backend, by design.
|
|
619
|
+
*/
|
|
620
|
+
function openBackend({ mode, dir }) {
|
|
621
|
+
if (mode !== 'local' && mode !== 'shared') return { ok: false, state: 'blocked', code: 'invalid-mode', reason: "mode must be 'local' or 'shared'" };
|
|
622
|
+
if (typeof dir !== 'string' || !dir) return { ok: false, state: 'blocked', code: 'backend-missing', reason: 'a backend directory is required' };
|
|
623
|
+
const backend = mode === 'local' ? createLocalBackend(dir) : createSharedBackend(dir);
|
|
624
|
+
const p = backend.probe();
|
|
625
|
+
return p.ok ? { ok: true, backend } : p;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// ---------------------------------------------------------------- offline export and import
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* A single self-checking file holding the portfolio store and ledger (and optionally the deletion log), for moving state to an
|
|
632
|
+
* air-gapped machine or archiving it. Reads nothing from the network. Refused if the store fails verification or any text in it has a
|
|
633
|
+
* secret shape.
|
|
634
|
+
*/
|
|
635
|
+
function exportState({ storeFile, outFile, retentionLog = null, now }) {
|
|
636
|
+
const storeText = external_node_fs_.readFileSync(storeFile, 'utf8');
|
|
637
|
+
if (storeText.length > BACKEND_LIMITS.maxExportBytes) throw (0,work_units/* typedError */.Zz)('EXPORT_TOO_LARGE', 'the store is larger than the export limit');
|
|
638
|
+
const store = JSON.parse(storeText);
|
|
639
|
+
const v = (0,work_units/* verifyStore */.H9)(store);
|
|
640
|
+
if (!v.ok) throw (0,work_units/* typedError */.Zz)('STORE_CORRUPT', `refusing to export a store that fails verification: ${v.errors[0].code}`);
|
|
641
|
+
const ledger = (0,scheduler/* readLedger */.SC)(storeFile);
|
|
642
|
+
const log = retentionLog && external_node_fs_.existsSync(retentionLog) ? external_node_fs_.readFileSync(retentionLog, 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l)) : [];
|
|
643
|
+
const body = { schema: STATE_EXPORT_SCHEMA, version: 1, ...(store.synthetic ? { synthetic: true } : {}), exportedAtMs: now, store, ledger, retentionLog: log };
|
|
644
|
+
const secret = (0,bundle/* findSecret */.mt)(JSON.stringify(body));
|
|
645
|
+
if (secret) throw (0,work_units/* typedError */.Zz)('SECRET_IN_EXPORT', `the state contains a secret-shaped string (${secret}); it was not exported`);
|
|
646
|
+
const doc = { ...body, digest: (0,identity/* digestOf */.ol)(body) };
|
|
647
|
+
external_node_fs_.mkdirSync(external_node_path_.dirname(external_node_path_.resolve(outFile)), { recursive: true });
|
|
648
|
+
const tmp = `${outFile}.tmp-${process.pid}`;
|
|
649
|
+
external_node_fs_.writeFileSync(tmp, JSON.stringify(doc), { mode: 0o600 });
|
|
650
|
+
external_node_fs_.renameSync(tmp, outFile);
|
|
651
|
+
return { ok: true, digest: doc.digest, units: Object.keys(store.units).length };
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/** Offline verification of an export: re-derives its digest and verifies the store inside. Executes nothing. */
|
|
655
|
+
function verifyStateExport(file) {
|
|
656
|
+
let doc;
|
|
657
|
+
try {
|
|
658
|
+
const st = external_node_fs_.lstatSync(file);
|
|
659
|
+
if (st.isSymbolicLink() || !st.isFile() || st.size > BACKEND_LIMITS.maxExportBytes) return { ok: false, code: 'BAD_FILE', reason: 'not a regular file within the size limit' };
|
|
660
|
+
doc = JSON.parse(external_node_fs_.readFileSync(file, 'utf8'));
|
|
661
|
+
} catch { return { ok: false, code: 'BAD_FILE', reason: 'unreadable or not JSON' }; }
|
|
662
|
+
if (doc?.schema !== STATE_EXPORT_SCHEMA || doc.version !== 1) return { ok: false, code: 'BAD_SCHEMA', reason: 'not a portfolio state export of a supported version' };
|
|
663
|
+
const { digest, ...body } = doc;
|
|
664
|
+
if ((0,identity/* digestOf */.ol)(body) !== digest) return { ok: false, code: 'DIGEST_MISMATCH', reason: 'the export was modified after it was written' };
|
|
665
|
+
const v = (0,work_units/* verifyStore */.H9)(doc.store);
|
|
666
|
+
if (!v.ok) return { ok: false, code: 'STORE_INVALID', reason: `the store inside fails verification: ${v.errors[0].code}` };
|
|
667
|
+
return { ok: true, doc };
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/** Import into a backend that holds no portfolio store yet. Verifies first; never overwrites. */
|
|
671
|
+
function importState({ from, backend, name = 'portfolio-store.json' }) {
|
|
672
|
+
const p = backend.probe();
|
|
673
|
+
if (!p.ok) return p;
|
|
674
|
+
const v = verifyStateExport(from);
|
|
675
|
+
if (!v.ok) return { ok: false, state: 'rejected', code: v.code, reason: v.reason };
|
|
676
|
+
const file = backend.storeFile(name);
|
|
677
|
+
return backend.withExclusive('import', () => {
|
|
678
|
+
if (external_node_fs_.existsSync(file)) return { ok: false, state: 'rejected', code: 'WOULD_OVERWRITE', reason: 'the backend already holds a portfolio store; import never overwrites' };
|
|
679
|
+
external_node_fs_.writeFileSync(file, JSON.stringify(v.doc.store), { flag: 'wx', mode: 0o600 });
|
|
680
|
+
external_node_fs_.writeFileSync((0,scheduler/* ledgerPathFor */.GN)(file), JSON.stringify(v.doc.ledger), { flag: 'wx', mode: 0o600 });
|
|
681
|
+
return { ok: true, file, units: Object.keys(v.doc.store.units).length };
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
;// CONCATENATED MODULE: ./src/posture/portfolio/cli.js
|
|
687
|
+
// `agentic-security portfolio <progress|retention|backend|export|import>` (X-707, X-708 CLI surface).
|
|
688
|
+
//
|
|
689
|
+
// portfolio progress --store <file> [--budgets <file>] [--findings <file>] [--blocking-severity <s>] [--now <ms>] [--json]
|
|
690
|
+
// The coverage-aware progress view (see portfolio/progress.js). Exit 0, 1 refused (feature off, store failed verification), 2 usage.
|
|
691
|
+
// portfolio retention plan|apply --records <file> --root <dir> [--policy <file>] [--holds <file>] [--store <file>]
|
|
692
|
+
// [--log <file> --actor <name>] [--now <ms>] [--json]
|
|
693
|
+
// Retention by class with legal holds (see portfolio/retention.js). `plan` deletes nothing. `apply` needs --log and --actor, logs
|
|
694
|
+
// each deletion first, and exits 1 if anything it should have deleted could not be deleted. Exit 0 ok, 1 refused or incomplete, 2 usage.
|
|
695
|
+
// portfolio retention verify-log --log <file> checks the hash chain of the deletion log. Exit 0 intact, 1 broken.
|
|
696
|
+
// portfolio backend probe --mode local|shared --dir <dir>
|
|
697
|
+
// Reports whether a backend is usable. An unusable shared backend is the typed `blocked` state, exit 1; nothing is created.
|
|
698
|
+
// portfolio export --store <file> --out <file> [--retention-log <file>] one self-checking, secret-free file for an air-gapped machine
|
|
699
|
+
// portfolio import --from <file> --mode local|shared --dir <dir> verifies first; never overwrites a store
|
|
700
|
+
//
|
|
701
|
+
// Every subcommand is off unless the `portfolio-assurance` feature is on (assurance/config.js), so a default install behaves exactly as
|
|
702
|
+
// before. Nothing here makes a network call: the whole command works air-gapped. The logic lives here, not in bin/, so tests run it
|
|
703
|
+
// without spawning the CLI; bin/ only dispatches. Paths are the operator's own and resolve against the working directory (the MCP
|
|
704
|
+
// tool, which takes paths from an agent, confines them to its session root instead).
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
const USAGE = [
|
|
716
|
+
'Usage: agentic-security portfolio progress --store <file> [--budgets <file>] [--findings <file>] [--blocking-severity <s>] [--now <ms>] [--json]',
|
|
717
|
+
' agentic-security portfolio retention plan|apply --records <file> --root <dir> [--policy <file>] [--holds <file>] [--store <file>] [--log <file> --actor <name>] [--now <ms>] [--json]',
|
|
718
|
+
' agentic-security portfolio retention verify-log --log <file>',
|
|
719
|
+
' agentic-security portfolio backend probe --mode local|shared --dir <dir>',
|
|
720
|
+
' agentic-security portfolio export --store <file> --out <file> [--retention-log <file>]',
|
|
721
|
+
' agentic-security portfolio import --from <file> --mode local|shared --dir <dir>',
|
|
722
|
+
].join('\n');
|
|
723
|
+
|
|
724
|
+
const abs = (cwd, p) => external_node_path_.resolve(cwd, String(p));
|
|
725
|
+
const readJsonFile = (file) => {
|
|
726
|
+
const st = external_node_fs_.lstatSync(file);
|
|
727
|
+
if (st.isSymbolicLink() || !st.isFile() || st.size > 32 * 1024 * 1024) throw new Error('not a regular JSON file within the size limit');
|
|
728
|
+
return JSON.parse(external_node_fs_.readFileSync(file, 'utf8'));
|
|
729
|
+
};
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* @param {{ _: string[], flags: object }} args
|
|
733
|
+
* @param {{ cwd?: string, out?: (s: string) => void, err?: (s: string) => void, env?: object, now?: () => number }} [io]
|
|
734
|
+
* @returns {Promise<number>} the exit code
|
|
735
|
+
*/
|
|
736
|
+
async function runPortfolioCommand(args, io = {}) {
|
|
737
|
+
const cwd = io.cwd || process.cwd();
|
|
738
|
+
const out = io.out || ((s) => process.stdout.write(s));
|
|
739
|
+
const err = io.err || ((s) => process.stderr.write(s));
|
|
740
|
+
const sub = args._[1];
|
|
741
|
+
const flags = args.flags || {};
|
|
742
|
+
const clock = io.now || (() => Date.now());
|
|
743
|
+
const emit = (value, lines) => out(flags.json ? `${JSON.stringify(value, null, 2)}\n` : `${lines.join('\n')}\n`);
|
|
744
|
+
if (!['progress', 'retention', 'backend', 'export', 'import'].includes(sub)) { err(`${USAGE}\n`); return 2; }
|
|
745
|
+
|
|
746
|
+
const config = (0,assurance_config.resolveAssuranceConfig)({ scanRoot: cwd, env: io.env || process.env });
|
|
747
|
+
const gate = (0,assurance_config/* featureStatus */.FX)(config, wording/* FEATURE_ID */.DU);
|
|
748
|
+
if (gate.status !== 'ok') { err(`agentic-security portfolio ${sub}: ${gate.status}: ${gate.reason ?? gate.code}\n`); return 1; }
|
|
749
|
+
const nowMs = flags.now !== undefined ? Number(flags.now) : clock();
|
|
750
|
+
|
|
751
|
+
try {
|
|
752
|
+
if (sub === 'progress') {
|
|
753
|
+
if (typeof flags.store !== 'string') { err(`${USAGE}\n`); return 2; }
|
|
754
|
+
const storePath = abs(cwd, flags.store);
|
|
755
|
+
const store = (0,work_units/* readStore */.uz)(storePath);
|
|
756
|
+
if (!store) { err('agentic-security portfolio progress: no portfolio store at that path\n'); return 1; }
|
|
757
|
+
const r = (0,progress/* buildProgressView */.TJ)({
|
|
758
|
+
store, ledger: (0,scheduler/* readLedger */.SC)(storePath), now: nowMs,
|
|
759
|
+
budgets: typeof flags.budgets === 'string' ? readJsonFile(abs(cwd, flags.budgets)) : null,
|
|
760
|
+
findings: typeof flags.findings === 'string' ? readJsonFile(abs(cwd, flags.findings)) : null,
|
|
761
|
+
blockingSeverity: typeof flags['blocking-severity'] === 'string' ? flags['blocking-severity'] : undefined,
|
|
762
|
+
});
|
|
763
|
+
if (!r.ok) { err(`agentic-security portfolio progress: ${r.errors[0].message}\n`); return 1; }
|
|
764
|
+
emit(r.view, r.view.lines);
|
|
765
|
+
return 0;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
if (sub === 'retention') {
|
|
769
|
+
const mode = args._[2];
|
|
770
|
+
if (mode === 'verify-log') {
|
|
771
|
+
if (typeof flags.log !== 'string') { err(`${USAGE}\n`); return 2; }
|
|
772
|
+
const v = verifyRetentionLog(abs(cwd, flags.log));
|
|
773
|
+
emit(v, [v.ok ? `Deletion log verified: ${v.entries.length} entr${v.entries.length === 1 ? 'y' : 'ies'}, chain intact.` : `Deletion log FAILED verification: ${v.errors[0].message}`]);
|
|
774
|
+
return v.ok ? 0 : 1;
|
|
775
|
+
}
|
|
776
|
+
if (!['plan', 'apply'].includes(mode) || typeof flags.records !== 'string' || typeof flags.root !== 'string') { err(`${USAGE}\n`); return 2; }
|
|
777
|
+
const input = {
|
|
778
|
+
records: readJsonFile(abs(cwd, flags.records)), now: nowMs,
|
|
779
|
+
policy: typeof flags.policy === 'string' ? readJsonFile(abs(cwd, flags.policy)) : undefined,
|
|
780
|
+
holds: typeof flags.holds === 'string' ? readJsonFile(abs(cwd, flags.holds)) : [],
|
|
781
|
+
store: typeof flags.store === 'string' ? (0,work_units/* readStore */.uz)(abs(cwd, flags.store)) : null,
|
|
782
|
+
};
|
|
783
|
+
if (mode === 'plan') {
|
|
784
|
+
const plan = planRetention(input);
|
|
785
|
+
if (!plan.ok) { err(`agentic-security portfolio retention: ${plan.errors[0].message}\n`); return 1; }
|
|
786
|
+
emit(plan, [`Retention plan (policy ${plan.policyVersion}): ${plan.summary.delete} to delete, ${plan.summary.keep} kept (${plan.summary.heldByLegalHold} under legal hold), ${plan.summary.protect} protected as required current receipts${plan.summary.expiredButRequired ? ` (${plan.summary.expiredButRequired} past retention but required: deletion blocked)` : ''}.`, 'Nothing was deleted.']);
|
|
787
|
+
return 0;
|
|
788
|
+
}
|
|
789
|
+
if (typeof flags.log !== 'string' || typeof flags.actor !== 'string') { err(`${USAGE}\n`); return 2; }
|
|
790
|
+
const r = applyRetention({ ...input, root: abs(cwd, flags.root), logFile: abs(cwd, flags.log), actor: flags.actor });
|
|
791
|
+
if (!r.ok) { err(`agentic-security portfolio retention: ${r.errors[0].message}\n`); return 1; }
|
|
792
|
+
emit(r, [`Retention applied: ${r.deleted.length} deleted and logged, ${r.blocked.length} blocked as required current receipts, ${r.failed.length} could not be deleted.`, ...r.failed.map((f) => ` ${f.id}: ${f.code}: ${f.message}`)]);
|
|
793
|
+
return r.failed.length ? 1 : 0;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
if (sub === 'backend') {
|
|
797
|
+
if (args._[2] !== 'probe' || typeof flags.dir !== 'string') { err(`${USAGE}\n`); return 2; }
|
|
798
|
+
const b = openBackend({ mode: flags.mode, dir: abs(cwd, flags.dir) });
|
|
799
|
+
if (!b.ok) { emit(b, [`Backend ${flags.mode ?? '?'}: ${b.state} (${b.code}): ${b.reason}`, 'Nothing was created and no other location is used instead.']); return 1; }
|
|
800
|
+
const d = b.backend.describe();
|
|
801
|
+
emit({ ok: true, ...d }, [`Backend ${d.kind} at ${d.root}: usable.`, ` Network filesystems: ${d.consistency.networkFilesystem}.`]);
|
|
802
|
+
return 0;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
if (sub === 'export') {
|
|
806
|
+
if (typeof flags.store !== 'string' || typeof flags.out !== 'string') { err(`${USAGE}\n`); return 2; }
|
|
807
|
+
const r = exportState({ storeFile: abs(cwd, flags.store), outFile: abs(cwd, flags.out), retentionLog: typeof flags['retention-log'] === 'string' ? abs(cwd, flags['retention-log']) : null, now: nowMs });
|
|
808
|
+
emit(r, [`Exported ${r.units} unit(s) to ${flags.out} (${r.digest}).`]);
|
|
809
|
+
return 0;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
if (sub === 'import') {
|
|
813
|
+
if (typeof flags.from !== 'string' || typeof flags.dir !== 'string') { err(`${USAGE}\n`); return 2; }
|
|
814
|
+
const b = openBackend({ mode: flags.mode, dir: abs(cwd, flags.dir) });
|
|
815
|
+
if (!b.ok) { err(`agentic-security portfolio import: ${b.state} (${b.code}): ${b.reason}\n`); return 1; }
|
|
816
|
+
const v = verifyStateExport(abs(cwd, flags.from));
|
|
817
|
+
if (!v.ok) { err(`agentic-security portfolio import: ${v.code}: ${v.reason}\n`); return 1; }
|
|
818
|
+
const r = importState({ from: abs(cwd, flags.from), backend: b.backend });
|
|
819
|
+
if (!r.ok) { err(`agentic-security portfolio import: ${r.code}: ${r.reason}\n`); return 1; }
|
|
820
|
+
emit(r, [`Imported ${r.units} unit(s) into ${r.file}.`]);
|
|
821
|
+
return 0;
|
|
822
|
+
}
|
|
823
|
+
} catch (e) {
|
|
824
|
+
err(`agentic-security portfolio ${sub}: ${e.code ?? 'error'}: ${String(e.message).slice(0, 300)}\n`);
|
|
825
|
+
return 1;
|
|
826
|
+
}
|
|
827
|
+
return 2;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
/***/ })
|
|
832
|
+
|
|
833
|
+
};
|