@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,250 @@
1
+ // modules/security/tests/scan_store.mjs
2
+ //
3
+ // Task 1002547 (goal 1000055) — the scan-record store's PURE validation seam.
4
+ // The security contract under test:
5
+ //
6
+ // * STRUCTURE ONLY, NEVER CONTENT — a findings_summary is a list of one-line,
7
+ // length-capped, known-keys-only claims; anything shaped like quoted target
8
+ // content (multi-line, fence-marked, unknown keys, oversized) is rejected,
9
+ // and every string is scrubbed of dangerous codepoints before it can land;
10
+ // * an ABORTED scan is recordable, and the verdict/completion coupling is
11
+ // enforced before the DB ever sees the row (a dead scan must never read as
12
+ // a passing one);
13
+ // * target_host is DERIVED from the validated URL, never trusted from the
14
+ // caller.
15
+ //
16
+ // DB-free — only the pure normalize/validate seam is exercised, so this runs
17
+ // in `node scripts/gds/run-unit-tests.js`.
18
+ // Run directly: node modules/security/tests/scan_store.mjs
19
+
20
+ import { strict as assert } from 'node:assert';
21
+ import { createRequire } from 'node:module';
22
+ import fs from 'node:fs';
23
+ import path from 'node:path';
24
+ import { fileURLToPath } from 'node:url';
25
+
26
+ const require = createRequire(import.meta.url);
27
+ const store = require('../scan-store.js');
28
+ const { normalizeScanPayload, SUMMARY_LIMITS } = store;
29
+ const { FENCE_MARKER } = require('../artifact-scan/fence-local.js');
30
+
31
+ let passed = 0;
32
+ let failed = 0;
33
+ function test(name, fn) {
34
+ try { fn(); passed++; console.log(` ok ${name}`); }
35
+ catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.message}`); }
36
+ }
37
+
38
+ function goodPayload(over = {}) {
39
+ return {
40
+ target_url: 'https://github.com/example/repo',
41
+ artifact_kind: 'repo',
42
+ resolved_sha: 'a'.repeat(40),
43
+ content_hash: 'b'.repeat(64),
44
+ file_count: 12,
45
+ total_bytes: 34567,
46
+ tier: 'default',
47
+ verdict: 'caution',
48
+ completion: 'complete',
49
+ findings_critical: 0,
50
+ findings_high: 1,
51
+ findings_medium: 2,
52
+ findings_low: 0,
53
+ findings_summary: [
54
+ { code: 'net_sink', severity: 'high', file: 'src/post.js', message: 'same-file read-then-send co-occurrence' },
55
+ ],
56
+ engine_version: 'scan-engine/0.1.0',
57
+ action_taken: 'none',
58
+ };
59
+ }
60
+
61
+ // ---------- the happy path ----------
62
+
63
+ test('a complete scan payload normalizes, with target_host derived from the URL', () => {
64
+ const r = normalizeScanPayload(goodPayload());
65
+ assert.equal(r.ok, true, JSON.stringify(r));
66
+ assert.equal(r.value.target_host, 'github.com');
67
+ assert.equal(r.value.verdict, 'caution');
68
+ });
69
+
70
+ test('a caller-supplied target_host is ignored — the host comes from the validated URL only', () => {
71
+ const r = normalizeScanPayload({ ...goodPayload(), target_host: 'trusted-looking.example' });
72
+ assert.equal(r.ok, true, JSON.stringify(r));
73
+ assert.equal(r.value.target_host, 'github.com');
74
+ });
75
+
76
+ // ---------- an aborted scan is recordable; the coupling is enforced ----------
77
+
78
+ test('an aborted scan with no verdict is recordable', () => {
79
+ const p = goodPayload();
80
+ delete p.verdict;
81
+ p.completion = 'aborted';
82
+ p.abort_code = 'clone_timeout';
83
+ const r = normalizeScanPayload(p);
84
+ assert.equal(r.ok, true, JSON.stringify(r));
85
+ assert.equal(r.value.verdict, null);
86
+ assert.equal(r.value.abort_code, 'clone_timeout');
87
+ });
88
+
89
+ test('a complete scan WITHOUT a verdict is rejected — silence must never read as passing', () => {
90
+ const p = goodPayload();
91
+ delete p.verdict;
92
+ const r = normalizeScanPayload(p);
93
+ assert.equal(r.ok, false);
94
+ assert.equal(r.code, 'verdict_completion_mismatch');
95
+ });
96
+
97
+ test('an aborted scan WITH a verdict is rejected — a dead scan cannot carry a conclusion', () => {
98
+ const p = goodPayload();
99
+ p.completion = 'aborted';
100
+ const r = normalizeScanPayload(p);
101
+ assert.equal(r.ok, false);
102
+ assert.equal(r.code, 'verdict_completion_mismatch');
103
+ });
104
+
105
+ test('an incomplete scan follows the same coupling: no verdict OK, with verdict rejected', () => {
106
+ const base = goodPayload();
107
+ delete base.verdict;
108
+ const ok = normalizeScanPayload({ ...base, completion: 'incomplete' });
109
+ assert.equal(ok.ok, true, JSON.stringify(ok));
110
+ assert.equal(ok.value.verdict, null);
111
+ const bad = normalizeScanPayload({ ...goodPayload(), completion: 'incomplete' });
112
+ assert.equal(bad.ok, false);
113
+ assert.equal(bad.code, 'verdict_completion_mismatch');
114
+ });
115
+
116
+ // ---------- identity fields ----------
117
+
118
+ test('a URL carrying embedded credentials is REJECTED — a token must never land in an all-ranks row', () => {
119
+ const r = normalizeScanPayload({ ...goodPayload(), target_url: 'https://ghp_secret123@github.com/org/private' });
120
+ assert.equal(r.ok, false);
121
+ assert.equal(r.code, 'bad_target_url');
122
+ });
123
+
124
+ test('the stored target_url is the PARSED serialization, never the raw caller string', () => {
125
+ const r = normalizeScanPayload({ ...goodPayload(), target_url: 'https://github.com/Example/Repo' });
126
+ assert.equal(r.ok, true, JSON.stringify(r));
127
+ assert.equal(r.value.target_url, new URL('https://github.com/Example/Repo').href);
128
+ });
129
+
130
+ test('a non-https target URL is rejected', () => {
131
+ const r = normalizeScanPayload({ ...goodPayload(), target_url: 'http://github.com/x/y' });
132
+ assert.equal(r.ok, false);
133
+ assert.equal(r.code, 'bad_target_url');
134
+ });
135
+
136
+ test('a malformed content_hash is rejected', () => {
137
+ const r = normalizeScanPayload({ ...goodPayload(), content_hash: 'not-hex' });
138
+ assert.equal(r.ok, false);
139
+ assert.equal(r.code, 'bad_content_hash');
140
+ });
141
+
142
+ test('a malformed resolved_sha is rejected', () => {
143
+ const r = normalizeScanPayload({ ...goodPayload(), resolved_sha: 'main' });
144
+ assert.equal(r.ok, false);
145
+ assert.equal(r.code, 'bad_resolved_sha');
146
+ });
147
+
148
+ test('bad enum values are rejected: tier, verdict, kind, action', () => {
149
+ for (const [field, value] of [
150
+ ['tier', 'ultra'], ['verdict', 'fine'], ['artifact_kind', 'zip'], ['action_taken', 'installed'],
151
+ ]) {
152
+ const r = normalizeScanPayload({ ...goodPayload(), [field]: value });
153
+ assert.equal(r.ok, false, `${field}=${value} must be rejected`);
154
+ }
155
+ });
156
+
157
+ // ---------- findings_summary: structure only, never content ----------
158
+
159
+ test('a multi-line summary message is rejected — one-LINE claims only', () => {
160
+ const p = goodPayload();
161
+ p.findings_summary = [{ code: 'x', severity: 'low', file: 'a.js', message: 'line one\nline two of quoted payload' }];
162
+ const r = normalizeScanPayload(p);
163
+ assert.equal(r.ok, false);
164
+ assert.equal(r.code, 'summary_not_one_line');
165
+ });
166
+
167
+ test('an unknown summary item key is rejected — no side channel for quoted content', () => {
168
+ const p = goodPayload();
169
+ p.findings_summary = [{ code: 'x', severity: 'low', file: 'a.js', message: 'ok', excerpt: 'quoted target bytes' }];
170
+ const r = normalizeScanPayload(p);
171
+ assert.equal(r.ok, false);
172
+ assert.equal(r.code, 'summary_unknown_key');
173
+ });
174
+
175
+ test('a summary string carrying the mirror fence marker is rejected as quoted content', () => {
176
+ const p = goodPayload();
177
+ p.findings_summary = [{ code: 'x', severity: 'low', file: 'a.js', message: `saw ${FENCE_MARKER} here` }];
178
+ const r = normalizeScanPayload(p);
179
+ assert.equal(r.ok, false);
180
+ assert.equal(r.code, 'summary_quoted_content');
181
+ });
182
+
183
+ test('dangerous codepoints are scrubbed out of summary strings before they can land', () => {
184
+ const p = goodPayload();
185
+ p.findings_summary = [{ code: 'x', severity: 'low', file: 'a.js', message: `igno${String.fromCodePoint(0xfeff)}re this` }];
186
+ const r = normalizeScanPayload(p);
187
+ assert.equal(r.ok, true, JSON.stringify(r));
188
+ assert.equal(r.value.findings_summary[0].message, 'ignore this');
189
+ });
190
+
191
+ test('too many summary items are rejected', () => {
192
+ const p = goodPayload();
193
+ p.findings_summary = Array.from({ length: SUMMARY_LIMITS.maxItems + 1 }, (_, i) => ({
194
+ code: 'x', severity: 'low', file: `f${i}.js`, message: 'm',
195
+ }));
196
+ const r = normalizeScanPayload(p);
197
+ assert.equal(r.ok, false);
198
+ assert.equal(r.code, 'summary_too_many_items');
199
+ });
200
+
201
+ test('an over-long summary message is rejected', () => {
202
+ const p = goodPayload();
203
+ p.findings_summary = [{ code: 'x', severity: 'low', file: 'a.js', message: 'm'.repeat(SUMMARY_LIMITS.maxMessageChars + 1) }];
204
+ const r = normalizeScanPayload(p);
205
+ assert.equal(r.ok, false);
206
+ assert.equal(r.code, 'summary_string_too_long');
207
+ });
208
+
209
+ test('a summary item severity outside the four levels is rejected', () => {
210
+ const p = goodPayload();
211
+ p.findings_summary = [{ code: 'x', severity: 'catastrophic', file: 'a.js', message: 'm' }];
212
+ const r = normalizeScanPayload(p);
213
+ assert.equal(r.ok, false);
214
+ assert.equal(r.code, 'summary_bad_severity');
215
+ });
216
+
217
+ test('negative findings counts are rejected', () => {
218
+ const r = normalizeScanPayload({ ...goodPayload(), findings_high: -1 });
219
+ assert.equal(r.ok, false);
220
+ assert.equal(r.code, 'bad_counts');
221
+ });
222
+
223
+ // ---------- the route file: rank gates stated where fitness can see them ----------
224
+
225
+ const here = path.dirname(fileURLToPath(import.meta.url));
226
+ const routeSrc = fs.readFileSync(path.join(here, '..', 'routes', 'scans.js'), 'utf8');
227
+
228
+ test("the POST is gated api.requireRank('metic', 'archon') — a verdict-shaped write is a trust-shaped write", () => {
229
+ assert.match(routeSrc, /router\.post\(\s*'\/security\/scans'[\s\S]{0,400}?api\.requireRank\('metic',\s*'archon'\)/);
230
+ });
231
+
232
+ test('the GET is requireBuilder at every rank — the newest builder needs the warning most', () => {
233
+ assert.match(routeSrc, /router\.get\(\s*'\/security\/scans'[\s\S]{0,200}?api\.requireBuilder/);
234
+ const getBlock = routeSrc.slice(routeSrc.indexOf("router.get("));
235
+ assert.ok(!/requireRank/.test(getBlock.slice(0, 300)), 'the read side must not be rank-gated');
236
+ });
237
+
238
+ test('a Xenos POST gets a DEFINED degraded path, not a bare 403', () => {
239
+ assert.match(routeSrc, /degraded/i);
240
+ assert.match(routeSrc, /report file/i);
241
+ });
242
+
243
+ test('module.json mounts the scans route', () => {
244
+ const manifest = JSON.parse(fs.readFileSync(path.join(here, '..', 'module.json'), 'utf8'));
245
+ assert.ok(manifest.contributes.routes.includes('scans'));
246
+ assert.ok(manifest.contributes.routes.includes('reports'), 'reports must stay mounted');
247
+ });
248
+
249
+ console.log(`\nscan_store: ${passed} passed, ${failed} failed`);
250
+ if (failed > 0) process.exit(1);
@@ -0,0 +1,116 @@
1
+ // modules/security/tests/tool_scans_migration.mjs
2
+ //
3
+ // Structural guards on security_001_tool_scans.sql. These are not a substitute
4
+ // for applying it — they are the invariants that must survive any later edit,
5
+ // checked in the DB-free gate where a live Postgres is not available.
6
+ //
7
+ // The load-bearing one is the verdict/completion coupling: an aborted scan must
8
+ // be unable to carry a verdict, and a complete one must be unable to omit it.
9
+ // Without that constraint a scan that died could be read as one that passed,
10
+ // which is the single most dangerous confusion this whole feature can produce.
11
+ //
12
+ // Run directly: node modules/security/tests/tool_scans_migration.mjs
13
+
14
+ import { strict as assert } from 'node:assert';
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+
19
+ const here = path.dirname(fileURLToPath(import.meta.url));
20
+ const SQL_PATH = path.join(here, '..', 'migrations', 'security_001_tool_scans.sql');
21
+ const sql = fs.readFileSync(SQL_PATH, 'utf8');
22
+ // Comments carry prose that would otherwise trip the keyword scans below.
23
+ const code = sql.replace(/--[^\n]*/g, '');
24
+
25
+ let passed = 0;
26
+ let failed = 0;
27
+ function test(name, fn) {
28
+ try { fn(); passed++; console.log(` ok ${name}`); }
29
+ catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.message}`); }
30
+ }
31
+
32
+ test('every created object is security_-namespaced (ADR 0083 §Decision #5)', () => {
33
+ const created = [...code.matchAll(/CREATE\s+(?:TABLE|INDEX|VIEW|TYPE|SEQUENCE)\s+(?:IF\s+NOT\s+EXISTS\s+)?([a-z_][a-z0-9_]*)/gi)]
34
+ .map((m) => m[1]);
35
+ assert.ok(created.length >= 4, `expected the table + its indexes, saw ${created.length}`);
36
+ for (const name of created) {
37
+ assert.ok(name.startsWith('security_'), `${name} is outside the security_ namespace`);
38
+ }
39
+ });
40
+
41
+ test('additive only — no destructive DDL', () => {
42
+ for (const re of [/\bDROP\s+(TABLE|COLUMN|INDEX|CONSTRAINT|TYPE)\b/i, /\bTRUNCATE\b/i, /\bALTER\s+TABLE\s+\w+\s+DROP\b/i]) {
43
+ assert.ok(!re.test(code), `destructive DDL found: ${re}`);
44
+ }
45
+ });
46
+
47
+ test('idempotent — table and every index use IF NOT EXISTS', () => {
48
+ assert.match(code, /CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+security_tool_scans/i);
49
+ const indexes = [...code.matchAll(/CREATE\s+INDEX(\s+IF\s+NOT\s+EXISTS)?/gi)];
50
+ assert.ok(indexes.length >= 3, `expected at least 3 indexes, saw ${indexes.length}`);
51
+ for (const m of indexes) {
52
+ assert.ok(m[1], 'an index is missing IF NOT EXISTS — the migration would fail on re-run');
53
+ }
54
+ });
55
+
56
+ test('runs in a single transaction', () => {
57
+ assert.match(code, /^\s*BEGIN;/m);
58
+ assert.match(code, /^\s*COMMIT;/m);
59
+ });
60
+
61
+ test('carries every column the scan record needs', () => {
62
+ for (const col of [
63
+ 'target_url', 'target_host', 'artifact_kind', 'resolved_sha', 'content_hash',
64
+ 'file_count', 'total_bytes', 'tier', 'verdict', 'completion', 'abort_code',
65
+ 'findings_critical', 'findings_high', 'findings_medium', 'findings_low',
66
+ 'findings_summary', 'action_taken', 'engine_version', 'scanned_by_builder_id',
67
+ 'created_at', 'updated_at',
68
+ ]) {
69
+ assert.ok(new RegExp(`\\b${col}\\b`).test(code), `missing column: ${col}`);
70
+ }
71
+ });
72
+
73
+ test('an aborted scan can never carry a verdict, and a complete one can never omit it', () => {
74
+ // The invariant that stops "the scan died" from ever reading as "it passed".
75
+ assert.match(code, /CHECK\s*\(\s*\(completion\s*=\s*'complete'\)\s*=\s*\(verdict\s+IS\s+NOT\s+NULL\)\s*\)/i);
76
+ });
77
+
78
+ test('completion records an aborted scan rather than losing it', () => {
79
+ assert.match(code, /completion\s+IN\s*\('complete',\s*'aborted',\s*'incomplete'\)/i);
80
+ });
81
+
82
+ test('verdict is constrained to the three honest values', () => {
83
+ assert.match(code, /verdict\s+IN\s*\('clean',\s*'caution',\s*'dangerous'\)/i);
84
+ });
85
+
86
+ test('the low tier is a recorded value, so a verdict is always read in tier context', () => {
87
+ assert.match(code, /tier\s+IN\s*\('low',\s*'default',\s*'high',\s*'xhigh',\s*'max'\)/i);
88
+ });
89
+
90
+ test('findings_summary is size-capped at the DB layer', () => {
91
+ // Defense in depth with the route: a hostile artifact must not push unbounded
92
+ // text into a field every future reader of this table renders.
93
+ assert.match(code, /length\(findings_summary::text\)\s*<=\s*\d+/i);
94
+ });
95
+
96
+ test('content_hash and scanner are indexed — the two lookups the table exists for', () => {
97
+ assert.match(code, /CREATE\s+INDEX\s+IF\s+NOT\s+EXISTS\s+security_tool_scans_content_hash_idx[\s\S]*?\(content_hash\)/i);
98
+ assert.match(code, /CREATE\s+INDEX\s+IF\s+NOT\s+EXISTS\s+security_tool_scans_scanner_idx[\s\S]*?\(scanned_by_builder_id/i);
99
+ });
100
+
101
+ test('a deleted builder does not delete the scan record', () => {
102
+ // Attribution is nullable; the security finding outlives the account.
103
+ assert.match(code, /scanned_by_builder_id\s+bigint\s+REFERENCES\s+builders\(id\)\s+ON\s+DELETE\s+SET\s+NULL/i);
104
+ });
105
+
106
+ test('findings_summary must be a JSON array, not a smuggled scalar or object', () => {
107
+ assert.match(code, /jsonb_typeof\(findings_summary\)\s*=\s*'array'/i);
108
+ });
109
+
110
+ test('updated_at is live — rows mutate post-insert (action_taken), so a touch trigger exists', () => {
111
+ assert.match(code, /CREATE\s+OR\s+REPLACE\s+FUNCTION\s+security_tool_scans_touch_updated_at\(\)/i);
112
+ assert.match(code, /CREATE\s+TRIGGER\s+security_tool_scans_updated_at\s+BEFORE\s+UPDATE\s+ON\s+security_tool_scans/i);
113
+ });
114
+
115
+ console.log(`\ntool_scans_migration: ${passed} passed, ${failed} failed`);
116
+ if (failed > 0) process.exit(1);
@@ -65,10 +65,11 @@ const MAX_DETAIL_TURNS = 5000; // a hard ceiling; ship.js bounds well under this
65
65
  // also a follow-up — see SR-17 notes.
66
66
  const MAX_DETAIL_ELEMENT_BYTES = 4 * 1024; // 4 KB per detail[] element
67
67
  const MAX_DETAIL_TOTAL_BYTES = 2 * 1024 * 1024; // 2 MB total detail[] payload
68
- // The three LIVE ranks (ADR 0018) — deliberately NOT db.RANKS, which is the full
69
- // 16-entry historical enum. Only these three are ever assigned to a builder, so
70
- // the search filter validates against them (a query for any other rank is a 400).
71
- const VALID_RANKS = new Set(['xenos', 'thetes', 'metic', 'archon']);
68
+ // The LIVE ranks — deliberately NOT db.RANKS, which is the full 16-entry
69
+ // historical enum. Only the live ladder is ever assigned to a builder, so the
70
+ // search filter validates against it (a query for any other rank is a 400).
71
+ // Derived from the doorway ladder (BV1.R108) rather than hand-copied.
72
+ const VALID_RANKS = new Set(api.LIVE_RANK_LADDER);
72
73
 
73
74
  // Per-builder read limiter for the session corpus (mirrors the memory router's
74
75
  // forensic-read limiter). Reads aren't covered by the global write limiter.
@@ -270,7 +271,7 @@ module.exports = function buildSessionsRouter() {
270
271
  // Express matches the literal paths first (the numeric :id never swallows them).
271
272
 
272
273
  // rank: archon — cross-builder corpus search (BFG-principal posture until 6C.1).
273
- router.get('/sessions/search', auth.requireBuilder, auth.requireRank('archon'), readRateLimit, async (req, res) => {
274
+ router.get('/sessions/search', auth.requireBuilder, auth.requirePermission('session.search'), readRateLimit, async (req, res) => {
274
275
  const q = req.query || {};
275
276
  const rank = q.rank ? String(q.rank) : null;
276
277
  if (rank && !VALID_RANKS.has(rank)) {
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.18.19",
3
+ "version": "1.19.3",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.18.19",
9
+ "version": "1.19.3",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.18.19",
3
+ "version": "1.19.3",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -700,9 +700,13 @@ input[type=text].bad{border-color:var(--err);}
700
700
  $('ghFresh').hidden = !(ok && !adopt);
701
701
  $('manualHome').hidden = !isManualHome();
702
702
  if (need) {
703
+ // Informed consent (ADR 0155): this grants `repo`, which GitHub's own screen
704
+ // words as full control of ALL your repositories — so say plainly why we ask
705
+ // and what we do with it, rather than letting GitHub's wording be the first
706
+ // the user hears of it. Private repos are the reason the scope is this wide.
703
707
  $('ghConnectMsg').textContent = adopt
704
- ? 'You’re signed in — creating your project needs one more GitHub permission. Allow repo access to pick the repository to adopt.'
705
- : 'You’re signed in — creating your project needs one more GitHub permission. Allow repo access and we’ll create the empty repo for you.';
708
+ ? 'You’re signed in — creating your project needs one more GitHub permission. GitHub will ask for repository access (it words this as access to all your repositories — that is the only scope it offers that can write to a private repo). We use it to read the repo you pick and push the setup commit to it. Nothing else is touched.'
709
+ : 'You’re signed in — creating your project needs one more GitHub permission. GitHub will ask for repository access (it words this as access to all your repositories — the narrower option cannot write to private repos). We use it to create your new empty repo and push the setup commit to it. Nothing else is touched.';
706
710
  }
707
711
  if (ok && adopt) initAdoptPicker();
708
712
  if (ok && !adopt) syncFresh();
@@ -934,7 +938,13 @@ input[type=text].bad{border-color:var(--err);}
934
938
  slug: state.slug,
935
939
  hosting_shape: 'standalone',
936
940
  domain: state.domain || undefined,
937
- target_ref: state.owner + '/' + state.repo
941
+ target_ref: state.owner + '/' + state.repo,
942
+ /* LOAD-BEARING for the control-plane runner, not just for the checklist we
943
+ render (task 1002562): 'adopt' makes it clone + layer onto the repo's
944
+ existing history instead of pushing a fresh tree, which a repo that already
945
+ has commits rejects as unrelated history. state.mode already tracked
946
+ greenfield|adopt for the UI — it has to reach the server too. */
947
+ onboard_mode: state.mode === 'adopt' ? 'adopt' : 'greenfield'
938
948
  })
939
949
  });
940
950
  }).then(function (res) {
@@ -41,7 +41,7 @@ const { cliClient, requireSession, arg } = require('./cli-lib');
41
41
  } else if (r.data?.error === 'task_not_found') {
42
42
  console.error(`Task #${taskId} not found.`);
43
43
  } else if (r.status === 403) {
44
- console.error('Abandoning a task is Archon-only.');
44
+ console.error('Abandoning a task needs Metic+ rank (ADR 0152).');
45
45
  } else {
46
46
  console.error(`abandon failed (${r.status}):`, r.data);
47
47
  }
@@ -126,6 +126,12 @@ ALLOWLIST=(
126
126
  # That's the GitHub org/user that owns the repo; not a builder identity.
127
127
  'scripts/deploy/provision-staging.sh'
128
128
 
129
+ # CODEOWNERS is GitHub's owner-review list (task 1002611 / ADR 0159): the
130
+ # format requires the literal @username on every entry. Repo-hosting
131
+ # identity, same nature as provision-staging.sh — not a builder identity
132
+ # hardcoded in code.
133
+ 'CODEOWNERS'
134
+
129
135
  # Dev Box app distribution (#904, ADR 0044): the PUBLIC releases repo lives
130
136
  # under the same GitHub account that owns the main repo — repo-hosting
131
137
  # identity (same nature as provision-staging.sh above), not a builder
@@ -1051,7 +1051,7 @@ async function cmdSweepDormant(pool, boxes, { apply }) {
1051
1051
  try {
1052
1052
  await deprovisionOne(pool, boxes, doApi, box, { actor: 'sweep:dormant' });
1053
1053
  reclaimed++;
1054
- const ping = postReclaimPing(`@${box.github_login} — your dev box was idle for ${days} days and has been reclaimed to save cost. Ask an Archon to re-provision when you're back; your memory is server-side and unaffected.`);
1054
+ const ping = postReclaimPing(`@${box.github_login} — your dev box was idle for ${days} days and has been reclaimed to save cost. Queue a fresh one anytime with POST /api/gds/box/ensure (no operator needed); your memory is server-side and unaffected.`);
1055
1055
  if (!ping.ok) pingFailed++;
1056
1056
  console.log(` ✓ reclaimed @${box.github_login} (parked ${days}d)${ping.ok ? '' : ' [chat ping failed]'}`);
1057
1057
  } catch (e) { errors++; await markErrorUnlessDestroyed(pool, boxes, box.builder_id, e.message); console.log(` ✖ @${box.github_login}: ${e.message}`); }
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env node
2
+ // scripts/gds/bump-version.js — the mechanical CORE_VERSION patch bump for the
3
+ // publish-on-merge lane (ADR 0161, task 1002620).
4
+ //
5
+ // WHY THIS EXISTS. ADR 0161 makes every merge to main publishable: when a merge
6
+ // lands without a human version bump, CI bumps a PATCH itself so the release
7
+ // has a unique version. The bump edits src/module-api.js in the two places the
8
+ // repo's version discipline lives — the `const CORE_VERSION` literal and the
9
+ // changelog comment block above it — because a bare literal change with no
10
+ // changelog line is exactly the drift the discipline exists to prevent.
11
+ //
12
+ // POLICY (deliberately narrow): this script only ever bumps PATCH. A MINOR or
13
+ // MAJOR is a statement about the doorway surface that only a human (or a far
14
+ // smarter check) can make — it stays an in-task hand edit, exactly as before.
15
+ //
16
+ // SHAPE-PRESERVING: deploy-divergence.js finds bump commits with
17
+ // `git log -G"^const CORE_VERSION = "` and reads the literal with a regex; the
18
+ // rewrite here keeps that exact line shape. tests/bump_version.mjs pins this.
19
+ //
20
+ // Usage:
21
+ // node scripts/gds/bump-version.js # dry-run: print the plan
22
+ // node scripts/gds/bump-version.js --apply # rewrite src/module-api.js
23
+ // node scripts/gds/bump-version.js --apply --note "run 123456" # extra provenance
24
+ // stdout (both modes): one JSON line { from, to, applied } for CI to capture.
25
+
26
+ 'use strict';
27
+
28
+ const fs = require('node:fs');
29
+ const path = require('node:path');
30
+
31
+ const TARGET = path.join(__dirname, '..', '..', 'src', 'module-api.js');
32
+ const CONST_RE = /^const CORE_VERSION = '(\d+)\.(\d+)\.(\d+)';.*$/m;
33
+ // The changelog block's closing divider sits immediately above the const line.
34
+ const DIVIDER = '// ---------------------------------------------------------------------------';
35
+
36
+ // Pure transform: (source text, optional note) -> { from, to, text }.
37
+ // Throws with a precise message on any shape it does not recognize — a silent
38
+ // mis-edit of the version literal is the worst possible failure mode here.
39
+ function bumpPatch(src, { note = '' } = {}) {
40
+ const m = src.match(CONST_RE);
41
+ if (!m) throw new Error(`bump-version: could not find the CORE_VERSION literal in ${TARGET} (expected: const CORE_VERSION = 'x.y.z';)`);
42
+ const [major, minor, patch] = [Number(m[1]), Number(m[2]), Number(m[3])];
43
+ const from = `${major}.${minor}.${patch}`;
44
+ const to = `${major}.${minor}.${patch + 1}`;
45
+
46
+ // 1. The changelog entry — inserted just above the divider that closes the
47
+ // changelog block, matching the existing two-line entry style.
48
+ const constLineStart = src.search(CONST_RE);
49
+ const dividerStart = src.lastIndexOf(DIVIDER, constLineStart);
50
+ if (dividerStart === -1) throw new Error('bump-version: could not find the changelog closing divider above CORE_VERSION');
51
+ const noteSuffix = note ? ` ${note}.` : '';
52
+ const entry =
53
+ `// ${to} — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges\n` +
54
+ `// landed since ${from} with no explicit bump.${noteSuffix} (task 1002620)\n`;
55
+
56
+ // 2. The literal — same line shape deploy-divergence greps for.
57
+ const newConstLine = `const CORE_VERSION = '${to}'; // CI auto-patch carrier (ADR 0161); see changelog above`;
58
+
59
+ const text = src.slice(0, dividerStart) + entry + src.slice(dividerStart).replace(CONST_RE, newConstLine);
60
+ return { from, to, text };
61
+ }
62
+
63
+ function main() {
64
+ const apply = process.argv.includes('--apply');
65
+ const noteIdx = process.argv.indexOf('--note');
66
+ const note = noteIdx !== -1 ? (process.argv[noteIdx + 1] || '') : '';
67
+
68
+ const src = fs.readFileSync(TARGET, 'utf8');
69
+ const { from, to, text } = bumpPatch(src, { note });
70
+ if (apply) fs.writeFileSync(TARGET, text);
71
+ else console.error(`bump-version: would bump ${from} → ${to} (dry-run; pass --apply to write)`);
72
+ console.log(JSON.stringify({ from, to, applied: apply }));
73
+ }
74
+
75
+ module.exports = { bumpPatch, CONST_RE };
76
+ if (require.main === module) main();
@@ -210,6 +210,21 @@ function formatClaimFailure(r, taskId) {
210
210
  lines.push(` ${d.what_to_do || 'Ship or release the current claim first.'}`);
211
211
  return { lines, exit: 1 };
212
212
  }
213
+ // task 1002589: the per-session DB backstop index tripped. Distinct from
214
+ // session_already_has_active_claim (the route guard's own refusal): since
215
+ // core_204 (task 1002570) the index keys per (session, worktree) —
216
+ // matching the guard — so this mainly surfaces the same-tree race the guard
217
+ // couldn't see, or fires on a DB still carrying the migration-180
218
+ // session-only index. Either way the target task itself is NOT taken — never
219
+ // render this as "claimed by another session".
220
+ if (code === 'session_already_has_bound_claim') {
221
+ const n = d.active_task_id;
222
+ lines.push(n
223
+ ? `Task #${taskId} is still free — but this session already holds a bound claim on #${n}${d.active_task_title ? `: ${d.active_task_title}` : ''}.`
224
+ : `Task #${taskId} is still free — but this session already holds a bound claim on another task.`);
225
+ lines.push(` ${d.what_to_do || `Run /builder-ship ${n || '<task>'} (or /builder-release ${n || '<task>'}) first, claim from a separate worktree, or claim with {"bind_session":false} (an unbound claim always coexists).`}`);
226
+ return { lines, exit: 1 };
227
+ }
213
228
  if (code === 'ALREADY_CLAIMED') {
214
229
  lines.push(`Task #${taskId} was just claimed by another session. Try a different task.`);
215
230
  return { lines, exit: 1 };
@@ -250,10 +250,22 @@ function printUnauthGuidanceAndExit() {
250
250
 
251
251
  // task 1050 (C): is this response the dev-box bootstrap-scoped 403? A box's baked
252
252
  // session (source='box', #919/ADR 0053) may only call the 4 on-box bootstrap
253
- // endpoints; everything else (claim/ship/me/…) returns 403 { error: 'box_scope' }.
254
- // Pure + exported so the detector is unit-tested.
253
+ // endpoints; everything else (claim/ship/me/…) returns a 403 box_scope.
254
+ //
255
+ // task 1002162: accept BOTH body shapes. The live server emits the ADR 0116 (R01)
256
+ // envelope — `{ error: { code, message, details? } }` from api-errors.js
257
+ // `envelope()` — so `res.data.error` is an OBJECT and the old `=== 'box_scope'`
258
+ // string test could never match. Both call sites below were therefore dead code
259
+ // (box-scope was only ever caught by box-auth-check.js instead). Older/flat paths
260
+ // still return `{ error: 'box_scope' }`, so normalize the same way
261
+ // claim.js:formatClaimFailure does rather than swapping one shape for the other.
262
+ //
263
+ // Pure + exported so the detector is unit-tested against the real envelope.
255
264
  function isBoxScope(res) {
256
- return !!res && res.status === 403 && !!res.data && res.data.error === 'box_scope';
265
+ if (!res || res.status !== 403 || !res.data) return false;
266
+ const errRaw = res.data.error;
267
+ const code = errRaw !== null && typeof errRaw === 'object' ? errRaw.code : errRaw;
268
+ return code === 'box_scope';
257
269
  }
258
270
 
259
271
  let _boxScopeExitInFlight = false;
@@ -652,7 +664,7 @@ function printCostWarning(meBody) {
652
664
  const cost = meBody && meBody.cost;
653
665
  if (!cost || typeof cost !== 'object') return;
654
666
  if (cost.level === 'over') {
655
- console.log(`⚠ BUDGET EXCEEDED — $${cost.spend_mtd_usd} of $${cost.budget_mtd_usd} this month (${cost.pct_used}%). Cost-incurring actions (ship, art pipeline) are blocked until an Archon raises your budget.`);
667
+ console.log(`⚠ BUDGET EXCEEDED — $${cost.spend_mtd_usd} of $${cost.budget_mtd_usd} this month (${cost.pct_used}%). Cost-incurring actions (ship, art pipeline) are blocked until a Metic or Archon raises your budget.`);
656
668
  } else if (cost.level === 'warn') {
657
669
  console.log(`⚠ Budget at ${cost.pct_used}% — $${cost.spend_mtd_usd} of $${cost.budget_mtd_usd} this month. You'll be blocked from cost-incurring actions at 100%.`);
658
670
  }