@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,66 @@
1
+ // modules/security/artifact-scan/limits.js — the honest-limits text
2
+ // (task 1002545, goal 1000055).
3
+ //
4
+ // This file OWNS the caveat language. The reducer computes the verdict and the
5
+ // assembler renders the report, but the sentences that admit what the scan
6
+ // could not see live here, as fixed templates with slots filled from real
7
+ // counts — so a steered panel can bias findings but can never soften the
8
+ // caveats, because no model-authored text is ever spliced into them.
9
+
10
+ 'use strict';
11
+
12
+ const { excerpt } = require('./detectors/shared');
13
+
14
+ // THE PASSING WORDING — the most important string in the feature. Emitted
15
+ // verbatim (slots filled) whenever a scan's computed verdict is 'clean'.
16
+ // Do not reword: the sentence is the product of an owner decision that a
17
+ // "clean" scan must still say exactly what it cannot promise.
18
+ const PASSING_WORDING =
19
+ 'No findings at the {tier} tier for commit {sha} — that is the absence of ' +
20
+ 'evidence, not proof this is harmless. I read the code but never ran it, so ' +
21
+ 'anything that only happens at runtime was invisible to me; {N} of {M} files ' +
22
+ 'could not be read at all and are listed in the report; the dependencies it ' +
23
+ 'declares and any server its config points at were never fetched or checked; ' +
24
+ 'and this covers only these exact bytes — one new commit voids it. If you go ' +
25
+ 'ahead, install this exact commit, from the copy I already read.';
26
+
27
+ // Fill the passing wording. tier and sha are artifact-adjacent strings, so
28
+ // they are scrubbed + capped HERE — the paragraph that carries the honesty
29
+ // promise must not depend on every caller remembering to sanitize. One GLOBAL
30
+ // pass with a replacer FUNCTION: a `$`-pattern in a value can never expand
31
+ // into the template, and a slot token inside a filled value is never
32
+ // re-scanned — so fill order cannot matter and a value containing "{sha}"
33
+ // stays literal (task 1002545 grader nit, fixed under task 1002548).
34
+ function passingWording({ tier, sha, filesUnread, filesTotal }) {
35
+ const slots = {
36
+ tier: () => excerpt(String(tier), 16),
37
+ sha: () => excerpt(String(sha), 64),
38
+ N: () => String(Number(filesUnread) || 0),
39
+ M: () => String(Number(filesTotal) || 0),
40
+ };
41
+ return PASSING_WORDING.replace(/\{(tier|sha|N|M)\}/g, (_, name) => slots[name]());
42
+ }
43
+
44
+ // The honest-limits lines for EVERY verdict (clean gets PASSING_WORDING on
45
+ // top). Same principle: fixed sentences, slots from counts.
46
+ function limitLines({ filesRead, filesUnread, declaredDeps, unscannable, lensProblems }) {
47
+ const lines = [
48
+ 'This was a static read of the exact bytes at one commit. Nothing was executed, installed, or fetched.',
49
+ ];
50
+ if (filesUnread > 0) {
51
+ lines.push(`${filesUnread} file(s) could not be read (binary, oversize, or unreadable) — their contents are UNREVIEWED.`);
52
+ }
53
+ if (declaredDeps > 0) {
54
+ lines.push(`${declaredDeps} declared dependencies were never fetched — installing would pull code nobody here read.`);
55
+ }
56
+ if (unscannable > 0) {
57
+ lines.push(`${unscannable} finding(s) point at code that lives outside these bytes (MCP commands, remote servers) and could not be scanned.`);
58
+ }
59
+ if (lensProblems > 0) {
60
+ lines.push(`${lensProblems} review lens(es) failed to complete — their surface counts as unreviewed, not as clean.`);
61
+ }
62
+ lines.push(`${filesRead} file(s) were actually read; the verdict covers those bytes only, and a new commit voids it.`);
63
+ return lines;
64
+ }
65
+
66
+ module.exports = { PASSING_WORDING, passingWording, limitLines };
@@ -0,0 +1,203 @@
1
+ // modules/security/artifact-scan/manifest.js — the deterministic inventory of
2
+ // what was fetched.
3
+ //
4
+ // The manifest is the scan's anchor. Three downstream promises rest on it being
5
+ // exact and reproducible:
6
+ // * "install this exact commit, from the copy I already read" — the install
7
+ // offer re-hashes and compares before printing a command;
8
+ // * "{N} of {M} files could not be read" — the honest-limits sentence quotes
9
+ // real counts, so unreadable entries are recorded, never skipped;
10
+ // * "we already scanned these exact bytes" — content_hash is the GDS key.
11
+ //
12
+ // Cross-platform determinism is therefore a correctness requirement: paths are
13
+ // normalized to forward slashes, entries sort by UTF-8 BYTES (localeCompare
14
+ // would drift by machine locale), and the hashed form is JSON-per-line so a
15
+ // path containing a quote, newline, or tab cannot forge a field boundary.
16
+ //
17
+ // Symlinks are never followed — lstat throughout. The hardened clone sets
18
+ // core.symlinks=false so a symlink arrives as inert text, but where that does
19
+ // not bite, a real symlink would otherwise let the walk read a file outside the
20
+ // quarantine. It is recorded by its link text and its target is never opened.
21
+ //
22
+ // Fully async: every filesystem call goes through fs.promises. A sync walk at
23
+ // CAPS limits (5,000 files / 64 MiB) blocks for seconds, and being called from
24
+ // an async function would only hide that behind an await.
25
+
26
+ 'use strict';
27
+
28
+ const crypto = require('node:crypto');
29
+ const fsp = require('node:fs/promises');
30
+ const path = require('node:path');
31
+
32
+ // Sibling within this module (intra-module requires are fine; a module may not
33
+ // require another MODULE or a core internal). CAPS lives with the quarantine
34
+ // because the budget is a property of the holding pen, not of the hasher.
35
+ const { CAPS } = require('./quarantine');
36
+
37
+ const KIND = Object.freeze({
38
+ FILE: 'file',
39
+ SYMLINK: 'symlink',
40
+ OTHER: 'other', // fifo, socket, block/char device — never opened
41
+ });
42
+
43
+ const HASH_CHUNK_BYTES = 1024 * 1024;
44
+
45
+ function toPosix(rel) {
46
+ return rel.split(path.sep).join('/');
47
+ }
48
+
49
+ // Pre-keyed so the comparator allocates nothing — encoding inside a comparator
50
+ // would allocate O(n log n) throwaway Buffers.
51
+ function sortByBytes(items, keyOf) {
52
+ return items
53
+ .map((item) => ({ item, key: Buffer.from(keyOf(item), 'utf8') }))
54
+ .sort((a, b) => Buffer.compare(a.key, b.key))
55
+ .map((w) => w.item);
56
+ }
57
+
58
+ // Hash in fixed chunks so peak memory stays flat regardless of file size. The
59
+ // buffer is owned by one buildManifest call and threaded down, not module-level:
60
+ // two concurrent manifests would otherwise share it and corrupt each other's
61
+ // digests, which async made possible.
62
+ async function sha256File(abs, buf) {
63
+ const h = crypto.createHash('sha256');
64
+ const fh = await fsp.open(abs, 'r');
65
+ try {
66
+ for (;;) {
67
+ const { bytesRead } = await fh.read(buf, 0, buf.length, null);
68
+ if (bytesRead <= 0) break;
69
+ h.update(buf.subarray(0, bytesRead));
70
+ }
71
+ } finally {
72
+ await fh.close();
73
+ }
74
+ return h.digest('hex');
75
+ }
76
+
77
+ // Thrown when a cap trips. Carries the finding the caller records — the abort is
78
+ // fail-closed and always surfaces, never a silent truncation.
79
+ class CapExceeded extends Error {
80
+ constructor(code, message, detail) {
81
+ super(message);
82
+ this.name = 'CapExceeded';
83
+ this.code = code;
84
+ this.detail = detail ?? null;
85
+ }
86
+ }
87
+
88
+ async function walk(rootDir, { maxFiles, maxBytes, skipNames, buf }) {
89
+ const entries = [];
90
+ let totalBytes = 0;
91
+
92
+ // Every recorded node counts against maxFiles, not just regular files: a tree
93
+ // of 10,000 symlinks is as much of a resource problem as 10,000 files, and a
94
+ // cap counting only the latter would let a crafted artifact walk past it.
95
+ const push = (entry) => {
96
+ if (entries.length + 1 > maxFiles) {
97
+ throw new CapExceeded('cap_max_files', `Artifact exceeds the ${maxFiles}-entry cap.`, { maxFiles });
98
+ }
99
+ entries.push(entry);
100
+ };
101
+
102
+ const visit = async (absDir) => {
103
+ const names = sortByBytes(await fsp.readdir(absDir), (n) => n);
104
+ for (const name of names) {
105
+ const abs = path.join(absDir, name);
106
+ const rel = toPosix(path.relative(rootDir, abs));
107
+ if (skipNames.has(name) && path.dirname(rel) === '.') continue;
108
+
109
+ const st = await fsp.lstat(abs); // lstat: never resolves a symlink
110
+ if (st.isSymbolicLink()) {
111
+ push({
112
+ path: rel,
113
+ kind: KIND.SYMLINK,
114
+ size: 0,
115
+ sha256: null,
116
+ // The link text, recorded as inert data. We do NOT open the target.
117
+ linkTarget: await fsp.readlink(abs),
118
+ });
119
+ continue;
120
+ }
121
+ if (st.isDirectory()) {
122
+ await visit(abs);
123
+ continue;
124
+ }
125
+ if (!st.isFile()) {
126
+ push({ path: rel, kind: KIND.OTHER, size: 0, sha256: null });
127
+ continue;
128
+ }
129
+
130
+ totalBytes += st.size;
131
+ if (totalBytes > maxBytes) {
132
+ throw new CapExceeded('cap_max_bytes', `Artifact exceeds the ${maxBytes}-byte cap.`, { maxBytes, totalBytes });
133
+ }
134
+ push({ path: rel, kind: KIND.FILE, size: st.size, sha256: await sha256File(abs, buf) });
135
+ }
136
+ };
137
+
138
+ await visit(rootDir);
139
+ return { entries: sortByBytes(entries, (e) => e.path), totalBytes };
140
+ }
141
+
142
+ // The exact bytes content_hash is taken over. One JSON array per entry, newline
143
+ // separated: unambiguous under any path content, stable across platforms.
144
+ function serialize(entries) {
145
+ return entries
146
+ .map((e) => JSON.stringify([e.path, e.kind, e.size, e.sha256, e.linkTarget ?? null]))
147
+ .join('\n');
148
+ }
149
+
150
+ // Build the manifest for a fetched tree.
151
+ // rootDir — the pristine directory (its .git should already be removed)
152
+ // skipNames — top-level names to ignore (default: .git, if still present)
153
+ //
154
+ // Returns { entries, fileCount, symlinkCount, otherCount, totalBytes, contentHash }.
155
+ // Throws CapExceeded when a budget trips.
156
+ async function buildManifest(rootDir, { maxFiles, maxBytes, skipNames = ['.git'] } = {}) {
157
+ if (!Number.isFinite(maxFiles) || !Number.isFinite(maxBytes)) {
158
+ throw new Error('buildManifest requires numeric maxFiles and maxBytes (fail-closed: no implicit unlimited).');
159
+ }
160
+ const buf = Buffer.allocUnsafe(HASH_CHUNK_BYTES);
161
+ const { entries, totalBytes } = await walk(rootDir, { maxFiles, maxBytes, skipNames: new Set(skipNames), buf });
162
+
163
+ let fileCount = 0;
164
+ let symlinkCount = 0;
165
+ let otherCount = 0;
166
+ for (const e of entries) {
167
+ if (e.kind === KIND.FILE) fileCount++;
168
+ else if (e.kind === KIND.SYMLINK) symlinkCount++;
169
+ else otherCount++;
170
+ }
171
+ return {
172
+ entries,
173
+ fileCount,
174
+ symlinkCount,
175
+ otherCount,
176
+ totalBytes,
177
+ contentHash: crypto.createHash('sha256').update(serialize(entries), 'utf8').digest('hex'),
178
+ };
179
+ }
180
+
181
+ // Re-hash a tree and compare it to a stored manifest. This is what stands
182
+ // between "the scan passed" and "here is the install command" — if the bytes
183
+ // moved underneath us, the offer must be withdrawn rather than honoured.
184
+ //
185
+ // A cap trip here means the tree GREW past the budget it was scanned under,
186
+ // which is itself proof it changed — so it resolves to ok:false rather than
187
+ // propagating. Answering "is this still what I read?" must never throw on the
188
+ // very tampering it exists to detect.
189
+ async function verifyUnchanged(rootDir, manifest, opts = {}) {
190
+ const maxFiles = opts.maxFiles ?? CAPS.maxFiles;
191
+ const maxBytes = opts.maxBytes ?? CAPS.maxBytes;
192
+ let fresh;
193
+ try {
194
+ fresh = await buildManifest(rootDir, { maxFiles, maxBytes });
195
+ } catch (err) {
196
+ if (err instanceof CapExceeded) return { ok: false, contentHash: null, reason: err.code };
197
+ throw err;
198
+ }
199
+ const ok = fresh.contentHash === manifest.contentHash;
200
+ return { ok, contentHash: fresh.contentHash, reason: ok ? null : 'content_hash_mismatch' };
201
+ }
202
+
203
+ module.exports = { KIND, CapExceeded, buildManifest, verifyUnchanged };
@@ -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
+ };