@bongos/core 1.18.18 → 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 +515 -195
  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 +99 -14
  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 -1
  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 +124 -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,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 };
@@ -0,0 +1,317 @@
1
+ // modules/security/artifact-scan/sanitize.js — the sanitize landing
2
+ // (task 1002548, goal 1000055).
3
+ //
4
+ // The sanitize offer is the narrowest of the three landings, and every one of
5
+ // its gates is ARITHMETIC over recorded evidence — the same rule as the
6
+ // reducer. planSanitize() decides eligibility from the reducer's components
7
+ // and the findings themselves; no model opinion, no narrative, no override.
8
+ // The owner's four gates, verbatim from the task:
9
+ //
10
+ // 1. NO MALICE INDICATOR — any critical finding anywhere (floor or panel),
11
+ // or a dangerous floor/verdict, kills the offer. A sanitized copy of a
12
+ // malicious artifact is a laundered artifact.
13
+ // 2. WHOLE-NODE REMOVALS ONLY — every finding must name a concrete manifest
14
+ // file that can be removed entire. We never edit inside a file: a
15
+ // line-level "fix" of hostile code is a claim of understanding the scan
16
+ // cannot honestly make.
17
+ // 3. FULL COVERAGE — nothing skipped, nothing unscannable, no unfetched
18
+ // dependencies or submodules. Static analysis cannot vouch for a copy it
19
+ // never fully read. Read off the reducer's coverage_cap, which is why
20
+ // fetch-stage findings must reach the floor report (runFloor's
21
+ // extraFindings) — routed anywhere else, this gate cannot see them.
22
+ // 4. CLEAN RE-SCAN BY FRESH SUBAGENTS — enforced by the SKILL, which
23
+ // re-runs the whole pipeline (manifest → mirror → floor → panel) over
24
+ // the sanitized copy in a SECOND quarantine with fresh contexts. This
25
+ // module only produces the copy; it never blesses it.
26
+ //
27
+ // buildSanitizedCopy() writes ONLY inside the quarantine — the sanitized tree
28
+ // lands at <quarantine>/sanitized/ and the diff at <quarantine>/sanitize-diff.md.
29
+ // The sanitized copy is PRISTINE-CLASS: raw artifact bytes (it must stay
30
+ // byte-identical to install), so no model may read it — the re-scan mirrors
31
+ // it first, same as any fetch. File reads follow the mirror.js confinement
32
+ // discipline: lexical check before any fs call, realpath verified inside the
33
+ // pen, read the verified resolved path.
34
+
35
+ 'use strict';
36
+
37
+ const crypto = require('node:crypto');
38
+ const fsp = require('node:fs/promises');
39
+ const path = require('node:path');
40
+
41
+ const { isInside } = require('./quarantine');
42
+ const { excerpt } = require('./detectors/shared');
43
+
44
+ const SANITIZED_DIRNAME = 'sanitized';
45
+ const DIFF_BASENAME = 'sanitize-diff.md';
46
+
47
+ // The cap detectors/shared.finding() applies to a finding's `file`. A finding
48
+ // carries the SCRUBBED, capped form of a manifest path; the manifest carries
49
+ // the raw bytes. Matching one against the other directly is the collision bug
50
+ // below — this constant is what makes the two comparable.
51
+ const PATH_MAX_CHARS = 300;
52
+ const CODE_MAX_CHARS = 64;
53
+ const MAX_REASON_CODES = 20;
54
+
55
+ function allFindings(floorReport = {}, lenses = []) {
56
+ const floor = Array.isArray(floorReport.findings) ? floorReport.findings : [];
57
+ const panel = [];
58
+ for (const lens of lenses) {
59
+ for (const f of (Array.isArray(lens && lens.findings) ? lens.findings : [])) panel.push(f);
60
+ }
61
+ return { floor, panel };
62
+ }
63
+
64
+ // Gate 1 as its own testable piece: is there any indicator of actual malice?
65
+ // Arithmetic on purpose — any critical-severity finding, from the floor or
66
+ // from any lens, or a dangerous floor verdict. Severity is the one field the
67
+ // pipeline already treats as evidence; a separate "looks malicious" judgment
68
+ // would reintroduce the opinion the reducer exists to exclude.
69
+ function hasMaliceIndicator(floorReport = {}, lenses = []) {
70
+ const { floor, panel } = allFindings(floorReport, lenses);
71
+ // Codes are model-authored on panel findings and artifact-derived on floor
72
+ // ones, and they flow into reasons[] which the skill relays to chat — so they
73
+ // pass the same scrub + one-line + cap every report string does.
74
+ const codes = [...floor, ...panel]
75
+ .filter((f) => f && f.severity === 'critical')
76
+ .map((f) => excerpt(String(f.code || 'unlabeled'), CODE_MAX_CHARS));
77
+ if (floorReport.floor_verdict === 'dangerous' && codes.length === 0) codes.push('floor_verdict_dangerous');
78
+ return { malicious: codes.length > 0, codes };
79
+ }
80
+
81
+ // The eligibility decision. Takes the REDUCER RESULT (never a narrative) plus
82
+ // the raw findings and the manifest entries, and returns
83
+ // { eligible, reasons, removals, unremovable, provenance }. `removals` is the
84
+ // sorted set of RAW manifest paths whose removal addresses every finding; a
85
+ // finding that names no concrete manifest file (e.g. file '(floor)', an
86
+ // off-box surface, or a path the manifest does not carry) is unremovable and
87
+ // kills the offer — fail closed, gate 2.
88
+ //
89
+ // PATH IDENTITY (review finding). A finding's `file` has been through
90
+ // scrubUntrusted + whitespace-collapse + a 300-char cap; a manifest path has
91
+ // not. Joining them directly let an artifact ship two files whose names differ
92
+ // only by a scrubbed codepoint — the removal set then named the benign twin
93
+ // while the flagged file was copied into sanitized/. So the join runs on the
94
+ // SCRUBBED form of both sides, and a scrubbed name that maps to more than one
95
+ // raw manifest entry is AMBIGUOUS: it becomes unremovable rather than a guess,
96
+ // because deleting the wrong file is exactly the failure this gate exists to
97
+ // prevent.
98
+ //
99
+ // PROVENANCE (review finding, split verdict). A panel finding's `file` is
100
+ // model-authored text, so it is both evidence AND a destructive instruction.
101
+ // The gates cannot verify it, so they LABEL it: `provenance` reports which
102
+ // removals a code-computed floor finding backs and which rest on panel text
103
+ // alone. The blast radius stays bounded by three things that do not trust the
104
+ // panel — removals are whole files inside the quarantine only, the human reads
105
+ // the diff, and the copy must pass a fresh-subagent re-scan (gate 4).
106
+ function planSanitize({ reduced, floorReport = {}, lenses = [], entries = [] } = {}) {
107
+ if (!reduced || !reduced.components) {
108
+ throw new Error('planSanitize requires the reducer result — sanitize eligibility is arithmetic, never a narrative');
109
+ }
110
+ const reasons = [];
111
+
112
+ const malice = hasMaliceIndicator(floorReport, lenses);
113
+ if (malice.malicious) reasons.push(`malice_indicator: ${malice.codes.join(', ')}`);
114
+ if (reduced.verdict === 'dangerous') reasons.push('verdict_dangerous');
115
+
116
+ if (reduced.components.coverage_cap !== 'clean') {
117
+ reasons.push(`coverage_incomplete: ${(reduced.coverage_reasons || []).join('; ') || 'coverage cap held'}`);
118
+ }
119
+ // Gates 1–3 are about what THIS scan proved, so the scan must have been a
120
+ // full one: a panel-bearing tier (low never ran a real panel) and a panel
121
+ // that completed healthy. The clean re-scan (gate 4) is the skill's job.
122
+ if (reduced.components.tier_clamp !== 'clean') reasons.push('tier_ran_no_full_panel');
123
+ if (reduced.components.lens_health !== 'clean') reasons.push('panel_unhealthy_or_absent');
124
+
125
+ const { floor, panel } = allFindings(floorReport, lenses);
126
+ const findings = [...floor.map((f) => ({ f, from: 'floor' })), ...panel.map((f) => ({ f, from: 'panel' }))];
127
+ if (findings.length === 0) reasons.push('nothing_to_remove');
128
+
129
+ // Scrubbed manifest name → the entries that render to it. EVERY kind is
130
+ // indexed, not just files: a symlink whose scrubbed name collides with a
131
+ // real file must make that name ambiguous, or a finding about the symlink
132
+ // schedules the innocent file for deletion (verification finding).
133
+ const byScrubbed = new Map();
134
+ for (const e of entries) {
135
+ if (!e || typeof e.path !== 'string') continue;
136
+ const key = excerpt(e.path, PATH_MAX_CHARS);
137
+ // A name that scrubs away to nothing (all-whitespace, all-zero-width) can
138
+ // never identify anything. Indexing it would let it match every finding
139
+ // that carries no file at all — the fail-open this gate exists to prevent.
140
+ if (key === '') continue;
141
+ if (!byScrubbed.has(key)) byScrubbed.set(key, []);
142
+ byScrubbed.get(key).push(e);
143
+ }
144
+
145
+ const removals = new Set();
146
+ const backedByFloor = new Set();
147
+ const unremovable = [];
148
+ for (const { f, from } of findings) {
149
+ const claimed = f && typeof f.file === 'string' ? excerpt(f.file, PATH_MAX_CHARS) : '';
150
+ const matches = claimed === '' ? [] : (byScrubbed.get(claimed) || []);
151
+ if (matches.length === 1 && matches[0].kind === 'file') {
152
+ removals.add(matches[0].path);
153
+ if (from === 'floor') backedByFloor.add(matches[0].path);
154
+ continue;
155
+ }
156
+ let reason = 'no_manifest_file';
157
+ if (matches.length > 1) reason = 'ambiguous_path';
158
+ else if (matches.length === 1) reason = 'not_a_regular_file';
159
+ unremovable.push({
160
+ code: excerpt(String((f && f.code) || 'unlabeled'), CODE_MAX_CHARS),
161
+ file: claimed,
162
+ reason,
163
+ });
164
+ }
165
+ if (unremovable.length > 0) {
166
+ // Count-capped as well as per-item capped: a hostile panel returning
167
+ // thousands of findings must not build one unbounded reason string.
168
+ const codes = unremovable.slice(0, MAX_REASON_CODES).map((u) => u.code).join(', ');
169
+ const more = unremovable.length > MAX_REASON_CODES ? ` (+${unremovable.length - MAX_REASON_CODES} more)` : '';
170
+ reasons.push(`not_whole_node_removable: ${codes}${more}`);
171
+ }
172
+
173
+ // removals[] carries RAW manifest paths because buildSanitizedCopy needs the
174
+ // real bytes to find the file on disk — so it is NOT display-safe and must
175
+ // never be printed. provenance carries the scrubbed rendering of the same
176
+ // paths and is what a human is shown (verification finding: a bidi override
177
+ // or an embedded newline in a filename otherwise forges the approval
178
+ // display at the exact moment deletions are approved).
179
+ const sorted = [...removals].sort();
180
+ const display = (p) => excerpt(p, PATH_MAX_CHARS);
181
+ return {
182
+ eligible: reasons.length === 0,
183
+ reasons,
184
+ removals: sorted,
185
+ unremovable,
186
+ provenance: {
187
+ floor_backed: sorted.filter((p) => backedByFloor.has(p)).map(display),
188
+ panel_only: sorted.filter((p) => !backedByFloor.has(p)).map(display),
189
+ },
190
+ };
191
+ }
192
+
193
+ // Copy the pristine tree minus the removals into <quarantine>/sanitized/, and
194
+ // write the diff (whole files removed — nothing else changed) next to it.
195
+ // Writes ONLY inside the quarantine; kept files are copied byte-identical.
196
+ // Refuses stale state (an existing sanitized/ dir) and any removal path the
197
+ // manifest does not carry — a typo'd removal must never silently keep a file
198
+ // the plan meant to drop.
199
+ //
200
+ // Every kept file is re-hashed against its manifest sha256 before it is
201
+ // written (review finding): the diff's promise that kept files are
202
+ // "byte-identical to the scanned pristine copy" was previously prose the code
203
+ // never tested, so anything that changed the pen between the scan and the
204
+ // sanitize — a concurrent process, another tool, a shared build box — was
205
+ // copied in under a provenance claim. Now a mismatch aborts.
206
+ //
207
+ // On ANY failure the partial sanitized/ tree is removed before the error
208
+ // propagates: a directory that looks like a finished sanitized copy but is
209
+ // silently missing files, with no diff beside it, is worse than no directory
210
+ // (and the non-recursive mkdir would otherwise refuse every retry).
211
+ async function buildSanitizedCopy({ pristineDir, quarantineRoot, entries, removals } = {}) {
212
+ if (!pristineDir || !quarantineRoot || !Array.isArray(entries) || !Array.isArray(removals) || removals.length === 0) {
213
+ throw new Error('buildSanitizedCopy requires pristineDir, quarantineRoot, manifest entries[], and a non-empty removals[].');
214
+ }
215
+ const fileEntries = new Map(entries.filter((e) => e && e.kind === 'file').map((e) => [e.path, e]));
216
+ for (const r of removals) {
217
+ if (!fileEntries.has(r)) throw new Error(`Removal "${excerpt(String(r), 300)}" is not a manifest file — refusing a plan the manifest does not vouch for.`);
218
+ }
219
+
220
+ const realQuarantine = await fsp.realpath(quarantineRoot);
221
+ const sanitizedDir = path.join(realQuarantine, SANITIZED_DIRNAME);
222
+ await fsp.mkdir(sanitizedDir); // non-recursive: an existing sanitized/ is stale state, refuse
223
+
224
+ const remove = new Set(removals);
225
+ const removed = [];
226
+ const skippedNonFile = [];
227
+ let copied = 0;
228
+ const madeDirs = new Set();
229
+
230
+ try {
231
+ for (const entry of entries) {
232
+ if (!entry || entry.kind !== 'file') {
233
+ if (entry) skippedNonFile.push(entry.path);
234
+ continue;
235
+ }
236
+ const rel = entry.path;
237
+ if (remove.has(rel)) {
238
+ removed.push({ path: rel, size: entry.size, sha256: entry.sha256 || null });
239
+ continue;
240
+ }
241
+
242
+ // Confinement, same discipline as mirror.js: lexical first, then physical.
243
+ if (typeof rel !== 'string' || rel.length === 0 || path.isAbsolute(rel)
244
+ || !isInside(pristineDir, path.resolve(pristineDir, ...rel.split('/')))) {
245
+ throw new Error(`Manifest path "${excerpt(String(rel), 300)}" escapes the pristine tree — refusing to build a sanitized copy over it.`);
246
+ }
247
+ const abs = path.resolve(pristineDir, ...rel.split('/'));
248
+ const real = await fsp.realpath(abs);
249
+ if (!isInside(realQuarantine, real)) {
250
+ throw new Error(`"${excerpt(rel, 300)}" resolves outside the quarantine — refusing.`);
251
+ }
252
+ const st = await fsp.lstat(abs);
253
+ if (st.isSymbolicLink()) {
254
+ throw new Error(`"${excerpt(rel, 300)}" is a symlink on disk though the manifest calls it a file — refusing.`);
255
+ }
256
+ const buf = await fsp.readFile(real); // the verified resolved path, not abs
257
+
258
+ // The diff claims every kept file is byte-identical to what was scanned.
259
+ // Prove it against the manifest hash rather than asserting it in prose.
260
+ if (!entry.sha256) {
261
+ throw new Error(`"${excerpt(rel, 300)}" has no manifest sha256 — refusing to claim byte-identity the manifest cannot vouch for.`);
262
+ }
263
+ const fresh = crypto.createHash('sha256').update(buf).digest('hex');
264
+ if (fresh !== entry.sha256) {
265
+ throw new Error(`"${excerpt(rel, 300)}" changed since the scan (manifest sha256 ${excerpt(String(entry.sha256), 64)}, on disk ${fresh}) — refusing to copy bytes nobody reviewed.`);
266
+ }
267
+
268
+ const dest = path.resolve(sanitizedDir, ...rel.split('/'));
269
+ if (!isInside(sanitizedDir, dest)) {
270
+ throw new Error(`"${excerpt(rel, 300)}" would land outside sanitized/ — refusing.`);
271
+ }
272
+ const destDir = path.dirname(dest);
273
+ if (!madeDirs.has(destDir)) {
274
+ await fsp.mkdir(destDir, { recursive: true });
275
+ madeDirs.add(destDir);
276
+ }
277
+ await fsp.writeFile(dest, buf);
278
+ copied++;
279
+ }
280
+
281
+ // The diff: whole files removed, nothing else touched. Paths and hashes are
282
+ // artifact-controlled strings, so they pass through the same scrub+cap every
283
+ // report string does.
284
+ const diffLines = [
285
+ '# Sanitize diff',
286
+ '',
287
+ 'Whole-file removals only — every kept file was re-hashed against its manifest sha256 before being copied.',
288
+ `Removed ${removed.length} file(s); copied ${copied} file(s) into ${SANITIZED_DIRNAME}/.`,
289
+ '',
290
+ ];
291
+ for (const r of removed) {
292
+ diffLines.push(`- REMOVED ${excerpt(r.path, 300)} (${Number(r.size) || 0} bytes, sha256 ${excerpt(String(r.sha256 || '(none)'), 64)})`);
293
+ }
294
+ if (skippedNonFile.length > 0) {
295
+ diffLines.push('');
296
+ diffLines.push(`Not copied (non-file manifest entries, never reviewed): ${skippedNonFile.map((p) => excerpt(String(p), 120)).join(', ')}`);
297
+ }
298
+ const diff = diffLines.join('\n');
299
+ const diffPath = path.join(realQuarantine, DIFF_BASENAME);
300
+ await fsp.writeFile(diffPath, diff, 'utf8');
301
+
302
+ return { sanitizedDir, diffPath, diff, copied, removed, skippedNonFile };
303
+ } catch (err) {
304
+ // No half-built tree survives a failure — see the header. Best-effort so a
305
+ // cleanup problem can never mask the real error.
306
+ await fsp.rm(sanitizedDir, { recursive: true, force: true }).catch(() => {});
307
+ throw err;
308
+ }
309
+ }
310
+
311
+ module.exports = {
312
+ SANITIZED_DIRNAME,
313
+ DIFF_BASENAME,
314
+ hasMaliceIndicator,
315
+ planSanitize,
316
+ buildSanitizedCopy,
317
+ };