@bongos/core 1.18.19 → 1.19.3

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 (259) hide show
  1. package/.bongos-core.json +547 -197
  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/bin/bongos.js +33 -7
  9. package/clients/bongos-client/README.md +1 -1
  10. package/clients/bongos-client/bongos-client.global.js +63 -41
  11. package/clients/bongos-client/index.cjs +63 -41
  12. package/clients/bongos-client/index.d.ts +78 -44
  13. package/clients/bongos-client/index.mjs +63 -41
  14. package/docs/adr/0020-security-model-public-stub.md +1 -1
  15. package/docs/adr/0097-one-active-claim-per-session-and-worktree-binding.md +2 -0
  16. package/docs/adr/0137-upstream-publish-policy-manual-owner-gated.md +1 -1
  17. package/docs/adr/0144-devbox-rehome-onto-cloudbongos-plane.md +1 -1
  18. package/docs/adr/0151-governance-permissions-as-atom-ranks-as-roles.md +2 -1
  19. package/docs/adr/0155-adopt-private-repo-widen-oauth-scope.md +51 -0
  20. package/docs/adr/0156-user-set-display-name-wins.md +62 -0
  21. package/docs/adr/0157-archon-is-rank-and-identity-only.md +93 -0
  22. package/docs/adr/0158-choke-point-verdict-taxonomy.md +174 -0
  23. package/docs/adr/0159-merge-gate-reconciliation.md +88 -0
  24. package/docs/adr/0160-main-audit-repo-verifiable-caller.md +77 -0
  25. package/docs/adr/0161-publish-on-merge.md +34 -0
  26. package/docs/adr/README.md +9 -0
  27. package/docs/api/openapi.json +1209 -233
  28. package/docs/api-reference.md +61 -46
  29. package/docs/architecture.md +1 -1
  30. package/docs/canonical-permissions.md +61 -16
  31. package/docs/design/vanilla-hall-ui-redesign-scope.md +1 -1
  32. package/docs/file-map.md +4 -1
  33. package/docs/onboarding/diagrams/assertions.json +3 -3
  34. package/docs/recipes/core-release-pipeline.md +181 -0
  35. package/docs/recipes/packaging-the-core.md +2 -0
  36. package/docs/recipes/parallel-goal-waves.md +1 -3
  37. package/docs/recipes/private-npm-distribution.md +6 -2
  38. package/docs/recipes/upgrading-the-core.md +2 -0
  39. package/migrations/core_197_display_name_source.sql +56 -0
  40. package/migrations/core_204_rekey_active_claim_per_session_worktree.sql +77 -0
  41. package/migrations/core_205_versions_track_default.sql +28 -0
  42. package/modules/autonomy/routes/autonomy.js +1 -1
  43. package/modules/builder-settings/db.js +7 -1
  44. package/modules/dev-box/app/src/vendor/bongos-client.cjs +63 -41
  45. package/modules/dev-box/routes/box.js +11 -9
  46. package/modules/discord/auth.js +3 -1
  47. package/modules/discord/routes/discord.js +8 -2
  48. package/modules/discord/ship-broadcast.js +48 -1
  49. package/modules/governance/catalog.js +70 -20
  50. package/modules/governance/db.js +102 -0
  51. package/modules/governance/migrations/governance_006_version_create.sql +38 -0
  52. package/modules/governance/migrations/governance_007_delegate_archon_ops_to_metic.sql +72 -0
  53. package/modules/governance/routes/governance.js +235 -6
  54. package/modules/grading/grader-workers/index.js +18 -9
  55. package/modules/hall-ui/public/gate.js +6 -2
  56. package/modules/hall-ui/public/goals-page.js +6 -2
  57. package/modules/hall-ui/public/governance.css +100 -0
  58. package/modules/hall-ui/public/governance.html +77 -0
  59. package/modules/hall-ui/public/governance.js +353 -0
  60. package/modules/hall-ui/public/harbor.js +14 -8
  61. package/modules/hall-ui/public/instances-panel.js +3 -1
  62. package/modules/hall-ui/public/new-project-wizard.js +6 -0
  63. package/modules/hall-ui/public/people.html +51 -0
  64. package/modules/hall-ui/public/people.js +100 -0
  65. package/modules/hall-ui/public/profile.html +2 -1
  66. package/modules/hall-ui/public/profile.js +9 -3
  67. package/modules/hall-ui/public/sessions.js +4 -2
  68. package/modules/hall-ui/public/shell.js +28 -7
  69. package/modules/hall-ui/public/task-detail.js +6 -3
  70. package/modules/hall-ui/public/watch.js +17 -9
  71. package/modules/ideas/blockers.js +44 -7
  72. package/modules/ideas/routes/blockers.js +12 -7
  73. package/modules/ideas/routes/inbox.js +2 -2
  74. package/modules/lifecycle/db.js +84 -17
  75. package/modules/lifecycle/github-push.js +37 -10
  76. package/modules/lifecycle/publish-reconciler.js +128 -31
  77. package/modules/lifecycle/routes/claims.js +10 -4
  78. package/modules/lifecycle/routes/dependencies.js +2 -2
  79. package/modules/lifecycle/routes/done-when.js +4 -4
  80. package/modules/lifecycle/routes/gate-approvals.js +9 -3
  81. package/modules/lifecycle/routes/goals.js +5 -5
  82. package/modules/lifecycle/routes/tasks.js +99 -69
  83. package/modules/lifecycle/routes/versions.js +13 -2
  84. package/modules/lifecycle/ship-card.js +38 -7
  85. package/modules/memory/routes/memory.js +2 -2
  86. package/modules/onboarding/routes/access-requests.js +3 -3
  87. package/modules/platform-identity/routes/projects.js +10 -6
  88. package/modules/platform-identity/routes/scouting.js +2 -2
  89. package/modules/provisioning/migrations/provisioning_004_onboard_mode.sql +30 -0
  90. package/modules/provisioning/provisioning.js +24 -6
  91. package/modules/provisioning/routes/provisioning.js +24 -6
  92. package/modules/security/artifact-scan/aggregate.js +133 -0
  93. package/modules/security/artifact-scan/detectors/capabilities.js +53 -0
  94. package/modules/security/artifact-scan/detectors/config-obfuscation.js +41 -0
  95. package/modules/security/artifact-scan/detectors/credentials.js +112 -0
  96. package/modules/security/artifact-scan/detectors/dependencies.js +115 -0
  97. package/modules/security/artifact-scan/detectors/hooks.js +116 -0
  98. package/modules/security/artifact-scan/detectors/injection.js +118 -0
  99. package/modules/security/artifact-scan/detectors/mcp.js +102 -0
  100. package/modules/security/artifact-scan/detectors/network.js +67 -0
  101. package/modules/security/artifact-scan/detectors/npm-lifecycle.js +67 -0
  102. package/modules/security/artifact-scan/detectors/permissions.js +91 -0
  103. package/modules/security/artifact-scan/detectors/shared.js +219 -0
  104. package/modules/security/artifact-scan/fence-local.js +99 -0
  105. package/modules/security/artifact-scan/fetch.js +330 -0
  106. package/modules/security/artifact-scan/floor.js +319 -0
  107. package/modules/security/artifact-scan/limits.js +66 -0
  108. package/modules/security/artifact-scan/manifest.js +203 -0
  109. package/modules/security/artifact-scan/mirror.js +158 -0
  110. package/modules/security/artifact-scan/quarantine.js +149 -0
  111. package/modules/security/artifact-scan/rebuild-spec.js +120 -0
  112. package/modules/security/artifact-scan/report.js +155 -0
  113. package/modules/security/artifact-scan/sanitize.js +317 -0
  114. package/modules/security/migrations/security_001_tool_scans.sql +145 -0
  115. package/modules/security/module.json +2 -2
  116. package/modules/security/routes/reports.js +11 -7
  117. package/modules/security/routes/scans.js +115 -0
  118. package/modules/security/scan-store.js +214 -0
  119. package/modules/security/tests/aggregate.mjs +283 -0
  120. package/modules/security/tests/artifact_scan.mjs +591 -0
  121. package/modules/security/tests/floor.mjs +549 -0
  122. package/modules/security/tests/mirror.mjs +350 -0
  123. package/modules/security/tests/sanitize_rebuild.mjs +499 -0
  124. package/modules/security/tests/scan_store.mjs +250 -0
  125. package/modules/security/tests/tool_scans_migration.mjs +116 -0
  126. package/modules/sessions/routes/sessions.js +6 -5
  127. package/package-lock.json +2 -2
  128. package/package.json +1 -1
  129. package/public-landing/create.html +13 -3
  130. package/scripts/gds/abandon.js +1 -1
  131. package/scripts/gds/audit-authorship.sh +6 -0
  132. package/scripts/gds/box.js +1 -1
  133. package/scripts/gds/bump-version.js +76 -0
  134. package/scripts/gds/claim.js +15 -0
  135. package/scripts/gds/cli-lib.js +16 -4
  136. package/scripts/gds/gating.js +28 -13
  137. package/scripts/gds/gen-api-docs.js +1 -1
  138. package/scripts/gds/goal.js +234 -0
  139. package/scripts/gds/init.js +77 -15
  140. package/scripts/gds/main-audit.js +80 -11
  141. package/scripts/gds/mirror-redact.js +6 -0
  142. package/scripts/gds/newcomer-floor.js +2 -2
  143. package/scripts/gds/provision.js +230 -94
  144. package/scripts/gds/run-unit-tests.js +9 -1
  145. package/scripts/gds/sandbox-stage.js +66 -9
  146. package/scripts/gds/search-ingest-md.js +1 -0
  147. package/scripts/gds/sequence.js +67 -8
  148. package/scripts/gds/ship.js +183 -50
  149. package/scripts/hall-preview/server.js +1 -1
  150. package/scripts/migrate.sh +5 -5
  151. package/src/bongos/auth.js +129 -27
  152. package/src/bongos/db-kernel.js +66 -5
  153. package/src/bongos/db.js +6 -7
  154. package/src/bongos/github-repos.js +19 -7
  155. package/src/bongos/goal-scope-check.js +1 -1
  156. package/src/bongos/module-scope-map.js +3 -1
  157. package/src/bongos/permission-path-check.js +1 -1
  158. package/src/bongos/route-rank-check.js +73 -30
  159. package/src/bongos/routes/audit-log.js +4 -2
  160. package/src/bongos/routes/auth.js +8 -4
  161. package/src/bongos/routes/backup.js +7 -4
  162. package/src/bongos/routes/builders.js +23 -11
  163. package/src/bongos/routes/github.js +15 -4
  164. package/src/bongos/routes/llm-cache.js +5 -2
  165. package/src/bongos/routes/modules.js +9 -5
  166. package/src/bongos/routes/security.js +21 -12
  167. package/src/bongos/serve-internal.js +22 -7
  168. package/src/module-api.js +96 -13
  169. package/src/platform-server.js +14 -0
  170. package/tests/api_client.mjs +18 -2
  171. package/tests/api_path_404.mjs +3 -0
  172. package/tests/art_stage.mjs +2 -1
  173. package/tests/atlas.mjs +32 -4
  174. package/tests/auth_admission_gate.mjs +17 -5
  175. package/tests/auth_error_brand.mjs +41 -21
  176. package/tests/auth_resolve_failure.mjs +155 -0
  177. package/tests/blocker_link.mjs +88 -0
  178. package/tests/bongos_cli.mjs +91 -0
  179. package/tests/bongos_downloads.mjs +2 -1
  180. package/tests/box_dns_repoint.mjs +2 -0
  181. package/tests/box_ship_permissions.mjs +2 -0
  182. package/tests/box_terminal_ssrf.mjs +23 -17
  183. package/tests/bump_version.mjs +71 -0
  184. package/tests/claim_error_surface.mjs +23 -0
  185. package/tests/claude_materialize.mjs +4 -0
  186. package/tests/cli_box_scope.mjs +40 -0
  187. package/tests/cloud_bongos_pack.mjs +34 -4
  188. package/tests/conductor.mjs +2 -1
  189. package/tests/criterion_authoring_gate.mjs +28 -5
  190. package/tests/currency_label.mjs +10 -1
  191. package/tests/deploy_instance.mjs +2 -0
  192. package/tests/devbox_downloads.mjs +2 -1
  193. package/tests/display_name.mjs +95 -6
  194. package/tests/eviction_policy.mjs +2 -1
  195. package/tests/file_map.mjs +10 -0
  196. package/tests/first_admin_bootstrap.mjs +10 -2
  197. package/tests/first_run_engine_check.mjs +2 -1
  198. package/tests/fitness.mjs +26 -6
  199. package/tests/gate_approvals.mjs +15 -2
  200. package/tests/github_push_land_proof.mjs +50 -4
  201. package/tests/github_repos_helpers.mjs +30 -5
  202. package/tests/github_token_store.mjs +23 -0
  203. package/tests/goal_abuse_matrix.mjs +13 -3
  204. package/tests/goal_cli.mjs +89 -0
  205. package/tests/governance_abuse_matrix.mjs +386 -0
  206. package/tests/governance_fold.mjs +156 -0
  207. package/tests/governance_fold_bench.mjs +50 -0
  208. package/tests/governance_live_proof.mjs +201 -0
  209. package/tests/governance_page_gates.mjs +21 -9
  210. package/tests/governance_parity.mjs +11 -2
  211. package/tests/governance_require_permission.mjs +125 -1
  212. package/tests/governance_routes.mjs +334 -0
  213. package/tests/grader.mjs +7 -3
  214. package/tests/hall_nav.mjs +14 -4
  215. package/tests/helpers.mjs +73 -0
  216. package/tests/hook_core_resolve.mjs +2 -1
  217. package/tests/host_topology_skips.mjs +97 -0
  218. package/tests/infra_exec_paths.mjs +14 -4
  219. package/tests/init.mjs +147 -0
  220. package/tests/known_failing_ratchet.mjs +89 -0
  221. package/tests/lifecycle-card.mjs +2 -1
  222. package/tests/main_audit.mjs +72 -1
  223. package/tests/memory_isolation.mjs +56 -9
  224. package/tests/migrate_root_resolution.mjs +1 -1
  225. package/tests/mirror_redact.mjs +19 -0
  226. package/tests/module_api.mjs +3 -1
  227. package/tests/module_loader.mjs +4 -0
  228. package/tests/modules.mjs +35 -7
  229. package/tests/modules_route.mjs +29 -5
  230. package/tests/onboarding_observability.mjs +49 -11
  231. package/tests/onboarding_route_signals.mjs +1 -0
  232. package/tests/one_active_claim_per_session.mjs +80 -0
  233. package/tests/patch_gate_ordering.mjs +102 -0
  234. package/tests/pre_compact.mjs +2 -1
  235. package/tests/preview_server.mjs +2 -1
  236. package/tests/profile_route.mjs +1 -0
  237. package/tests/provision.mjs +170 -0
  238. package/tests/provisioning_onboard_mode.mjs +180 -0
  239. package/tests/publish_manifest.mjs +16 -3
  240. package/tests/publish_reconciler.mjs +181 -10
  241. package/tests/rank_tier_single_source.mjs +205 -0
  242. package/tests/recall_hints.mjs +2 -1
  243. package/tests/repo_map.mjs +4 -0
  244. package/tests/route_permission_check.mjs +14 -5
  245. package/tests/rubric_fixtures.mjs +3 -0
  246. package/tests/run_routine_two_root.mjs +2 -0
  247. package/tests/sandbox_stage.mjs +69 -7
  248. package/tests/search_ingest.mjs +7 -0
  249. package/tests/security_docs.mjs +28 -6
  250. package/tests/sequence.mjs +64 -0
  251. package/tests/session-cards.mjs +2 -1
  252. package/tests/session_start_freshness.mjs +2 -1
  253. package/tests/ship_broadcast_server.mjs +63 -1
  254. package/tests/ship_card.mjs +66 -0
  255. package/tests/ship_ci_deploy.mjs +66 -1
  256. package/tests/ship_smoke_gate.mjs +76 -0
  257. package/tests/versions_route.mjs +15 -0
  258. package/tests/workflow_exec_paths.mjs +16 -4
  259. package/tests/worktree_guard.mjs +2 -1
@@ -0,0 +1,317 @@
1
+ // modules/security/artifact-scan/sanitize.js — the sanitize landing
2
+ // (task 1002548, goal 1000055).
3
+ //
4
+ // The sanitize offer is the narrowest of the three landings, and every one of
5
+ // its gates is ARITHMETIC over recorded evidence — the same rule as the
6
+ // reducer. planSanitize() decides eligibility from the reducer's components
7
+ // and the findings themselves; no model opinion, no narrative, no override.
8
+ // The owner's four gates, verbatim from the task:
9
+ //
10
+ // 1. NO MALICE INDICATOR — any critical finding anywhere (floor or panel),
11
+ // or a dangerous floor/verdict, kills the offer. A sanitized copy of a
12
+ // malicious artifact is a laundered artifact.
13
+ // 2. WHOLE-NODE REMOVALS ONLY — every finding must name a concrete manifest
14
+ // file that can be removed entire. We never edit inside a file: a
15
+ // line-level "fix" of hostile code is a claim of understanding the scan
16
+ // cannot honestly make.
17
+ // 3. FULL COVERAGE — nothing skipped, nothing unscannable, no unfetched
18
+ // dependencies or submodules. Static analysis cannot vouch for a copy it
19
+ // never fully read. Read off the reducer's coverage_cap, which is why
20
+ // fetch-stage findings must reach the floor report (runFloor's
21
+ // extraFindings) — routed anywhere else, this gate cannot see them.
22
+ // 4. CLEAN RE-SCAN BY FRESH SUBAGENTS — enforced by the SKILL, which
23
+ // re-runs the whole pipeline (manifest → mirror → floor → panel) over
24
+ // the sanitized copy in a SECOND quarantine with fresh contexts. This
25
+ // module only produces the copy; it never blesses it.
26
+ //
27
+ // buildSanitizedCopy() writes ONLY inside the quarantine — the sanitized tree
28
+ // lands at <quarantine>/sanitized/ and the diff at <quarantine>/sanitize-diff.md.
29
+ // The sanitized copy is PRISTINE-CLASS: raw artifact bytes (it must stay
30
+ // byte-identical to install), so no model may read it — the re-scan mirrors
31
+ // it first, same as any fetch. File reads follow the mirror.js confinement
32
+ // discipline: lexical check before any fs call, realpath verified inside the
33
+ // pen, read the verified resolved path.
34
+
35
+ 'use strict';
36
+
37
+ const crypto = require('node:crypto');
38
+ const fsp = require('node:fs/promises');
39
+ const path = require('node:path');
40
+
41
+ const { isInside } = require('./quarantine');
42
+ const { excerpt } = require('./detectors/shared');
43
+
44
+ const SANITIZED_DIRNAME = 'sanitized';
45
+ const DIFF_BASENAME = 'sanitize-diff.md';
46
+
47
+ // The cap detectors/shared.finding() applies to a finding's `file`. A finding
48
+ // carries the SCRUBBED, capped form of a manifest path; the manifest carries
49
+ // the raw bytes. Matching one against the other directly is the collision bug
50
+ // below — this constant is what makes the two comparable.
51
+ const PATH_MAX_CHARS = 300;
52
+ const CODE_MAX_CHARS = 64;
53
+ const MAX_REASON_CODES = 20;
54
+
55
+ function allFindings(floorReport = {}, lenses = []) {
56
+ const floor = Array.isArray(floorReport.findings) ? floorReport.findings : [];
57
+ const panel = [];
58
+ for (const lens of lenses) {
59
+ for (const f of (Array.isArray(lens && lens.findings) ? lens.findings : [])) panel.push(f);
60
+ }
61
+ return { floor, panel };
62
+ }
63
+
64
+ // Gate 1 as its own testable piece: is there any indicator of actual malice?
65
+ // Arithmetic on purpose — any critical-severity finding, from the floor or
66
+ // from any lens, or a dangerous floor verdict. Severity is the one field the
67
+ // pipeline already treats as evidence; a separate "looks malicious" judgment
68
+ // would reintroduce the opinion the reducer exists to exclude.
69
+ function hasMaliceIndicator(floorReport = {}, lenses = []) {
70
+ const { floor, panel } = allFindings(floorReport, lenses);
71
+ // Codes are model-authored on panel findings and artifact-derived on floor
72
+ // ones, and they flow into reasons[] which the skill relays to chat — so they
73
+ // pass the same scrub + one-line + cap every report string does.
74
+ const codes = [...floor, ...panel]
75
+ .filter((f) => f && f.severity === 'critical')
76
+ .map((f) => excerpt(String(f.code || 'unlabeled'), CODE_MAX_CHARS));
77
+ if (floorReport.floor_verdict === 'dangerous' && codes.length === 0) codes.push('floor_verdict_dangerous');
78
+ return { malicious: codes.length > 0, codes };
79
+ }
80
+
81
+ // The eligibility decision. Takes the REDUCER RESULT (never a narrative) plus
82
+ // the raw findings and the manifest entries, and returns
83
+ // { eligible, reasons, removals, unremovable, provenance }. `removals` is the
84
+ // sorted set of RAW manifest paths whose removal addresses every finding; a
85
+ // finding that names no concrete manifest file (e.g. file '(floor)', an
86
+ // off-box surface, or a path the manifest does not carry) is unremovable and
87
+ // kills the offer — fail closed, gate 2.
88
+ //
89
+ // PATH IDENTITY (review finding). A finding's `file` has been through
90
+ // scrubUntrusted + whitespace-collapse + a 300-char cap; a manifest path has
91
+ // not. Joining them directly let an artifact ship two files whose names differ
92
+ // only by a scrubbed codepoint — the removal set then named the benign twin
93
+ // while the flagged file was copied into sanitized/. So the join runs on the
94
+ // SCRUBBED form of both sides, and a scrubbed name that maps to more than one
95
+ // raw manifest entry is AMBIGUOUS: it becomes unremovable rather than a guess,
96
+ // because deleting the wrong file is exactly the failure this gate exists to
97
+ // prevent.
98
+ //
99
+ // PROVENANCE (review finding, split verdict). A panel finding's `file` is
100
+ // model-authored text, so it is both evidence AND a destructive instruction.
101
+ // The gates cannot verify it, so they LABEL it: `provenance` reports which
102
+ // removals a code-computed floor finding backs and which rest on panel text
103
+ // alone. The blast radius stays bounded by three things that do not trust the
104
+ // panel — removals are whole files inside the quarantine only, the human reads
105
+ // the diff, and the copy must pass a fresh-subagent re-scan (gate 4).
106
+ function planSanitize({ reduced, floorReport = {}, lenses = [], entries = [] } = {}) {
107
+ if (!reduced || !reduced.components) {
108
+ throw new Error('planSanitize requires the reducer result — sanitize eligibility is arithmetic, never a narrative');
109
+ }
110
+ const reasons = [];
111
+
112
+ const malice = hasMaliceIndicator(floorReport, lenses);
113
+ if (malice.malicious) reasons.push(`malice_indicator: ${malice.codes.join(', ')}`);
114
+ if (reduced.verdict === 'dangerous') reasons.push('verdict_dangerous');
115
+
116
+ if (reduced.components.coverage_cap !== 'clean') {
117
+ reasons.push(`coverage_incomplete: ${(reduced.coverage_reasons || []).join('; ') || 'coverage cap held'}`);
118
+ }
119
+ // Gates 1–3 are about what THIS scan proved, so the scan must have been a
120
+ // full one: a panel-bearing tier (low never ran a real panel) and a panel
121
+ // that completed healthy. The clean re-scan (gate 4) is the skill's job.
122
+ if (reduced.components.tier_clamp !== 'clean') reasons.push('tier_ran_no_full_panel');
123
+ if (reduced.components.lens_health !== 'clean') reasons.push('panel_unhealthy_or_absent');
124
+
125
+ const { floor, panel } = allFindings(floorReport, lenses);
126
+ const findings = [...floor.map((f) => ({ f, from: 'floor' })), ...panel.map((f) => ({ f, from: 'panel' }))];
127
+ if (findings.length === 0) reasons.push('nothing_to_remove');
128
+
129
+ // Scrubbed manifest name → the entries that render to it. EVERY kind is
130
+ // indexed, not just files: a symlink whose scrubbed name collides with a
131
+ // real file must make that name ambiguous, or a finding about the symlink
132
+ // schedules the innocent file for deletion (verification finding).
133
+ const byScrubbed = new Map();
134
+ for (const e of entries) {
135
+ if (!e || typeof e.path !== 'string') continue;
136
+ const key = excerpt(e.path, PATH_MAX_CHARS);
137
+ // A name that scrubs away to nothing (all-whitespace, all-zero-width) can
138
+ // never identify anything. Indexing it would let it match every finding
139
+ // that carries no file at all — the fail-open this gate exists to prevent.
140
+ if (key === '') continue;
141
+ if (!byScrubbed.has(key)) byScrubbed.set(key, []);
142
+ byScrubbed.get(key).push(e);
143
+ }
144
+
145
+ const removals = new Set();
146
+ const backedByFloor = new Set();
147
+ const unremovable = [];
148
+ for (const { f, from } of findings) {
149
+ const claimed = f && typeof f.file === 'string' ? excerpt(f.file, PATH_MAX_CHARS) : '';
150
+ const matches = claimed === '' ? [] : (byScrubbed.get(claimed) || []);
151
+ if (matches.length === 1 && matches[0].kind === 'file') {
152
+ removals.add(matches[0].path);
153
+ if (from === 'floor') backedByFloor.add(matches[0].path);
154
+ continue;
155
+ }
156
+ let reason = 'no_manifest_file';
157
+ if (matches.length > 1) reason = 'ambiguous_path';
158
+ else if (matches.length === 1) reason = 'not_a_regular_file';
159
+ unremovable.push({
160
+ code: excerpt(String((f && f.code) || 'unlabeled'), CODE_MAX_CHARS),
161
+ file: claimed,
162
+ reason,
163
+ });
164
+ }
165
+ if (unremovable.length > 0) {
166
+ // Count-capped as well as per-item capped: a hostile panel returning
167
+ // thousands of findings must not build one unbounded reason string.
168
+ const codes = unremovable.slice(0, MAX_REASON_CODES).map((u) => u.code).join(', ');
169
+ const more = unremovable.length > MAX_REASON_CODES ? ` (+${unremovable.length - MAX_REASON_CODES} more)` : '';
170
+ reasons.push(`not_whole_node_removable: ${codes}${more}`);
171
+ }
172
+
173
+ // removals[] carries RAW manifest paths because buildSanitizedCopy needs the
174
+ // real bytes to find the file on disk — so it is NOT display-safe and must
175
+ // never be printed. provenance carries the scrubbed rendering of the same
176
+ // paths and is what a human is shown (verification finding: a bidi override
177
+ // or an embedded newline in a filename otherwise forges the approval
178
+ // display at the exact moment deletions are approved).
179
+ const sorted = [...removals].sort();
180
+ const display = (p) => excerpt(p, PATH_MAX_CHARS);
181
+ return {
182
+ eligible: reasons.length === 0,
183
+ reasons,
184
+ removals: sorted,
185
+ unremovable,
186
+ provenance: {
187
+ floor_backed: sorted.filter((p) => backedByFloor.has(p)).map(display),
188
+ panel_only: sorted.filter((p) => !backedByFloor.has(p)).map(display),
189
+ },
190
+ };
191
+ }
192
+
193
+ // Copy the pristine tree minus the removals into <quarantine>/sanitized/, and
194
+ // write the diff (whole files removed — nothing else changed) next to it.
195
+ // Writes ONLY inside the quarantine; kept files are copied byte-identical.
196
+ // Refuses stale state (an existing sanitized/ dir) and any removal path the
197
+ // manifest does not carry — a typo'd removal must never silently keep a file
198
+ // the plan meant to drop.
199
+ //
200
+ // Every kept file is re-hashed against its manifest sha256 before it is
201
+ // written (review finding): the diff's promise that kept files are
202
+ // "byte-identical to the scanned pristine copy" was previously prose the code
203
+ // never tested, so anything that changed the pen between the scan and the
204
+ // sanitize — a concurrent process, another tool, a shared build box — was
205
+ // copied in under a provenance claim. Now a mismatch aborts.
206
+ //
207
+ // On ANY failure the partial sanitized/ tree is removed before the error
208
+ // propagates: a directory that looks like a finished sanitized copy but is
209
+ // silently missing files, with no diff beside it, is worse than no directory
210
+ // (and the non-recursive mkdir would otherwise refuse every retry).
211
+ async function buildSanitizedCopy({ pristineDir, quarantineRoot, entries, removals } = {}) {
212
+ if (!pristineDir || !quarantineRoot || !Array.isArray(entries) || !Array.isArray(removals) || removals.length === 0) {
213
+ throw new Error('buildSanitizedCopy requires pristineDir, quarantineRoot, manifest entries[], and a non-empty removals[].');
214
+ }
215
+ const fileEntries = new Map(entries.filter((e) => e && e.kind === 'file').map((e) => [e.path, e]));
216
+ for (const r of removals) {
217
+ if (!fileEntries.has(r)) throw new Error(`Removal "${excerpt(String(r), 300)}" is not a manifest file — refusing a plan the manifest does not vouch for.`);
218
+ }
219
+
220
+ const realQuarantine = await fsp.realpath(quarantineRoot);
221
+ const sanitizedDir = path.join(realQuarantine, SANITIZED_DIRNAME);
222
+ await fsp.mkdir(sanitizedDir); // non-recursive: an existing sanitized/ is stale state, refuse
223
+
224
+ const remove = new Set(removals);
225
+ const removed = [];
226
+ const skippedNonFile = [];
227
+ let copied = 0;
228
+ const madeDirs = new Set();
229
+
230
+ try {
231
+ for (const entry of entries) {
232
+ if (!entry || entry.kind !== 'file') {
233
+ if (entry) skippedNonFile.push(entry.path);
234
+ continue;
235
+ }
236
+ const rel = entry.path;
237
+ if (remove.has(rel)) {
238
+ removed.push({ path: rel, size: entry.size, sha256: entry.sha256 || null });
239
+ continue;
240
+ }
241
+
242
+ // Confinement, same discipline as mirror.js: lexical first, then physical.
243
+ if (typeof rel !== 'string' || rel.length === 0 || path.isAbsolute(rel)
244
+ || !isInside(pristineDir, path.resolve(pristineDir, ...rel.split('/')))) {
245
+ throw new Error(`Manifest path "${excerpt(String(rel), 300)}" escapes the pristine tree — refusing to build a sanitized copy over it.`);
246
+ }
247
+ const abs = path.resolve(pristineDir, ...rel.split('/'));
248
+ const real = await fsp.realpath(abs);
249
+ if (!isInside(realQuarantine, real)) {
250
+ throw new Error(`"${excerpt(rel, 300)}" resolves outside the quarantine — refusing.`);
251
+ }
252
+ const st = await fsp.lstat(abs);
253
+ if (st.isSymbolicLink()) {
254
+ throw new Error(`"${excerpt(rel, 300)}" is a symlink on disk though the manifest calls it a file — refusing.`);
255
+ }
256
+ const buf = await fsp.readFile(real); // the verified resolved path, not abs
257
+
258
+ // The diff claims every kept file is byte-identical to what was scanned.
259
+ // Prove it against the manifest hash rather than asserting it in prose.
260
+ if (!entry.sha256) {
261
+ throw new Error(`"${excerpt(rel, 300)}" has no manifest sha256 — refusing to claim byte-identity the manifest cannot vouch for.`);
262
+ }
263
+ const fresh = crypto.createHash('sha256').update(buf).digest('hex');
264
+ if (fresh !== entry.sha256) {
265
+ throw new Error(`"${excerpt(rel, 300)}" changed since the scan (manifest sha256 ${excerpt(String(entry.sha256), 64)}, on disk ${fresh}) — refusing to copy bytes nobody reviewed.`);
266
+ }
267
+
268
+ const dest = path.resolve(sanitizedDir, ...rel.split('/'));
269
+ if (!isInside(sanitizedDir, dest)) {
270
+ throw new Error(`"${excerpt(rel, 300)}" would land outside sanitized/ — refusing.`);
271
+ }
272
+ const destDir = path.dirname(dest);
273
+ if (!madeDirs.has(destDir)) {
274
+ await fsp.mkdir(destDir, { recursive: true });
275
+ madeDirs.add(destDir);
276
+ }
277
+ await fsp.writeFile(dest, buf);
278
+ copied++;
279
+ }
280
+
281
+ // The diff: whole files removed, nothing else touched. Paths and hashes are
282
+ // artifact-controlled strings, so they pass through the same scrub+cap every
283
+ // report string does.
284
+ const diffLines = [
285
+ '# Sanitize diff',
286
+ '',
287
+ 'Whole-file removals only — every kept file was re-hashed against its manifest sha256 before being copied.',
288
+ `Removed ${removed.length} file(s); copied ${copied} file(s) into ${SANITIZED_DIRNAME}/.`,
289
+ '',
290
+ ];
291
+ for (const r of removed) {
292
+ diffLines.push(`- REMOVED ${excerpt(r.path, 300)} (${Number(r.size) || 0} bytes, sha256 ${excerpt(String(r.sha256 || '(none)'), 64)})`);
293
+ }
294
+ if (skippedNonFile.length > 0) {
295
+ diffLines.push('');
296
+ diffLines.push(`Not copied (non-file manifest entries, never reviewed): ${skippedNonFile.map((p) => excerpt(String(p), 120)).join(', ')}`);
297
+ }
298
+ const diff = diffLines.join('\n');
299
+ const diffPath = path.join(realQuarantine, DIFF_BASENAME);
300
+ await fsp.writeFile(diffPath, diff, 'utf8');
301
+
302
+ return { sanitizedDir, diffPath, diff, copied, removed, skippedNonFile };
303
+ } catch (err) {
304
+ // No half-built tree survives a failure — see the header. Best-effort so a
305
+ // cleanup problem can never mask the real error.
306
+ await fsp.rm(sanitizedDir, { recursive: true, force: true }).catch(() => {});
307
+ throw err;
308
+ }
309
+ }
310
+
311
+ module.exports = {
312
+ SANITIZED_DIRNAME,
313
+ DIFF_BASENAME,
314
+ hasMaliceIndicator,
315
+ planSanitize,
316
+ buildSanitizedCopy,
317
+ };
@@ -0,0 +1,145 @@
1
+ -- security_001_tool_scans.sql — the durable record of an external-artifact scan.
2
+ --
3
+ -- One row per `/scan-before-install` run, so a scan is queryable across builders
4
+ -- rather than living only in one session's chat and one file on one laptop. The
5
+ -- builder most likely to install a shiny new tool is the newest one, so the read
6
+ -- side of this table exists to let one builder's scan warn the next.
7
+ --
8
+ -- STRUCTURE ONLY, NEVER TARGET CONTENT. The row carries the verdict, the tier,
9
+ -- the pinned SHA, the content hash, per-severity counts, and one-line finding
10
+ -- claims — never quoted bytes from the artifact. Two reasons: a scanned artifact
11
+ -- is hostile input, and anything stored here is later rendered to whoever reads
12
+ -- the table, so a quoted payload would make this table a delivery mechanism for
13
+ -- the very injection the scan exists to catch. The `findings_summary` size cap
14
+ -- below is the DB-layer half of that rule; the route validates it too.
15
+ --
16
+ -- AN ABORTED SCAN IS RECORDABLE. `completion` distinguishes a finished scan from
17
+ -- one that died (clone timeout, cap exceeded, host resolved private). That is
18
+ -- deliberate: a scan that produced no verdict must be visible as "aborted, here
19
+ -- is why" rather than being indistinguishable from silence — otherwise a failed
20
+ -- scan reads like a clean one.
21
+ --
22
+ -- Module migrations are ADDITIVE and NAMESPACED (ADR 0083 §Decision #5): every
23
+ -- object is security_-prefixed, there is no down-migration, and the filename is
24
+ -- security_-prefixed (ADR 0108 §5) so its stem never collides across repos.
25
+ -- `scripts/migrate.sh` discovers + applies this when the security module is
26
+ -- enabled (default:true). Idempotent: safe to re-run.
27
+
28
+ BEGIN;
29
+
30
+ CREATE TABLE IF NOT EXISTS security_tool_scans (
31
+ id bigserial PRIMARY KEY,
32
+
33
+ -- What was scanned. target_url is the https URL as validated; target_host is
34
+ -- split out so "have we seen anything from this host" is an index-able question.
35
+ target_url text NOT NULL,
36
+ target_host text,
37
+ -- 'repo' (a plain GitHub repo) | 'plugin' | 'marketplace'. Text plus a CHECK
38
+ -- rather than a Postgres enum TYPE: the constraint pins today's known kinds
39
+ -- fail-closed, and widening to a new kind is a one-line constraint change in a
40
+ -- later security_* migration — not an enum-type migration.
41
+ artifact_kind text NOT NULL,
42
+
43
+ -- The pinned commit. NULL only when the scan aborted before resolving HEAD.
44
+ -- A branch or tag is never an install source; only this SHA is.
45
+ resolved_sha text,
46
+ -- sha256 over the sorted (path, kind, size, sha256) manifest. The dedupe key
47
+ -- for "we already scanned these exact bytes", and what the install offer
48
+ -- re-checks so the artifact cannot change between scanning and installing.
49
+ content_hash text,
50
+ file_count integer,
51
+ total_bytes bigint,
52
+
53
+ -- Thoroughness the scan ran at. The low tier can never reach a clean verdict,
54
+ -- so tier is load-bearing context for reading `verdict`, not decoration.
55
+ tier text NOT NULL,
56
+ -- clean | caution | dangerous. NULL when completion <> 'complete' — an
57
+ -- unfinished scan has no verdict, and must never be read as a passing one.
58
+ verdict text,
59
+ completion text NOT NULL DEFAULT 'complete',
60
+ -- the abort's finding code (clone_timeout, cap_max_bytes, host_resolves_private…)
61
+ abort_code text,
62
+
63
+ findings_critical integer NOT NULL DEFAULT 0,
64
+ findings_high integer NOT NULL DEFAULT 0,
65
+ findings_medium integer NOT NULL DEFAULT 0,
66
+ findings_low integer NOT NULL DEFAULT 0,
67
+ -- One-line claims only (code, severity, file, short message). The full report
68
+ -- with evidence lives in the builder's own user directory, never here and
69
+ -- never in the repo: a scan report is a timestamped accusation about a named
70
+ -- third party, and docs/ is a public AGPL tree indexed by /recall.
71
+ findings_summary jsonb NOT NULL DEFAULT '[]'::jsonb,
72
+
73
+ -- What the builder did with the verdict: none | install_command_printed |
74
+ -- sanitized_copy | rebuild_spec | declined. Note the scanner never installs;
75
+ -- the strongest value here is that a command was PRINTED for the user to run.
76
+ action_taken text,
77
+ -- engine version that produced this row, so an old row is never mistaken for
78
+ -- a verdict the current detector set would reach.
79
+ engine_version text NOT NULL,
80
+
81
+ scanned_by_builder_id bigint REFERENCES builders(id) ON DELETE SET NULL,
82
+ created_at timestamptz NOT NULL DEFAULT now(),
83
+ updated_at timestamptz NOT NULL DEFAULT now(),
84
+
85
+ CONSTRAINT security_tool_scans_completion_chk
86
+ CHECK (completion IN ('complete', 'aborted', 'incomplete')),
87
+ CONSTRAINT security_tool_scans_verdict_chk
88
+ CHECK (verdict IS NULL OR verdict IN ('clean', 'caution', 'dangerous')),
89
+ -- A finished scan must carry a verdict, and an unfinished one must not. This is
90
+ -- the invariant that keeps "the scan died" from ever being read as "it passed".
91
+ CONSTRAINT security_tool_scans_verdict_completion_chk
92
+ CHECK ((completion = 'complete') = (verdict IS NOT NULL)),
93
+ CONSTRAINT security_tool_scans_tier_chk
94
+ CHECK (tier IN ('low', 'default', 'high', 'xhigh', 'max')),
95
+ CONSTRAINT security_tool_scans_artifact_kind_chk
96
+ CHECK (artifact_kind IN ('repo', 'plugin', 'marketplace')),
97
+ CONSTRAINT security_tool_scans_action_chk
98
+ CHECK (action_taken IS NULL OR action_taken IN
99
+ ('none', 'install_command_printed', 'sanitized_copy', 'rebuild_spec', 'declined')),
100
+ CONSTRAINT security_tool_scans_counts_chk
101
+ CHECK (findings_critical >= 0 AND findings_high >= 0
102
+ AND findings_medium >= 0 AND findings_low >= 0),
103
+ -- Defense in depth with the route's own cap: a hostile artifact must not be
104
+ -- able to push unbounded text through a summary field into every future reader.
105
+ CONSTRAINT security_tool_scans_summary_size_chk
106
+ CHECK (length(findings_summary::text) <= 65536),
107
+ -- The summary is a LIST of one-line claims — pinning the shape at the DB layer
108
+ -- means a scalar or object smuggled past the route can never land.
109
+ CONSTRAINT security_tool_scans_summary_shape_chk
110
+ CHECK (jsonb_typeof(findings_summary) = 'array'),
111
+ CONSTRAINT security_tool_scans_url_len_chk
112
+ CHECK (length(target_url) <= 2048)
113
+ );
114
+
115
+ -- "Have these exact bytes been scanned before?" — the dedupe lookup. Partial: an
116
+ -- aborted scan never produced a content_hash, and those NULLs are dead weight.
117
+ CREATE INDEX IF NOT EXISTS security_tool_scans_content_hash_idx
118
+ ON security_tool_scans (content_hash)
119
+ WHERE content_hash IS NOT NULL;
120
+
121
+ -- "What has this builder scanned lately?" — the scanner's own history.
122
+ CREATE INDEX IF NOT EXISTS security_tool_scans_scanner_idx
123
+ ON security_tool_scans (scanned_by_builder_id, created_at DESC);
124
+
125
+ -- "Anything scanned from this host?" — cheap reputation lookup across builders.
126
+ CREATE INDEX IF NOT EXISTS security_tool_scans_host_idx
127
+ ON security_tool_scans (target_host, created_at DESC)
128
+ WHERE target_host IS NOT NULL;
129
+
130
+ -- Rows ARE mutated after insert (action_taken is recorded once the builder acts
131
+ -- on the verdict), so updated_at needs a real trigger — without one it silently
132
+ -- equals created_at forever. Idempotent: OR REPLACE + DROP IF EXISTS.
133
+ CREATE OR REPLACE FUNCTION security_tool_scans_touch_updated_at() RETURNS trigger AS $$
134
+ BEGIN
135
+ NEW.updated_at = now();
136
+ RETURN NEW;
137
+ END;
138
+ $$ LANGUAGE plpgsql;
139
+
140
+ DROP TRIGGER IF EXISTS security_tool_scans_updated_at ON security_tool_scans;
141
+ CREATE TRIGGER security_tool_scans_updated_at
142
+ BEFORE UPDATE ON security_tool_scans
143
+ FOR EACH ROW EXECUTE FUNCTION security_tool_scans_touch_updated_at();
144
+
145
+ COMMIT;
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "key": "security",
3
3
  "title": "Security (red-team · bounty)",
4
- "description": "The red-team / vulnerability-report intake and the bounty payout system (rank-gated Metic+): the /builder-redteam skill files a report, the Archon dashboard adjudicates (confirm/dispute/fix/link-fix), and the env-aware bounty table sets the per-severity drachmae payout. A core-domain module carved out of the GDS core in BV1.R79 (ADR 0093 §1; 02-module-map §5 — security-the-FEATURE, NOT security-the-property, which stays kernel). default-on because every instance can take red-team reports. Registers the `security` kernel PORT the core /public/bounty-table resolves for the payout schedule.",
4
+ "description": "The red-team / vulnerability-report intake and the bounty payout system (rank-gated Metic+): the /builder-redteam skill files a report, the Archon dashboard adjudicates (confirm/dispute/fix/link-fix), and the env-aware bounty table sets the per-severity drachmae payout. A core-domain module carved out of the GDS core in BV1.R79 (ADR 0093 §1; 02-module-map §5 — security-the-FEATURE, NOT security-the-property, which stays kernel). default-on because every instance can take red-team reports. Registers the `security` kernel PORT the core /public/bounty-table resolves for the payout schedule. Also owns the scan-before-install surface (goal 1000055): the artifact-scan engine (quarantine/manifest/mirror) and the security_tool_scans record — POST Metic+, GET every rank.",
5
5
  "coreVersion": "^1.8.0",
6
6
  "default": true,
7
- "contributes": { "routes": ["reports"] },
7
+ "contributes": { "routes": ["reports", "scans"], "migrations": true },
8
8
  "provides": ["security"],
9
9
  "consumes": []
10
10
  }
@@ -62,7 +62,7 @@ module.exports = function buildSecurityReportsRouter() {
62
62
  router.post(
63
63
  '/security/reports',
64
64
  api.requireBuilder,
65
- api.requireRank('metic', 'archon'),
65
+ api.requirePermission('security.report.file'),
66
66
  async (req, res) => {
67
67
  if (validateOrRespond(req, res, {
68
68
  target: { required: true, type: 'string', maxLength: LIMITS.TITLE, minLength: 1 },
@@ -99,11 +99,15 @@ module.exports = function buildSecurityReportsRouter() {
99
99
  // lifecycle bucket (open / repro_attempted / confirmed / disputed /
100
100
  // fixed / wontfix); omitted = all.
101
101
  //
102
- // rank: archon — adjudication surface; reading every builder's reports.
102
+ // rank: metic+archon — adjudication surface; reading every builder's reports.
103
+ // BV1.R105 left this on requireRank because `security.report.adjudicate` was declared
104
+ // over the adjudication WRITES + /security/docs CRUD, not over this read. ADR 0157
105
+ // extends that atom's guards to name GET /security/reports and lowers its floor
106
+ // archon→metic, so the mapping now exists and the audience is the working rank.
103
107
  router.get(
104
108
  '/security/reports',
105
109
  api.requireBuilder,
106
- api.requireRank('archon'),
110
+ api.requirePermission('security.report.adjudicate'),
107
111
  async (req, res) => {
108
112
  const status = req.query.status ? String(req.query.status) : null;
109
113
  if (status && !security.VULN_STATUSES.includes(status)) {
@@ -135,7 +139,7 @@ module.exports = function buildSecurityReportsRouter() {
135
139
  router.post(
136
140
  '/security/reports/:id/confirm',
137
141
  api.requireBuilder,
138
- api.requireRank('archon'),
142
+ api.requirePermission('security.report.adjudicate'),
139
143
  async (req, res) => {
140
144
  // drachmae_awarded is OPTIONAL and deliberately UNTYPED here: the handler
141
145
  // accepts a number, a stringified number, '' and null alike, and the inline
@@ -174,7 +178,7 @@ module.exports = function buildSecurityReportsRouter() {
174
178
  router.post(
175
179
  '/security/reports/:id/dispute',
176
180
  api.requireBuilder,
177
- api.requireRank('archon'),
181
+ api.requirePermission('security.report.adjudicate'),
178
182
  async (req, res) => {
179
183
  const id = parseId(req, res, { code: 'bad_report_id', positiveInt: true });
180
184
  if (id === null) return;
@@ -199,7 +203,7 @@ module.exports = function buildSecurityReportsRouter() {
199
203
  router.post(
200
204
  '/security/reports/:id/fix',
201
205
  api.requireBuilder,
202
- api.requireRank('archon'),
206
+ api.requirePermission('security.report.adjudicate'),
203
207
  async (req, res) => {
204
208
  const id = parseId(req, res, { code: 'bad_report_id', positiveInt: true });
205
209
  if (id === null) return;
@@ -225,7 +229,7 @@ module.exports = function buildSecurityReportsRouter() {
225
229
  router.post(
226
230
  '/security/reports/:id/link-fix',
227
231
  api.requireBuilder,
228
- api.requireRank('archon'),
232
+ api.requirePermission('security.report.adjudicate'),
229
233
  async (req, res) => {
230
234
  // task_id is REQUIRED but untyped: the handler accepts a number or a
231
235
  // stringified number and coerces below, so a type would narrow the live
@@ -0,0 +1,115 @@
1
+ // modules/security/routes/scans.js — the scan-record surface (task 1002547,
2
+ // goal 1000055): POST one row per /scan-before-install run, GET the rows for a
3
+ // content hash so one builder's scan warns the next.
4
+ //
5
+ // Rank shape, deliberately asymmetric:
6
+ // * WRITE is Metic+ — a verdict-shaped write is a trust-shaped write; a row
7
+ // here tells every future reader "these bytes were judged X".
8
+ // * READ is every rank — the builder most likely to install a shiny new tool
9
+ // is the newest one, and the warning must reach exactly them.
10
+ //
11
+ // A Xenos who runs a scan still gets a DEFINED path (below), not a dead-end:
12
+ // the refusal names what they keep (report file + chat summary) and what to do
13
+ // next, so the degraded experience is a documented contract, not a mystery 403.
14
+ //
15
+ // Same idioms as reports.js next door: doorway-only requires, validateOrRespond
16
+ // schema, res.fail codes, factory export mounted via module.json contributes.
17
+
18
+ const express = require('express');
19
+ const api = require('../../../src/module-api');
20
+ const scanStore = require('../scan-store');
21
+ const { validateOrRespond } = api;
22
+
23
+ // Interposed BEFORE api.requireRank so a Xenos gets the documented degraded
24
+ // path instead of the generic rank refusal. Anyone Metic+ falls through to the
25
+ // real gate (which fitness/route-rank-check reads as this route's rank).
26
+ function xenosDegradedPath(req, res, next) {
27
+ if (api.rankMeetsThreshold(req.builder?.rank, 'metic')) return next();
28
+ return res.fail('scan_row_requires_metic', 403, {
29
+ degraded_path:
30
+ 'Your scan itself is unaffected — keep the report file in your user directory and the chat summary. ' +
31
+ 'What your rank cannot do is write the SHARED scan record other builders query. ' +
32
+ 'Ask any Metic+ builder to POST this scan, or re-run the record step after promotion.',
33
+ kept: ['report_file', 'chat_summary'],
34
+ not_recorded: ['gds_scan_row'],
35
+ });
36
+ }
37
+
38
+ module.exports = function buildSecurityScansRouter() {
39
+ const router = express.Router();
40
+
41
+ // POST /api/bongos/security/scans — record one scan run.
42
+ //
43
+ // Body: structure only, never quoted target content — the deep checks
44
+ // (one-line claims, fence-marker rejection, scrubbing, verdict/completion
45
+ // coupling) live in scanStore.normalizeScanPayload; the schema here rejects
46
+ // unknown/typo'd top-level fields (strict default) and pins basic shapes.
47
+ //
48
+ // rank: metic+archon — a verdict-shaped write is a trust-shaped write.
49
+ router.post(
50
+ '/security/scans',
51
+ api.requireBuilder,
52
+ xenosDegradedPath,
53
+ api.requireRank('metic', 'archon'),
54
+ async (req, res) => {
55
+ if (validateOrRespond(req, res, {
56
+ target_url: { required: true, type: 'string', maxLength: 2048, minLength: 9 },
57
+ artifact_kind: { required: true, type: 'string', enum: [...scanStore.ARTIFACT_KINDS] },
58
+ resolved_sha: { type: 'string', maxLength: 40 },
59
+ content_hash: { type: 'string', maxLength: 64 },
60
+ file_count: { type: 'integer', min: 0 },
61
+ total_bytes: { type: 'integer', min: 0 },
62
+ tier: { required: true, type: 'string', enum: [...scanStore.TIERS] },
63
+ verdict: { type: 'string', enum: [...scanStore.VERDICTS] },
64
+ completion: { required: true, type: 'string', enum: [...scanStore.COMPLETIONS] },
65
+ abort_code: { type: 'string', maxLength: 64 },
66
+ findings_critical: { type: 'integer', min: 0 },
67
+ findings_high: { type: 'integer', min: 0 },
68
+ findings_medium: { type: 'integer', min: 0 },
69
+ findings_low: { type: 'integer', min: 0 },
70
+ findings_summary: { type: 'array' },
71
+ action_taken: { type: 'string', enum: [...scanStore.ACTIONS] },
72
+ engine_version: { required: true, type: 'string', maxLength: 64, minLength: 1 },
73
+ })) return;
74
+
75
+ const norm = scanStore.normalizeScanPayload(req.body);
76
+ if (!norm.ok) return res.fail(norm.code, 400, norm.detail);
77
+
78
+ try {
79
+ const scan = await scanStore.insertScan(norm.value, req.builder.id);
80
+ res.status(201).json({ ok: true, scan });
81
+ } catch (err) {
82
+ console.error('[gds] POST /security/scans', err);
83
+ res.fail('scan_record_failed', 500);
84
+ }
85
+ }
86
+ );
87
+
88
+ // GET /api/bongos/security/scans?content_hash=<64 hex>
89
+ //
90
+ // "Have these exact bytes been scanned before, and what did the scan say?"
91
+ // Newest first. Rows carry structure only, so reading them can never expose
92
+ // the reader to the scanned artifact's content.
93
+ //
94
+ // rank: any-builder — deliberately open to every rank (see header).
95
+ router.get(
96
+ '/security/scans',
97
+ api.requireBuilder,
98
+ async (req, res) => {
99
+ const contentHash = String(req.query.content_hash || '').toLowerCase();
100
+ if (!/^[0-9a-f]{64}$/.test(contentHash)) {
101
+ return res.fail('bad_content_hash', 400, { expected: '64 lowercase hex chars' });
102
+ }
103
+ try {
104
+ const scans = await scanStore.findScans({ contentHash });
105
+ res.set('Cache-Control', 'no-store');
106
+ res.json({ scans });
107
+ } catch (err) {
108
+ console.error('[gds] GET /security/scans', err);
109
+ res.fail('scan_list_failed', 500);
110
+ }
111
+ }
112
+ );
113
+
114
+ return router;
115
+ };