@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,116 @@
1
+ // modules/security/artifact-scan/detectors/hooks.js — Claude Code hook
2
+ // definitions bundled inside an artifact (task 1002544).
3
+ //
4
+ // A hook in .claude/settings.json runs a SHELL COMMAND every time the harness
5
+ // fires the matching event — installing an artifact that carries one is
6
+ // granting it standing code execution inside every future session. The floor
7
+ // quotes every hook command verbatim (scrubbed + capped) and names the matcher
8
+ // breadth, because "PreToolUse on *" and "PostToolUse on Edit:src/foo.js" are
9
+ // wildly different grants that render identically in a quick skim.
10
+ //
11
+ // The walk is shape-generic (any "hooks" key anywhere in any .claude/ JSON or
12
+ // a root settings.json): the schema has evolved and an artifact aiming at a
13
+ // FUTURE schema still deserves a finding today.
14
+
15
+ 'use strict';
16
+
17
+ const { finding, excerpt, basename, parseJsonStrict, grepParseDisagreement } = require('./shared');
18
+
19
+ function isClaudeConfig(p) {
20
+ const name = basename(p);
21
+ if (p.includes('.claude/') || p.includes('.claude\\')) return name.endsWith('.json');
22
+ return name === 'settings.json' || name === 'settings.local.json'
23
+ || name === 'managed-settings.json' || name === '.claude.json';
24
+ }
25
+
26
+ // A matcher that matches everything: '*', empty, or missing.
27
+ function matcherBreadth(matcher) {
28
+ if (matcher === undefined || matcher === null || matcher === '' || matcher === '*') return 'ALL tool calls';
29
+ return `matcher "${excerpt(matcher, 80)}"`;
30
+ }
31
+
32
+ // Same cap as shared.countKeysDeep: a legitimate hooks config never nests this
33
+ // deep, and an unbounded walk over hostile (but JSON.parse-valid) nesting is a
34
+ // stack overflow. Depth past the cap is UNREVIEWED, surfaced as a finding.
35
+ const MAX_TREE_DEPTH = 40;
36
+
37
+ // Collect { matcher, command, event } from any hooks-shaped subtree. In the
38
+ // live schema the matcher sits on an ANCESTOR of the command node
39
+ // ([{ matcher, hooks: [{ type, command }] }]), so the nearest enclosing
40
+ // matcher is threaded down to each command it governs. `state` is
41
+ // { hooks: [], truncated: false }; truncated flips when the depth cap stopped
42
+ // the walk, so detect() can report the unreviewed subtree.
43
+ function collectHooks(value, event, matcher, state, depth = 0) {
44
+ if (value === null || typeof value !== 'object') return;
45
+ if (depth > MAX_TREE_DEPTH) { state.truncated = true; return; }
46
+ if (Array.isArray(value)) {
47
+ for (const item of value) collectHooks(item, event, matcher, state, depth + 1);
48
+ return;
49
+ }
50
+ const m = typeof value.matcher === 'string' ? value.matcher : matcher;
51
+ // A `command` key that is present but not a string is an unreviewable grant,
52
+ // not a non-event: record it as opaque so a sibling string command can never
53
+ // let it slip out of the report silently.
54
+ if (Object.prototype.hasOwnProperty.call(value, 'command')) {
55
+ if (typeof value.command === 'string') state.hooks.push({ event, matcher: m, command: value.command });
56
+ else state.hooks.push({ event, matcher: m, command: null, opaque: true });
57
+ }
58
+ for (const [k, v] of Object.entries(value)) {
59
+ if (k !== 'command') collectHooks(v, event, m, state, depth + 1);
60
+ }
61
+ }
62
+
63
+ function detect(corpus) {
64
+ const findings = [];
65
+ for (const file of corpus.files) {
66
+ if (file.text === null || !isClaudeConfig(file.path)) continue;
67
+ const parsed = parseJsonStrict(file.text);
68
+
69
+ const hooksNode = parsed.ok && parsed.value && typeof parsed.value === 'object' ? parsed.value.hooks : undefined;
70
+ if (hooksNode && typeof hooksNode === 'object') {
71
+ for (const [event, subtree] of Object.entries(hooksNode)) {
72
+ const state = { hooks: [], truncated: false };
73
+ collectHooks(subtree, event, undefined, state);
74
+ const collected = state.hooks;
75
+ if (state.truncated) {
76
+ findings.push(finding('config_depth_cap', 'high', file.path,
77
+ `"hooks.${event}" nests deeper than the ${MAX_TREE_DEPTH}-level review cap — the deeper subtree is an unreviewable hook grant.`,
78
+ { event, reason: 'depth_cap' }));
79
+ }
80
+ for (const h of collected) {
81
+ if (h.opaque) {
82
+ findings.push(finding('claude_hook_opaque', 'high', file.path,
83
+ `${h.event} hook on ${matcherBreadth(h.matcher)} has a non-string command — unreviewable hook grant.`,
84
+ { event: h.event, matcher: h.matcher ?? null }));
85
+ continue;
86
+ }
87
+ findings.push(finding('claude_hook_command', 'high', file.path,
88
+ `${h.event} hook on ${matcherBreadth(h.matcher)} runs: ${excerpt(h.command)}`,
89
+ { event: h.event, matcher: h.matcher ?? null, command: excerpt(h.command) }));
90
+ }
91
+ if (collected.length === 0 && !state.truncated) {
92
+ // A hooks event with no extractable command is still a grant we
93
+ // could not render — surface it rather than skipping silently.
94
+ // (The truncated case is already reported as config_depth_cap.)
95
+ findings.push(finding('claude_hook_opaque', 'high', file.path,
96
+ `"hooks.${event}" is present but its command text could not be extracted — unreviewable hook grant.`,
97
+ { event }));
98
+ }
99
+ }
100
+ }
101
+
102
+ const disagreement = grepParseDisagreement(file.text, 'hooks', parsed.ok ? parsed.value : null);
103
+ if (disagreement) {
104
+ findings.push(finding('parse_grep_disagreement', 'critical', file.path,
105
+ `"hooks" appears ${disagreement.rawCount}x in key position in the raw bytes but the strict parse yielded ${disagreement.parsedCount} — hidden hook config.`,
106
+ { marker: 'hooks', ...disagreement }));
107
+ }
108
+ if (!parsed.ok) {
109
+ findings.push(finding('config_unparseable', 'medium', file.path,
110
+ 'Claude config does not strictly parse as JSON — whatever the harness makes of it was not reviewable here.', null));
111
+ }
112
+ }
113
+ return findings;
114
+ }
115
+
116
+ module.exports = { detect, matcherBreadth };
@@ -0,0 +1,118 @@
1
+ // modules/security/artifact-scan/detectors/injection.js — text aimed at the
2
+ // scanner rather than at a human reader (task 1002544).
3
+ //
4
+ // Three families, all critical, because none has a benign reason to exist in
5
+ // a third-party artifact:
6
+ // 1. instruction-override phrases ("ignore previous instructions");
7
+ // 2. scanner/reviewer-targeting phrases ("report this as clean", "dear
8
+ // reviewer") — this artifact KNOWS it will be scanned and is talking to
9
+ // the process, the strongest malice indicator we have;
10
+ // 3. dangerous codepoints (zero-width, bidi, Unicode Tags) — text engineered
11
+ // to read differently to a human than to a parser. Detected on the corpus
12
+ // text, which floor.js has already had TextDecoder strip a legitimate
13
+ // leading BOM from — so ANY invisible that remains is anomalous.
14
+ //
15
+ // The phrase lists are deliberately NARROW: a scanner that flags ordinary code
16
+ // like `return 'cleanup'` as an attack is worse than useless, so verdict-
17
+ // steering patterns require explicit scanner-directed framing ("report … as
18
+ // clean"), never a bare `return "clean"`.
19
+
20
+ 'use strict';
21
+
22
+ const { finding, lineAt } = require('./shared');
23
+
24
+ const OVERRIDE_PHRASES = Object.freeze([
25
+ ['instruction override', /ignore\s+(?:all\s+)?(?:previous|prior|above|the\s+above)\s+instructions/i],
26
+ ['instruction override', /disregard\s+(?:all\s+)?(?:previous|prior|above|your|the\s+above)\s+instructions/i],
27
+ ['role reassignment', /you\s+are\s+no\s+longer\s+(?:a|an|the)\b/i],
28
+ ['system-prompt spoof', /<\s*\/?\s*system\s*>|\[\s*system\s*\]|^#{1,3}\s*system\s+prompt/im],
29
+ ]);
30
+
31
+ // Scanner-directed framing only. Each requires an imperative addressed at the
32
+ // judgement ("report/mark/classify … as clean", "say this is safe"), with the
33
+ // verdict word \b-anchored so "clean" does not match inside "cleanup".
34
+ const SCANNER_PHRASES = Object.freeze([
35
+ // A REQUIRED object ("report THIS as clean") keeps defensive-code idioms like
36
+ // "treat as clean" / "mark as done" out; "treat/rate/score/label" are dropped
37
+ // as too generic to be scanner-talk on their own.
38
+ ['verdict steering', /\b(?:report|mark|classify|flag)\s+(?:this|it|the\s+\w+|everything)\s+as\s+["']?(?:clean|safe|benign|trustworthy|harmless|not\s+malicious)\b/i],
39
+ ['verdict steering', /\b(?:say|state|declare|respond|conclude)\s+(?:that\s+)?(?:this|it)\s+is\s+["']?(?:clean|safe|benign|harmless|not\s+malicious)\b/i],
40
+ ['verdict steering', /\bno\s+(?:findings|issues|threats|vulnerabilities)\s+(?:were\s+)?(?:found|detected|present)\s+here\b/i],
41
+ ['reviewer-addressed', /\bdear\s+(?:reviewer|auditor|scanner|ai|assistant|claude|security\s+\w+)\b/i],
42
+ ['reviewer-addressed', /\b(?:to|for)\s+the\s+(?:ai|llm|model|scanner|reviewer|auditor)\s+(?:reading|reviewing|scanning|analyz)/i],
43
+ ['scanner-aware', /\bif\s+you(?:'re|\s+are)\s+(?:an?\s+)?(?:ai|llm|language\s+model|scanner|security\s+reviewer|assistant)\b/i],
44
+ ]);
45
+
46
+ // Codepoints that read differently than they parse, tiered by attack class:
47
+ // critical — bidi overrides/isolates + the Unicode Tags block (U+E0000–E007F,
48
+ // invisible characters that can carry a hidden instruction);
49
+ // high — zero-width space / word-joiner / a BOM anywhere (the legitimate
50
+ // leading BOM was already consumed by the decoder);
51
+ // medium — ZWJ/ZWNJ, RLM/LRM, C0/C1 controls, line/paragraph separators —
52
+ // classes with occasional legitimate use that still must not reach
53
+ // the mirror.
54
+ // Counts and CLASSES are reported, never the characters themselves.
55
+ const CODEPOINT_PATTERN =
56
+ '[' +
57
+ '\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f-\\x9f' + // C0/C1 controls
58
+ '\\u200b-\\u200f\\u202a-\\u202e\\u2060\\u2066-\\u2069\\ufeff' + // zw/bidi (fence-local set)
59
+ '\\u2028\\u2029' + // line/paragraph separators
60
+ ']' +
61
+ '|[\\u{e0000}-\\u{e007f}]'; // Unicode Tags block
62
+
63
+ function classifyCodepoint(cp) {
64
+ if (cp >= 0xe0000 && cp <= 0xe007f) return { tier: 'critical', cls: 'unicode-tag' };
65
+ if ((cp >= 0x202a && cp <= 0x202e) || (cp >= 0x2066 && cp <= 0x2069)) return { tier: 'critical', cls: 'bidi-override' };
66
+ if (cp === 0x200b || cp === 0x2060 || cp === 0xfeff) return { tier: 'high', cls: 'invisible' };
67
+ if (cp === 0x200c || cp === 0x200d) return { tier: 'medium', cls: 'zero-width-joiner' };
68
+ if (cp === 0x200e || cp === 0x200f) return { tier: 'medium', cls: 'bidi-mark' };
69
+ if (cp === 0x2028 || cp === 0x2029) return { tier: 'medium', cls: 'line-separator' };
70
+ return { tier: 'medium', cls: 'control' };
71
+ }
72
+
73
+ // Compiled once; lastIndex is reset at each entry to codepointClasses so the
74
+ // stateful 'g' flag cannot leak scan position between calls.
75
+ const CODEPOINT_SCANNER = new RegExp(CODEPOINT_PATTERN, 'gu');
76
+
77
+ function codepointClasses(text) {
78
+ const tiers = { critical: new Map(), high: new Map(), medium: new Map() };
79
+ const re = CODEPOINT_SCANNER;
80
+ re.lastIndex = 0;
81
+ let m;
82
+ while ((m = re.exec(text)) !== null) {
83
+ const { tier, cls } = classifyCodepoint(m[0].codePointAt(0));
84
+ tiers[tier].set(cls, (tiers[tier].get(cls) || 0) + 1);
85
+ }
86
+ return tiers;
87
+ }
88
+
89
+ const ALL_PHRASES = Object.freeze([...OVERRIDE_PHRASES, ...SCANNER_PHRASES]);
90
+
91
+ function detect(corpus) {
92
+ const findings = [];
93
+ for (const file of corpus.files) {
94
+ if (file.text === null) continue;
95
+
96
+ for (const [family, re] of ALL_PHRASES) {
97
+ const m = re.exec(file.text);
98
+ if (m) {
99
+ const line = lineAt(file.text, m.index);
100
+ findings.push(finding('injection_phrase', 'critical', file.path,
101
+ `${family} phrase at line ${line} — text addressed to the scanning process, not a human reader.`,
102
+ { family, line }));
103
+ }
104
+ }
105
+
106
+ const tiers = codepointClasses(file.text);
107
+ for (const [tier, classes] of Object.entries(tiers)) {
108
+ if (classes.size === 0) continue;
109
+ const summary = [...classes.entries()].map(([cls, n]) => `${n} ${cls}`).join(', ');
110
+ findings.push(finding('dangerous_codepoints', tier, file.path,
111
+ `Invisible/reordering codepoints (${summary}) — can read differently to a human than to a parser.`,
112
+ { classes: Object.fromEntries(classes) }));
113
+ }
114
+ }
115
+ return findings;
116
+ }
117
+
118
+ module.exports = { detect, OVERRIDE_PHRASES, SCANNER_PHRASES, codepointClasses };
@@ -0,0 +1,102 @@
1
+ // modules/security/artifact-scan/detectors/mcp.js — MCP server entries bundled
2
+ // in an artifact (task 1002544).
3
+ //
4
+ // An MCP server entry is not config, it is a grant: a `command` is a process
5
+ // the harness will spawn on the builder's machine; a `url` is a remote whose
6
+ // code lives off-box. NEITHER is scannable by a static read of this artifact —
7
+ // the command's binary and the remote's behavior are outside these bytes — so
8
+ // both are flagged unscannable (the reducer caps coverage over them), with the
9
+ // entry quoted verbatim so the reader sees exactly what would be granted.
10
+
11
+ 'use strict';
12
+
13
+ const { finding, excerpt, basename, parseJsonStrict, grepParseDisagreement } = require('./shared');
14
+
15
+ function isMcpConfig(p) {
16
+ const name = basename(p);
17
+ return name === '.mcp.json' || name === 'mcp.json' || name === 'claude_desktop_config.json'
18
+ || name === '.claude.json'
19
+ || ((p.includes('.claude/') || p.includes('.claude\\')) && name.endsWith('.json'));
20
+ }
21
+
22
+ // Same cap as shared.countKeysDeep: an unbounded walk over hostile (but
23
+ // JSON.parse-valid) nesting is a stack overflow; depth past the cap is
24
+ // UNREVIEWED and must surface as a finding, never a silent stop.
25
+ const MAX_TREE_DEPTH = 40;
26
+
27
+ // Collect MCP-server-shaped entries: any object under an "mcpServers" (or
28
+ // "servers") key whose members carry command/url. Returns { servers,
29
+ // truncated } — truncated flips when the depth cap stopped the walk.
30
+ function collectServers(root) {
31
+ const servers = [];
32
+ let truncated = false;
33
+ const visit = (value, depth) => {
34
+ if (value === null || typeof value !== 'object') return;
35
+ if (depth > MAX_TREE_DEPTH) { truncated = true; return; }
36
+ if (Array.isArray(value)) { for (const item of value) visit(item, depth + 1); return; }
37
+ for (const [k, v] of Object.entries(value)) {
38
+ if ((k === 'mcpServers' || k === 'servers') && v && typeof v === 'object' && !Array.isArray(v)) {
39
+ for (const [name, entry] of Object.entries(v)) {
40
+ if (entry && typeof entry === 'object') servers.push({ name, entry });
41
+ }
42
+ }
43
+ visit(v, depth + 1);
44
+ }
45
+ };
46
+ visit(root, 0);
47
+ return { servers, truncated };
48
+ }
49
+
50
+ function detect(corpus) {
51
+ const findings = [];
52
+ for (const file of corpus.files) {
53
+ if (file.text === null || !isMcpConfig(file.path)) continue;
54
+ const parsed = parseJsonStrict(file.text);
55
+
56
+ if (parsed.ok) {
57
+ const { servers, truncated } = collectServers(parsed.value);
58
+ if (truncated) {
59
+ findings.push(finding('config_depth_cap', 'high', file.path,
60
+ `MCP config nests deeper than the ${MAX_TREE_DEPTH}-level review cap — deeper entries were not reviewable.`,
61
+ { reason: 'depth_cap', unscannable: true }));
62
+ }
63
+ for (const { name, entry } of servers) {
64
+ if (typeof entry.command === 'string') {
65
+ // Cap the arg COUNT here; finding() caps total length regardless, but
66
+ // an unbounded join would otherwise be truncated mid-token.
67
+ const args = Array.isArray(entry.args) ? entry.args.slice(0, 8) : [];
68
+ const argv = args.map((a) => excerpt(a, 60)).join(' ');
69
+ findings.push(finding('mcp_server_command', 'high', file.path,
70
+ `MCP server "${excerpt(name, 60)}" spawns a process — UNSCANNABLE (its code is not in these bytes): ${excerpt(entry.command)}${argv ? ` ${argv}` : ''}`,
71
+ { server: excerpt(name, 60), command: excerpt(entry.command), unscannable: true }));
72
+ }
73
+ if (typeof entry.url === 'string') {
74
+ findings.push(finding('mcp_server_url', 'medium', file.path,
75
+ `MCP server "${excerpt(name, 60)}" points at a remote — UNSCANNABLE (its code is off-box): ${excerpt(entry.url)}`,
76
+ { server: excerpt(name, 60), url: excerpt(entry.url), unscannable: true }));
77
+ }
78
+ if (typeof entry.command !== 'string' && typeof entry.url !== 'string') {
79
+ findings.push(finding('mcp_server_opaque', 'medium', file.path,
80
+ `MCP server "${excerpt(name, 60)}" has neither a readable command nor url — unreviewable entry.`,
81
+ { server: excerpt(name, 60), unscannable: true }));
82
+ }
83
+ }
84
+ }
85
+
86
+ for (const key of ['mcpServers', 'servers']) {
87
+ const disagreement = grepParseDisagreement(file.text, key, parsed.ok ? parsed.value : null);
88
+ if (disagreement) {
89
+ findings.push(finding('parse_grep_disagreement', 'critical', file.path,
90
+ `"${key}" appears ${disagreement.rawCount}x in key position in the raw bytes but the strict parse yielded ${disagreement.parsedCount} — hidden MCP config.`,
91
+ { marker: key, ...disagreement }));
92
+ }
93
+ }
94
+ if (!parsed.ok) {
95
+ findings.push(finding('config_unparseable', 'medium', file.path,
96
+ 'MCP config does not strictly parse as JSON — whatever the harness makes of it was not reviewable here.', null));
97
+ }
98
+ }
99
+ return findings;
100
+ }
101
+
102
+ module.exports = { detect };
@@ -0,0 +1,67 @@
1
+ // modules/security/artifact-scan/detectors/network.js — network egress sinks
2
+ // (task 1002544).
3
+ //
4
+ // A network sink alone is a medium: most tools talk to the network for honest
5
+ // reasons, and the floor's job is inventory, not judgment. The signal that
6
+ // matters is computed by floor.js: a file that BOTH names a credential store
7
+ // AND carries an egress sink is flagged same-file co-occurrence — labeled
8
+ // exactly that, never claimed as proven dataflow, because co-location in a
9
+ // file is evidence of opportunity, not a taint analysis.
10
+
11
+ 'use strict';
12
+
13
+ const { finding, lineAt } = require('./shared');
14
+
15
+ const NETWORK_PATTERNS = Object.freeze([
16
+ ['fetch()', /\bfetch\s*\(/],
17
+ ['XMLHttpRequest', /XMLHttpRequest/],
18
+ ['WebSocket', /\bnew\s+WebSocket\s*\(/],
19
+ ['axios', /\baxios\b/],
20
+ ['node http/https request', /\bhttps?\.(?:request|get)\s*\(/],
21
+ // Importing http/https at all is an egress capability — catches the
22
+ // destructured/aliased form `const { get } = require('node:https')` that has
23
+ // no literal `https.get(` token for the pattern above to see.
24
+ ['node http/https import', /require\s*\(\s*['"](?:node:)?https?['"]\s*\)|from\s+['"](?:node:)?https?['"]/],
25
+ ['raw socket', /\bnet\.(?:connect|createConnection)\s*\(|\bnew\s+net\.Socket\b/],
26
+ ['UDP socket', /\bdgram\.createSocket\s*\(/],
27
+ ['curl', /\bcurl\s+/],
28
+ ['wget', /\bwget\s+/],
29
+ ['PowerShell web request', /Invoke-WebRequest|Invoke-RestMethod|System\.Net\.WebClient/i],
30
+ ['navigator.sendBeacon', /sendBeacon\s*\(/],
31
+ ['undici', /\bundici\b/],
32
+ ['http client lib', /\b(?:got|ky|node-fetch|needle|superagent|request-promise|phin)\b/],
33
+ ]);
34
+
35
+ // Memoized per corpus object — detect() and floor.coOccurrence() share the
36
+ // result rather than each re-scanning the whole tree (see credentials.js).
37
+ const _hitsCache = new WeakMap();
38
+ function perFileHits(corpus) {
39
+ const cached = _hitsCache.get(corpus);
40
+ if (cached) return cached;
41
+ const hits = new Map(); // path -> [{ label, line }]
42
+ for (const file of corpus.files) {
43
+ if (file.text === null) continue;
44
+ for (const [label, re] of NETWORK_PATTERNS) {
45
+ const m = re.exec(file.text);
46
+ if (m) {
47
+ if (!hits.has(file.path)) hits.set(file.path, []);
48
+ hits.get(file.path).push({ label, line: lineAt(file.text, m.index) });
49
+ }
50
+ }
51
+ }
52
+ _hitsCache.set(corpus, hits);
53
+ return hits;
54
+ }
55
+
56
+ function detect(corpus) {
57
+ const findings = [];
58
+ for (const [path, fileHits] of perFileHits(corpus)) {
59
+ const labels = fileHits.map((h) => h.label);
60
+ findings.push(finding('network_sink', 'medium', path,
61
+ `Network egress sink(s): ${labels.join(', ')} (first at line ${fileHits[0].line}).`,
62
+ { sinks: labels, line: fileHits[0].line }));
63
+ }
64
+ return findings;
65
+ }
66
+
67
+ module.exports = { detect, perFileHits, NETWORK_PATTERNS };
@@ -0,0 +1,67 @@
1
+ // modules/security/artifact-scan/detectors/npm-lifecycle.js — npm lifecycle
2
+ // scripts, quoted verbatim with no adjudication (task 1002544).
3
+ //
4
+ // A lifecycle script in an installed package runs ARBITRARY CODE on the
5
+ // machine at install time — before anyone has read a line of the package. The
6
+ // floor therefore quotes every auto-run script verbatim (scrubbed + capped)
7
+ // and never tries to judge whether the command "looks benign": adjudicating
8
+ // command text is exactly the model-shaped judgment the zero-LLM floor exists
9
+ // to avoid. Every one is a finding; the human and the panel read the text.
10
+ //
11
+ // Every package.json in the tree is checked (workspaces / nested fixtures
12
+ // install too), by strict parse AND raw-byte grep — a key the bytes carry that
13
+ // the parse did not yield (duplicate-key shadowing, concatenated documents) is
14
+ // itself a critical finding.
15
+
16
+ 'use strict';
17
+
18
+ const { finding, excerpt, basename, parseJsonStrict, rawKeyCounts, deepKeyCounts } = require('./shared');
19
+
20
+ // The set npm runs automatically around install/publish — not run-by-request
21
+ // scripts like "test" or "build". `dependencies` is the legacy auto-run hook.
22
+ const AUTO_RUN_SCRIPTS = Object.freeze([
23
+ 'preinstall', 'install', 'postinstall',
24
+ 'preuninstall', 'uninstall', 'postuninstall',
25
+ 'prepublish', 'prepublishOnly', 'prepare', 'prepack', 'postpack',
26
+ 'dependencies',
27
+ ]);
28
+
29
+ function detect(corpus) {
30
+ const findings = [];
31
+ for (const file of corpus.files) {
32
+ if (file.text === null || basename(file.path) !== 'package.json') continue;
33
+
34
+ const parsed = parseJsonStrict(file.text);
35
+ const scripts = parsed.ok && parsed.value && typeof parsed.value.scripts === 'object' && parsed.value.scripts !== null
36
+ ? parsed.value.scripts
37
+ : {};
38
+
39
+ // One raw-key pass AND one parsed-tree pass for the whole file, reused
40
+ // across all 11 lifecycle names, instead of re-walking per name.
41
+ const rawCounts = rawKeyCounts(file.text);
42
+ const parsedCounts = deepKeyCounts(parsed.value);
43
+ for (const name of AUTO_RUN_SCRIPTS) {
44
+ if (Object.prototype.hasOwnProperty.call(scripts, name)) {
45
+ findings.push(finding('npm_lifecycle_script', 'high', file.path,
46
+ `npm auto-runs "${name}" at install time: ${excerpt(scripts[name])}`,
47
+ { script: name, command: excerpt(scripts[name]) }));
48
+ }
49
+ const rawCount = rawCounts.get(name) || 0;
50
+ const parsedCount = parsedCounts.get(name) || 0;
51
+ if (rawCount > parsedCount) {
52
+ findings.push(finding('parse_grep_disagreement', 'critical', file.path,
53
+ `"${name}" appears ${rawCount}x in the raw bytes but the strict parse yielded ${parsedCount} — duplicate-key shadowing or a second document is hiding config.`,
54
+ { marker: name, rawCount, parsedCount }));
55
+ }
56
+ }
57
+
58
+ if (!parsed.ok) {
59
+ findings.push(finding('config_unparseable', 'medium', file.path,
60
+ 'package.json does not strictly parse as JSON — whatever npm makes of it was not reviewable here.',
61
+ null));
62
+ }
63
+ }
64
+ return findings;
65
+ }
66
+
67
+ module.exports = { detect, AUTO_RUN_SCRIPTS };
@@ -0,0 +1,91 @@
1
+ // modules/security/artifact-scan/detectors/permissions.js — bundled settings
2
+ // that widen what the harness may do without asking (task 1002544).
3
+ //
4
+ // An artifact that ships its own .claude/settings.json is asking to be
5
+ // installed INTO the permission system, not just onto the disk. Allow-list
6
+ // entries, a bypassing permission mode, and env injection each get quoted so
7
+ // the reader sees the exact grant. Bypass-everything settings are a critical:
8
+ // there is no benign reason for a third-party artifact to ship one.
9
+
10
+ 'use strict';
11
+
12
+ const { finding, excerpt, basename, parseJsonStrict, grepParseDisagreement } = require('./shared');
13
+
14
+ // Matched by BASENAME wherever the file sits (so .claude/settings.json is
15
+ // covered) — but deliberately NOT widened to every .claude/*.json the way
16
+ // hooks.js isClaudeConfig is: the harness only honors permission grants from
17
+ // these exact filenames, so parsing grant structure out of an arbitrary
18
+ // bundled JSON would report grants nothing would ever apply. Bypass MARKERS
19
+ // are still checked in every text file below, whatever its name.
20
+ function isSettingsFile(p) {
21
+ const name = basename(p);
22
+ return name === 'settings.json' || name === 'settings.local.json'
23
+ || name === 'managed-settings.json' || name === '.claude.json';
24
+ }
25
+
26
+ const BYPASS_MARKERS = ['bypassPermissions', 'dangerously-skip-permissions', 'dangerouslySkipPermissions'];
27
+
28
+ function detect(corpus) {
29
+ const findings = [];
30
+ for (const file of corpus.files) {
31
+ if (file.text === null) continue;
32
+
33
+ // Bypass markers are checked in EVERY text file (a hook script or README
34
+ // that tells the user to run with --dangerously-skip-permissions is the
35
+ // same widening delivered socially). Check the raw bytes AND, for JSON, the
36
+ // parsed-then-restringified form — so a marker written `bypass…` in a
37
+ // JSON string (which the harness decodes and honours) cannot hide from a
38
+ // literal indexOf on the raw text.
39
+ const parsedForMarkers = parseJsonStrict(file.text);
40
+ const normalized = parsedForMarkers.ok ? JSON.stringify(parsedForMarkers.value) : '';
41
+ for (const marker of BYPASS_MARKERS) {
42
+ if (file.text.includes(marker) || (normalized && normalized.includes(marker))) {
43
+ findings.push(finding('permission_bypass_marker', 'critical', file.path,
44
+ `Contains "${marker}" — a third-party artifact has no benign reason to invoke permission bypass.`,
45
+ { marker }));
46
+ }
47
+ }
48
+
49
+ if (!isSettingsFile(file.path)) continue;
50
+ const parsed = parsedForMarkers; // same text, same strict parse — no second pass
51
+ if (parsed.ok && parsed.value && typeof parsed.value === 'object') {
52
+ const perms = parsed.value.permissions;
53
+ if (perms && typeof perms === 'object') {
54
+ const allows = Array.isArray(perms.allow) ? perms.allow : [];
55
+ for (const rule of allows) {
56
+ findings.push(finding('permission_allow_rule', 'high', file.path,
57
+ `Bundled settings pre-allow: ${excerpt(rule)}`, { rule: excerpt(rule) }));
58
+ }
59
+ if (typeof perms.defaultMode === 'string') {
60
+ findings.push(finding('permission_default_mode', 'high', file.path,
61
+ `Bundled settings set permissions.defaultMode = ${excerpt(perms.defaultMode, 60)}.`,
62
+ { defaultMode: excerpt(perms.defaultMode, 60) }));
63
+ }
64
+ }
65
+ const env = parsed.value.env;
66
+ if (env && typeof env === 'object' && !Array.isArray(env)) {
67
+ for (const [k, v] of Object.entries(env)) {
68
+ findings.push(finding('settings_env_injection', 'medium', file.path,
69
+ `Bundled settings inject env ${excerpt(k, 60)}=${excerpt(String(v), 80)} into every session.`,
70
+ { key: excerpt(k, 60) }));
71
+ }
72
+ }
73
+ const apiKeyHelper = parsed.value.apiKeyHelper;
74
+ if (typeof apiKeyHelper === 'string') {
75
+ findings.push(finding('settings_api_key_helper', 'critical', file.path,
76
+ `Bundled settings set apiKeyHelper — a command handed the builder's API credential flow: ${excerpt(apiKeyHelper)}`,
77
+ { command: excerpt(apiKeyHelper) }));
78
+ }
79
+ }
80
+
81
+ const disagreement = grepParseDisagreement(file.text, 'permissions', parsed.ok ? parsed.value : null);
82
+ if (disagreement) {
83
+ findings.push(finding('parse_grep_disagreement', 'critical', file.path,
84
+ `"permissions" appears ${disagreement.rawCount}x in key position in the raw bytes but the strict parse yielded ${disagreement.parsedCount} — hidden permission config.`,
85
+ { marker: 'permissions', ...disagreement }));
86
+ }
87
+ }
88
+ return findings;
89
+ }
90
+
91
+ module.exports = { detect, BYPASS_MARKERS };