@clear-capabilities/agentic-security-scanner 0.172.1 → 0.174.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (201) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/bin/agentic-security.js +37 -0
  3. package/dist/119.index.js +197 -0
  4. package/dist/128.index.js +129 -0
  5. package/dist/1293.index.js +5689 -0
  6. package/dist/1351.index.js +133 -0
  7. package/dist/1387.index.js +271 -0
  8. package/dist/1437.index.js +23 -0
  9. package/dist/1445.index.js +110 -0
  10. package/dist/1505.index.js +151 -0
  11. package/dist/1557.index.js +293 -0
  12. package/dist/1621.index.js +311 -0
  13. package/dist/1751.index.js +160 -0
  14. package/dist/1765.index.js +727 -0
  15. package/dist/1861.index.js +690 -0
  16. package/dist/1898.index.js +116 -0
  17. package/dist/1906.index.js +433 -0
  18. package/dist/1920.index.js +1 -291
  19. package/dist/208.index.js +1631 -0
  20. package/dist/2199.index.js +0 -239
  21. package/dist/2223.index.js +159 -0
  22. package/dist/2530.index.js +152 -0
  23. package/dist/2565.index.js +116 -0
  24. package/dist/2574.index.js +127 -0
  25. package/dist/2620.index.js +345 -0
  26. package/dist/2773.index.js +98 -0
  27. package/dist/2775.index.js +32 -0
  28. package/dist/2966.index.js +171 -0
  29. package/dist/2972.index.js +87 -0
  30. package/dist/2991.index.js +131 -0
  31. package/dist/3063.index.js +833 -0
  32. package/dist/3075.index.js +227 -0
  33. package/dist/3121.index.js +297 -0
  34. package/dist/3314.index.js +263 -0
  35. package/dist/3480.index.js +1822 -0
  36. package/dist/3481.index.js +24 -0
  37. package/dist/3506.index.js +268 -0
  38. package/dist/3533.index.js +591 -0
  39. package/dist/3653.index.js +450 -0
  40. package/dist/3822.index.js +131 -0
  41. package/dist/3868.index.js +292 -0
  42. package/dist/3917.index.js +152 -0
  43. package/dist/3918.index.js +192 -0
  44. package/dist/4090.index.js +288 -0
  45. package/dist/4121.index.js +1190 -0
  46. package/dist/4142.index.js +0 -239
  47. package/dist/4336.index.js +436 -0
  48. package/dist/4387.index.js +532 -0
  49. package/dist/4413.index.js +302 -0
  50. package/dist/4476.index.js +4510 -0
  51. package/dist/4491.index.js +133 -0
  52. package/dist/4538.index.js +185 -0
  53. package/dist/4684.index.js +319 -0
  54. package/dist/4740.index.js +165 -0
  55. package/dist/4780.index.js +209 -0
  56. package/dist/4802.index.js +225 -0
  57. package/dist/4812.index.js +379 -0
  58. package/dist/4835.index.js +126 -0
  59. package/dist/494.index.js +226 -0
  60. package/dist/4991.index.js +549 -0
  61. package/dist/5199.index.js +305 -0
  62. package/dist/5216.index.js +109 -0
  63. package/dist/5439.index.js +301 -0
  64. package/dist/5443.index.js +97 -0
  65. package/dist/5496.index.js +377 -0
  66. package/dist/550.index.js +2 -1
  67. package/dist/5801.index.js +588 -0
  68. package/dist/5858.index.js +223 -0
  69. package/dist/5933.index.js +143 -0
  70. package/dist/601.index.js +23 -0
  71. package/dist/6173.index.js +273 -0
  72. package/dist/6291.index.js +1871 -0
  73. package/dist/6312.index.js +666 -0
  74. package/dist/634.index.js +16182 -0
  75. package/dist/644.index.js +87 -0
  76. package/dist/6508.index.js +283 -0
  77. package/dist/6560.index.js +645 -0
  78. package/dist/6576.index.js +422 -0
  79. package/dist/6579.index.js +271 -0
  80. package/dist/6745.index.js +305 -0
  81. package/dist/6818.index.js +21642 -0
  82. package/dist/6972.index.js +167 -0
  83. package/dist/6995.index.js +163 -0
  84. package/dist/7249.index.js +191 -0
  85. package/dist/7372.index.js +1197 -0
  86. package/dist/7546.index.js +541 -0
  87. package/dist/76.index.js +448 -0
  88. package/dist/7879.index.js +163 -0
  89. package/dist/8025.index.js +130 -0
  90. package/dist/8054.index.js +248 -0
  91. package/dist/8097.index.js +201 -0
  92. package/dist/8126.index.js +191 -0
  93. package/dist/8178.index.js +78 -0
  94. package/dist/818.index.js +212 -0
  95. package/dist/8382.index.js +93 -0
  96. package/dist/8408.index.js +261 -0
  97. package/dist/8540.index.js +174 -0
  98. package/dist/8635.index.js +802 -0
  99. package/dist/8688.index.js +410 -0
  100. package/dist/8699.index.js +779 -0
  101. package/dist/8891.index.js +457 -0
  102. package/dist/8916.index.js +885 -0
  103. package/dist/9146.index.js +170 -0
  104. package/dist/9168.index.js +841 -0
  105. package/dist/9292.index.js +369 -0
  106. package/dist/9382.index.js +135 -11
  107. package/dist/9422.index.js +866 -0
  108. package/dist/9440.index.js +453 -0
  109. package/dist/9479.index.js +152 -0
  110. package/dist/9481.index.js +220 -0
  111. package/dist/9539.index.js +100 -0
  112. package/dist/9547.index.js +103 -0
  113. package/dist/9560.index.js +41 -61
  114. package/dist/9692.index.js +90 -0
  115. package/dist/9800.index.js +213 -1
  116. package/dist/9899.index.js +138 -0
  117. package/dist/9981.index.js +206 -0
  118. package/dist/999.index.js +117 -0
  119. package/dist/agentic-security.mjs +27 -27
  120. package/dist/agentic-security.mjs.sha256 +1 -1
  121. package/package.json +31 -6
  122. package/src/context-intelligence/candidates.js +2 -1
  123. package/src/context-intelligence/capabilities.js +2 -1
  124. package/src/context-intelligence/cli.js +2 -0
  125. package/src/context-intelligence/config.js +3 -1
  126. package/src/context-intelligence/controls.js +49 -1
  127. package/src/context-intelligence/counterexample.js +200 -0
  128. package/src/context-intelligence/cross-route.js +111 -0
  129. package/src/context-intelligence/findings.js +166 -0
  130. package/src/context-intelligence/invariants.js +197 -0
  131. package/src/context-intelligence/queue.js +6 -1
  132. package/src/context-intelligence/reachability.js +2 -2
  133. package/src/context-intelligence/review.js +68 -4
  134. package/src/dataflow/access-paths.js +11 -0
  135. package/src/dataflow/analysis-limits.js +66 -0
  136. package/src/dataflow/engine.js +282 -24
  137. package/src/dataflow/service-edges.js +174 -0
  138. package/src/dataflow/summaries.js +7 -1
  139. package/src/discovery/judge.js +2 -1
  140. package/src/engine.js +82 -14
  141. package/src/fix/apply-fix-service.js +55 -8
  142. package/src/fix/batch-journal.js +84 -0
  143. package/src/fix/exploit-replay.js +51 -0
  144. package/src/fix/footprint.js +65 -0
  145. package/src/fix/patch-policy.js +49 -0
  146. package/src/fix/secret-persistence.js +39 -0
  147. package/src/fix/test-commands.js +84 -0
  148. package/src/fix/verification-adapters.js +97 -0
  149. package/src/fix/verification-record.js +243 -0
  150. package/src/fix/verify-patch.js +89 -0
  151. package/src/ir/CLAUDE.md +5 -0
  152. package/src/ir/callgraph.js +23 -1
  153. package/src/ir/module-resolver.js +315 -0
  154. package/src/ir/parser-js.js +21 -2
  155. package/src/ir/semantic-graph.js +245 -0
  156. package/src/language/haskell-findings.js +2 -0
  157. package/src/language/haskell-guards.js +1 -1
  158. package/src/language/haskell-ir.js +3 -1
  159. package/src/language/haskell-models.js +94 -0
  160. package/src/language/haskell-security-rules.js +20 -0
  161. package/src/language/nixos-hardening.js +72 -0
  162. package/src/lineage/build-attested-correlation.js +63 -0
  163. package/src/lineage/deployment-identity.js +76 -0
  164. package/src/mcp/tools.js +13 -34
  165. package/src/pipeline/canonical-output.js +64 -0
  166. package/src/pipeline/detector-runner.js +7 -0
  167. package/src/pipeline/scan-health.js +14 -0
  168. package/src/pipeline/supervised-analyzers.js +125 -0
  169. package/src/pipeline/supervised-child.js +17 -0
  170. package/src/pipeline/telemetry.js +79 -0
  171. package/src/posture/accuracy-scorecard.js +98 -9
  172. package/src/posture/artifact-registry.js +5 -0
  173. package/src/posture/assurance/evidence-registry.js +215 -0
  174. package/src/posture/assurance/release-package-verify.js +135 -0
  175. package/src/posture/assurance/release-package.js +118 -0
  176. package/src/posture/assurance/release-verdict.js +94 -0
  177. package/src/posture/assurance/waiver.js +200 -0
  178. package/src/posture/compliance-truth.js +100 -0
  179. package/src/posture/evaluation/scan-child.js +3 -1
  180. package/src/posture/evaluation/why-missed.js +3 -1
  181. package/src/posture/exploitability-evidence.js +141 -0
  182. package/src/posture/fix-verify.js +4 -2
  183. package/src/posture/fleet/exposure-ranking.js +115 -0
  184. package/src/posture/fleet/service-chain-graph.js +150 -0
  185. package/src/posture/graph-reconciliation.js +154 -0
  186. package/src/posture/provenance/coverage.js +103 -0
  187. package/src/posture/provenance/git-evidence.js +84 -0
  188. package/src/posture/provenance/origin-resolver.js +7 -1
  189. package/src/posture/provenance/predicate-replay.js +14 -4
  190. package/src/posture/runtime-correlation.js +61 -15
  191. package/src/posture/runtime-explanation.js +116 -0
  192. package/src/posture/runtime-trace-ingest.js +194 -0
  193. package/src/posture/scan-checkpoint.js +38 -10
  194. package/src/posture/waf-ingest.js +7 -2
  195. package/src/report/index.js +4 -0
  196. package/src/report/oscal.js +38 -0
  197. package/src/report/perspectives.js +150 -0
  198. package/src/sast/_comment-strip.js +8 -1
  199. package/src/sast/ruby.js +26 -0
  200. package/src/sast/zip-slip.js +79 -0
  201. package/src/util/string-memo.js +38 -0
@@ -0,0 +1,833 @@
1
+ export const id = 3063;
2
+ export const ids = [3063,6995];
3
+ export const modules = {
4
+
5
+ /***/ 66995:
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__(69259);
18
+ /* harmony import */ var _artifact_registry_js__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(81734);
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)(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)(fp, JSON.stringify(remaining, null, 2) + '\n');
156
+ }
157
+ return removedCount;
158
+ }
159
+
160
+
161
+ /***/ }),
162
+
163
+ /***/ 23063:
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__(20622);
178
+ // EXTERNAL MODULE: ./src/posture/portfolio/wording.js
179
+ var wording = __webpack_require__(33695);
180
+ // EXTERNAL MODULE: ./src/posture/portfolio/work-units.js
181
+ var work_units = __webpack_require__(42284);
182
+ // EXTERNAL MODULE: ./src/posture/portfolio/scheduler.js
183
+ var scheduler = __webpack_require__(98406);
184
+ // EXTERNAL MODULE: ./src/posture/portfolio/progress.js
185
+ var progress = __webpack_require__(83480);
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__(66995);
190
+ // EXTERNAL MODULE: ./src/posture/assurance/identity.js
191
+ var identity = __webpack_require__(91026);
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__(64130);
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
+ };