@bongos/core 1.18.19 → 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 +514 -194
  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 +5 -5
  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 -13
  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 +1 -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,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
+ };
@@ -0,0 +1,214 @@
1
+ // modules/security/scan-store.js — the security_tool_scans domain (task
2
+ // 1002547, goal 1000055): one durable, queryable row per /scan-before-install
3
+ // run, so one builder's scan warns the next.
4
+ //
5
+ // STRUCTURE ONLY, NEVER TARGET CONTENT. Everything stored here is later
6
+ // rendered to whoever reads the table, so a quoted payload would make this
7
+ // table a delivery mechanism for the very injection the scan exists to catch.
8
+ // The normalize step therefore rejects anything shaped like quoted content —
9
+ // multi-line messages, unknown item keys, fence-marked strings, oversized
10
+ // summaries — and scrubs dangerous codepoints out of what remains. The DB's
11
+ // own constraints (size cap, array shape, verdict/completion coupling) are the
12
+ // second layer of the same rule.
13
+ //
14
+ // AN ABORTED SCAN IS RECORDABLE. completion='aborted'/'incomplete' with no
15
+ // verdict is a first-class row — a scan that died must be visible as "died,
16
+ // here is why", never laundered into a verdict or lost.
17
+ //
18
+ // The pure seam (normalizeScanPayload) is exported for the DB-free unit gate;
19
+ // insertScan/findScans are the thin SQL on top. Pool reaches through the
20
+ // published doorway; the scrub is an intra-module require of the vendored
21
+ // fence-local (a module never requires a sibling module or a core internal).
22
+
23
+ 'use strict';
24
+
25
+ const { pool } = require('../../src/module-api');
26
+ const { scrubUntrusted, FENCE_MARKER } = require('./artifact-scan/fence-local');
27
+
28
+ // Mirrors of the DB CHECK constraints — enforced here first so a bad payload
29
+ // gets a clean 400 with a named code instead of a raw constraint violation.
30
+ const TIERS = Object.freeze(['low', 'default', 'high', 'xhigh', 'max']);
31
+ const VERDICTS = Object.freeze(['clean', 'caution', 'dangerous']);
32
+ const COMPLETIONS = Object.freeze(['complete', 'aborted', 'incomplete']);
33
+ const ARTIFACT_KINDS = Object.freeze(['repo', 'plugin', 'marketplace']);
34
+ const ACTIONS = Object.freeze(['none', 'install_command_printed', 'sanitized_copy', 'rebuild_spec', 'declined']);
35
+ const SEVERITIES = Object.freeze(['critical', 'high', 'medium', 'low']);
36
+
37
+ // One-line claims only. maxSerializedBytes stays under the DB's 65536 cap so
38
+ // the route rejects before the constraint ever fires.
39
+ const SUMMARY_LIMITS = Object.freeze({
40
+ maxItems: 200,
41
+ maxCodeChars: 64,
42
+ maxFileChars: 300,
43
+ maxMessageChars: 300,
44
+ maxSerializedBytes: 60000,
45
+ });
46
+ const SUMMARY_ITEM_KEYS = Object.freeze(['code', 'severity', 'file', 'message']);
47
+
48
+ const HEX64 = /^[0-9a-f]{64}$/;
49
+ const SHA_GIT = /^[0-9a-f]{7,40}$/i;
50
+
51
+ const fail = (code, detail) => ({ ok: false, code, detail: detail ?? null });
52
+
53
+ // Scrub + one-line + length + no-fence-marker checks for one summary string.
54
+ // Returns { ok, value } | { ok:false, code }.
55
+ function cleanSummaryString(raw, maxChars) {
56
+ const s = scrubUntrusted(raw);
57
+ if (/[\r\n]/.test(s)) return fail('summary_not_one_line');
58
+ if (s.length > maxChars) return fail('summary_string_too_long', { limit: maxChars });
59
+ if (s.includes(FENCE_MARKER)) return fail('summary_quoted_content');
60
+ return { ok: true, value: s };
61
+ }
62
+
63
+ function nonNegInt(v) {
64
+ return Number.isInteger(v) && v >= 0;
65
+ }
66
+
67
+ // Validate + normalize one scan payload into the exact column shape insertScan
68
+ // writes. Pure — no DB, no I/O — so the whole contract is unit-testable.
69
+ // target_host is DERIVED from the validated URL; a caller-supplied host field
70
+ // is deliberately ignored (never trust the label over the address).
71
+ function normalizeScanPayload(body) {
72
+ const b = body || {};
73
+
74
+ let url;
75
+ try {
76
+ url = new URL(String(b.target_url));
77
+ } catch {
78
+ return fail('bad_target_url');
79
+ }
80
+ if (url.protocol !== 'https:') return fail('bad_target_url', { reason: 'https only' });
81
+ if (String(b.target_url).length > 2048) return fail('bad_target_url', { reason: 'too long' });
82
+ // Embedded userinfo (https://ghp_xxx@github.com/…) would persist a live
83
+ // credential into a row EVERY rank can read. Refuse it outright — and store
84
+ // the parsed serialization below, never the caller's raw string, so nothing
85
+ // the URL parser did not vouch for can reach the table.
86
+ if (url.username || url.password) return fail('bad_target_url', { reason: 'credentials in URL are never stored' });
87
+
88
+ if (!ARTIFACT_KINDS.includes(b.artifact_kind)) return fail('bad_artifact_kind', { allowed: ARTIFACT_KINDS });
89
+ if (!TIERS.includes(b.tier)) return fail('bad_tier', { allowed: TIERS });
90
+ if (!COMPLETIONS.includes(b.completion)) return fail('bad_completion', { allowed: COMPLETIONS });
91
+
92
+ const hasVerdict = b.verdict !== undefined && b.verdict !== null;
93
+ if (hasVerdict && !VERDICTS.includes(b.verdict)) return fail('bad_verdict', { allowed: VERDICTS });
94
+ // The invariant that keeps "the scan died" from ever being read as "it
95
+ // passed": complete ⟺ verdict present. Same rule the DB enforces.
96
+ if ((b.completion === 'complete') !== hasVerdict) return fail('verdict_completion_mismatch');
97
+
98
+ if (b.resolved_sha != null && !SHA_GIT.test(String(b.resolved_sha))) return fail('bad_resolved_sha');
99
+ if (b.content_hash != null && !HEX64.test(String(b.content_hash))) return fail('bad_content_hash');
100
+
101
+ if (b.action_taken != null && !ACTIONS.includes(b.action_taken)) return fail('bad_action_taken', { allowed: ACTIONS });
102
+ if (typeof b.engine_version !== 'string' || b.engine_version.length === 0 || b.engine_version.length > 64) {
103
+ return fail('bad_engine_version');
104
+ }
105
+ if (b.abort_code != null && (typeof b.abort_code !== 'string' || b.abort_code.length > 64)) {
106
+ return fail('bad_abort_code');
107
+ }
108
+
109
+ if (b.file_count != null && !nonNegInt(b.file_count)) return fail('bad_counts', { field: 'file_count' });
110
+ if (b.total_bytes != null && !nonNegInt(b.total_bytes)) return fail('bad_counts', { field: 'total_bytes' });
111
+ const counts = {};
112
+ for (const sev of ['critical', 'high', 'medium', 'low']) {
113
+ const v = b[`findings_${sev}`] ?? 0;
114
+ if (!nonNegInt(v)) return fail('bad_counts', { field: `findings_${sev}` });
115
+ counts[sev] = v;
116
+ }
117
+
118
+ const rawSummary = b.findings_summary ?? [];
119
+ if (!Array.isArray(rawSummary)) return fail('summary_not_array');
120
+ if (rawSummary.length > SUMMARY_LIMITS.maxItems) return fail('summary_too_many_items', { limit: SUMMARY_LIMITS.maxItems });
121
+ const summary = [];
122
+ for (const item of rawSummary) {
123
+ if (item == null || typeof item !== 'object' || Array.isArray(item)) return fail('summary_bad_item');
124
+ for (const key of Object.keys(item)) {
125
+ if (!SUMMARY_ITEM_KEYS.includes(key)) return fail('summary_unknown_key', { key });
126
+ }
127
+ if (!SEVERITIES.includes(item.severity)) return fail('summary_bad_severity', { allowed: SEVERITIES });
128
+ const code = cleanSummaryString(String(item.code ?? ''), SUMMARY_LIMITS.maxCodeChars);
129
+ if (!code.ok) return code;
130
+ const file = cleanSummaryString(String(item.file ?? ''), SUMMARY_LIMITS.maxFileChars);
131
+ if (!file.ok) return file;
132
+ const message = cleanSummaryString(String(item.message ?? ''), SUMMARY_LIMITS.maxMessageChars);
133
+ if (!message.ok) return message;
134
+ summary.push({ code: code.value, severity: item.severity, file: file.value, message: message.value });
135
+ }
136
+ const serialized = JSON.stringify(summary);
137
+ if (Buffer.byteLength(serialized, 'utf8') > SUMMARY_LIMITS.maxSerializedBytes) {
138
+ return fail('summary_too_large', { limit: SUMMARY_LIMITS.maxSerializedBytes });
139
+ }
140
+
141
+ if (url.href.length > 2048) return fail('bad_target_url', { reason: 'too long' });
142
+
143
+ return {
144
+ ok: true,
145
+ value: {
146
+ target_url: url.href,
147
+ target_host: url.hostname,
148
+ artifact_kind: b.artifact_kind,
149
+ resolved_sha: b.resolved_sha != null ? String(b.resolved_sha).toLowerCase() : null,
150
+ content_hash: b.content_hash != null ? String(b.content_hash) : null,
151
+ file_count: b.file_count ?? null,
152
+ total_bytes: b.total_bytes ?? null,
153
+ tier: b.tier,
154
+ verdict: hasVerdict ? b.verdict : null,
155
+ completion: b.completion,
156
+ abort_code: b.abort_code ?? null,
157
+ findings_critical: counts.critical,
158
+ findings_high: counts.high,
159
+ findings_medium: counts.medium,
160
+ findings_low: counts.low,
161
+ findings_summary: summary,
162
+ action_taken: b.action_taken ?? null,
163
+ engine_version: b.engine_version,
164
+ },
165
+ };
166
+ }
167
+
168
+ // ---------- SQL (thin — everything above already validated) ----------
169
+
170
+ async function insertScan(value, scannedByBuilderId) {
171
+ const { rows } = await pool.query(
172
+ `INSERT INTO security_tool_scans
173
+ (target_url, target_host, artifact_kind, resolved_sha, content_hash,
174
+ file_count, total_bytes, tier, verdict, completion, abort_code,
175
+ findings_critical, findings_high, findings_medium, findings_low,
176
+ findings_summary, action_taken, engine_version, scanned_by_builder_id)
177
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19)
178
+ RETURNING *`,
179
+ [
180
+ value.target_url, value.target_host, value.artifact_kind, value.resolved_sha, value.content_hash,
181
+ value.file_count, value.total_bytes, value.tier, value.verdict, value.completion, value.abort_code,
182
+ value.findings_critical, value.findings_high, value.findings_medium, value.findings_low,
183
+ JSON.stringify(value.findings_summary), value.action_taken, value.engine_version, scannedByBuilderId,
184
+ ]
185
+ );
186
+ return rows[0];
187
+ }
188
+
189
+ // "Have these exact bytes been scanned before?" — the read every rank gets.
190
+ // Explicit projection: exactly the columns the any-rank read surface intends,
191
+ // so a future column addition never widens this boundary by default.
192
+ async function findScans({ contentHash, limit = 20 }) {
193
+ const { rows } = await pool.query(
194
+ `SELECT id, target_url, target_host, artifact_kind, resolved_sha, content_hash,
195
+ file_count, total_bytes, tier, verdict, completion, abort_code,
196
+ findings_critical, findings_high, findings_medium, findings_low,
197
+ findings_summary, action_taken, engine_version, scanned_by_builder_id,
198
+ created_at, updated_at
199
+ FROM security_tool_scans
200
+ WHERE content_hash = $1
201
+ ORDER BY created_at DESC
202
+ LIMIT $2`,
203
+ [contentHash, Math.min(limit, 100)]
204
+ );
205
+ return rows;
206
+ }
207
+
208
+ module.exports = {
209
+ normalizeScanPayload,
210
+ insertScan,
211
+ findScans,
212
+ TIERS, VERDICTS, COMPLETIONS, ARTIFACT_KINDS, ACTIONS, SEVERITIES,
213
+ SUMMARY_LIMITS,
214
+ };