@clear-capabilities/agentic-security-scanner 0.172.0 → 0.173.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 (202) hide show
  1. package/CHANGELOG.md +29 -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/1336.index.js +101 -101
  7. package/dist/1351.index.js +133 -0
  8. package/dist/1387.index.js +271 -0
  9. package/dist/1437.index.js +23 -0
  10. package/dist/1445.index.js +110 -0
  11. package/dist/1505.index.js +151 -0
  12. package/dist/1557.index.js +293 -0
  13. package/dist/1621.index.js +311 -0
  14. package/dist/1751.index.js +160 -0
  15. package/dist/1765.index.js +727 -0
  16. package/dist/1861.index.js +690 -0
  17. package/dist/1898.index.js +116 -0
  18. package/dist/1906.index.js +433 -0
  19. package/dist/1920.index.js +1 -291
  20. package/dist/208.index.js +1631 -0
  21. package/dist/2199.index.js +0 -239
  22. package/dist/2223.index.js +159 -0
  23. package/dist/2530.index.js +152 -0
  24. package/dist/2565.index.js +116 -0
  25. package/dist/2574.index.js +127 -0
  26. package/dist/2620.index.js +345 -0
  27. package/dist/2773.index.js +98 -0
  28. package/dist/2775.index.js +32 -0
  29. package/dist/2966.index.js +171 -0
  30. package/dist/2972.index.js +87 -0
  31. package/dist/2991.index.js +131 -0
  32. package/dist/3063.index.js +833 -0
  33. package/dist/3075.index.js +227 -0
  34. package/dist/3121.index.js +297 -0
  35. package/dist/3314.index.js +263 -0
  36. package/dist/3480.index.js +1822 -0
  37. package/dist/3481.index.js +24 -0
  38. package/dist/3506.index.js +268 -0
  39. package/dist/3533.index.js +591 -0
  40. package/dist/3653.index.js +450 -0
  41. package/dist/3822.index.js +131 -0
  42. package/dist/3868.index.js +292 -0
  43. package/dist/3917.index.js +152 -0
  44. package/dist/3918.index.js +192 -0
  45. package/dist/4090.index.js +288 -0
  46. package/dist/4121.index.js +1190 -0
  47. package/dist/4142.index.js +0 -239
  48. package/dist/4336.index.js +436 -0
  49. package/dist/4387.index.js +532 -0
  50. package/dist/4413.index.js +302 -0
  51. package/dist/4476.index.js +4510 -0
  52. package/dist/4491.index.js +133 -0
  53. package/dist/4538.index.js +185 -0
  54. package/dist/4684.index.js +319 -0
  55. package/dist/4740.index.js +165 -0
  56. package/dist/4780.index.js +209 -0
  57. package/dist/4802.index.js +225 -0
  58. package/dist/4812.index.js +379 -0
  59. package/dist/4835.index.js +126 -0
  60. package/dist/494.index.js +226 -0
  61. package/dist/4991.index.js +549 -0
  62. package/dist/5199.index.js +305 -0
  63. package/dist/5216.index.js +109 -0
  64. package/dist/5439.index.js +301 -0
  65. package/dist/5443.index.js +97 -0
  66. package/dist/5496.index.js +377 -0
  67. package/dist/550.index.js +2 -1
  68. package/dist/5801.index.js +588 -0
  69. package/dist/5858.index.js +223 -0
  70. package/dist/5933.index.js +143 -0
  71. package/dist/601.index.js +23 -0
  72. package/dist/6173.index.js +273 -0
  73. package/dist/6291.index.js +1871 -0
  74. package/dist/6312.index.js +666 -0
  75. package/dist/634.index.js +16182 -0
  76. package/dist/644.index.js +87 -0
  77. package/dist/6508.index.js +283 -0
  78. package/dist/6560.index.js +645 -0
  79. package/dist/6576.index.js +422 -0
  80. package/dist/6579.index.js +271 -0
  81. package/dist/6745.index.js +305 -0
  82. package/dist/6818.index.js +21642 -0
  83. package/dist/6972.index.js +167 -0
  84. package/dist/6995.index.js +163 -0
  85. package/dist/7249.index.js +191 -0
  86. package/dist/7372.index.js +1197 -0
  87. package/dist/7405.index.js +23 -6
  88. package/dist/7546.index.js +541 -0
  89. package/dist/76.index.js +448 -0
  90. package/dist/7879.index.js +163 -0
  91. package/dist/8025.index.js +130 -0
  92. package/dist/8054.index.js +248 -0
  93. package/dist/8097.index.js +201 -0
  94. package/dist/8126.index.js +191 -0
  95. package/dist/8178.index.js +78 -0
  96. package/dist/818.index.js +212 -0
  97. package/dist/8382.index.js +93 -0
  98. package/dist/8408.index.js +261 -0
  99. package/dist/8540.index.js +174 -0
  100. package/dist/8635.index.js +802 -0
  101. package/dist/8688.index.js +410 -0
  102. package/dist/8699.index.js +779 -0
  103. package/dist/8891.index.js +457 -0
  104. package/dist/8916.index.js +885 -0
  105. package/dist/9146.index.js +170 -0
  106. package/dist/9168.index.js +841 -0
  107. package/dist/9292.index.js +369 -0
  108. package/dist/9382.index.js +338 -48
  109. package/dist/9422.index.js +866 -0
  110. package/dist/9440.index.js +453 -0
  111. package/dist/9479.index.js +152 -0
  112. package/dist/9481.index.js +220 -0
  113. package/dist/9539.index.js +100 -0
  114. package/dist/9547.index.js +103 -0
  115. package/dist/9560.index.js +41 -61
  116. package/dist/9692.index.js +90 -0
  117. package/dist/9800.index.js +213 -1
  118. package/dist/9899.index.js +138 -0
  119. package/dist/9981.index.js +206 -0
  120. package/dist/999.index.js +117 -0
  121. package/dist/agentic-security.mjs +28 -28
  122. package/dist/agentic-security.mjs.sha256 +1 -1
  123. package/package.json +31 -6
  124. package/src/context-intelligence/CLAUDE.md +4 -0
  125. package/src/context-intelligence/adapters/haskell-hie.js +28 -1
  126. package/src/context-intelligence/candidates.js +2 -1
  127. package/src/context-intelligence/canonical.js +25 -0
  128. package/src/context-intelligence/capabilities.js +2 -1
  129. package/src/context-intelligence/cli.js +16 -10
  130. package/src/context-intelligence/config.js +4 -1
  131. package/src/context-intelligence/controls.js +108 -10
  132. package/src/context-intelligence/counterexample.js +200 -0
  133. package/src/context-intelligence/cross-route.js +111 -0
  134. package/src/context-intelligence/entrypoints.js +2 -2
  135. package/src/context-intelligence/evaluation/index.js +11 -4
  136. package/src/context-intelligence/evaluation/scripted-reader.js +12 -2
  137. package/src/context-intelligence/findings.js +166 -0
  138. package/src/context-intelligence/invariants.js +197 -0
  139. package/src/context-intelligence/queue.js +6 -1
  140. package/src/context-intelligence/reachability.js +10 -4
  141. package/src/context-intelligence/review.js +129 -16
  142. package/src/dataflow/access-paths.js +11 -0
  143. package/src/dataflow/analysis-limits.js +66 -0
  144. package/src/dataflow/engine.js +272 -20
  145. package/src/dataflow/service-edges.js +174 -0
  146. package/src/dataflow/summaries.js +7 -1
  147. package/src/discovery/judge.js +2 -1
  148. package/src/engine.js +82 -14
  149. package/src/fix/apply-fix-service.js +55 -8
  150. package/src/fix/batch-journal.js +84 -0
  151. package/src/fix/exploit-replay.js +51 -0
  152. package/src/fix/footprint.js +65 -0
  153. package/src/fix/patch-policy.js +49 -0
  154. package/src/fix/secret-persistence.js +39 -0
  155. package/src/fix/test-commands.js +84 -0
  156. package/src/fix/verification-adapters.js +97 -0
  157. package/src/fix/verification-record.js +243 -0
  158. package/src/fix/verify-patch.js +89 -0
  159. package/src/ir/CLAUDE.md +5 -0
  160. package/src/ir/callgraph.js +23 -1
  161. package/src/ir/module-resolver.js +315 -0
  162. package/src/ir/parser-js.js +21 -2
  163. package/src/ir/semantic-graph.js +245 -0
  164. package/src/lineage/build-attested-correlation.js +63 -0
  165. package/src/lineage/deployment-identity.js +76 -0
  166. package/src/mcp/tools.js +13 -34
  167. package/src/pipeline/canonical-output.js +64 -0
  168. package/src/pipeline/detector-runner.js +7 -0
  169. package/src/pipeline/scan-health.js +14 -0
  170. package/src/pipeline/supervised-analyzers.js +125 -0
  171. package/src/pipeline/supervised-child.js +17 -0
  172. package/src/pipeline/telemetry.js +79 -0
  173. package/src/posture/accuracy-scorecard.js +98 -9
  174. package/src/posture/artifact-registry.js +5 -0
  175. package/src/posture/assurance/evidence-registry.js +215 -0
  176. package/src/posture/assurance/release-package-verify.js +135 -0
  177. package/src/posture/assurance/release-package.js +118 -0
  178. package/src/posture/assurance/release-verdict.js +94 -0
  179. package/src/posture/assurance/waiver.js +200 -0
  180. package/src/posture/compliance-truth.js +100 -0
  181. package/src/posture/evaluation/scan-child.js +3 -1
  182. package/src/posture/evaluation/why-missed.js +3 -1
  183. package/src/posture/exploitability-evidence.js +141 -0
  184. package/src/posture/fix-verify.js +4 -2
  185. package/src/posture/fleet/exposure-ranking.js +115 -0
  186. package/src/posture/fleet/service-chain-graph.js +150 -0
  187. package/src/posture/graph-reconciliation.js +154 -0
  188. package/src/posture/provenance/coverage.js +103 -0
  189. package/src/posture/provenance/git-evidence.js +84 -0
  190. package/src/posture/provenance/origin-resolver.js +7 -1
  191. package/src/posture/provenance/predicate-replay.js +14 -4
  192. package/src/posture/runtime-correlation.js +61 -15
  193. package/src/posture/runtime-explanation.js +116 -0
  194. package/src/posture/runtime-trace-ingest.js +194 -0
  195. package/src/posture/scan-checkpoint.js +38 -10
  196. package/src/posture/waf-ingest.js +7 -2
  197. package/src/report/index.js +4 -0
  198. package/src/report/oscal.js +38 -0
  199. package/src/report/perspectives.js +150 -0
  200. package/src/sast/_comment-strip.js +8 -1
  201. package/src/sast/ruby.js +26 -0
  202. package/src/util/string-memo.js +38 -0
@@ -0,0 +1,1190 @@
1
+ export const id = 4121;
2
+ export const ids = [4121,4413];
3
+ export const modules = {
4
+
5
+ /***/ 44121:
6
+ /***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
7
+
8
+ // ESM COMPAT FLAG
9
+ __webpack_require__.r(__webpack_exports__);
10
+
11
+ // EXPORTS
12
+ __webpack_require__.d(__webpack_exports__, {
13
+ cmdFleet: () => (/* binding */ cmdFleet)
14
+ });
15
+
16
+ // EXTERNAL MODULE: external "node:fs"
17
+ var external_node_fs_ = __webpack_require__(73024);
18
+ // EXTERNAL MODULE: external "node:path"
19
+ var external_node_path_ = __webpack_require__(76760);
20
+ // EXTERNAL MODULE: ./src/util/yaml.js + 1 modules
21
+ var yaml = __webpack_require__(32469);
22
+ ;// CONCATENATED MODULE: ./src/posture/fleet/fleet-manifest.js
23
+ // The fleet manifest (PRD 4 "Fleet Manifest"). `fleet.yaml`:
24
+ //
25
+ // organization: example
26
+ // repositories:
27
+ // - id: frontend
28
+ // path: ../frontend
29
+ // workers: 4
30
+ // perRepoTimeout: 20m
31
+ //
32
+ // Parsing reuses util/yaml.js (the one YAML entry point in this codebase). Validation is closed-world: an unknown
33
+ // top-level key, a duplicate repository id, or a path that does not resolve to a real directory all refuse the
34
+ // whole manifest rather than silently skipping one repo.
35
+
36
+
37
+
38
+
39
+ const FLEET_MANIFEST_SCHEMA_KEYS = Object.freeze(['organization', 'repositories', 'workers', 'perRepoTimeout']);
40
+ const DURATION_RE = /^(\d+)(s|m|h)$/;
41
+
42
+ function parseDuration(s, fallbackMs) {
43
+ if (typeof s === 'number' && s > 0) return s * 1000;
44
+ const m = DURATION_RE.exec(String(s ?? ''));
45
+ if (!m) return fallbackMs;
46
+ const n = Number(m[1]);
47
+ return m[2] === 's' ? n * 1000 : m[2] === 'm' ? n * 60000 : n * 3600000;
48
+ }
49
+
50
+ /** Validate a parsed manifest object against the manifest's own base directory (for path resolution). */
51
+ function validateFleetManifest(doc, { baseDir = '.' } = {}) {
52
+ const errors = [];
53
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return { ok: false, errors: ['manifest must be a YAML mapping'] };
54
+ for (const k of Object.keys(doc)) if (!FLEET_MANIFEST_SCHEMA_KEYS.includes(k)) errors.push(`unknown top-level key: ${k}`);
55
+ if (typeof doc.organization !== 'string' || !doc.organization) errors.push('organization is required and must be a string');
56
+ if (!Array.isArray(doc.repositories) || doc.repositories.length === 0) errors.push('repositories must be a non-empty array');
57
+ const seen = new Set();
58
+ const repos = [];
59
+ for (const [i, r] of (Array.isArray(doc.repositories) ? doc.repositories : []).entries()) {
60
+ if (!r || typeof r !== 'object') { errors.push(`repositories[${i}] must be a mapping`); continue; }
61
+ if (typeof r.id !== 'string' || !/^[a-z][a-z0-9-]{0,63}$/.test(r.id)) { errors.push(`repositories[${i}].id must be a lowercase kebab-case string`); continue; }
62
+ if (seen.has(r.id)) { errors.push(`duplicate repository id: ${r.id}`); continue; }
63
+ seen.add(r.id);
64
+ if (typeof r.path !== 'string' || !r.path) { errors.push(`repositories[${i}].path is required`); continue; }
65
+ const abs = external_node_path_.resolve(baseDir, r.path);
66
+ let isDir = false;
67
+ try { isDir = external_node_fs_.statSync(abs).isDirectory(); } catch { /* reported below */ }
68
+ if (!isDir) errors.push(`repositories[${i}].path does not resolve to a directory: ${r.path}`);
69
+ repos.push({ id: r.id, path: abs });
70
+ }
71
+ if (doc.workers != null && !(Number.isInteger(doc.workers) && doc.workers >= 1 && doc.workers <= 32)) errors.push('workers must be an integer in 1..32');
72
+ if (doc.perRepoTimeout != null && parseDuration(doc.perRepoTimeout, null) == null) errors.push(`perRepoTimeout is not a valid duration: ${doc.perRepoTimeout}`);
73
+ if (errors.length) return { ok: false, errors };
74
+ return {
75
+ ok: true, errors: [],
76
+ manifest: {
77
+ organization: doc.organization, repositories: repos,
78
+ workers: doc.workers ?? 4, perRepoTimeoutMs: parseDuration(doc.perRepoTimeout, 20 * 60000),
79
+ },
80
+ };
81
+ }
82
+
83
+ function loadFleetManifest(filePath) {
84
+ let text;
85
+ try { text = external_node_fs_.readFileSync(filePath, 'utf8'); } catch (e) { return { ok: false, errors: [`cannot read ${filePath}: ${e.message}`] }; }
86
+ let doc;
87
+ try { doc = (0,yaml/* load */.Hh)(text); } catch (e) { return { ok: false, errors: [`invalid YAML: ${e.message}`] }; }
88
+ return validateFleetManifest(doc, { baseDir: external_node_path_.dirname(external_node_path_.resolve(filePath)) });
89
+ }
90
+
91
+ function starterManifestYaml(organization = 'example') {
92
+ return `organization: ${organization}
93
+ repositories:
94
+ - id: example-repo
95
+ path: .
96
+ workers: 4
97
+ perRepoTimeout: 20m
98
+ `;
99
+ }
100
+
101
+ ;// CONCATENATED MODULE: ./src/posture/fleet/fleet-graph.js
102
+ // The cross-repository dependency graph for the fleet feature (PRD 4).
103
+ //
104
+ // Reads each repo's own manifest to learn (a) the package name it publishes under, if any, and (b) the package
105
+ // names it depends on. An edge A -> B means "A declares a dependency that resolves to B's own published name" —
106
+ // i.e. B is upstream of A, and a defect in B can affect A. This is a real, lightweight cross-repo dependency graph
107
+ // built purely from manifests already on disk; it does not need deployment config (Kubernetes/compose/Terraform)
108
+ // the way the existing deployment-boundary graph does, so a fleet of plain application repos works out of the box.
109
+
110
+
111
+
112
+ const MANIFESTS = Object.freeze([
113
+ { file: 'package.json', ecosystem: 'npm' },
114
+ { file: 'go.mod', ecosystem: 'go' },
115
+ { file: 'Cargo.toml', ecosystem: 'cargo' },
116
+ { file: 'requirements.txt', ecosystem: 'pypi' },
117
+ { file: 'pyproject.toml', ecosystem: 'pypi' },
118
+ ]);
119
+
120
+ function readBounded(p, maxBytes = 1024 * 1024) {
121
+ try {
122
+ const st = external_node_fs_.statSync(p);
123
+ if (!st.isFile() || st.size > maxBytes) return null;
124
+ return external_node_fs_.readFileSync(p, 'utf8');
125
+ } catch { return null; }
126
+ }
127
+
128
+ function parsePackageJson(text) {
129
+ let j;
130
+ try { j = JSON.parse(text); } catch { return { name: null, deps: new Set() }; }
131
+ const deps = new Set([...Object.keys(j.dependencies || {}), ...Object.keys(j.devDependencies || {}), ...Object.keys(j.peerDependencies || {})]);
132
+ return { name: typeof j.name === 'string' ? j.name : null, deps };
133
+ }
134
+
135
+ function parseGoMod(text) {
136
+ const m = /^module\s+(\S+)/m.exec(text);
137
+ const deps = new Set();
138
+ const block = /require\s*\(([\s\S]*?)\)/.exec(text);
139
+ const lines = block ? block[1].split('\n') : text.split('\n').filter((l) => /^require\s/.test(l.trim()));
140
+ for (const raw of lines) {
141
+ const l = raw.replace(/^require\s+/, '').trim();
142
+ const dm = /^(\S+)\s+v?\S+/.exec(l);
143
+ if (dm) deps.add(dm[1]);
144
+ }
145
+ return { name: m ? m[1] : null, deps };
146
+ }
147
+
148
+ function parseCargoToml(text) {
149
+ const nm = /\[package\][\s\S]*?\bname\s*=\s*"([^"]+)"/.exec(text);
150
+ const deps = new Set();
151
+ const section = /\[dependencies\]([\s\S]*?)(\n\[|$)/.exec(text);
152
+ if (section) {
153
+ for (const line of section[1].split('\n')) {
154
+ const dm = /^\s*([A-Za-z0-9_-]+)\s*=/.exec(line);
155
+ if (dm) deps.add(dm[1]);
156
+ }
157
+ }
158
+ return { name: nm ? nm[1] : null, deps };
159
+ }
160
+
161
+ function parseRequirementsTxt(text) {
162
+ const deps = new Set();
163
+ for (const raw of text.split('\n')) {
164
+ const l = raw.trim();
165
+ if (!l || l.startsWith('#') || l.startsWith('-')) continue;
166
+ const dm = /^([A-Za-z0-9._-]+)/.exec(l);
167
+ if (dm) deps.add(dm[1].toLowerCase());
168
+ }
169
+ return { name: null, deps };
170
+ }
171
+
172
+ function parsePyprojectToml(text) {
173
+ const nm = /\[project\][\s\S]*?\bname\s*=\s*"([^"]+)"/.exec(text);
174
+ const deps = new Set();
175
+ const section = /dependencies\s*=\s*\[([\s\S]*?)\]/.exec(text);
176
+ if (section) {
177
+ for (const m of section[1].matchAll(/"([A-Za-z0-9._-]+)/g)) deps.add(m[1].toLowerCase());
178
+ }
179
+ return { name: nm ? nm[1] : null, deps };
180
+ }
181
+
182
+ const PARSERS = { 'package.json': parsePackageJson, 'go.mod': parseGoMod, 'Cargo.toml': parseCargoToml, 'requirements.txt': parseRequirementsTxt, 'pyproject.toml': parsePyprojectToml };
183
+
184
+ /** One repo's declared identity and dependency names, merged across every manifest file it has. */
185
+ function readRepoManifest(repoPath) {
186
+ let name = null;
187
+ const deps = new Set();
188
+ const ecosystems = new Set();
189
+ for (const { file, ecosystem } of MANIFESTS) {
190
+ const text = readBounded(external_node_path_.join(repoPath, file));
191
+ if (text == null) continue;
192
+ const parsed = PARSERS[file](text);
193
+ if (parsed.name && !name) name = parsed.name;
194
+ for (const d of parsed.deps) deps.add(d);
195
+ ecosystems.add(ecosystem);
196
+ }
197
+ return { name, deps, ecosystems: [...ecosystems] };
198
+ }
199
+
200
+ /**
201
+ * Build the fleet dependency graph. `repos`: [{id, path}]. An edge {from, to} means `from` depends on `to` (so a
202
+ * defect in `to` can affect `from`). Matching is by declared package NAME only — two repos that happen to share a
203
+ * name under different ecosystems are not disambiguated, a disclosed limit, not a guess.
204
+ */
205
+ function buildFleetDependencyGraph(repos) {
206
+ const manifests = new Map();
207
+ for (const r of repos) manifests.set(r.id, { ...r, manifest: readRepoManifest(r.path) });
208
+
209
+ const byName = new Map();
210
+ for (const [id, r] of manifests) if (r.manifest.name) {
211
+ if (!byName.has(r.manifest.name)) byName.set(r.manifest.name, []);
212
+ byName.get(r.manifest.name).push(id);
213
+ }
214
+
215
+ const edges = [];
216
+ for (const [id, r] of manifests) {
217
+ for (const dep of r.manifest.deps) {
218
+ const targets = byName.get(dep);
219
+ if (!targets) continue;
220
+ for (const to of targets) if (to !== id) edges.push({ from: id, to, via: dep });
221
+ }
222
+ }
223
+ const nodes = [...manifests.values()].map((r) => ({ id: r.id, path: r.path, declaredName: r.manifest.name, ecosystems: r.manifest.ecosystems }));
224
+ return { nodes, edges };
225
+ }
226
+
227
+ /** Transitive downstream closure: which fleet repos depend on `repoId`, directly or transitively, with the shortest path to each. */
228
+ function blastRadius(graph, repoId) {
229
+ const incoming = new Map();
230
+ for (const e of graph.edges) { if (!incoming.has(e.to)) incoming.set(e.to, []); incoming.get(e.to).push(e.from); }
231
+ const best = new Map();
232
+ const queue = [{ id: repoId, path: [repoId] }];
233
+ while (queue.length) {
234
+ const { id, path: p } = queue.shift();
235
+ for (const from of incoming.get(id) || []) {
236
+ if (best.has(from)) continue;
237
+ const newPath = [...p, from];
238
+ best.set(from, { depth: newPath.length - 1, path: newPath });
239
+ queue.push({ id: from, path: newPath });
240
+ }
241
+ }
242
+ return [...best.entries()].map(([id, v]) => ({ id, ...v })).sort((a, b) => a.depth - b.depth || (a.id < b.id ? -1 : 1));
243
+ }
244
+
245
+ // EXTERNAL MODULE: ./src/posture/policy-bundle.js
246
+ var policy_bundle = __webpack_require__(14413);
247
+ ;// CONCATENATED MODULE: ./src/posture/fleet.js
248
+ // PRD Epic 5 — fleet orchestration.
249
+ //
250
+ // Many repositories, one invocation, one rolled-up result set. The existing
251
+ // `org-scan` could iterate repos; what it could not do is survive an
252
+ // interruption, bound its own concurrency, tell you what is NEW since the last
253
+ // run, or route a finding to the person who owns the file. Those four are the
254
+ // difference between a loop and something an organisation can put on a
255
+ // schedule.
256
+ //
257
+ // THE HARD LINE (PRD §2.2, §10): everything here runs from the CLI on the
258
+ // customer's infrastructure. There is no server, no account, no multi-tenancy,
259
+ // and the "UI" is a static artifact generated from local files. A fleet feature
260
+ // is exactly where a local-first tool drifts into a platform, so the constraint
261
+ // is stated here rather than left to judgement.
262
+ //
263
+ // FOUR PROPERTIES, EACH OF WHICH IS A FAILURE MODE IF ABSENT:
264
+ //
265
+ // 1. ISOLATION — one repo that explodes must not take the run with it. A
266
+ // fleet scan that dies on repo 3 of 50 is worse than useless: it looks
267
+ // like a completed scan of 3 repos.
268
+ // 2. RESUMABILITY — the checkpoint records COMPLETED repos, and is written
269
+ // after each one. A resumed run skips them rather than re-scanning, which
270
+ // is what makes a 50-repo run survivable on a laptop or a flaky runner.
271
+ // 3. BOUNDED CONCURRENCY — an unbounded fan-out over 50 repos will exhaust
272
+ // memory or file handles on the machine it was supposed to help.
273
+ // 4. HONEST ROLL-UP — a repo that FAILED is counted as failed, never folded
274
+ // into "0 findings". Silent zeros are how a fleet report says everything
275
+ // is fine because nothing ran.
276
+ //
277
+ // DELTA IS COMPUTED FROM STABLE IDS, not counts. "3 more criticals than last
278
+ // week" tells you nothing about which; a set difference over stableId tells you
279
+ // exactly what appeared, and is what a scheduled run should notify on.
280
+
281
+
282
+
283
+
284
+
285
+ const SCHEMA = 'agentic-security/fleet-state@1';
286
+
287
+ /** Read a checkpoint, or an empty one. Never throws. */
288
+ function loadFleetState(stateFile) {
289
+ try {
290
+ const j = JSON.parse(external_node_fs_.readFileSync(stateFile, 'utf8'));
291
+ if (j && j.schema === SCHEMA) return j;
292
+ } catch { /* absent or unreadable -> start clean */ }
293
+ return { schema: SCHEMA, completed: {}, startedAt: null };
294
+ }
295
+
296
+ function _saveFleetState(stateFile, state) {
297
+ try {
298
+ external_node_fs_.mkdirSync(external_node_path_.dirname(stateFile), { recursive: true });
299
+ external_node_fs_.writeFileSync(stateFile, JSON.stringify(state, null, 2));
300
+ return true;
301
+ } catch { return false; }
302
+ }
303
+
304
+ /**
305
+ * Owners for a path, from a CODEOWNERS file.
306
+ *
307
+ * Last matching rule wins, which is the documented CODEOWNERS precedence — a
308
+ * first-match implementation silently routes to the most general owner, which
309
+ * is the one least able to act on the finding.
310
+ */
311
+ function ownersFor(codeownersText, filePath) {
312
+ if (typeof codeownersText !== 'string' || !filePath) return [];
313
+ let owners = [];
314
+ for (const raw of codeownersText.split('\n')) {
315
+ const line = raw.replace(/#.*$/, '').trim();
316
+ if (!line) continue;
317
+ const parts = line.split(/\s+/);
318
+ const pattern = parts.shift();
319
+ const these = parts.filter(p => p.startsWith('@') || p.includes('@'));
320
+ if (!these.length) continue;
321
+ if (_matchesGlob(pattern, filePath)) owners = these;
322
+ }
323
+ return owners;
324
+ }
325
+
326
+ function _matchesGlob(pattern, file) {
327
+ const f = file.replace(/^\.?\//, '');
328
+ let p = pattern.replace(/^\.?\//, '');
329
+ if (p === '*') return true;
330
+ if (p.endsWith('/')) return f.startsWith(p);
331
+ const PLACEHOLDER = '\uE000'; // escape SEQUENCE in source -- 6 ASCII chars, not a raw embedded byte
332
+ const rx = new RegExp('^' + p
333
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
334
+ .replace(/\*\*/g, PLACEHOLDER)
335
+ .replace(/\*/g, '[^/]*')
336
+ .replace(new RegExp(PLACEHOLDER, 'g'), '.*') + '(/.*)?$');
337
+ return rx.test(f);
338
+ }
339
+
340
+ function _readCodeowners(repoRoot) {
341
+ for (const rel of ['CODEOWNERS', '.github/CODEOWNERS', 'docs/CODEOWNERS']) {
342
+ try { return external_node_fs_.readFileSync(external_node_path_.join(repoRoot, rel), 'utf8'); } catch { /* next */ }
343
+ }
344
+ return null;
345
+ }
346
+
347
+ const SEVERITIES = ['critical', 'high', 'medium', 'low', 'info'];
348
+
349
+ function _summarise(scan) {
350
+ const all = [
351
+ ...(scan.findings || []), ...(scan.secrets || []),
352
+ ...(scan.logicVulns || []), ...(scan.supplyChain || []),
353
+ ];
354
+ const bySeverity = Object.fromEntries(SEVERITIES.map(s => [s, 0]));
355
+ const ids = [];
356
+ let proven = 0;
357
+ for (const f of all) {
358
+ const sev = String(f.severity || 'info').toLowerCase();
359
+ if (sev in bySeverity) bySeverity[sev]++;
360
+ if (f.stableId) ids.push(f.stableId);
361
+ if (f.proofTier === 'execution-proven') proven++;
362
+ }
363
+ return { total: all.length, bySeverity, proven, ids, findings: all };
364
+ }
365
+
366
+ /**
367
+ * M4 §4.4 — provenance-aware debt for ONE repo, computed here (inside the
368
+ * worker, from the untrimmed findings array) rather than inside `rollupFleet`,
369
+ * because FR-1005 forbids raw finding data from surviving onto the per-repo
370
+ * result entry (`test('FR-1005: ... the entry must not carry the raw findings
371
+ * array at all')` in fleet.test.js) — a finding can carry a raw source
372
+ * snippet, so only this derived summary (a finding id, a commit date, a day
373
+ * count; never a file path or snippet) is threaded onto `entry.provenanceDebt`.
374
+ *
375
+ * A finding contributes only when its `findingProvenance.status` is
376
+ * `'complete'` — the honest end of the confidence spectrum (see
377
+ * `provenance/schema.js`'s `PROVENANCE_STATUS`). `partial`/`uncommitted`/etc.
378
+ * are not proof of origin and must never win "oldest".
379
+ */
380
+ function _provenanceDebtForRepo(findings, now = Date.now()) {
381
+ let oldest = null;
382
+ let completeCount = 0;
383
+ for (const f of (findings || [])) {
384
+ if (f.findingProvenance?.status !== 'complete') continue;
385
+ const authorDate = f.findingProvenance?.findingOrigin?.authorDate;
386
+ if (!authorDate) continue;
387
+ completeCount++;
388
+ const ageDays = Math.max(0, Math.floor((now - Date.parse(authorDate)) / 86400000));
389
+ if (!oldest || ageDays > oldest.ageDays) {
390
+ // FR-PROV-019: "Reports never show an age without its basis and
391
+ // confidence." The basis is structurally implied here (only
392
+ // status:'complete' findings reach this line, so the age is always
393
+ // git-derived, and the rendered text says "proven-origin") — but the
394
+ // confidence half has to be carried explicitly, same as
395
+ // mttr.js's renderSlaSummary. Captured at selection time so the
396
+ // renderers below cannot show the age without it.
397
+ oldest = {
398
+ findingId: f.id || f.stableId || null,
399
+ authorDate,
400
+ ageDays,
401
+ confidence: f.findingProvenance?.confidence?.level || null,
402
+ };
403
+ }
404
+ }
405
+ return { oldest, completeCount };
406
+ }
407
+
408
+ /**
409
+ * Scan many repositories with bounded concurrency, resumably.
410
+ *
411
+ * `runScan` is injected so this module has no engine import cycle and so the
412
+ * orchestration can be tested without running real scans.
413
+ */
414
+ async function runFleet({
415
+ repos = [], concurrency = 4, stateFile, runScan,
416
+ resume = true, previous = null, onProgress = () => {},
417
+ // X-704: repositories whose every durable portfolio work unit is verified and current
418
+ // (portfolio/work-units.js `fullyVerifiedRepositories`). They are skipped like a resumed completed repo,
419
+ // and, as with a resumed repo, the skip is reported in `skipped` rather than counted as scanned.
420
+ portfolioVerified = [],
421
+ } = {}) {
422
+ if (typeof runScan !== 'function') {
423
+ return { ok: false, reason: 'no runScan supplied' };
424
+ }
425
+ const state = (resume && stateFile) ? loadFleetState(stateFile) : { schema: SCHEMA, completed: {}, startedAt: null };
426
+ const results = [];
427
+ const skipped = [];
428
+
429
+ const queue = repos.filter((r) => {
430
+ if ((resume && state.completed[r]) || portfolioVerified.includes(r)) { skipped.push(r); return false; }
431
+ return true;
432
+ });
433
+
434
+ const limit = Math.max(1, Math.min(concurrency, 32));
435
+ let cursor = 0;
436
+
437
+ async function worker() {
438
+ while (cursor < queue.length) {
439
+ const repo = queue[cursor++];
440
+ let entry;
441
+ try {
442
+ const { scan } = await runScan(repo);
443
+ const s = _summarise(scan);
444
+ const codeowners = _readCodeowners(repo);
445
+ // Route each finding to the owner of its file. Done here rather than at
446
+ // report time because the CODEOWNERS file lives in the repo, and by
447
+ // report time the fleet has moved on.
448
+ const owners = {};
449
+ if (codeowners) {
450
+ for (const f of s.findings) {
451
+ if (!f.file) continue;
452
+ const o = ownersFor(codeowners, f.file);
453
+ for (const own of o) (owners[own] ||= []).push(f.stableId || `${f.file}:${f.line}`);
454
+ }
455
+ }
456
+ // FR-1006: "assurance-health views" and "policy drift" — a repo can
457
+ // be RISK-clean (zero/low findings) while still having a governance
458
+ // gap (a partial scan, a rejected/expired policy bundle nobody
459
+ // noticed). scanHealth comes straight from the engine's own
460
+ // computeScanHealth (FR-206) — nothing new is inferred here, this is
461
+ // the first time the fleet rollup SURFACES a signal that already
462
+ // existed per-scan. computePolicyDrift is null (not an empty object)
463
+ // when the repo has no policy bundles configured at all — matching
464
+ // this session's "no baseline configured -> no-op" convention.
465
+ let policyDrift = null;
466
+ try { policyDrift = (0,policy_bundle/* computePolicyDrift */.rE)(repo); } catch { /* best-effort — never fail a repo's scan over drift reporting */ }
467
+ // M4 §4.4: provenance-aware debt, computed HERE (from `s.findings`,
468
+ // the untrimmed array) rather than in `rollupFleet` — by the time
469
+ // `rollupFleet` sees `results`, the raw findings are gone (FR-1005).
470
+ // Only the derived summary (id/date/age, never a file or snippet)
471
+ // survives onto the entry. `mttr` threads through the SAME aggregate
472
+ // `mttr.js`'s `computeMTTR` already produces (`scan.mttr`, stamped by
473
+ // the CLI's persistence step — see posture/mttr.js) rather than
474
+ // inventing a second notion of remediation time; there is no
475
+ // `scan.remediatedFindings` field anywhere in this codebase.
476
+ const provenanceDebt = _provenanceDebtForRepo(s.findings);
477
+ entry = {
478
+ repo, ok: true, total: s.total, bySeverity: s.bySeverity,
479
+ proven: s.proven, ids: s.ids, owners, scannedAt: new Date().toISOString(),
480
+ scanHealth: scan.scanHealth || null, policyDrift,
481
+ provenanceDebt, mttr: scan.mttr || null,
482
+ };
483
+ } catch (e) {
484
+ // A failed repo is FAILED, never zero findings.
485
+ entry = { repo, ok: false, error: String(e?.message || e), total: null, bySeverity: null, ids: [], scannedAt: new Date().toISOString(), scanHealth: null, policyDrift: null, provenanceDebt: null, mttr: null };
486
+ }
487
+ results.push(entry);
488
+ if (stateFile) {
489
+ // FR-1006 (assurance-hardening PRD): "scan freshness" — this field
490
+ // existed as an always-null stub before (never read anywhere, never
491
+ // populated), which is the same as not existing for anyone trying to
492
+ // answer "when was this repo last scanned". Stamped from the SAME
493
+ // entry.scannedAt the results array already carries, not a second,
494
+ // possibly-diverging timestamp.
495
+ state.completed[repo] = { at: entry.scannedAt, ok: entry.ok, total: entry.total };
496
+ _saveFleetState(stateFile, state);
497
+ }
498
+ onProgress(entry);
499
+ }
500
+ }
501
+
502
+ await Promise.all(Array.from({ length: limit }, () => worker()));
503
+ return { ok: true, results, skipped, rollup: rollupFleet(results, previous) };
504
+ }
505
+
506
+ /**
507
+ * Roll many per-repo results into one view, with a delta when a previous run
508
+ * is supplied.
509
+ */
510
+ function rollupFleet(results, previous = null) {
511
+ const scanned = results.filter(r => r.ok);
512
+ const failed = results.filter(r => !r.ok);
513
+ const bySeverity = Object.fromEntries(SEVERITIES.map(s => [s, 0]));
514
+ let total = 0, proven = 0;
515
+ for (const r of scanned) {
516
+ total += r.total || 0;
517
+ proven += r.proven || 0;
518
+ for (const s of SEVERITIES) bySeverity[s] += r.bySeverity?.[s] || 0;
519
+ }
520
+
521
+ // Delta by stable id, not by count.
522
+ let newFindings = null;
523
+ if (previous && Array.isArray(previous.results)) {
524
+ const before = new Set(previous.results.flatMap(r => r.ids || []));
525
+ newFindings = {};
526
+ for (const r of scanned) {
527
+ const fresh = (r.ids || []).filter(id => !before.has(id));
528
+ if (fresh.length) newFindings[r.repo] = fresh;
529
+ }
530
+ }
531
+
532
+ // FR-1006: governance/coverage gaps, counted and surfaced SEPARATELY from
533
+ // the risk rollup above (total/bySeverity/proven) — a repo can contribute
534
+ // zero to those and still need attention here. `null` scanHealth (a
535
+ // failed repo, or a runScan that predates FR-206) is not counted as
536
+ // partial — absence of a signal is not evidence of a problem.
537
+ const partialScanHealth = scanned.filter(r => r.scanHealth && r.scanHealth.status !== 'complete');
538
+ const withPolicyDrift = scanned.filter(r => r.policyDrift && (r.policyDrift.overrides.length || r.policyDrift.rejected.length));
539
+
540
+ // M4 §4.4: provenance-aware debt + MTTR, surfaced as its own section
541
+ // alongside the FR-1006 governance section above — same additive pattern,
542
+ // same reason: a repo can be risk-clean and still carry old, PROVEN debt,
543
+ // and that must not be folded into (or hidden by) the risk rollup above.
544
+ //
545
+ // `oldestProvenDebt` only ever comes from a repo's own `provenanceDebt`,
546
+ // which `runFleet`'s worker computed from `findingProvenance.status ===
547
+ // 'complete'` findings only — never a wall-clock guess dressed as proof.
548
+ // A repo with no complete-status findings is disclosed BY NAME in
549
+ // `reposWithNoProvenDebt`, never silently absent from a "no debt" story a
550
+ // reader could misread as "this repo has no old findings" when the truth
551
+ // is "we couldn't PROVE any origin here". A `results` entry that predates
552
+ // this field (no `provenanceDebt` key at all — e.g. an older checkpoint,
553
+ // or a hand-built test fixture) is treated the same as "none found",
554
+ // consistent with every other optional signal in this rollup (scanHealth,
555
+ // policyDrift).
556
+ let oldestProvenDebt = null;
557
+ const reposWithNoProvenDebt = [];
558
+ const perRepoOldestProvenDebt = [];
559
+ for (const r of scanned) {
560
+ const oldest = r.provenanceDebt?.oldest || null;
561
+ if (!oldest) { reposWithNoProvenDebt.push(r.repo); continue; }
562
+ const withRepo = { repo: r.repo, ...oldest };
563
+ perRepoOldestProvenDebt.push(withRepo);
564
+ if (!oldestProvenDebt || oldest.ageDays > oldestProvenDebt.ageDays) oldestProvenDebt = withRepo;
565
+ }
566
+
567
+ // Fleet-wide MTTR reuses each repo's own `scan.mttr` (mttr.js's
568
+ // `computeMTTR` output — `{count, meanDays, medianDays, perSeverity}`),
569
+ // combined as a count-weighted mean, rather than recomputing wall-clock
570
+ // remediation time here from a second source of truth. `byAgeBasis` is
571
+ // deliberately always `{}`: `computeMTTR` does not track ageBasis per
572
+ // remediated finding (only per-severity), and there is no
573
+ // `scan.remediatedFindings` array anywhere in this codebase to derive one
574
+ // from — see `runFleet`'s worker comment. Reporting a fabricated breakdown
575
+ // would be worse than reporting none.
576
+ //
577
+ // FINAL-REVIEW FIX (M4, item 4): `mttr` used to default to `{n:0, ...}`
578
+ // whenever no scanned repo's `.mttr` counted a remediation — which is
579
+ // exactly what happens on EVERY real production fleet run, since
580
+ // `scripts/fleet.mjs` drives `runFleet` with `runScan` straight from
581
+ // `src/runScan.js`, and that function never sets `.mttr` at all (the only
582
+ // real setter is the CLI's OWN single-repo persistence step in
583
+ // `bin/agentic-security.js`, which fleet mode does not go through — see
584
+ // that file's comment for the full investigation). `{n:0,...}` and "no
585
+ // repo ever attempted to compute this" are materially different facts:
586
+ // the first is a real, honest zero; the second is a missing capability.
587
+ // Collapsing them made every real fleet run silently render a fabricated-
588
+ // looking "no remediated findings recorded yet" line that reads as a
589
+ // measurement rather than an absence.
590
+ //
591
+ // `anyMttrSupplied` distinguishes them: it is true only when at least one
592
+ // SCANNED repo's entry carried a non-null `.mttr` object at all — which
593
+ // only happens when something upstream (the CLI's own persistence path,
594
+ // or a caller-supplied `runScan` that wires the same pipeline itself)
595
+ // actually ran `computeMTTR`. `mttr` stays `null` — not a zeroed object —
596
+ // when nothing did, so a consumer can tell "tracked, zero fixes so far"
597
+ // from "not tracked at all" without inspecting `n`.
598
+ let mttrCount = 0, mttrWeightedDays = 0, anyMttrSupplied = false;
599
+ for (const r of scanned) {
600
+ const m = r.mttr;
601
+ if (m == null) continue;
602
+ anyMttrSupplied = true;
603
+ if (typeof m.count === 'number' && m.count > 0 && typeof m.meanDays === 'number') {
604
+ mttrCount += m.count;
605
+ mttrWeightedDays += m.meanDays * m.count;
606
+ }
607
+ }
608
+ const mttr = anyMttrSupplied
609
+ ? { n: mttrCount, meanDays: mttrCount ? mttrWeightedDays / mttrCount : null, byAgeBasis: {} }
610
+ : null;
611
+
612
+ return {
613
+ repos: results.length,
614
+ scanned: scanned.length,
615
+ // Surfaced at the top level, not buried: a fleet report whose failures are
616
+ // a footnote reads as a clean fleet.
617
+ failed: failed.length,
618
+ failures: failed.map(f => ({ repo: f.repo, error: f.error })),
619
+ total, proven, bySeverity,
620
+ reposWithPartialScanHealth: partialScanHealth.length,
621
+ reposWithPolicyDrift: withPolicyDrift.length,
622
+ ...(newFindings ? { newFindings } : {}),
623
+ provenance: { oldestProvenDebt, reposWithNoProvenDebt, perRepoOldestProvenDebt, mttr },
624
+ };
625
+ }
626
+
627
+ /** One-line summary. Leads with failures when there are any. */
628
+ function renderFleetSummary(rollup) {
629
+ if (!rollup) return null;
630
+ const head = rollup.failed
631
+ ? `${rollup.failed} of ${rollup.repos} repo(s) FAILED to scan — their findings are unknown, not zero; `
632
+ : '';
633
+ const sev = SEVERITIES.filter(s => rollup.bySeverity[s]).map(s => `${rollup.bySeverity[s]} ${s}`).join(', ');
634
+ const nf = rollup.newFindings
635
+ ? ` ${Object.values(rollup.newFindings).reduce((a, b) => a + b.length, 0)} new since last run.`
636
+ : '';
637
+ // FR-1006: governance/coverage gaps reported as their own clause, never
638
+ // folded into the risk sentence — a fleet with zero findings but 12 repos
639
+ // on a partial scan is not "clean."
640
+ const gov = [];
641
+ if (rollup.reposWithPartialScanHealth) gov.push(`${rollup.reposWithPartialScanHealth} repo(s) with a partial (not complete) scan`);
642
+ if (rollup.reposWithPolicyDrift) gov.push(`${rollup.reposWithPolicyDrift} repo(s) with policy drift or a rejected policy bundle`);
643
+ const govClause = gov.length ? ` GOVERNANCE: ${gov.join('; ')}.` : '';
644
+ // M4 §4.4: proven-origin debt as its own clause, matching the governance
645
+ // clause's honesty rule — a null `oldestProvenDebt` gets an explicit "none"
646
+ // line rather than silently vanishing from the sentence, which would read
647
+ // as "no debt" instead of "nothing PROVEN yet."
648
+ const prov = rollup.provenance;
649
+ let provClause = '';
650
+ if (prov) {
651
+ // FR-PROV-019: the age never ships without its basis ("proven-origin",
652
+ // structurally guaranteed by the status:'complete' filter upstream) AND
653
+ // its confidence. A finding whose provenance carried no confidence level
654
+ // says so rather than implying certainty by omission.
655
+ const bits = prov.oldestProvenDebt
656
+ ? [`oldest proven-origin finding: ${prov.oldestProvenDebt.repo} (${prov.oldestProvenDebt.ageDays}d, `
657
+ + `${prov.oldestProvenDebt.confidence ? `${String(prov.oldestProvenDebt.confidence).toUpperCase()} confidence` : 'confidence unknown'})`]
658
+ : ['no proven-origin findings across the fleet'];
659
+ if (prov.reposWithNoProvenDebt.length) bits.push(`${prov.reposWithNoProvenDebt.length} repo(s) with no complete-status provenance`);
660
+ provClause = ` PROVENANCE: ${bits.join('; ')}.`;
661
+ }
662
+ // X-707: portfolio progress is its own clause, present only when the `portfolio-assurance` feature attached it
663
+ // (portfolio/progress.js `attachPortfolioProgress`), so flag-off output is byte-identical. It states verified units and
664
+ // repositories separately from the scan counts above: a finished controller is not a passing portfolio.
665
+ const pp = rollup.portfolioProgress;
666
+ const portfolioClause = pp ? ` PORTFOLIO: ${pp.units.verified}/${pp.units.total} unit(s) verified, ${pp.coverage.fullyVerified}/${pp.coverage.total} repo(s) fully verified, ${pp.review.pending} pending human review; ${pp.completion.statement}` : '';
667
+ return `${head}${rollup.scanned} repo(s) scanned, ${rollup.total} finding(s)`
668
+ + `${sev ? ` (${sev})` : ''}, ${rollup.proven} execution-proven.${nf}${govClause}${provClause}${portfolioClause}`;
669
+ }
670
+
671
+ /**
672
+ * A self-contained rollup page. No network, no scripts, no fonts — it must
673
+ * render from local artifacts on an air-gapped machine (PRD AC3).
674
+ */
675
+ function renderFleetHtml(rollup, results) {
676
+ const esc = (s) => String(s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
677
+ // FR-1006: "scan freshness" — WHEN each repo was last scanned, not just
678
+ // whether it was. A failed repo still carries a real scannedAt (the
679
+ // attempt happened; it just didn't succeed) so a reader can tell "failed
680
+ // just now" from "failed, and nobody has retried in weeks" apart.
681
+ const rows = results.map(r => r.ok
682
+ ? `<tr><td>${esc(r.repo)}</td><td>${r.total}</td>`
683
+ + SEVERITIES.map(s => `<td>${r.bySeverity?.[s] ?? 0}</td>`).join('')
684
+ + `<td>${r.proven ?? 0}</td><td>${esc(r.scannedAt || 'unknown')}</td></tr>`
685
+ : `<tr class="fail"><td>${esc(r.repo)}</td><td colspan="7">SCAN FAILED — ${esc(r.error)}</td><td>${esc(r.scannedAt || 'unknown')}</td></tr>`).join('\n');
686
+ // FR-1006: a SEPARATE table, not a column bolted onto the risk table above
687
+ // — "distinguishes risk findings from scan and governance coverage gaps"
688
+ // is most honestly satisfied by keeping the two visually and structurally
689
+ // apart, not by merging them into one row a reader has to parse. Only
690
+ // repos with something to report appear here; a fleet with nothing to flag
691
+ // gets a one-line "nothing to report" rather than an empty table.
692
+ const govRows = results
693
+ .filter(r => (r.scanHealth && r.scanHealth.status !== 'complete') || (r.policyDrift && (r.policyDrift.overrides.length || r.policyDrift.rejected.length)))
694
+ .map(r => {
695
+ const healthCell = r.scanHealth && r.scanHealth.status !== 'complete'
696
+ ? esc(`${r.scanHealth.status}: ${(r.scanHealth.conditions || []).join('; ') || 'see scanHealth'}`)
697
+ : '—';
698
+ const driftParts = [];
699
+ if (r.policyDrift?.overrides?.length) {
700
+ driftParts.push(...r.policyDrift.overrides.map(o => `${esc(o.key)} overridden by ${esc(o.overriddenBy)}`));
701
+ }
702
+ if (r.policyDrift?.rejected?.length) {
703
+ driftParts.push(...r.policyDrift.rejected.map(x => `${esc(x.scope)} policy REJECTED: ${esc(x.reason)}`));
704
+ }
705
+ const driftCell = driftParts.length ? driftParts.join('; ') : '—';
706
+ return `<tr><td>${esc(r.repo)}</td><td>${healthCell}</td><td>${driftCell}</td></tr>`;
707
+ }).join('\n');
708
+ const govSection = govRows
709
+ ? `<h2>Governance / coverage gaps</h2>
710
+ <table border="1" cellpadding="4">
711
+ <tr><th>repo</th><th>scan health</th><th>policy drift</th></tr>
712
+ ${govRows}
713
+ </table>`
714
+ : `<h2>Governance / coverage gaps</h2>
715
+ <p>Nothing to report — every scanned repo has a complete scan and no policy drift.</p>`;
716
+ // M4 §4.4: a SEPARATE section, same precedent as the governance section
717
+ // above — proven-origin debt is neither a risk-severity fact nor a
718
+ // governance-coverage fact, so it gets its own visual and structural space
719
+ // rather than being bolted onto either existing table. Lists the oldest
720
+ // finding PER REPO (not just the single fleet-wide oldest), so a reader can
721
+ // see which repos are carrying old proven debt, not only the single worst
722
+ // one.
723
+ const prov = rollup.provenance;
724
+ let provSection = '';
725
+ if (prov) {
726
+ // FR-PROV-019: the confidence column is not decoration — an age column
727
+ // with no confidence beside it is exactly the "age without its basis and
728
+ // confidence" the PRD forbids. The basis is the table's own framing
729
+ // (every row is proven-origin by construction); the confidence has to be
730
+ // shown per row, and an absent level reads "unknown" rather than blank,
731
+ // so a missing value can never be mistaken for a confident one.
732
+ const provRows = (prov.perRepoOldestProvenDebt || [])
733
+ .map(p => `<tr><td>${esc(p.repo)}</td><td>${esc(p.findingId ?? 'unknown')}</td><td>${esc(p.authorDate)}</td><td>${p.ageDays}</td><td>${esc(p.confidence ? String(p.confidence).toUpperCase() : 'unknown')}</td></tr>`)
734
+ .join('\n');
735
+ const provTable = provRows
736
+ ? `<table border="1" cellpadding="4">
737
+ <tr><th>repo</th><th>finding</th><th>author date</th><th>age (days)</th><th>origin confidence</th></tr>
738
+ ${provRows}
739
+ </table>`
740
+ : `<p>No complete-status (proven-origin) findings across the fleet.</p>`;
741
+ const noProvenNote = prov.reposWithNoProvenDebt.length
742
+ ? `<p>${prov.reposWithNoProvenDebt.length} repo(s) with no complete-status provenance: ${prov.reposWithNoProvenDebt.map(esc).join(', ')}</p>`
743
+ : '';
744
+ const mttrByBasisRows = Object.entries(prov.mttr?.byAgeBasis || {})
745
+ .map(([basis, n]) => `<tr><td>${esc(basis)}</td><td>${n}</td></tr>`).join('\n');
746
+ const mttrByBasisTable = mttrByBasisRows
747
+ ? `<table border="1" cellpadding="4">
748
+ <tr><th>age basis</th><th>count</th></tr>
749
+ ${mttrByBasisRows}
750
+ </table>`
751
+ : '';
752
+ // Three distinct states, not two (final-review fix, item 4): `prov.mttr
753
+ // === null` means no scanned repo ever supplied a real mttr.js aggregate
754
+ // at all — the honest disclosure is "not available", never "no
755
+ // remediated findings recorded yet", which reads as a real zero
756
+ // measurement rather than an absent capability. A non-null `mttr` with
757
+ // `n === 0` is the legitimate zero: something DID track remediation and
758
+ // genuinely found none yet.
759
+ const mttrLine = !prov.mttr
760
+ ? `<p>Fleet MTTR: not available — fleet mode does not yet track finding remediation history across runs.</p>`
761
+ : prov.mttr.n > 0
762
+ ? `<p>Fleet MTTR: ${prov.mttr.n} remediated finding(s)${prov.mttr.meanDays != null ? `, mean ${Math.round(prov.mttr.meanDays)}d to remediate` : ''}.</p>${mttrByBasisTable}`
763
+ : `<p>Fleet MTTR: no remediated findings recorded yet.</p>`;
764
+ provSection = `<h2>Provenance-Proven Debt</h2>
765
+ ${provTable}
766
+ ${noProvenNote}
767
+ ${mttrLine}`;
768
+ }
769
+ return `<!-- generated offline; no external resources -->
770
+ <h1>Fleet scan</h1>
771
+ <p>${esc(renderFleetSummary(rollup) || '')}</p>
772
+ <h2>Risk findings</h2>
773
+ <table border="1" cellpadding="4">
774
+ <tr><th>repo</th><th>total</th>${SEVERITIES.map(s => `<th>${s}</th>`).join('')}<th>proven</th><th>scanned at</th></tr>
775
+ ${rows}
776
+ </table>
777
+ ${govSection}
778
+ ${provSection}`;
779
+ }
780
+
781
+ const _internals = { SCHEMA, SEVERITIES, _matchesGlob, _summarise, _provenanceDebtForRepo };
782
+
783
+ // EXTERNAL MODULE: ./src/posture/state-dir.js
784
+ var state_dir = __webpack_require__(69259);
785
+ ;// CONCATENATED MODULE: ./src/posture/fleet/cli.js
786
+ // `agentic-security fleet init|scan|status|resume|query`.
787
+ //
788
+ // Reuses the existing posture/fleet.js (runFleet: bounded concurrency, resumable checkpoint, per-repo isolation,
789
+ // honest rollup) for the scanning half — this module adds only the manifest-driven entry point and the
790
+ // cross-repository dependency graph (fleet-graph.js) that runFleet has no notion of.
791
+
792
+
793
+
794
+
795
+
796
+
797
+
798
+ const GRAPH_FILE = 'fleet-graph.json';
799
+ const STATE_FILE = 'fleet-state.json';
800
+
801
+ function graphPath(root) { return (0,state_dir.statePath)(root, GRAPH_FILE); }
802
+ function statePathFor(root) { return (0,state_dir.statePath)(root, STATE_FILE); }
803
+
804
+ async function cmdFleet(args, root) {
805
+ const sub = args._[1];
806
+ if (sub === 'init') return cmdInit(args, root);
807
+ if (sub === 'scan') return cmdScan(args, root);
808
+ if (sub === 'status') return cmdStatus(args, root);
809
+ if (sub === 'resume') return cmdScan(args, root, { resumeOnly: true });
810
+ if (sub === 'query') return cmdQuery(args, root);
811
+ console.error('usage: fleet init | scan <manifest.yaml> | status <manifest.yaml> | resume <manifest.yaml> | query --depends-on <repo-id> [<manifest.yaml>]');
812
+ return 4;
813
+ }
814
+
815
+ function manifestArg(args) {
816
+ const m = args._[2];
817
+ return m && !String(m).startsWith('--') ? m : 'fleet.yaml';
818
+ }
819
+
820
+ async function cmdInit(args, root) {
821
+ const target = external_node_path_.resolve(root, manifestArg(args) === 'fleet.yaml' ? 'fleet.yaml' : manifestArg(args));
822
+ if (external_node_fs_.existsSync(target) && !args.flags.force) { console.error(`${target} already exists. Pass --force to overwrite.`); return 4; }
823
+ external_node_fs_.writeFileSync(target, starterManifestYaml(args.flags.organization || 'example'));
824
+ console.log(`Wrote ${target}. Edit it to list your repositories, then run: agentic-security fleet scan ${external_node_path_.basename(target)}`);
825
+ return 0;
826
+ }
827
+
828
+ async function cmdScan(args, root, { resumeOnly = false } = {}) {
829
+ const manifestFile = external_node_path_.resolve(root, manifestArg(args));
830
+ const loaded = loadFleetManifest(manifestFile);
831
+ if (!loaded.ok) { console.error(`Invalid fleet manifest: ${loaded.errors.join('; ')}`); return 4; }
832
+ const { manifest } = loaded;
833
+
834
+ if (!(0,state_dir/* ensureStateDir */.xH)(root)) { console.error('No safe state directory for this project.'); return 4; }
835
+ const prevRollupFile = (0,state_dir.statePath)(root, 'fleet-rollup.json');
836
+ let previous = null;
837
+ try { previous = JSON.parse(external_node_fs_.readFileSync(prevRollupFile, 'utf8')); } catch { /* first run */ }
838
+
839
+ const { runScan: runScanFn } = await Promise.resolve(/* import() */).then(__webpack_require__.bind(__webpack_require__, 58307));
840
+ // runScanFn resolves to { scan, meta } already — runFleet's worker expects exactly that shape from runScan, so
841
+ // this must NOT wrap `scan` a second time (an earlier draft did, and every repo silently reported 0 findings:
842
+ // `_summarise` reads `scan.findings` defensively rather than throwing on the wrong shape).
843
+ const runScan = (repoPath) => runScanFn(repoPath, { provenance: false });
844
+
845
+ console.error(`[fleet] scanning ${manifest.repositories.length} repository(ies) for ${manifest.organization} (${manifest.workers} worker(s))...`);
846
+ const result = await runFleet({
847
+ repos: manifest.repositories.map((r) => r.id), concurrency: manifest.workers,
848
+ stateFile: statePathFor(root), resume: true, previous,
849
+ runScan: async (repoId) => runScan(manifest.repositories.find((r) => r.id === repoId).path),
850
+ onProgress: (entry) => { console.error(`[fleet] ${entry.ok ? 'done' : 'FAILED'}: ${entry.repo}${entry.ok ? ` (${entry.total} finding(s))` : ` — ${entry.error}`}`); },
851
+ });
852
+ if (resumeOnly && result.skipped.length === manifest.repositories.length) console.error('[fleet] nothing to resume — every repository was already complete.');
853
+
854
+ (0,state_dir.safeWriteState)(prevRollupFile, JSON.stringify(result.rollup, null, 2));
855
+ const graph = buildFleetDependencyGraph(manifest.repositories);
856
+ (0,state_dir.safeWriteState)(graphPath(root), JSON.stringify({ organization: manifest.organization, builtAt: new Date().toISOString(), ...graph }, null, 2));
857
+
858
+ console.log(renderFleetSummary(result.rollup));
859
+ console.log(`\nRepositories requested: ${manifest.repositories.length}`);
860
+ console.log(`Complete: ${result.results.filter((r) => r.ok).length + result.skipped.length}`);
861
+ console.log(`Failed: ${result.results.filter((r) => !r.ok).length}`);
862
+ const anyFailed = result.results.some((r) => !r.ok);
863
+ return anyFailed ? 1 : 0;
864
+ }
865
+
866
+ async function cmdStatus(args, root) {
867
+ const f = (0,state_dir.statePath)(root, 'fleet-rollup.json');
868
+ if (!external_node_fs_.existsSync(f)) { console.error('No fleet scan has been run yet. Run `agentic-security fleet scan <manifest.yaml>` first.'); return 4; }
869
+ const rollup = JSON.parse(external_node_fs_.readFileSync(f, 'utf8'));
870
+ if (args.flags.json) console.log(JSON.stringify(rollup, null, 2));
871
+ else console.log(renderFleetSummary(rollup));
872
+ return 0;
873
+ }
874
+
875
+ async function cmdQuery(args, root) {
876
+ const target = args.flags['depends-on'];
877
+ if (!target) { console.error('usage: fleet query --depends-on <repo-id>'); return 4; }
878
+ const f = graphPath(root);
879
+ if (!external_node_fs_.existsSync(f)) { console.error('No fleet dependency graph yet. Run `agentic-security fleet scan <manifest.yaml>` first.'); return 4; }
880
+ const graph = JSON.parse(external_node_fs_.readFileSync(f, 'utf8'));
881
+ if (!graph.nodes.some((n) => n.id === target)) { console.error(`${target} is not a repository in the last fleet scan.`); return 4; }
882
+ const radius = blastRadius(graph, target);
883
+ if (args.flags.json) { console.log(JSON.stringify({ target, blastRadius: radius }, null, 2)); return 0; }
884
+ if (!radius.length) { console.log(`No fleet repository depends on ${target} (by declared package name).`); return 0; }
885
+ console.log(`Repositories depending on ${target} (directly or transitively):`);
886
+ for (const r of radius) console.log(` ${r.id} (depth ${r.depth}, via ${r.path.slice(1).join(' -> ')})`);
887
+ return 0;
888
+ }
889
+
890
+
891
+ /***/ }),
892
+
893
+ /***/ 14413:
894
+ /***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
895
+
896
+ /* harmony export */ __webpack_require__.d(__webpack_exports__, {
897
+ /* harmony export */ buildPolicyBundle: () => (/* binding */ buildPolicyBundle),
898
+ /* harmony export */ ensurePolicyKeyPair: () => (/* binding */ ensurePolicyKeyPair),
899
+ /* harmony export */ loadPolicyBundles: () => (/* binding */ loadPolicyBundles),
900
+ /* harmony export */ loadPolicyPublicKey: () => (/* binding */ loadPolicyPublicKey),
901
+ /* harmony export */ rE: () => (/* binding */ computePolicyDrift),
902
+ /* harmony export */ resolveEffectivePolicy: () => (/* binding */ resolveEffectivePolicy),
903
+ /* harmony export */ signPolicyBundle: () => (/* binding */ signPolicyBundle)
904
+ /* harmony export */ });
905
+ /* unused harmony exports POLICY_BUNDLE_SCHEMA, SCOPES, canonicalPolicyBytes, verifyPolicyBundle */
906
+ /* harmony import */ var node_fs__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(73024);
907
+ /* harmony import */ var node_os__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(48161);
908
+ /* harmony import */ var node_path__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(76760);
909
+ /* harmony import */ var node_crypto__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(77598);
910
+ /* harmony import */ var _evidence_bundle_js__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(32876);
911
+ /* harmony import */ var _state_dir_js__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(69259);
912
+ // Signed, portable policy bundles (assurance-hardening PRD FR-1001).
913
+ //
914
+ // "Support signed portable policy bundles with organization, repository, and
915
+ // environment inheritance" | "Tampered or expired policy is rejected;
916
+ // effective policy is explainable."
917
+ //
918
+ // WHY REUSE evidence-bundle.js's Ed25519 SCHEME, NOT integrity.js's HMAC
919
+ // -------------------------------------------------------------------------
920
+ // A policy bundle is authored by one party (typically a central security
921
+ // team, "the organization") and distributed to many repositories that never
922
+ // had the signing key. integrity.js's per-install symmetric HMAC is
923
+ // tamper-evidence for the SAME install that wrote it — a different install
924
+ // verifying a bundle it didn't sign is exactly the case that scheme cannot
925
+ // serve. evidence-bundle.js already solved this for findings: Ed25519,
926
+ // verify with only the public key. This module reuses that primitive
927
+ // directly (canonicalisation shape, allowlist-signed-fields discipline,
928
+ // exclusive-create key generation) rather than inventing a third signing
929
+ // mechanism, per this session's own established "survey before building a
930
+ // parallel primitive" practice (see D-0023/D-0025 for the same discipline
931
+ // applied to identity and exception mechanisms respectively).
932
+ //
933
+ // EA-03 LESSON, APPLIED HERE FROM THE START
934
+ // -------------------------------------------------------------------------
935
+ // evidence-bundle.js's own history (see its EA-03 comment) is the reason
936
+ // `canonicalPolicyBytes` signs an explicit ALLOWLIST and `verifyPolicyBundle`
937
+ // REJECTS any top-level key outside it: a bundle with a field stapled on
938
+ // after signing must fail verification, not silently pass with the addition
939
+ // unverified. Getting this right on the first pass — rather than discovering
940
+ // the gap the way evidence-bundle.js did — is the entire point of writing it
941
+ // down here.
942
+ //
943
+ // SCOPE — WHAT THIS MODULE DOES NOT DO
944
+ // -------------------------------------------------------------------------
945
+ // It does not invent a policy SCHEMA — `policy` is an opaque object; whatever
946
+ // keys a bundle carries (egress rules, severity floors, approved providers,
947
+ // anything else this repo already treats as policy) are merged the same way.
948
+ // It does not implement deep/recursive merging — inheritance is shallow,
949
+ // top-level-key override (organization -> repository -> environment, most
950
+ // specific wins per key), matching how every other flat policy config file
951
+ // in this repo (egress-policy.yml, compliance-severity-policy.json, …) is
952
+ // already shaped. A future consumer needing nested-key inheritance is new,
953
+ // separate scope, not assumed here.
954
+
955
+
956
+
957
+
958
+
959
+
960
+
961
+
962
+ const POLICY_BUNDLE_SCHEMA = 'agentic-security/policy-bundle@1';
963
+
964
+ const SCOPES = ['organization', 'repository', 'environment'];
965
+ // Inheritance order — later entries override earlier ones, per top-level key.
966
+ const INHERITANCE_ORDER = ['organization', 'repository', 'environment'];
967
+
968
+ function keyDir() {
969
+ const xdg = process.env.XDG_CONFIG_HOME || node_path__WEBPACK_IMPORTED_MODULE_2__.join(node_os__WEBPACK_IMPORTED_MODULE_1__.homedir(), '.config');
970
+ return node_path__WEBPACK_IMPORTED_MODULE_2__.join(xdg, 'agentic-security', 'policy-bundles');
971
+ }
972
+
973
+ /** A SEPARATE Ed25519 keypair from evidence-bundle.js's finding-attestation
974
+ * key — signing "this policy is authentic" and "this finding really came
975
+ * from a scan run by me" are different trust domains and must not share a
976
+ * key. Reuses evidence-bundle.js's hardened, race-safe generation logic
977
+ * directly (its exclusive-create collision handling is the kind of code
978
+ * worth NOT re-implementing) rather than duplicating it — the separation
979
+ * that matters is the DIRECTORY (`policy-bundles/`, isolated from
980
+ * evidence-bundle.js's own key dir), not the leaf filenames, which are
981
+ * `attest-key.pem`/`attest-key.pub.pem` regardless of caller (a
982
+ * evidence-bundle.js internal, not overridable here) — a readability wart,
983
+ * not a security one, since the directory is what prevents key reuse. */
984
+ function ensurePolicyKeyPair(dir = keyDir()) {
985
+ return (0,_evidence_bundle_js__WEBPACK_IMPORTED_MODULE_4__.ensureKeyPair)(dir);
986
+ }
987
+
988
+ /**
989
+ * Deterministic JSON, keys sorted at every level — identical algorithm to
990
+ * evidence-bundle.js's canonicalJson (duplicated rather than imported: it is
991
+ * a pure, three-line function, and importing it would couple this module's
992
+ * signature format to evidence-bundle.js's internals for no real benefit).
993
+ */
994
+ function canonicalJson(value) {
995
+ if (value === null || typeof value !== 'object') return JSON.stringify(value ?? null);
996
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
997
+ const keys = Object.keys(value).sort();
998
+ return `{${keys.map(k => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(',')}}`;
999
+ }
1000
+
1001
+ // The complete set of fields a legitimately-built bundle carries, BEFORE
1002
+ // signing. This is the signed allowlist AND (in verifyPolicyBundle) the
1003
+ // complete set of keys a signed bundle is permitted to have alongside
1004
+ // `signature` — see the EA-03 note above for why both matter.
1005
+ const BUNDLE_FIELDS = ['schema', 'scope', 'policy', 'issuedAt', 'expiresAt'];
1006
+ const BUNDLE_TOP_LEVEL_KEYS = new Set([...BUNDLE_FIELDS, 'signature']);
1007
+
1008
+ function canonicalPolicyBytes(bundle) {
1009
+ const signed = {};
1010
+ for (const k of BUNDLE_FIELDS) signed[k] = bundle[k] ?? null;
1011
+ return Buffer.from(canonicalJson(signed), 'utf8');
1012
+ }
1013
+
1014
+ /**
1015
+ * Build an unsigned bundle. `policy` is caller-supplied and opaque — this
1016
+ * module does not validate its shape, only its provenance and freshness.
1017
+ */
1018
+ function buildPolicyBundle(scope, policy, { issuedAt, expiresAt } = {}) {
1019
+ if (!SCOPES.includes(scope)) return null;
1020
+ if (!policy || typeof policy !== 'object' || Array.isArray(policy)) return null;
1021
+ return {
1022
+ schema: POLICY_BUNDLE_SCHEMA,
1023
+ scope,
1024
+ policy,
1025
+ issuedAt: issuedAt ?? new Date().toISOString(),
1026
+ expiresAt: expiresAt ?? null,
1027
+ };
1028
+ }
1029
+
1030
+ /** Sign a bundle. Returns a new object; the input is not mutated. */
1031
+ function signPolicyBundle(bundle, privateKeyPem) {
1032
+ const sig = node_crypto__WEBPACK_IMPORTED_MODULE_3__.sign(null, canonicalPolicyBytes(bundle), privateKeyPem);
1033
+ return {
1034
+ ...bundle,
1035
+ signature: { algorithm: 'ed25519', canonicalisation: POLICY_BUNDLE_SCHEMA, value: sig.toString('base64') },
1036
+ };
1037
+ }
1038
+
1039
+ /**
1040
+ * Verify a bundle: signature authenticity AND freshness. Never throws — a
1041
+ * malformed or tampered bundle from an untrusted source (a repo checking out
1042
+ * whatever an org distributed) is an expected input, not exceptional.
1043
+ *
1044
+ * @returns {{ok: boolean, reason: string|null}}
1045
+ */
1046
+ function verifyPolicyBundle(bundle, publicKeyPem, { now = Date.now() } = {}) {
1047
+ if (!bundle || typeof bundle !== 'object') return { ok: false, reason: 'bundle is not an object' };
1048
+ if (bundle.schema !== POLICY_BUNDLE_SCHEMA) return { ok: false, reason: `unrecognised schema: ${bundle.schema}` };
1049
+ if (!SCOPES.includes(bundle.scope)) return { ok: false, reason: `unrecognised scope: ${bundle.scope}` };
1050
+ const unknownKeys = Object.keys(bundle).filter(k => !BUNDLE_TOP_LEVEL_KEYS.has(k));
1051
+ if (unknownKeys.length) {
1052
+ return { ok: false, reason: `unrecognised top-level key(s) not covered by the signature: ${unknownKeys.join(', ')}` };
1053
+ }
1054
+ const sig = bundle.signature;
1055
+ if (!sig?.value) return { ok: false, reason: 'bundle is unsigned' };
1056
+ if (sig.algorithm !== 'ed25519') return { ok: false, reason: `unsupported algorithm: ${sig.algorithm}` };
1057
+ if (!publicKeyPem) return { ok: false, reason: 'no public key supplied' };
1058
+ let sigOk = false;
1059
+ try {
1060
+ sigOk = node_crypto__WEBPACK_IMPORTED_MODULE_3__.verify(null, canonicalPolicyBytes(bundle), publicKeyPem, Buffer.from(sig.value, 'base64'));
1061
+ } catch (e) {
1062
+ return { ok: false, reason: `verification error: ${e.message}` };
1063
+ }
1064
+ if (!sigOk) return { ok: false, reason: 'signature does not match the bundle contents — it was modified after signing' };
1065
+ if (bundle.expiresAt) {
1066
+ const exp = Date.parse(bundle.expiresAt);
1067
+ if (!Number.isFinite(exp)) return { ok: false, reason: `expiresAt is not a valid date: ${bundle.expiresAt}` };
1068
+ if (exp < now) return { ok: false, reason: `policy expired on ${bundle.expiresAt}` };
1069
+ }
1070
+ return { ok: true, reason: null };
1071
+ }
1072
+
1073
+ /**
1074
+ * Merge verified bundles into one effective policy, in inheritance order
1075
+ * (organization -> repository -> environment; most specific wins per key).
1076
+ * A tampered or expired bundle is EXCLUDED from the merge (rejected, not
1077
+ * silently dropped — its scope and reason are reported so a rejection is
1078
+ * visible, not just absent). `provenance[key]` names which scope's bundle
1079
+ * last set that key — the "effective policy is explainable" half of the
1080
+ * acceptance criterion.
1081
+ *
1082
+ * @param {Array<{scope: string, bundle: object}>} entries
1083
+ * @param {string} publicKeyPem
1084
+ * @returns {{effective: object, provenance: Record<string,string>,
1085
+ * accepted: string[], rejected: Array<{scope:string, reason:string}>}}
1086
+ */
1087
+ function resolveEffectivePolicy(entries, publicKeyPem, { now = Date.now() } = {}) {
1088
+ const byScope = new Map();
1089
+ for (const e of entries || []) {
1090
+ if (e && SCOPES.includes(e.scope)) byScope.set(e.scope, e.bundle);
1091
+ }
1092
+ const effective = {};
1093
+ const provenance = {};
1094
+ const accepted = [];
1095
+ const rejected = [];
1096
+ for (const scope of INHERITANCE_ORDER) {
1097
+ const bundle = byScope.get(scope);
1098
+ if (!bundle) continue;
1099
+ const v = verifyPolicyBundle(bundle, publicKeyPem, { now });
1100
+ if (!v.ok) { rejected.push({ scope, reason: v.reason }); continue; }
1101
+ accepted.push(scope);
1102
+ for (const [k, val] of Object.entries(bundle.policy)) {
1103
+ effective[k] = val;
1104
+ provenance[k] = scope;
1105
+ }
1106
+ }
1107
+ return { effective, provenance, accepted, rejected };
1108
+ }
1109
+
1110
+ /**
1111
+ * Load whichever policy bundle files exist under
1112
+ * `.agentic-security/policy-bundles/{organization,repository,environment}.json`.
1113
+ * Degrades gracefully — a missing directory or missing/malformed individual
1114
+ * file is simply absent from the result, never thrown. Read-first-in-try/
1115
+ * catch throughout (no existsSync-then-readFileSync — D-0012/D-0022).
1116
+ */
1117
+ function loadPolicyBundles(scanRoot) {
1118
+ if (!scanRoot) return [];
1119
+ const entries = [];
1120
+ for (const scope of SCOPES) {
1121
+ let fp;
1122
+ try { fp = (0,_state_dir_js__WEBPACK_IMPORTED_MODULE_5__.statePath)(scanRoot, 'policy-bundles', `${scope}.json`); } catch { continue; }
1123
+ let raw;
1124
+ try { raw = node_fs__WEBPACK_IMPORTED_MODULE_0__.readFileSync(fp, 'utf8'); } catch { continue; }
1125
+ try {
1126
+ const bundle = JSON.parse(raw);
1127
+ if (bundle && typeof bundle === 'object') entries.push({ scope, bundle });
1128
+ } catch { /* malformed — skip, do not throw */ }
1129
+ }
1130
+ return entries;
1131
+ }
1132
+
1133
+ /**
1134
+ * Read the operator-supplied public key an org distributes alongside its
1135
+ * bundles, from `.agentic-security/policy-bundle-public-key.pem`. Returns
1136
+ * null (not a throw) if absent or unreadable — resolution then rejects
1137
+ * every bundle for lack of a key, same as any other "no public key
1138
+ * supplied" case in verifyPolicyBundle.
1139
+ */
1140
+ function loadPolicyPublicKey(scanRoot) {
1141
+ if (!scanRoot) return null;
1142
+ let fp;
1143
+ try { fp = (0,_state_dir_js__WEBPACK_IMPORTED_MODULE_5__.statePath)(scanRoot, 'policy-bundle-public-key.pem'); } catch { return null; }
1144
+ try { return node_fs__WEBPACK_IMPORTED_MODULE_0__.readFileSync(fp, 'utf8'); } catch { return null; }
1145
+ }
1146
+
1147
+ /**
1148
+ * FR-1006 ("policy drift"): does this repository's LOCALLY resolved effective
1149
+ * policy diverge from what the organization-scope bundle alone specifies —
1150
+ * and is anything the repo is relying on actually being rejected (tampered
1151
+ * or expired) rather than enforced? Both are GOVERNANCE gaps, distinct from
1152
+ * risk findings, which is the exact distinction FR-1006's acceptance
1153
+ * criterion asks fleet output to make.
1154
+ *
1155
+ * No bundles configured at all is a no-op (`null`), matching this session's
1156
+ * established convention: drift can only be reported relative to a
1157
+ * baseline, and a repo with no organization bundle has no baseline to drift
1158
+ * from.
1159
+ *
1160
+ * @returns {{accepted: string[], rejected: Array<{scope,reason}>,
1161
+ * overrides: Array<{key, organizationValue, effectiveValue, overriddenBy}>}
1162
+ * | null}
1163
+ */
1164
+ function computePolicyDrift(scanRoot) {
1165
+ const entries = loadPolicyBundles(scanRoot);
1166
+ if (!entries.length) return null;
1167
+ const publicKeyPem = loadPolicyPublicKey(scanRoot);
1168
+ const { effective, provenance, accepted, rejected } = resolveEffectivePolicy(entries, publicKeyPem);
1169
+
1170
+ const overrides = [];
1171
+ const orgEntry = entries.find(e => e.scope === 'organization');
1172
+ if (orgEntry && accepted.includes('organization')) {
1173
+ for (const [key, organizationValue] of Object.entries(orgEntry.bundle.policy || {})) {
1174
+ const overriddenBy = provenance[key];
1175
+ if (overriddenBy && overriddenBy !== 'organization' && !_deepEqual(effective[key], organizationValue)) {
1176
+ overrides.push({ key, organizationValue, effectiveValue: effective[key], overriddenBy });
1177
+ }
1178
+ }
1179
+ }
1180
+ return { accepted, rejected, overrides };
1181
+ }
1182
+
1183
+ function _deepEqual(a, b) {
1184
+ return JSON.stringify(a) === JSON.stringify(b);
1185
+ }
1186
+
1187
+
1188
+ /***/ })
1189
+
1190
+ };