@bongos/core 1.18.18 → 1.19.2

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 (251) hide show
  1. package/.bongos-core.json +515 -195
  2. package/.claude/skills/builder-box/SKILL.md +1 -1
  3. package/.claude/skills/builder-claim/SKILL.md +1 -1
  4. package/.claude/skills/goal-create/SKILL.md +1 -1
  5. package/.claude/skills/idea-triage/SKILL.md +1 -1
  6. package/.claude/skills/planning-session/SKILL.md +5 -5
  7. package/.claude/skills/scan-before-install/SKILL.md +165 -0
  8. package/clients/bongos-client/README.md +1 -1
  9. package/clients/bongos-client/bongos-client.global.js +63 -41
  10. package/clients/bongos-client/index.cjs +63 -41
  11. package/clients/bongos-client/index.d.ts +78 -44
  12. package/clients/bongos-client/index.mjs +63 -41
  13. package/docs/adr/0020-security-model-public-stub.md +1 -1
  14. package/docs/adr/0097-one-active-claim-per-session-and-worktree-binding.md +2 -0
  15. package/docs/adr/0144-devbox-rehome-onto-cloudbongos-plane.md +1 -1
  16. package/docs/adr/0151-governance-permissions-as-atom-ranks-as-roles.md +2 -1
  17. package/docs/adr/0155-adopt-private-repo-widen-oauth-scope.md +51 -0
  18. package/docs/adr/0156-user-set-display-name-wins.md +62 -0
  19. package/docs/adr/0157-archon-is-rank-and-identity-only.md +93 -0
  20. package/docs/adr/0158-choke-point-verdict-taxonomy.md +174 -0
  21. package/docs/adr/0159-merge-gate-reconciliation.md +88 -0
  22. package/docs/adr/0160-main-audit-repo-verifiable-caller.md +77 -0
  23. package/docs/adr/README.md +8 -0
  24. package/docs/api/openapi.json +1209 -233
  25. package/docs/api-reference.md +61 -46
  26. package/docs/architecture.md +1 -1
  27. package/docs/canonical-permissions.md +61 -16
  28. package/docs/design/vanilla-hall-ui-redesign-scope.md +1 -1
  29. package/docs/file-map.md +4 -1
  30. package/docs/onboarding/diagrams/assertions.json +3 -3
  31. package/docs/recipes/core-release-pipeline.md +179 -0
  32. package/docs/recipes/packaging-the-core.md +2 -0
  33. package/docs/recipes/parallel-goal-waves.md +1 -3
  34. package/docs/recipes/private-npm-distribution.md +2 -0
  35. package/docs/recipes/upgrading-the-core.md +2 -0
  36. package/migrations/core_197_display_name_source.sql +56 -0
  37. package/migrations/core_204_rekey_active_claim_per_session_worktree.sql +77 -0
  38. package/modules/autonomy/routes/autonomy.js +1 -1
  39. package/modules/builder-settings/db.js +7 -1
  40. package/modules/dev-box/app/src/vendor/bongos-client.cjs +63 -41
  41. package/modules/dev-box/routes/box.js +11 -9
  42. package/modules/discord/auth.js +3 -1
  43. package/modules/discord/routes/discord.js +8 -2
  44. package/modules/discord/ship-broadcast.js +48 -1
  45. package/modules/governance/catalog.js +70 -20
  46. package/modules/governance/db.js +102 -0
  47. package/modules/governance/migrations/governance_006_version_create.sql +38 -0
  48. package/modules/governance/migrations/governance_007_delegate_archon_ops_to_metic.sql +72 -0
  49. package/modules/governance/routes/governance.js +235 -6
  50. package/modules/grading/grader-workers/index.js +18 -9
  51. package/modules/hall-ui/public/gate.js +6 -2
  52. package/modules/hall-ui/public/goals-page.js +6 -2
  53. package/modules/hall-ui/public/governance.css +100 -0
  54. package/modules/hall-ui/public/governance.html +77 -0
  55. package/modules/hall-ui/public/governance.js +353 -0
  56. package/modules/hall-ui/public/harbor.js +14 -8
  57. package/modules/hall-ui/public/instances-panel.js +3 -1
  58. package/modules/hall-ui/public/new-project-wizard.js +6 -0
  59. package/modules/hall-ui/public/people.html +51 -0
  60. package/modules/hall-ui/public/people.js +100 -0
  61. package/modules/hall-ui/public/profile.html +2 -1
  62. package/modules/hall-ui/public/profile.js +9 -3
  63. package/modules/hall-ui/public/sessions.js +4 -2
  64. package/modules/hall-ui/public/shell.js +28 -7
  65. package/modules/hall-ui/public/task-detail.js +6 -3
  66. package/modules/hall-ui/public/watch.js +17 -9
  67. package/modules/ideas/blockers.js +44 -7
  68. package/modules/ideas/routes/blockers.js +12 -7
  69. package/modules/ideas/routes/inbox.js +2 -2
  70. package/modules/lifecycle/db.js +84 -17
  71. package/modules/lifecycle/github-push.js +37 -10
  72. package/modules/lifecycle/publish-reconciler.js +128 -31
  73. package/modules/lifecycle/routes/claims.js +10 -4
  74. package/modules/lifecycle/routes/dependencies.js +2 -2
  75. package/modules/lifecycle/routes/done-when.js +4 -4
  76. package/modules/lifecycle/routes/gate-approvals.js +9 -3
  77. package/modules/lifecycle/routes/goals.js +5 -5
  78. package/modules/lifecycle/routes/tasks.js +16 -16
  79. package/modules/lifecycle/routes/versions.js +13 -2
  80. package/modules/lifecycle/ship-card.js +38 -7
  81. package/modules/memory/routes/memory.js +2 -2
  82. package/modules/onboarding/routes/access-requests.js +3 -3
  83. package/modules/platform-identity/routes/projects.js +10 -6
  84. package/modules/platform-identity/routes/scouting.js +2 -2
  85. package/modules/provisioning/migrations/provisioning_004_onboard_mode.sql +30 -0
  86. package/modules/provisioning/provisioning.js +24 -6
  87. package/modules/provisioning/routes/provisioning.js +24 -6
  88. package/modules/security/artifact-scan/aggregate.js +133 -0
  89. package/modules/security/artifact-scan/detectors/capabilities.js +53 -0
  90. package/modules/security/artifact-scan/detectors/config-obfuscation.js +41 -0
  91. package/modules/security/artifact-scan/detectors/credentials.js +112 -0
  92. package/modules/security/artifact-scan/detectors/dependencies.js +115 -0
  93. package/modules/security/artifact-scan/detectors/hooks.js +116 -0
  94. package/modules/security/artifact-scan/detectors/injection.js +118 -0
  95. package/modules/security/artifact-scan/detectors/mcp.js +102 -0
  96. package/modules/security/artifact-scan/detectors/network.js +67 -0
  97. package/modules/security/artifact-scan/detectors/npm-lifecycle.js +67 -0
  98. package/modules/security/artifact-scan/detectors/permissions.js +91 -0
  99. package/modules/security/artifact-scan/detectors/shared.js +219 -0
  100. package/modules/security/artifact-scan/fence-local.js +99 -0
  101. package/modules/security/artifact-scan/fetch.js +330 -0
  102. package/modules/security/artifact-scan/floor.js +319 -0
  103. package/modules/security/artifact-scan/limits.js +66 -0
  104. package/modules/security/artifact-scan/manifest.js +203 -0
  105. package/modules/security/artifact-scan/mirror.js +158 -0
  106. package/modules/security/artifact-scan/quarantine.js +149 -0
  107. package/modules/security/artifact-scan/rebuild-spec.js +120 -0
  108. package/modules/security/artifact-scan/report.js +155 -0
  109. package/modules/security/artifact-scan/sanitize.js +317 -0
  110. package/modules/security/migrations/security_001_tool_scans.sql +145 -0
  111. package/modules/security/module.json +2 -2
  112. package/modules/security/routes/reports.js +11 -7
  113. package/modules/security/routes/scans.js +115 -0
  114. package/modules/security/scan-store.js +214 -0
  115. package/modules/security/tests/aggregate.mjs +283 -0
  116. package/modules/security/tests/artifact_scan.mjs +591 -0
  117. package/modules/security/tests/floor.mjs +549 -0
  118. package/modules/security/tests/mirror.mjs +350 -0
  119. package/modules/security/tests/sanitize_rebuild.mjs +499 -0
  120. package/modules/security/tests/scan_store.mjs +250 -0
  121. package/modules/security/tests/tool_scans_migration.mjs +116 -0
  122. package/modules/sessions/routes/sessions.js +6 -5
  123. package/package-lock.json +2 -2
  124. package/package.json +1 -1
  125. package/public-landing/create.html +13 -3
  126. package/scripts/gds/abandon.js +1 -1
  127. package/scripts/gds/audit-authorship.sh +6 -0
  128. package/scripts/gds/box.js +1 -1
  129. package/scripts/gds/claim.js +15 -0
  130. package/scripts/gds/cli-lib.js +16 -4
  131. package/scripts/gds/gating.js +28 -13
  132. package/scripts/gds/gen-api-docs.js +1 -1
  133. package/scripts/gds/goal.js +234 -0
  134. package/scripts/gds/init.js +70 -15
  135. package/scripts/gds/main-audit.js +80 -11
  136. package/scripts/gds/mirror-redact.js +6 -0
  137. package/scripts/gds/newcomer-floor.js +2 -2
  138. package/scripts/gds/provision.js +230 -94
  139. package/scripts/gds/run-unit-tests.js +9 -1
  140. package/scripts/gds/sandbox-stage.js +66 -9
  141. package/scripts/gds/search-ingest-md.js +1 -0
  142. package/scripts/gds/sequence.js +67 -8
  143. package/scripts/gds/ship.js +183 -50
  144. package/scripts/hall-preview/server.js +1 -1
  145. package/scripts/migrate.sh +99 -14
  146. package/src/bongos/auth.js +129 -27
  147. package/src/bongos/db-kernel.js +66 -5
  148. package/src/bongos/db.js +6 -7
  149. package/src/bongos/github-repos.js +19 -7
  150. package/src/bongos/goal-scope-check.js +1 -1
  151. package/src/bongos/module-scope-map.js +3 -1
  152. package/src/bongos/permission-path-check.js +1 -1
  153. package/src/bongos/route-rank-check.js +73 -30
  154. package/src/bongos/routes/audit-log.js +4 -2
  155. package/src/bongos/routes/auth.js +8 -4
  156. package/src/bongos/routes/backup.js +7 -4
  157. package/src/bongos/routes/builders.js +23 -11
  158. package/src/bongos/routes/github.js +15 -4
  159. package/src/bongos/routes/llm-cache.js +5 -2
  160. package/src/bongos/routes/modules.js +9 -5
  161. package/src/bongos/routes/security.js +21 -12
  162. package/src/bongos/serve-internal.js +22 -7
  163. package/src/module-api.js +86 -1
  164. package/src/platform-server.js +14 -0
  165. package/tests/api_client.mjs +18 -2
  166. package/tests/api_path_404.mjs +3 -0
  167. package/tests/art_stage.mjs +2 -1
  168. package/tests/atlas.mjs +32 -4
  169. package/tests/auth_admission_gate.mjs +17 -5
  170. package/tests/auth_error_brand.mjs +41 -21
  171. package/tests/auth_resolve_failure.mjs +155 -0
  172. package/tests/blocker_link.mjs +88 -0
  173. package/tests/bongos_downloads.mjs +2 -1
  174. package/tests/box_dns_repoint.mjs +2 -0
  175. package/tests/box_ship_permissions.mjs +2 -0
  176. package/tests/box_terminal_ssrf.mjs +23 -17
  177. package/tests/claim_error_surface.mjs +23 -0
  178. package/tests/claude_materialize.mjs +4 -0
  179. package/tests/cli_box_scope.mjs +40 -0
  180. package/tests/cloud_bongos_pack.mjs +34 -4
  181. package/tests/conductor.mjs +2 -1
  182. package/tests/criterion_authoring_gate.mjs +28 -5
  183. package/tests/currency_label.mjs +10 -1
  184. package/tests/deploy_instance.mjs +2 -0
  185. package/tests/devbox_downloads.mjs +2 -1
  186. package/tests/display_name.mjs +95 -6
  187. package/tests/eviction_policy.mjs +2 -1
  188. package/tests/file_map.mjs +10 -0
  189. package/tests/first_admin_bootstrap.mjs +10 -2
  190. package/tests/first_run_engine_check.mjs +2 -1
  191. package/tests/fitness.mjs +26 -6
  192. package/tests/gate_approvals.mjs +15 -2
  193. package/tests/github_push_land_proof.mjs +50 -4
  194. package/tests/github_repos_helpers.mjs +30 -5
  195. package/tests/github_token_store.mjs +23 -0
  196. package/tests/goal_abuse_matrix.mjs +13 -3
  197. package/tests/goal_cli.mjs +89 -0
  198. package/tests/governance_abuse_matrix.mjs +386 -0
  199. package/tests/governance_fold.mjs +156 -0
  200. package/tests/governance_fold_bench.mjs +50 -0
  201. package/tests/governance_live_proof.mjs +201 -0
  202. package/tests/governance_page_gates.mjs +21 -9
  203. package/tests/governance_parity.mjs +11 -2
  204. package/tests/governance_require_permission.mjs +125 -1
  205. package/tests/governance_routes.mjs +334 -0
  206. package/tests/grader.mjs +7 -3
  207. package/tests/hall_nav.mjs +14 -4
  208. package/tests/helpers.mjs +73 -0
  209. package/tests/hook_core_resolve.mjs +2 -1
  210. package/tests/host_topology_skips.mjs +97 -0
  211. package/tests/infra_exec_paths.mjs +14 -4
  212. package/tests/init.mjs +128 -0
  213. package/tests/known_failing_ratchet.mjs +89 -0
  214. package/tests/lifecycle-card.mjs +2 -1
  215. package/tests/main_audit.mjs +72 -1
  216. package/tests/memory_isolation.mjs +56 -9
  217. package/tests/migrate_root_resolution.mjs +124 -1
  218. package/tests/mirror_redact.mjs +19 -0
  219. package/tests/module_api.mjs +3 -1
  220. package/tests/module_loader.mjs +4 -0
  221. package/tests/modules.mjs +35 -7
  222. package/tests/modules_route.mjs +29 -5
  223. package/tests/onboarding_observability.mjs +49 -11
  224. package/tests/onboarding_route_signals.mjs +1 -0
  225. package/tests/one_active_claim_per_session.mjs +80 -0
  226. package/tests/pre_compact.mjs +2 -1
  227. package/tests/preview_server.mjs +2 -1
  228. package/tests/profile_route.mjs +1 -0
  229. package/tests/provision.mjs +170 -0
  230. package/tests/provisioning_onboard_mode.mjs +180 -0
  231. package/tests/publish_manifest.mjs +16 -3
  232. package/tests/publish_reconciler.mjs +181 -10
  233. package/tests/rank_tier_single_source.mjs +205 -0
  234. package/tests/recall_hints.mjs +2 -1
  235. package/tests/repo_map.mjs +4 -0
  236. package/tests/route_permission_check.mjs +14 -5
  237. package/tests/rubric_fixtures.mjs +3 -0
  238. package/tests/run_routine_two_root.mjs +2 -0
  239. package/tests/sandbox_stage.mjs +69 -7
  240. package/tests/search_ingest.mjs +7 -0
  241. package/tests/security_docs.mjs +28 -6
  242. package/tests/sequence.mjs +64 -0
  243. package/tests/session-cards.mjs +2 -1
  244. package/tests/session_start_freshness.mjs +2 -1
  245. package/tests/ship_broadcast_server.mjs +63 -1
  246. package/tests/ship_card.mjs +66 -0
  247. package/tests/ship_ci_deploy.mjs +66 -1
  248. package/tests/ship_smoke_gate.mjs +76 -0
  249. package/tests/versions_route.mjs +15 -0
  250. package/tests/workflow_exec_paths.mjs +16 -4
  251. package/tests/worktree_guard.mjs +2 -1
@@ -0,0 +1,330 @@
1
+ // modules/security/artifact-scan/fetch.js — the hardened fetch.
2
+ //
3
+ // `git clone` is not an inert download: by default it runs filters and hooks,
4
+ // reads the user's config, follows submodules into other protocols, materializes
5
+ // symlinks anywhere, and prompts for credentials. Each setting below closes one
6
+ // of those doors and is annotated at its use site. Nothing beyond git itself is
7
+ // ever executed. Async by design — see defaultRun.
8
+
9
+ 'use strict';
10
+
11
+ const dns = require('node:dns');
12
+ const fsp = require('node:fs/promises');
13
+ const path = require('node:path');
14
+ const { spawn } = require('node:child_process');
15
+
16
+ const { CAPS } = require('./quarantine');
17
+ const { buildManifest, CapExceeded } = require('./manifest');
18
+
19
+ const MAX_CAPTURE_BYTES = 1 * 1024 * 1024;
20
+ // git's stderr can name internal hosts and paths; only a short excerpt travels,
21
+ // and only in a finding's detail, never in a message a caller might echo.
22
+ const STDERR_EXCERPT = 300;
23
+
24
+ // Literal addresses git must never be pointed at. Without this the scanner is an
25
+ // SSRF gadget: an attacker-chosen URL makes OUR process fetch cloud metadata, or
26
+ // probe internal hosts via the difference between an error and a timeout.
27
+ function blockedAddress(hostname) {
28
+ const h = String(hostname).toLowerCase().replace(/^\[|\]$/g, '');
29
+ if (h === '') return 'an empty host';
30
+ if (h === 'localhost' || h.endsWith('.localhost')) return 'loopback';
31
+ if (h === '::1' || h === '::') return 'IPv6 loopback/unspecified';
32
+ if (/^f[cd][0-9a-f]{2}:/.test(h)) return 'an IPv6 unique-local address';
33
+ if (/^fe80:/.test(h)) return 'an IPv6 link-local address';
34
+
35
+ // WHATWG URL canonicalizes ::ffff:127.0.0.1 to ::ffff:7f00:1, so the hex form
36
+ // must decode too — checking only the dotted spelling is itself a bypass.
37
+ let v4 = h;
38
+ const dotted = h.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
39
+ const hex = h.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
40
+ if (dotted) v4 = dotted[1];
41
+ else if (hex) {
42
+ const [hi, lo] = [parseInt(hex[1], 16), parseInt(hex[2], 16)];
43
+ v4 = `${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`;
44
+ }
45
+
46
+ const m = v4.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
47
+ if (!m) return null;
48
+ const [a, b] = [Number(m[1]), Number(m[2])];
49
+ if (a === 127) return 'loopback';
50
+ if (a === 0) return 'the unspecified range';
51
+ if (a === 10) return 'a private (RFC 1918) address';
52
+ if (a === 172 && b >= 16 && b <= 31) return 'a private (RFC 1918) address';
53
+ if (a === 192 && b === 168) return 'a private (RFC 1918) address';
54
+ if (a === 169 && b === 254) return 'the link-local range (cloud metadata lives at 169.254.169.254)';
55
+ if (a === 100 && b >= 64 && b <= 127) return 'the carrier-grade NAT range';
56
+ if (a >= 224) return 'a multicast/reserved address';
57
+ return null;
58
+ }
59
+
60
+ // https only: ssh:// executes a remote command, file:// and ext:: reach local
61
+ // paths, git:// is unauthenticated. A URL is refused, never rewritten — quietly
62
+ // "fixing" an attacker's URL into one we like is its own vulnerability.
63
+ function validateTargetUrl(raw) {
64
+ const value = String(raw ?? '').trim();
65
+ if (!value) return { ok: false, refusal: 'No URL given.' };
66
+ let u;
67
+ try { u = new URL(value); } catch { return { ok: false, refusal: `Not a URL: ${value}` }; }
68
+
69
+ if (u.protocol !== 'https:') return { ok: false, refusal: `Only https:// targets are scannable — got "${u.protocol}".` };
70
+ if (u.username || u.password) return { ok: false, refusal: 'Refusing a URL with embedded credentials.' };
71
+ const blocked = blockedAddress(u.hostname);
72
+ if (blocked) return { ok: false, refusal: `Refusing to point the scanner at ${blocked} (${u.hostname}).` };
73
+ return { ok: true, url: u.toString(), host: u.hostname };
74
+ }
75
+
76
+ function defaultResolveHost(hostname) {
77
+ return dns.promises.lookup(hostname, { all: true }).then((rs) => rs.map((r) => r.address));
78
+ }
79
+
80
+ // Resolve the name ourselves and refuse if ANY answer is private. A literal-only
81
+ // check is trivially bypassed by pointing a domain's A record at 169.254.169.254.
82
+ //
83
+ // RESIDUAL LIMIT, stated rather than papered over: git re-resolves when it
84
+ // connects, so a name that answers publicly here and privately microseconds
85
+ // later (true DNS rebinding) still gets through. Closing that needs address
86
+ // pinning git does not expose. This removes the trivial attack, not the race.
87
+ async function assertHostResolvesPublic(hostname, resolveHost) {
88
+ let addresses;
89
+ try {
90
+ addresses = await resolveHost(hostname);
91
+ } catch (err) {
92
+ throw abort('host_unresolvable', `Could not resolve "${hostname}" — refusing to fetch.`, { hostname });
93
+ }
94
+ for (const addr of addresses) {
95
+ const blocked = blockedAddress(addr);
96
+ if (blocked) {
97
+ throw abort('host_resolves_private', `"${hostname}" resolves to ${blocked} (${addr}) — refusing to fetch.`, { hostname, addr });
98
+ }
99
+ }
100
+ return addresses;
101
+ }
102
+
103
+ // The `-c key=value` pairs applied to every git invocation. This list IS the
104
+ // security control, not boilerplate.
105
+ function hardeningConfig(q) {
106
+ return [
107
+ 'core.symlinks=false', // a symlink becomes inert text, not a live pointer at ~/.aws
108
+ `core.hooksPath=${q.hooksDir}`, // must be a real EMPTY dir — /dev/null is not a directory on Windows
109
+ 'protocol.file.allow=never', // blocks file:// submodule/url-rewrite escapes
110
+ 'protocol.ext.allow=never', // blocks ext:: command execution
111
+ 'transfer.fsckObjects=true',
112
+ 'fetch.fsckObjects=true',
113
+ 'credential.helper=', // never offer a stored credential to a third-party host
114
+ 'core.askPass=',
115
+ 'filter.lfs.smudge=', // an LFS smudge filter is a process git runs on checkout
116
+ 'filter.lfs.process=',
117
+ 'filter.lfs.required=false',
118
+ 'advice.detachedHead=false',
119
+ ].flatMap((kv) => ['-c', kv]);
120
+ }
121
+
122
+ // Only what git needs. The builder's ambient env — API tokens, DB passwords,
123
+ // cloud credentials — is deliberately not forwarded to a process talking to a
124
+ // server chosen by the person we are investigating. Proxy vars ARE forwarded, a
125
+ // deliberate call: without them a builder behind a corporate proxy cannot scan.
126
+ const ENV_PASSTHROUGH = [
127
+ 'PATH', 'Path', 'PATHEXT',
128
+ 'HOME', 'USERPROFILE', 'HOMEDRIVE', 'HOMEPATH',
129
+ 'SystemRoot', 'SYSTEMROOT', 'ComSpec', 'COMSPEC', 'windir',
130
+ 'TEMP', 'TMP', 'TMPDIR',
131
+ 'LANG', 'LC_ALL',
132
+ 'SSL_CERT_FILE', 'SSL_CERT_DIR', 'GIT_SSL_CAINFO',
133
+ 'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'no_proxy',
134
+ ];
135
+
136
+ // Deduped case-insensitively: Windows' process.env is case-insensitive, so
137
+ // reading both 'PATH' and 'Path' yields the same value under two keys, and the
138
+ // duplicate makes CreateProcess fail to resolve git at all (spawn ENOENT).
139
+ function hardenedEnv(q) {
140
+ const env = Object.create(null);
141
+ const seen = new Set();
142
+ for (const key of ENV_PASSTHROUGH) {
143
+ const lower = key.toLowerCase();
144
+ if (seen.has(lower) || process.env[key] === undefined) continue;
145
+ seen.add(lower);
146
+ env[key] = process.env[key];
147
+ }
148
+ return Object.assign(env, {
149
+ GIT_TERMINAL_PROMPT: '0',
150
+ GIT_ASKPASS: '',
151
+ SSH_ASKPASS: '',
152
+ GIT_CONFIG_NOSYSTEM: '1',
153
+ GIT_CONFIG_GLOBAL: q.gitConfigFile,
154
+ GIT_CONFIG_SYSTEM: q.gitConfigFile,
155
+ GIT_LFS_SKIP_SMUDGE: '1',
156
+ GCM_INTERACTIVE: 'never',
157
+ });
158
+ }
159
+
160
+ // Async spawn, never spawnSync: a 120s block would stall the event loop for any
161
+ // caller that ever runs inside a request. argv is an array (no shell), so a URL
162
+ // containing shell metacharacters is data, never a command. Output is bounded.
163
+ function defaultRun(args, { cwd, env, timeoutMs }) {
164
+ return new Promise((resolve) => {
165
+ let child;
166
+ try {
167
+ child = spawn('git', args, { cwd, env, windowsHide: true });
168
+ } catch (err) {
169
+ return resolve({ status: null, stdout: '', stderr: '', timedOut: false, truncated: false, error: String(err.message) });
170
+ }
171
+ let stdout = '';
172
+ let stderr = '';
173
+ let timedOut = false;
174
+ // Capture is bounded, but a caller that PARSES stdout must be able to tell
175
+ // a complete answer from a clipped one — silently dropping the tail of
176
+ // `ls-files` hid gitlinks, and an unnoticed missing submodule is exactly
177
+ // the "unreviewed reads as clean" failure the floor exists to prevent.
178
+ let truncated = false;
179
+ const timer = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, timeoutMs);
180
+ child.stdout.on('data', (d) => {
181
+ if (stdout.length < MAX_CAPTURE_BYTES) stdout += d;
182
+ else truncated = true;
183
+ });
184
+ child.stderr.on('data', (d) => { if (stderr.length < MAX_CAPTURE_BYTES) stderr += d; });
185
+ child.on('error', (err) => {
186
+ clearTimeout(timer);
187
+ resolve({ status: null, stdout, stderr, timedOut, truncated, error: String(err.message) });
188
+ });
189
+ child.on('close', (status) => {
190
+ clearTimeout(timer);
191
+ resolve({ status, stdout, stderr, timedOut, truncated, error: null });
192
+ });
193
+ });
194
+ }
195
+
196
+ // "<mode> <sha> <stage>\t<path>". Read BEFORE .git is deleted — the index is the
197
+ // only remaining witness that a file was really a symlink (120000) or a
198
+ // submodule (160000) once core.symlinks=false has flattened it.
199
+ function parseLsFiles(stdout) {
200
+ const modes = [];
201
+ for (const line of String(stdout).split('\n')) {
202
+ if (!line.trim()) continue;
203
+ const tab = line.indexOf('\t');
204
+ if (tab === -1) continue;
205
+ modes.push({ mode: line.slice(0, line.indexOf(' ')), path: line.slice(tab + 1) });
206
+ }
207
+ return modes;
208
+ }
209
+
210
+ function finding(code, severity, message, detail) {
211
+ return { code, severity, message, detail: detail ?? null };
212
+ }
213
+
214
+ // Every throw out of fetchArtifact carries evidence, so a scan that died is
215
+ // recordable as "aborted, here is why" rather than as silence.
216
+ function abort(code, message, detail) {
217
+ const err = new Error(message);
218
+ err.code = code;
219
+ err.finding = finding(code, 'high', message, detail);
220
+ return err;
221
+ }
222
+
223
+ // Fetch one artifact into an existing quarantine.
224
+ // Returns { url, host, addresses, sha, pristineDir, manifest, gitModes, findings }.
225
+ async function fetchArtifact(rawUrl, {
226
+ quarantine,
227
+ timeoutMs = CAPS.cloneTimeoutMs,
228
+ maxFiles = CAPS.maxFiles,
229
+ maxBytes = CAPS.maxBytes,
230
+ run = defaultRun,
231
+ resolveHost = defaultResolveHost,
232
+ } = {}) {
233
+ if (!quarantine || !quarantine.pristineDir) {
234
+ throw new Error('fetchArtifact requires a quarantine from createQuarantine().');
235
+ }
236
+ const valid = validateTargetUrl(rawUrl);
237
+ if (!valid.ok) throw abort('url_refused', valid.refusal, null);
238
+
239
+ const q = quarantine;
240
+ const env = hardenedEnv(q);
241
+ const cfg = hardeningConfig(q);
242
+ const findings = [];
243
+
244
+ const addresses = await assertHostResolvesPublic(valid.host, resolveHost);
245
+
246
+ const clone = await run(
247
+ [...cfg, 'clone', '--depth', '1', '--single-branch', '--no-tags', '--no-recurse-submodules', '--quiet', '--', valid.url, q.pristineDir],
248
+ { cwd: q.root, env, timeoutMs }
249
+ );
250
+
251
+ if (clone.timedOut) {
252
+ throw abort('clone_timeout', `Clone exceeded the ${timeoutMs}ms cap and was killed.`, { timeoutMs, url: valid.url });
253
+ }
254
+ if (clone.status !== 0) {
255
+ throw abort('clone_failed', `Clone failed (exit ${clone.status}).`, {
256
+ status: clone.status,
257
+ stderr_excerpt: String(clone.stderr || clone.error || '').trim().slice(0, STDERR_EXCERPT),
258
+ });
259
+ }
260
+
261
+ const rev = await run([...cfg, 'rev-parse', 'HEAD'], { cwd: q.pristineDir, env, timeoutMs });
262
+ if (rev.status !== 0) {
263
+ throw abort('head_unresolved', 'Could not resolve HEAD.', { stderr_excerpt: String(rev.stderr || '').trim().slice(0, STDERR_EXCERPT) });
264
+ }
265
+ const sha = rev.stdout.trim();
266
+
267
+ // The index is the ONLY witness that a path was really a symlink (120000) or
268
+ // a submodule (160000) once core.symlinks=false has flattened it, and a
269
+ // gitlink leaves an empty directory the manifest walk records nothing for.
270
+ // So when this read is incomplete the artifact's true shape is UNKNOWN —
271
+ // that must be a finding, never an empty list. Both fail-opens below were
272
+ // found by verifying the fix that folds these findings into the floor: the
273
+ // fold is worthless if the evidence was never produced.
274
+ const ls = await run([...cfg, 'ls-files', '-s'], { cwd: q.pristineDir, env, timeoutMs });
275
+ const gitModes = ls.status === 0 ? parseLsFiles(ls.stdout) : [];
276
+ if (ls.status !== 0 || ls.timedOut) {
277
+ findings.push(finding('git_index_unread', 'high',
278
+ 'Could not read the git index — whether this artifact carries submodules or symlinks is UNKNOWN, not "none".',
279
+ { status: ls.status, timedOut: !!ls.timedOut, unscannable: true }));
280
+ } else if (ls.truncated) {
281
+ findings.push(finding('git_index_truncated', 'high',
282
+ 'The git index listing was too large to read in full — any submodule or symlink past the cut is UNSEEN, so this artifact\'s shape is only partly known.',
283
+ { captured_bytes: ls.stdout.length, entries_parsed: gitModes.length, unscannable: true }));
284
+ }
285
+
286
+ for (const m of gitModes) {
287
+ if (m.mode === '120000') {
288
+ findings.push(finding('symlink_in_artifact', 'medium', `Symlink "${m.path}" — materialized as inert text, its target was never opened.`, m));
289
+ } else if (m.mode === '160000') {
290
+ // unscannable: a gitlink materializes as an EMPTY directory, so the
291
+ // manifest walk records no entry for it and the floor has nothing to
292
+ // skip — without this flag the reducer's coverage cap cannot see that
293
+ // real third-party code went unreviewed (review finding).
294
+ findings.push(finding('submodule_not_fetched', 'medium', `Submodule at "${m.path}" was NOT fetched and is therefore unreviewed.`, { ...m, unscannable: true }));
295
+ }
296
+ }
297
+
298
+ // The SHA and modes are captured, so a bare content tree makes the manifest
299
+ // mean exactly "the files this artifact would install".
300
+ await fsp.rm(path.join(q.pristineDir, '.git'), { recursive: true, force: true });
301
+
302
+ let manifest;
303
+ try {
304
+ manifest = await buildManifest(q.pristineDir, { maxFiles, maxBytes });
305
+ } catch (err) {
306
+ if (err instanceof CapExceeded) {
307
+ throw abort(err.code, `${err.message} Scan aborted — a partial read cannot produce an honest verdict.`, err.detail);
308
+ }
309
+ throw err;
310
+ }
311
+
312
+ if (manifest.symlinkCount > 0) {
313
+ findings.push(finding('real_symlink_present', 'high', `${manifest.symlinkCount} real symlink(s) survived core.symlinks=false; targets were not followed.`, null));
314
+ }
315
+ if (manifest.otherCount > 0) {
316
+ findings.push(finding('unreadable_node', 'medium', `${manifest.otherCount} non-regular file(s) were recorded but not read.`, null));
317
+ }
318
+
319
+ return { url: valid.url, host: valid.host, addresses, sha, pristineDir: q.pristineDir, manifest, gitModes, findings };
320
+ }
321
+
322
+ module.exports = {
323
+ fetchArtifact,
324
+ validateTargetUrl,
325
+ blockedAddress,
326
+ assertHostResolvesPublic,
327
+ hardeningConfig,
328
+ hardenedEnv,
329
+ parseLsFiles,
330
+ };
@@ -0,0 +1,319 @@
1
+ // modules/security/artifact-scan/floor.js — the zero-LLM deterministic floor
2
+ // (task 1002544, goal 1000055).
3
+ //
4
+ // The floor's whole purpose is that a successful prompt injection cannot erase
5
+ // its own evidence: this report is computed by CODE and written to disk BEFORE
6
+ // any model reads a byte of the artifact. A panel can add findings on top; the
7
+ // reducer takes the MAXIMUM of this floor and everything downstream, so
8
+ // nothing a model says can lower what the detectors already proved.
9
+ //
10
+ // The floor reads the PRISTINE copy, not the mirror. Code is immune to
11
+ // persuasion, so the injection-safety argument for the mirror does not apply —
12
+ // and the mirror is scrubbed, so codepoint evidence EXISTS only in pristine.
13
+ // Reads follow the same confinement discipline as mirror.js: manifest-driven,
14
+ // lexically checked before any fs call, realpath-verified inside the pen.
15
+ //
16
+ // Verdict arithmetic (monotone by construction):
17
+ // any critical finding → 'dangerous'
18
+ // any finding at all → 'caution'
19
+ // no findings → 'clean'
20
+ // A detector that crashes contributes a finding (never silence), so a broken
21
+ // detector degrades the verdict toward caution rather than toward clean.
22
+
23
+ 'use strict';
24
+
25
+ const fsp = require('node:fs/promises');
26
+ const path = require('node:path');
27
+ const { TextDecoder } = require('node:util');
28
+
29
+ const { isInside } = require('./quarantine');
30
+ const { MIRROR_CAPS } = require('./mirror');
31
+ const { scrubUntrusted } = require('./fence-local');
32
+ const { finding, isSensitivePath, SEVERITIES, excerpt } = require('./detectors/shared');
33
+
34
+ const npmLifecycle = require('./detectors/npm-lifecycle');
35
+ const hooks = require('./detectors/hooks');
36
+ const mcp = require('./detectors/mcp');
37
+ const permissions = require('./detectors/permissions');
38
+ const credentials = require('./detectors/credentials');
39
+ const network = require('./detectors/network');
40
+ const injection = require('./detectors/injection');
41
+ const dependencies = require('./detectors/dependencies');
42
+ const configObfuscation = require('./detectors/config-obfuscation');
43
+ const capabilities = require('./detectors/capabilities');
44
+
45
+ const FLOOR_VERSION = '1.0.0';
46
+ const REPORT_BASENAME = 'floor-report.json';
47
+
48
+ // Bounds on the fetch-stage fold (see runFloor's extraFindings).
49
+ const MAX_EXTRA_FINDINGS = 500;
50
+ const CODE_MAX_CHARS = 64;
51
+
52
+ // Shared with the reducer (task 1002545): the max() it takes is over this rank.
53
+ const VERDICT_RANK = Object.freeze({ clean: 0, caution: 1, dangerous: 2 });
54
+
55
+ const UTF8_STRICT = new TextDecoder('utf-8', { fatal: true });
56
+
57
+ const DETECTORS = Object.freeze([
58
+ ['npm-lifecycle', npmLifecycle.detect],
59
+ ['claude-hooks', hooks.detect],
60
+ ['mcp-servers', mcp.detect],
61
+ ['permissions', permissions.detect],
62
+ ['credential-stores', credentials.detect],
63
+ ['network-sinks', network.detect],
64
+ ['injection-text', injection.detect],
65
+ ['dependencies', dependencies.detect],
66
+ ['config-obfuscation', configObfuscation.detect],
67
+ ]);
68
+
69
+ // Per-chunk parallel-read width. Bounded so a huge artifact cannot exhaust
70
+ // file descriptors; chunked (not a free-running pool) so files[]/skipped[]
71
+ // keep deterministic manifest order.
72
+ const READ_CONCURRENCY = 16;
73
+
74
+ // Manifest-driven confined read of every renderable text file. Anything the
75
+ // floor cannot honestly read (binary, oversize, escaping, unreadable) becomes
76
+ // a skipped record — counted, never silently dropped — because "I did not read
77
+ // it" must always be visible to the reducer's coverage cap.
78
+ async function buildCorpus({ pristineDir, quarantineRoot, entries, caps = {}, realQuarantine: realQuarantineIn = null }) {
79
+ const maxFileBytes = caps.maxFileBytes ?? MIRROR_CAPS.maxFileBytes;
80
+ const realQuarantine = realQuarantineIn || await fsp.realpath(quarantineRoot);
81
+
82
+ // One entry → { file } | { skip }, so chunked parallel reads reassemble in
83
+ // manifest order below. The RAW rel is used for every filesystem operation;
84
+ // the STORED path is scrubbed at this chokepoint, because manifest paths are
85
+ // artifact-controlled and corpus records flow into the report (coverage,
86
+ // capability inventory) — the report must never become a carrier.
87
+ const readOne = async (entry) => {
88
+ const rel = entry.path;
89
+ const safeRel = scrubUntrusted(rel);
90
+ const skip = (reason, bytes) => ({ skip: { path: safeRel, reason, bytes: bytes || 0 } });
91
+ if (entry.kind !== 'file') return skip(entry.kind === 'symlink' ? 'symlink' : 'special', entry.size);
92
+ if (typeof rel !== 'string' || rel.length === 0 || path.isAbsolute(rel)
93
+ || !isInside(pristineDir, path.resolve(pristineDir, ...rel.split('/')))) {
94
+ return skip('path_escape', entry.size);
95
+ }
96
+ if (entry.size > maxFileBytes) return skip('oversize', entry.size);
97
+
98
+ const abs = path.resolve(pristineDir, ...rel.split('/'));
99
+ let buf;
100
+ try {
101
+ const real = await fsp.realpath(abs);
102
+ if (!isInside(realQuarantine, real)) return skip('path_escape', entry.size);
103
+ const st = await fsp.lstat(abs);
104
+ if (st.isSymbolicLink()) return skip('path_escape', entry.size);
105
+ // Read the verified resolved path — same TOCTOU close as mirror.js.
106
+ buf = await fsp.readFile(real);
107
+ } catch {
108
+ return skip('unreadable', entry.size);
109
+ }
110
+
111
+ try {
112
+ return { file: { path: safeRel, size: buf.length, text: UTF8_STRICT.decode(buf) } };
113
+ } catch {
114
+ return skip('non_utf8', buf.length);
115
+ }
116
+ };
117
+
118
+ const files = [];
119
+ const skipped = [];
120
+ for (let i = 0; i < entries.length; i += READ_CONCURRENCY) {
121
+ const chunk = await Promise.all(entries.slice(i, i + READ_CONCURRENCY).map(readOne));
122
+ for (const r of chunk) {
123
+ if (r.file) files.push(r.file);
124
+ else skipped.push(r.skip);
125
+ }
126
+ }
127
+ return { files, skipped };
128
+ }
129
+
130
+ function computeFloorVerdict(findings) {
131
+ if (findings.some((f) => f.severity === 'critical')) return 'dangerous';
132
+ return findings.length > 0 ? 'caution' : 'clean';
133
+ }
134
+
135
+ function countBySeverity(findings) {
136
+ const counts = { critical: 0, high: 0, medium: 0, low: 0 };
137
+ for (const f of findings) counts[f.severity] = (counts[f.severity] || 0) + 1;
138
+ return counts;
139
+ }
140
+
141
+ // The same-file join: a file that both names a credential store AND carries a
142
+ // network sink. Labeled exactly "same-file co-occurrence" — the floor has no
143
+ // dataflow analysis and must never imply one; co-location is opportunity, and
144
+ // opportunity inside one file is the strongest static exfil signal we can
145
+ // honestly claim.
146
+ function coOccurrence(corpus) {
147
+ const creds = credentials.perFileHits(corpus);
148
+ const sinks = network.perFileHits(corpus);
149
+ const findings = [];
150
+ for (const [file, credHits] of creds) {
151
+ const sinkHits = sinks.get(file);
152
+ if (!sinkHits) continue;
153
+ // Only FILE-class credential hits (a concrete on-disk secret path) arm this
154
+ // critical. An env-var NAME next to a fetch() is the single most common
155
+ // shape in ordinary tooling, so it must not be the read half of a
156
+ // read-then-send accusation — it still stands as its own medium finding.
157
+ const fileCreds = credHits.filter((h) => h.klass === 'file');
158
+ if (fileCreds.length === 0) continue;
159
+ findings.push(finding('read_then_send_same_file', 'critical', file,
160
+ `Same-file co-occurrence: names ${fileCreds[0].label} (line ${fileCreds[0].line}) AND carries ${sinkHits[0].label} (line ${sinkHits[0].line}). Co-location, not proven dataflow.`,
161
+ {
162
+ credential_stores: fileCreds.map((h) => h.label),
163
+ network_sinks: sinkHits.map((h) => h.label),
164
+ claimed_as: 'same-file co-occurrence, not dataflow',
165
+ }));
166
+ }
167
+ return findings;
168
+ }
169
+
170
+ // Coverage findings: a skip is only honest if it cannot resolve to "clean". A
171
+ // skipped SENSITIVE file (a config that changes what the harness/installer
172
+ // does) becomes a finding; an artifact NONE of whose bytes were readable
173
+ // becomes a finding. Both keep the floor's own verdict off 'clean' when
174
+ // executable/config content went unreviewed — the header's promise that
175
+ // unreviewed never reads as clean, enforced in the floor itself and not left to
176
+ // the downstream reducer alone.
177
+ function coverageFindings(corpus) {
178
+ const findings = [];
179
+ for (const s of corpus.skipped) {
180
+ if (isSensitivePath(s.path)) {
181
+ findings.push(finding('unreviewed_sensitive_file', 'high', s.path,
182
+ `A security-relevant file was skipped (${s.reason}) and never reviewed — the installer/harness can still read it.`,
183
+ { reason: s.reason, bytes: s.bytes || 0 }));
184
+ }
185
+ }
186
+ if (corpus.files.length === 0 && corpus.skipped.length > 0) {
187
+ findings.push(finding('nothing_readable', 'high', '(floor)',
188
+ `None of the artifact's ${corpus.skipped.length} file(s) were readable as text — the floor reviewed nothing.`,
189
+ { skipped: corpus.skipped.length }));
190
+ }
191
+ return findings;
192
+ }
193
+
194
+ // Run every detector crash-isolated: a detector that throws yields a finding,
195
+ // never a hole in the report. `detectors` is injectable so the crash-isolation
196
+ // contract is unit-testable with a deliberately-throwing detector.
197
+ function runDetectors(corpus, detectors = DETECTORS) {
198
+ const findings = [];
199
+ const errors = [];
200
+ for (const [name, detect] of detectors) {
201
+ try {
202
+ findings.push(...detect(corpus));
203
+ } catch (err) {
204
+ errors.push({ detector: name, error: String(err && err.message ? err.message : err) });
205
+ findings.push(finding('detector_error', 'high', '(floor)',
206
+ `Detector "${name}" crashed — its surface is UNREVIEWED and counts against coverage, not toward clean.`,
207
+ { detector: name }));
208
+ }
209
+ }
210
+ return { findings, errors };
211
+ }
212
+
213
+ // Build + persist the floor report. Returns the report object; the file at
214
+ // <quarantineRoot>/floor-report.json is on disk before this resolves — callers
215
+ // (the skill) MUST await this before any model reads a byte.
216
+ //
217
+ // `extraFindings` carries code-computed evidence produced BEFORE the floor ran
218
+ // — fetchArtifact's findings (unfetched submodules, surviving symlinks,
219
+ // non-regular nodes). They belong in the persisted report for the same reason
220
+ // every detector finding does: they are arithmetic, not opinion, and the
221
+ // reducer takes its floor component from this report. Routing them only to a
222
+ // chat summary let a repo whose submodule was never fetched reduce to CLEAN
223
+ // (review finding) — an artifact with unreviewed code must never read as clean.
224
+ async function runFloor({ pristineDir, quarantineRoot, entries, sha = null, contentHash = null, caps = {}, extraFindings = [] }) {
225
+ // Resolved once, shared by the corpus reads and the persist below. Allowed to
226
+ // throw: an unresolvable pen means no report can honestly land anywhere.
227
+ const realQuarantine = await fsp.realpath(quarantineRoot);
228
+ let corpus = { files: [], skipped: [] };
229
+ let corpusError = null;
230
+ try {
231
+ corpus = await buildCorpus({ pristineDir, quarantineRoot, entries, caps, realQuarantine });
232
+ } catch (err) {
233
+ corpusError = String(err && err.message ? err.message : err);
234
+ }
235
+
236
+ const { findings, errors } = runDetectors(corpus);
237
+ // Each post-detector step is crash-isolated too: the whole point of the floor
238
+ // is that a report ALWAYS lands, so a throw in co-occurrence, the coverage
239
+ // pass, or the capability inventory must degrade to a finding, never abort
240
+ // the persist. isolate() runs a step, appends its result, and on a throw
241
+ // records a detector_error (which contributes caution) instead of propagating.
242
+ const isolate = (name, fn) => {
243
+ try { return fn(); }
244
+ catch (err) {
245
+ errors.push({ detector: name, error: String(err && err.message ? err.message : err) });
246
+ findings.push(finding('detector_error', 'high', '(floor)',
247
+ `Step "${name}" crashed — its surface is UNREVIEWED and counts against coverage, not toward clean.`, { detector: name }));
248
+ return null;
249
+ }
250
+ };
251
+
252
+ findings.push(...(isolate('co-occurrence', () => coOccurrence(corpus)) || []));
253
+ findings.push(...(isolate('coverage', () => coverageFindings(corpus)) || []));
254
+ // Re-built through finding() so fetch-stage strings get the same scrub + cap
255
+ // every other finding gets — a caller's array is untrusted input here too.
256
+ // `code` does NOT pass through finding()'s chokepoint (it is a label, not a
257
+ // quoted string), so it is scrubbed + capped explicitly here; and the array
258
+ // is length-capped, because an unbounded fold would let one stage balloon
259
+ // the persisted report.
260
+ findings.push(...(isolate('fetch-stage', () => (Array.isArray(extraFindings) ? extraFindings : [])
261
+ .filter((f) => f && typeof f === 'object')
262
+ .slice(0, MAX_EXTRA_FINDINGS)
263
+ .map((f) => finding(
264
+ excerpt(String(f.code || 'fetch_finding'), CODE_MAX_CHARS),
265
+ SEVERITIES.includes(f.severity) ? f.severity : 'high',
266
+ String(f.file || '(fetch)'),
267
+ String(f.message || ''),
268
+ f.detail ?? null
269
+ ))) || []));
270
+ const capabilityInventory = isolate('capabilities', () => capabilities.inventory(corpus)) || [];
271
+ if (corpusError) {
272
+ findings.push(finding('corpus_error', 'high', '(floor)',
273
+ 'The floor could not read the artifact tree at all — nothing here was reviewed.', { error: corpusError }));
274
+ }
275
+
276
+ const report = {
277
+ floor_version: FLOOR_VERSION,
278
+ detectors_run: DETECTORS.map(([name]) => name),
279
+ detector_errors: errors,
280
+ generated_at: new Date().toISOString(),
281
+ target: { sha, content_hash: contentHash },
282
+ findings,
283
+ counts: countBySeverity(findings),
284
+ floor_verdict: computeFloorVerdict(findings),
285
+ // Printed, never scored — the reader's power budget for the artifact.
286
+ capabilities: capabilityInventory,
287
+ coverage: {
288
+ files_read: corpus.files.length,
289
+ bytes_read: corpus.files.reduce((sum, f) => sum + f.size, 0),
290
+ files_skipped: corpus.skipped,
291
+ bytes_skipped: corpus.skipped.reduce((sum, s) => sum + (s.bytes || 0), 0),
292
+ },
293
+ };
294
+
295
+ // Atomic persist inside the pen: write-then-rename so a crash mid-write can
296
+ // never leave a half-valid report a later stage might trust. Persist is the
297
+ // one step that MUST be allowed to throw — a report we could not write to disk
298
+ // is not a report, and the caller needs to know the floor did not land.
299
+ const outPath = path.join(realQuarantine, REPORT_BASENAME);
300
+ const tmpPath = `${outPath}.tmp`;
301
+ await fsp.writeFile(tmpPath, JSON.stringify(report, null, 2), 'utf8');
302
+ await fsp.rename(tmpPath, outPath);
303
+
304
+ return { report, reportPath: outPath };
305
+ }
306
+
307
+ module.exports = {
308
+ FLOOR_VERSION,
309
+ REPORT_BASENAME,
310
+ VERDICT_RANK,
311
+ DETECTORS,
312
+ buildCorpus,
313
+ computeFloorVerdict,
314
+ countBySeverity,
315
+ coOccurrence,
316
+ coverageFindings,
317
+ runDetectors,
318
+ runFloor,
319
+ };