@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,158 @@
1
+ // modules/security/artifact-scan/mirror.js — the reading-room copy of a
2
+ // quarantined artifact (task 1002543, goal 1000055).
3
+ //
4
+ // Two copies of a fetched artifact exist in the quarantine: the PRISTINE copy
5
+ // no model is ever allowed to read, and this mirror — where every readable
6
+ // file has already been scrubbed of control/zero-width/bidi characters and
7
+ // wrapped in an on-disk fence before any agent can open it. The defense is
8
+ // structural: even a raw Read call against mirror/ returns data-never-
9
+ // instructions, with no dependence on any agent's discipline.
10
+ //
11
+ // What does NOT get mirrored becomes a STUB RECORD instead: symlinks (never
12
+ // followed), non-UTF8/binary files, oversize files, special files, and any
13
+ // path that escapes the pen lexically or physically. Stub bytes are counted as
14
+ // UNREVIEWED — downstream the reducer caps the verdict at caution over them,
15
+ // because static analysis cannot clear what it never read.
16
+ //
17
+ // The walk is driven by the MANIFEST entries, not a fresh readdir: the mirror
18
+ // renders exactly the inventory that was hashed, so "what we read" and "what
19
+ // we fingerprinted" can never drift apart.
20
+ //
21
+ // Confinement is checked twice per file, fail-closed:
22
+ // 1. lexically — a manifest path that is absolute or resolves above the
23
+ // pristine root is refused before any fs call touches it;
24
+ // 2. physically — the file's realpath must live under the quarantine root,
25
+ // so a real on-disk symlink smuggled past kind detection still cannot
26
+ // pull outside bytes into the mirror.
27
+ //
28
+ // Fully async (fs.promises) for the same reason as manifest.js: a sync walk at
29
+ // cap limits blocks the event loop for seconds.
30
+
31
+ 'use strict';
32
+
33
+ const fsp = require('node:fs/promises');
34
+ const path = require('node:path');
35
+ const { TextDecoder } = require('node:util');
36
+
37
+ // Intra-module siblings (a module may require its own files, never another
38
+ // module or a core internal).
39
+ const { isInside } = require('./quarantine');
40
+ const { newFenceDelim, fenceFile } = require('./fence-local');
41
+
42
+ // Per-file readability budget. A "text" file past this size is recorded as a
43
+ // stub rather than rendered readable — a 40 MB minified blob is unreviewable
44
+ // text in practice, and unreviewable must always surface as unreviewed.
45
+ const MIRROR_CAPS = Object.freeze({
46
+ maxFileBytes: 1024 * 1024, // 1 MiB per mirrored file
47
+ });
48
+
49
+ const INDEX_BASENAME = 'mirror-index.json';
50
+
51
+ // Lexical containment for a manifest-relative path: must be relative, and must
52
+ // stay under root after resolution. Directional — anything else is an escape.
53
+ function relEscapes(rootDir, rel) {
54
+ if (typeof rel !== 'string' || rel.length === 0) return true;
55
+ if (path.isAbsolute(rel)) return true;
56
+ return !isInside(rootDir, path.resolve(rootDir, ...rel.split('/')));
57
+ }
58
+
59
+ // Decode strictly as UTF-8. A single invalid sequence means "not text we can
60
+ // honestly render" — the file becomes a stub, never a lossily-decoded mirror.
61
+ // One shared decoder: non-streaming decode() keeps no state between calls.
62
+ const UTF8_STRICT = new TextDecoder('utf-8', { fatal: true });
63
+ function strictUtf8(buf) {
64
+ try {
65
+ return UTF8_STRICT.decode(buf);
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ // Build the reading-room mirror for one scan.
72
+ // pristineDir — the pristine snapshot (never modified here)
73
+ // mirrorDir — where scrubbed+fenced copies are written
74
+ // quarantineRoot — the pen; every realpath must resolve under it
75
+ // entries — manifest entries ({ path, kind, size, ... }) from buildManifest
76
+ // caps — { maxFileBytes } override (tests); defaults MIRROR_CAPS
77
+ //
78
+ // Returns { mirrored, stubs, unreviewedBytes, fenceDelim, indexPath }. A stub
79
+ // is { path, reason: symlink|special|oversize|non_utf8|path_escape|unreadable,
80
+ // bytes }. Never throws on a per-file problem — a file the mirror cannot
81
+ // honestly render is a recorded stub, not a crashed scan.
82
+ async function buildMirror({ pristineDir, mirrorDir, quarantineRoot, entries, caps = {} }) {
83
+ if (!pristineDir || !mirrorDir || !quarantineRoot || !Array.isArray(entries)) {
84
+ throw new Error('buildMirror requires pristineDir, mirrorDir, quarantineRoot and manifest entries[].');
85
+ }
86
+ const maxFileBytes = caps.maxFileBytes ?? MIRROR_CAPS.maxFileBytes;
87
+ const realQuarantine = await fsp.realpath(quarantineRoot);
88
+ const fenceDelim = newFenceDelim();
89
+
90
+ const mirrored = [];
91
+ const stubs = [];
92
+ const madeDirs = new Set();
93
+ const stub = (rel, reason, bytes) => stubs.push({ path: rel, reason, bytes });
94
+
95
+ for (const entry of entries) {
96
+ const rel = entry.path;
97
+
98
+ if (entry.kind === 'symlink') { stub(rel, 'symlink', entry.size || 0); continue; }
99
+ if (entry.kind !== 'file') { stub(rel, 'special', entry.size || 0); continue; }
100
+
101
+ // 1. Lexical confinement — before any fs call touches the path.
102
+ if (relEscapes(pristineDir, rel)) { stub(rel, 'path_escape', entry.size || 0); continue; }
103
+ const abs = path.resolve(pristineDir, ...rel.split('/'));
104
+
105
+ if (entry.size > maxFileBytes) { stub(rel, 'oversize', entry.size); continue; }
106
+
107
+ // 2. Physical confinement — the realpath must stay inside the pen, so a
108
+ // real symlink mislabeled "file" cannot pull outside bytes into the mirror.
109
+ let buf;
110
+ try {
111
+ const real = await fsp.realpath(abs);
112
+ if (!isInside(realQuarantine, real)) { stub(rel, 'path_escape', entry.size || 0); continue; }
113
+ const st = await fsp.lstat(abs);
114
+ if (st.isSymbolicLink()) { stub(rel, 'path_escape', entry.size || 0); continue; }
115
+ // Read the VERIFIED resolved path, not the original — reading `abs` would
116
+ // reopen the race the realpath check just closed (TOCTOU: a swap between
117
+ // lstat and read would be followed silently).
118
+ buf = await fsp.readFile(real);
119
+ } catch {
120
+ // Missing/unreadable on disk though present in the manifest — that
121
+ // disagreement is itself worth surfacing, as unreviewed.
122
+ stub(rel, 'unreadable', entry.size || 0);
123
+ continue;
124
+ }
125
+
126
+ const text = strictUtf8(buf);
127
+ if (text === null) { stub(rel, 'non_utf8', buf.length); continue; }
128
+
129
+ const dest = path.resolve(mirrorDir, ...rel.split('/'));
130
+ if (!isInside(mirrorDir, dest)) { stub(rel, 'path_escape', buf.length); continue; }
131
+ const destDir = path.dirname(dest);
132
+ if (!madeDirs.has(destDir)) {
133
+ await fsp.mkdir(destDir, { recursive: true });
134
+ madeDirs.add(destDir);
135
+ }
136
+ await fsp.writeFile(dest, fenceFile(fenceDelim, rel, text), 'utf8');
137
+ mirrored.push({ path: rel, bytes: buf.length });
138
+ }
139
+
140
+ const unreviewedBytes = stubs.reduce((sum, s) => sum + (s.bytes || 0), 0);
141
+
142
+ // The index is the trusted record downstream stages (floor, reducer) read:
143
+ // which files are renderable, which bytes were never reviewed, and the fence
144
+ // delimiter the panel should expect. Written inside the pen, nowhere else.
145
+ const indexPath = path.join(realQuarantine, INDEX_BASENAME);
146
+ const index = {
147
+ fenceDelim,
148
+ mirrored,
149
+ stubs,
150
+ unreviewedBytes,
151
+ counts: { mirrored: mirrored.length, stubs: stubs.length },
152
+ };
153
+ await fsp.writeFile(indexPath, JSON.stringify(index, null, 2), 'utf8');
154
+
155
+ return { mirrored, stubs, unreviewedBytes, fenceDelim, indexPath };
156
+ }
157
+
158
+ module.exports = { buildMirror, MIRROR_CAPS, INDEX_BASENAME };
@@ -0,0 +1,149 @@
1
+ // modules/security/artifact-scan/quarantine.js — the holding pen an untrusted
2
+ // artifact is fetched into.
3
+ //
4
+ // The scan engine's first rule is that a suspect artifact never gets a chance to
5
+ // act. Everything downstream (the mirror, the detectors, the panel) assumes the
6
+ // bytes it reads live somewhere that satisfies three location properties:
7
+ //
8
+ // 1. OUTSIDE THE REPO — so a scanned artifact can never be mistaken for our
9
+ // own source, land in a diff, or be picked up by a fitness/lint sweep.
10
+ // 2. OUTSIDE ONEDRIVE (or any sync root) — a synced folder uploads whatever
11
+ // lands in it. Fetching malware into a synced directory copies it to the
12
+ // builder's cloud storage and every other machine on that account.
13
+ // 3. OUTSIDE EVERY WORKSPACE ROOT — this is the subtle one. A Claude session
14
+ // auto-loads CLAUDE.md and .claude/ from directories inside its workspace.
15
+ // An artifact carrying its own CLAUDE.md, dropped inside a workspace root,
16
+ // is prompt injection delivered through the harness itself rather than
17
+ // through any file an agent chose to read. Callers pass their workspace
18
+ // roots in `forbiddenRoots`; we refuse to build a quarantine under one.
19
+ //
20
+ // The pen also holds the three neutralizing fixtures the hardened clone needs:
21
+ // an EMPTY REAL hooks directory (git's core.hooksPath must be a real directory
22
+ // — /dev/null works on macOS and is not a directory on Windows), and an empty
23
+ // gitconfig used for both GIT_CONFIG_GLOBAL and GIT_CONFIG_SYSTEM so the
24
+ // builder's own git config (insteadOf URL rewrites, a global hooksPath, a
25
+ // credential helper) cannot influence the fetch.
26
+ //
27
+ // Pure-ish: filesystem only. No DB, no network, no doorway — so it is unit
28
+ // testable and safe to require from a CLI.
29
+
30
+ 'use strict';
31
+
32
+ const fs = require('node:fs');
33
+ const os = require('node:os');
34
+ const path = require('node:path');
35
+
36
+ // modules/security/artifact-scan/ -> modules/security/ -> modules/ -> repo root
37
+ const REPO_ROOT = path.resolve(__dirname, '..', '..', '..');
38
+
39
+ // Fail-closed budget. Exceeding any of these aborts the scan and records a
40
+ // finding — it never silently truncates, because a scan that quietly stopped
41
+ // reading at 80% of a repo and still says "no findings" is a lie.
42
+ const CAPS = Object.freeze({
43
+ maxFiles: 5000,
44
+ maxBytes: 64 * 1024 * 1024, // 64 MiB across the whole artifact
45
+ cloneTimeoutMs: 120_000,
46
+ });
47
+
48
+ // A directory is "sync-backed" if any segment names a known consumer-sync root.
49
+ // Deliberately a name test rather than a path test: OneDrive/Dropbox relocate,
50
+ // and the property we care about ("anything written here is uploaded") travels
51
+ // with the folder, not with one machine's absolute path.
52
+ const SYNC_SEGMENTS = ['onedrive', 'dropbox', 'google drive', 'icloud drive'];
53
+
54
+ function isSyncBacked(p) {
55
+ return path
56
+ .resolve(p)
57
+ .split(/[\\/]/)
58
+ .some((seg) => SYNC_SEGMENTS.includes(seg.toLowerCase()));
59
+ }
60
+
61
+ // Lexical containment: is `candidate` equal to, or under, `root`? Directional on
62
+ // purpose — path.relative escaping upward ('..') or returning an absolute path
63
+ // both mean "outside". Used to reject a quarantine site, and by callers to keep
64
+ // derived paths inside the pen.
65
+ function isInside(root, candidate) {
66
+ const rel = path.relative(path.resolve(root), path.resolve(candidate));
67
+ if (rel === '') return true;
68
+ return !rel.startsWith('..') && !path.isAbsolute(rel);
69
+ }
70
+
71
+ // Why a chosen site is unusable, or null when it is fine. Split out from
72
+ // createQuarantine so the rule set is directly testable without touching disk.
73
+ function siteRefusal(site, forbiddenRoots) {
74
+ if (isInside(REPO_ROOT, site)) return `inside the repository (${REPO_ROOT})`;
75
+ if (isSyncBacked(site)) return 'inside a cloud-sync folder (it would upload the artifact)';
76
+ for (const root of forbiddenRoots) {
77
+ if (!root) continue;
78
+ if (isInside(root, site)) return `inside a workspace root (${root}) where its CLAUDE.md could auto-load`;
79
+ }
80
+ return null;
81
+ }
82
+
83
+ // Build a fresh, uniquely-named pen.
84
+ //
85
+ // `forbiddenRoots` is REQUIRED, not optional-with-an-empty-default. Workspace
86
+ // isolation is the primary defense against an artifact's own CLAUDE.md being
87
+ // auto-loaded as instructions, and a defense that silently does nothing when a
88
+ // caller forgets a parameter is not a defense. Callers with genuinely no
89
+ // workspace roots must say so by passing [] explicitly. The repo root and sync
90
+ // folders are refused unconditionally regardless.
91
+ //
92
+ // Returns { root, pristineDir, mirrorDir, hooksDir, gitConfigFile, cleanup() }.
93
+ // pristineDir holds the bytes no model is ever allowed to read; mirrorDir is
94
+ // where the scrubbed reading-room copy is written (task 1002543).
95
+ function createQuarantine({ tmpRoot = os.tmpdir(), forbiddenRoots } = {}) {
96
+ if (!Array.isArray(forbiddenRoots)) {
97
+ throw new Error(
98
+ 'createQuarantine requires forbiddenRoots: string[] — the workspace roots a quarantine must never live under. ' +
99
+ 'Pass [] explicitly if this caller genuinely has none.'
100
+ );
101
+ }
102
+ const refusal = siteRefusal(tmpRoot, forbiddenRoots);
103
+ if (refusal) {
104
+ throw new Error(
105
+ `Refusing to create a quarantine under ${tmpRoot} — it is ${refusal}. ` +
106
+ 'Pass a different tmpRoot outside the repo, outside any sync folder, and outside every workspace root.'
107
+ );
108
+ }
109
+
110
+ const root = fs.mkdtempSync(path.join(tmpRoot, 'bongos-scan-'));
111
+ // mkdtemp resolves through whatever tmpRoot really is (on macOS /tmp is a
112
+ // symlink to /private/tmp), so re-check the REAL location before we trust it.
113
+ const realRoot = fs.realpathSync(root);
114
+ const realRefusal = siteRefusal(realRoot, forbiddenRoots);
115
+ if (realRefusal) {
116
+ fs.rmSync(root, { recursive: true, force: true });
117
+ throw new Error(`Refusing to use quarantine ${realRoot} — it resolves ${realRefusal}.`);
118
+ }
119
+
120
+ const pristineDir = path.join(realRoot, 'pristine');
121
+ const mirrorDir = path.join(realRoot, 'mirror');
122
+ const hooksDir = path.join(realRoot, 'empty-hooks');
123
+ const gitConfigFile = path.join(realRoot, 'empty-gitconfig');
124
+
125
+ fs.mkdirSync(mirrorDir);
126
+ fs.mkdirSync(hooksDir); // must exist and be EMPTY: git's core.hooksPath target
127
+ fs.writeFileSync(gitConfigFile, '', 'utf8');
128
+ // pristineDir is deliberately NOT created — `git clone` wants to create it.
129
+
130
+ return {
131
+ root: realRoot,
132
+ pristineDir,
133
+ mirrorDir,
134
+ hooksDir,
135
+ gitConfigFile,
136
+ cleanup() {
137
+ fs.rmSync(realRoot, { recursive: true, force: true });
138
+ },
139
+ };
140
+ }
141
+
142
+ module.exports = {
143
+ CAPS,
144
+ REPO_ROOT,
145
+ createQuarantine,
146
+ siteRefusal,
147
+ isSyncBacked,
148
+ isInside,
149
+ };
@@ -0,0 +1,120 @@
1
+ // modules/security/artifact-scan/rebuild-spec.js — the beyond-repair landing
2
+ // (task 1002548, goal 1000055).
3
+ //
4
+ // When an artifact is dangerous, or sanitize's gates fail, the honest offer
5
+ // left is: rebuild the FUNCTIONALITY from scratch, specified from the
6
+ // artifact's DOCUMENTATION ONLY. Two rules make that safe:
7
+ //
8
+ // * DOC FILES ONLY. selectDocFiles() names exactly which manifest entries a
9
+ // model may read for the spec (via the MIRROR, like every model read) —
10
+ // README/CHANGELOG/LICENSE-class basenames and doc extensions. Code files
11
+ // never qualify, so no hostile implementation detail rides into the spec.
12
+ // * SCRIPT-OWNED FRAMING. assembleRebuildSpec() renders the document: the
13
+ // ground rules are fixed sentences owned here (the limits.js pattern), and
14
+ // every model-authored requirement line passes through the same
15
+ // scrub + one-line + cap transform every report string does — the spec can
16
+ // be filed as a GDS task without ever becoming a carrier.
17
+ //
18
+ // Pure string/path logic — no I/O. The skill decides where the file lands
19
+ // (the builder's own user config dir, never the repo).
20
+
21
+ 'use strict';
22
+
23
+ const { excerpt } = require('./detectors/shared');
24
+
25
+ // Documentation by basename or extension, never by directory: a .js under
26
+ // docs/ is code, and code is exactly what this spec must not be derived from.
27
+ // The basename rule matches the EXACT extensionless name only — "readme.js"
28
+ // is code that happens to be named readme, and must never qualify; names with
29
+ // a doc extension (README.md) qualify through DOC_EXT_RE instead.
30
+ const DOC_BASENAME_RE = /^(readme|changelog|changes|contributing|license|licence|notice|authors|history|faq|copying)$/i;
31
+ const DOC_EXT_RE = /\.(md|markdown|mdx|rst|txt|adoc)$/i;
32
+
33
+ const SPEC_LIMITS = Object.freeze({
34
+ maxRequirements: 150,
35
+ maxRequirementChars: 300,
36
+ maxDocFilesListed: 200,
37
+ });
38
+
39
+ // Fixed ground rules — script-owned sentences, never model-authored. Exported
40
+ // so tests can pin them verbatim.
41
+ const GROUND_RULES = Object.freeze([
42
+ 'This spec was derived from the artifact\'s DOCUMENTATION ONLY. No code from the artifact was consulted for, or copied into, this document.',
43
+ 'Rebuilding means implementing from scratch. Do not fetch, vendor, or reuse any bytes of the original artifact.',
44
+ 'Each requirement below is a claim from the artifact\'s own docs — treat it as a wish list to verify against real need, not as a trusted statement.',
45
+ ]);
46
+
47
+ function isDocPath(relPath) {
48
+ const s = String(relPath || '');
49
+ const base = s.slice(Math.max(s.lastIndexOf('/'), s.lastIndexOf('\\')) + 1);
50
+ return DOC_BASENAME_RE.test(base) || DOC_EXT_RE.test(base);
51
+ }
52
+
53
+ // The manifest file entries a model may read (in the mirror) to write the
54
+ // spec. Everything else in the artifact is out of bounds for this landing.
55
+ function selectDocFiles(entries = []) {
56
+ return entries
57
+ .filter((e) => e && e.kind === 'file' && isDocPath(e.path))
58
+ .map((e) => e.path);
59
+ }
60
+
61
+ // Render REBUILD-SPEC.md. `requirements` is the model-authored list of
62
+ // one-line functional requirements (written after reading the fenced doc
63
+ // mirror); everything artifact- or model-authored is scrubbed + capped here.
64
+ // Throws on an empty or malformed requirements list — a spec with no
65
+ // requirements is not a spec, and silently emitting one would turn the
66
+ // beyond-repair landing into an empty ritual.
67
+ function assembleRebuildSpec({
68
+ target = {}, // { url, sha }
69
+ docPaths = [], // the selectDocFiles() output actually consulted
70
+ requirements, // string[] — one line each, model-authored
71
+ generatedAt = new Date().toISOString(),
72
+ } = {}) {
73
+ if (!Array.isArray(requirements) || requirements.length === 0) {
74
+ throw new Error('assembleRebuildSpec requires a non-empty requirements[] — a spec with no requirements is not a spec.');
75
+ }
76
+ if (requirements.length > SPEC_LIMITS.maxRequirements) {
77
+ throw new Error(`assembleRebuildSpec caps requirements at ${SPEC_LIMITS.maxRequirements} — split the spec instead.`);
78
+ }
79
+ for (const r of requirements) {
80
+ if (typeof r !== 'string' || r.trim().length === 0) {
81
+ throw new Error('assembleRebuildSpec requirements must be non-empty strings.');
82
+ }
83
+ }
84
+ if (!Array.isArray(docPaths)) throw new Error('assembleRebuildSpec requires docPaths[] (may be empty).');
85
+
86
+ const out = [];
87
+ out.push('# REBUILD-SPEC — rebuild this functionality from scratch');
88
+ out.push('');
89
+ out.push(`Original artifact: ${excerpt(String(target.url || '(unknown)'), 300)} @ \`${excerpt(String(target.sha || '(unknown)'), 64)}\` · ${generatedAt}`);
90
+ out.push('');
91
+ out.push('## Ground rules (fixed)');
92
+ out.push('');
93
+ for (const rule of GROUND_RULES) out.push(`- ${rule}`);
94
+ out.push('');
95
+ out.push('## Documentation consulted');
96
+ out.push('');
97
+ if (docPaths.length === 0) out.push('_None — the artifact shipped no readable documentation; the requirements below rest on its stated purpose only._');
98
+ for (const p of docPaths.slice(0, SPEC_LIMITS.maxDocFilesListed)) {
99
+ out.push(`- ${excerpt(String(p), 300)}`);
100
+ }
101
+ if (docPaths.length > SPEC_LIMITS.maxDocFilesListed) {
102
+ out.push(`- …and ${docPaths.length - SPEC_LIMITS.maxDocFilesListed} more`);
103
+ }
104
+ out.push('');
105
+ out.push('## Functional requirements (from the docs — verify before building)');
106
+ out.push('');
107
+ for (const r of requirements) {
108
+ out.push(`- ${excerpt(r, SPEC_LIMITS.maxRequirementChars)}`);
109
+ }
110
+ out.push('');
111
+ return out.join('\n');
112
+ }
113
+
114
+ module.exports = {
115
+ SPEC_LIMITS,
116
+ GROUND_RULES,
117
+ isDocPath,
118
+ selectDocFiles,
119
+ assembleRebuildSpec,
120
+ };
@@ -0,0 +1,155 @@
1
+ // modules/security/artifact-scan/report.js — the report assembler
2
+ // (task 1002545, goal 1000055).
3
+ //
4
+ // A SCRIPT assembles the report: the verdict headline and the honest-limits
5
+ // block are emitted here, from the reducer's arithmetic and limits.js's fixed
6
+ // sentences — so a steered panel can bias findings but can never soften the
7
+ // caveats or write the headline. Every quoted string that originates in the
8
+ // artifact or in a model-authored lens finding passes through the same
9
+ // scrub + single-line + cap transform the floor's findings use, so the report
10
+ // can quote what was found without ever re-delivering the payload.
11
+
12
+ 'use strict';
13
+
14
+ const { excerpt } = require('./detectors/shared');
15
+ const { passingWording, limitLines } = require('./limits');
16
+ const { LENS_OK } = require('./aggregate');
17
+
18
+ const SEVERITY_ORDER = Object.freeze(['critical', 'high', 'medium', 'low']);
19
+
20
+ // One finding → one report line. Code/file/message are untrusted (artifact or
21
+ // model authored) — scrubbed + capped here regardless of who produced them.
22
+ function findingLine(f, source) {
23
+ const sev = SEVERITY_ORDER.includes(f && f.severity) ? f.severity : 'high';
24
+ const code = excerpt(String((f && f.code) || 'unlabeled'), 64);
25
+ const file = excerpt(String((f && f.file) || '(unknown)'), 300);
26
+ const message = excerpt(String((f && f.message) || ''), 300);
27
+ return `- [${sev}] \`${code}\` ${file} — ${message} _(${source})_`;
28
+ }
29
+
30
+ const SEVERITY_PREFIXES = Object.freeze(SEVERITY_ORDER.map((s) => `- [${s}]`));
31
+
32
+ function sortBySeverity(lines) {
33
+ return lines.sort((a, b) => {
34
+ const ai = SEVERITY_PREFIXES.findIndex((p) => a.startsWith(p));
35
+ const bi = SEVERITY_PREFIXES.findIndex((p) => b.startsWith(p));
36
+ return ai - bi;
37
+ });
38
+ }
39
+
40
+ // Assemble the full markdown report. Pure string building — no I/O; the skill
41
+ // decides where the file lands (the builder's own user dir, never the repo).
42
+ function assembleReport({
43
+ target = {}, // { url, sha, contentHash, kind }
44
+ tier = 'low',
45
+ reduced, // reduceVerdict() result — REQUIRED, the script's arithmetic
46
+ floorReport = {},
47
+ lenses = [],
48
+ engineVersion = 'unknown',
49
+ generatedAt = new Date().toISOString(),
50
+ } = {}) {
51
+ if (!reduced || !reduced.verdict) {
52
+ throw new Error('assembleReport requires the reducer result — the report never invents a verdict');
53
+ }
54
+ const cov = floorReport.coverage || {};
55
+ const skipped = Array.isArray(cov.files_skipped) ? cov.files_skipped : [];
56
+ const filesRead = Number(cov.files_read) || 0;
57
+ const filesTotal = filesRead + skipped.length;
58
+ const floorFindings = Array.isArray(floorReport.findings) ? floorReport.findings : [];
59
+ const lensProblems = lenses.filter((l) => !l || l.status !== LENS_OK);
60
+ const declaredDeps = floorFindings
61
+ .filter((f) => f && f.code === 'dependencies_unfetched')
62
+ .reduce((sum, f) => sum + (Number(f.detail && f.detail.declared) || 0), 0);
63
+ const unscannable = floorFindings.filter((f) => f && f.detail && f.detail.unscannable === true).length;
64
+ const sha = target.sha || floorReport.target?.sha || '(unknown)';
65
+
66
+ const out = [];
67
+
68
+ // ---- Headline: the script's arithmetic, never a lens's prose ----
69
+ out.push(`# Scan verdict: ${reduced.verdict.toUpperCase()}`);
70
+ out.push('');
71
+ out.push(`Target: ${excerpt(String(target.url || '(unknown)'), 300)} @ \`${excerpt(String(sha), 64)}\` · tier: ${excerpt(String(tier), 16)} · ${generatedAt}`);
72
+ out.push('');
73
+ out.push('Verdict arithmetic (max of all components):');
74
+ for (const [name, v] of Object.entries(reduced.components)) {
75
+ out.push(`- ${name}: ${v}`);
76
+ }
77
+ if (reduced.coverage_reasons.length > 0) {
78
+ out.push(`- coverage cap held at caution because: ${reduced.coverage_reasons.join('; ')}`);
79
+ }
80
+ out.push('');
81
+
82
+ // ---- Honest limits: fixed sentences, slots from real counts ----
83
+ out.push('## Honest limits');
84
+ out.push('');
85
+ if (reduced.verdict === 'clean') {
86
+ out.push(passingWording({ tier, sha, filesUnread: skipped.length, filesTotal }));
87
+ out.push('');
88
+ }
89
+ for (const line of limitLines({
90
+ filesRead,
91
+ filesUnread: skipped.length,
92
+ declaredDeps,
93
+ unscannable,
94
+ lensProblems: lensProblems.length,
95
+ })) {
96
+ out.push(`- ${line}`);
97
+ }
98
+ out.push('');
99
+
100
+ // ---- Findings: floor first (code-computed), then each lens ----
101
+ out.push('## Findings');
102
+ out.push('');
103
+ const lines = floorFindings.map((f) => findingLine(f, 'floor'));
104
+ for (const lens of lenses) {
105
+ const name = excerpt(String((lens && lens.lens) || 'unnamed-lens'), 40);
106
+ for (const f of (Array.isArray(lens && lens.findings) ? lens.findings : [])) {
107
+ lines.push(findingLine(f, `lens:${name}`));
108
+ }
109
+ }
110
+ if (lines.length === 0) out.push('_No findings. See Honest limits above for what that does and does not mean._');
111
+ else out.push(...sortBySeverity(lines));
112
+ out.push('');
113
+
114
+ // ---- Capability inventory: printed, never scored ----
115
+ const capabilities = Array.isArray(floorReport.capabilities) ? floorReport.capabilities : [];
116
+ out.push('## Capability inventory (what this artifact CAN do — informational, not scored)');
117
+ out.push('');
118
+ if (capabilities.length === 0) out.push('_None detected._');
119
+ for (const cap of capabilities) {
120
+ const name = excerpt(String(cap.capability || ''), 40);
121
+ const files = (Array.isArray(cap.files) ? cap.files : []).map((p) => excerpt(String(p), 120)).join(', ');
122
+ out.push(`- ${name} (${Number(cap.count) || 0}): ${files}`);
123
+ }
124
+ out.push('');
125
+
126
+ // ---- What was NOT checked: skips + off-box surfaces + lens failures ----
127
+ out.push('## What was NOT checked');
128
+ out.push('');
129
+ if (lenses.length === 0) {
130
+ out.push('- the review panel produced NO lens results — the entire panel surface is UNREVIEWED');
131
+ }
132
+ if (skipped.length === 0 && unscannable === 0 && declaredDeps === 0 && lensProblems.length === 0 && lenses.length > 0) {
133
+ out.push('_Every file in the manifest was read; no off-box surfaces were declared._');
134
+ }
135
+ for (const s of skipped) {
136
+ out.push(`- ${excerpt(String(s.path), 300)} — skipped (${excerpt(String(s.reason), 40)}, ${Number(s.bytes) || 0} bytes) — UNREVIEWED`);
137
+ }
138
+ if (unscannable > 0) out.push(`- ${unscannable} unscannable surface(s): MCP commands / remote servers whose code is not in these bytes`);
139
+ if (declaredDeps > 0) out.push(`- ${declaredDeps} declared dependencies — never fetched, never read`);
140
+ for (const l of lensProblems) {
141
+ out.push(`- lens ${excerpt(String((l && l.lens) || 'unnamed'), 40)} did not complete (${excerpt(String((l && l.status) || 'missing'), 24)}) — its surface is UNREVIEWED`);
142
+ }
143
+ out.push('');
144
+
145
+ // ---- Scan facts ----
146
+ out.push('## Scan facts');
147
+ out.push('');
148
+ out.push(`- engine: ${excerpt(String(engineVersion), 64)}`);
149
+ out.push(`- content hash: \`${excerpt(String(target.contentHash || '(none)'), 64)}\``);
150
+ out.push(`- files read: ${filesRead} · skipped: ${skipped.length} · floor findings: ${floorFindings.length}`);
151
+
152
+ return out.join('\n');
153
+ }
154
+
155
+ module.exports = { assembleReport, findingLine };