@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
@@ -21,6 +21,14 @@ const INSTANCE_STATES = ['requested', 'provisioning', 'active', 'tearing_down',
21
21
  // standalone — its OWN repo checkout + pinned @cloudbongos/core npm dep, co-located on
22
22
  // the box (ADR 0108 destination model; owner-hosted, $0). See provision.js.
23
23
  const HOSTING_SHAPES = ['co-tenant', 'dedicated', 'standalone'];
24
+ // How the instance's repo was onboarded (migration provisioning_004, ADR 0121):
25
+ // greenfield — a fresh EMPTY repo; the runner scaffolds it and pushes the first commit
26
+ // adopt — a repo the owner ALREADY has; the runner must LAYER onto it, preserving
27
+ // its history and never overwriting a pre-existing file
28
+ // Load-bearing, not cosmetic: the runner's scaffold branches on it. Before this existed
29
+ // provision.js ran the greenfield path unconditionally and adopting an existing repo
30
+ // always failed as an unrelated-history non-fast-forward (task 1002562).
31
+ const ONBOARD_MODES = ['greenfield', 'adopt'];
24
32
  // 'oauth-manifest' (task 2080) is queueable too — the GitHub-App-Manifest onboarding
25
33
  // conversion. Today the runner drains it by scanning provisioning_oauth_manifests
26
34
  // (drainOAuthManifests), not the intent queue, but it stays a valid action so the
@@ -119,6 +127,11 @@ function publicInstance(row) {
119
127
  port: row.port,
120
128
  tier: row.tier,
121
129
  target_ref: row.target_ref,
130
+ // Surfaced so a reader can tell WHICH scaffold path a row took — the difference
131
+ // between "we created this repo" and "we layered onto the owner's" is the first
132
+ // thing you need when diagnosing a standup (task 1002562). Coalesced for rows read
133
+ // before provisioning_004 added the column.
134
+ onboard_mode: row.onboard_mode || 'greenfield',
122
135
  cost_estimate_usd: Number(row.cost_estimate_usd || 0),
123
136
  error_note: row.error_note || null,
124
137
  created_at: row.created_at,
@@ -268,17 +281,22 @@ async function listAllInstances(db, { status } = {}) {
268
281
  // re-fetches the existing row and returns { created:false } so the ROUTE can
269
282
  // decide (same owner → idempotent re-request; different owner → 409). Never
270
283
  // silently reassigns ownership.
271
- async function createInstance(db, { ownerBuilderId, slug, targetRef, hostingShape, domain, dbName, port, tier }) {
284
+ async function createInstance(db, { ownerBuilderId, slug, targetRef, hostingShape, domain, dbName, port, tier, onboardMode }) {
272
285
  const shape = HOSTING_SHAPES.includes(hostingShape) ? hostingShape : 'co-tenant';
286
+ // Unknown/absent → 'greenfield', matching the column default. Falling back to the
287
+ // SAFE mode matters: greenfield refuses to push over existing history (no --force
288
+ // anywhere), whereas defaulting an unknown value to 'adopt' would put the layering
289
+ // path on a repo nobody said was pre-existing.
290
+ const mode = ONBOARD_MODES.includes(onboardMode) ? onboardMode : 'greenfield';
273
291
  const cost = costEstimateUsd({ hostingShape: shape, tier });
274
292
  try {
275
293
  const { rows } = await db.query(
276
294
  `INSERT INTO provisioning_instances
277
- (owner_builder_id, slug, target_ref, hosting_shape, domain, db_name, port, tier, cost_estimate_usd)
278
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
295
+ (owner_builder_id, slug, target_ref, hosting_shape, domain, db_name, port, tier, cost_estimate_usd, onboard_mode)
296
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
279
297
  RETURNING *`,
280
298
  [ownerBuilderId, slug, targetRef ?? null, shape, domain ?? null,
281
- dbName ?? null, port ?? null, tier ?? null, cost]
299
+ dbName ?? null, port ?? null, tier ?? null, cost, mode]
282
300
  );
283
301
  return { instance: rows[0], created: true };
284
302
  } catch (e) {
@@ -521,8 +539,8 @@ async function costLedger(db, { limit = 500 } = {}) {
521
539
 
522
540
  module.exports = {
523
541
  // constants
524
- INSTANCE_STATES, HOSTING_SHAPES, INTENT_ACTIONS, TEARDOWN_FROM, TIER_MONTHLY_USD,
525
- CERT_ELIGIBLE_STATES,
542
+ INSTANCE_STATES, HOSTING_SHAPES, ONBOARD_MODES, INTENT_ACTIONS, TEARDOWN_FROM,
543
+ TIER_MONTHLY_USD, CERT_ELIGIBLE_STATES,
526
544
  // pure
527
545
  isValidSlug, costEstimateUsd, publicInstance, normalizeHostname,
528
546
  // pure — GitHub App Manifest flow (task 2080)
@@ -231,7 +231,7 @@ module.exports = function provisioningRoutes() {
231
231
 
232
232
  // GET /provisioning/fleet — the full instance roster across all owners.
233
233
  // rank: archon (exposes every owner's instance + cost; the /boxes precedent).
234
- router.get('/provisioning/fleet', auth.requireBuilder, auth.requireRank('archon'), async (req, res) => {
234
+ router.get('/provisioning/fleet', auth.requireBuilder, auth.requirePermission('provisioning.fleet.manage'), async (req, res) => {
235
235
  try {
236
236
  const status = req.query.status || undefined;
237
237
  const rows = await provisioning.listAllInstances(pool, { status });
@@ -245,7 +245,7 @@ module.exports = function provisioningRoutes() {
245
245
  // GET /provisioning/fleet/cost-ledger — per-instance monthly estimate + total.
246
246
  // rank: archon — cost oversight across the fleet (the /boxes/cost-ledger precedent).
247
247
  // Declared among the archon routes (archon-first) for correct auditor classification.
248
- router.get('/provisioning/fleet/cost-ledger', auth.requireBuilder, auth.requireRank('archon'), async (req, res) => {
248
+ router.get('/provisioning/fleet/cost-ledger', auth.requireBuilder, auth.requirePermission('provisioning.fleet.manage'), async (req, res) => {
249
249
  try {
250
250
  // R14 (#2001 / ADR 0119): shared helper; per-endpoint cap preserved (default 500, max 2000).
251
251
  const { limit } = api.parsePagination(req, { defaultLimit: 500, maxLimit: 2000 });
@@ -262,7 +262,7 @@ module.exports = function provisioningRoutes() {
262
262
  // ENQUEUES a 'teardown' intent for the control-plane runner; the web tier never
263
263
  // holds cloud tokens. rank: archon (acts on another owner's infrastructure).
264
264
  // Declared among the archon routes (archon-first).
265
- router.post('/provisioning/instances/:id/force-teardown', auth.requireBuilder, auth.requireRank('archon'), async (req, res) => {
265
+ router.post('/provisioning/instances/:id/force-teardown', auth.requireBuilder, auth.requirePermission('provisioning.fleet.manage'), async (req, res) => {
266
266
  const id = parseId(req, res); // (req, res) — parseId sends 400 bad_id + returns null on failure
267
267
  if (!id) return;
268
268
  try {
@@ -292,7 +292,13 @@ module.exports = function provisioningRoutes() {
292
292
  // the runner (task P3) does the real standup. Idempotent on slug for the SAME
293
293
  // owner (re-request returns the existing row); a slug owned by ANOTHER builder → 409.
294
294
  // Body: { slug (required), target_ref?, hosting_shape? (co-tenant|dedicated),
295
- // domain?, tier? }.
295
+ // domain?, tier?, onboard_mode? (greenfield|adopt) }.
296
+ //
297
+ // onboard_mode is LOAD-BEARING, not a label (task 1002562): the control-plane runner
298
+ // branches its repo scaffold on it. 'adopt' means target_ref is a repo the owner
299
+ // ALREADY has, so the runner must clone + layer onto its history instead of pushing a
300
+ // fresh tree at `main` (which an existing repo rejects as unrelated history). Absent →
301
+ // 'greenfield', the safe default.
296
302
  router.post('/provisioning/instances', auth.requireBuilder, async (req, res) => {
297
303
  if (validateOrRespond(req, res, {
298
304
  slug: { required: true, type: 'string', maxLength: 40 },
@@ -300,6 +306,7 @@ module.exports = function provisioningRoutes() {
300
306
  hosting_shape: { type: 'string', maxLength: 20 },
301
307
  domain: { type: 'string', maxLength: 253 },
302
308
  tier: { type: 'string', maxLength: 40 },
309
+ onboard_mode: { type: 'string', maxLength: 20 },
303
310
  })) return;
304
311
  const body = req.body || {};
305
312
  const slug = String(body.slug || '').trim().toLowerCase();
@@ -310,11 +317,22 @@ module.exports = function provisioningRoutes() {
310
317
  if (!provisioning.HOSTING_SHAPES.includes(shape)) {
311
318
  return res.fail('bad_hosting_shape', 400, { valid: provisioning.HOSTING_SHAPES });
312
319
  }
320
+ const onboardMode = body.onboard_mode || 'greenfield';
321
+ if (!provisioning.ONBOARD_MODES.includes(onboardMode)) {
322
+ return res.fail('bad_onboard_mode', 400, { valid: provisioning.ONBOARD_MODES });
323
+ }
324
+ // Adopting is meaningless without knowing WHICH repo to layer onto, and the runner
325
+ // would fall back to greenfield behavior — so refuse here rather than mislabel the row.
326
+ if (onboardMode === 'adopt' && !String(body.target_ref || '').trim()) {
327
+ return res.fail('target_ref_required_for_adopt', 400, {
328
+ reason: 'adopt layers onto an existing repo, so target_ref (owner/repo) is required',
329
+ });
330
+ }
313
331
  try {
314
332
  const { instance, created } = await provisioning.createInstance(pool, {
315
333
  ownerBuilderId: req.builder.id, slug,
316
334
  targetRef: body.target_ref, hostingShape: shape,
317
- domain: body.domain, tier: body.tier,
335
+ domain: body.domain, tier: body.tier, onboardMode,
318
336
  });
319
337
  // Slug already taken by ANOTHER builder — refuse (never reassign ownership).
320
338
  if (!created && String(instance.owner_builder_id) !== String(req.builder.id)) {
@@ -325,7 +343,7 @@ module.exports = function provisioningRoutes() {
325
343
  if (created) {
326
344
  await provisioning.recordEvent(pool, {
327
345
  instanceId: instance.id, ownerBuilderId: req.builder.id, event: 'request',
328
- detail: `provision requested (shape=${shape}${body.domain ? `, domain=${body.domain}` : ''})`,
346
+ detail: `provision requested (shape=${shape}, mode=${onboardMode}${body.domain ? `, domain=${body.domain}` : ''})`,
329
347
  actor: 'api:self',
330
348
  }).catch(() => {});
331
349
  }
@@ -0,0 +1,133 @@
1
+ // modules/security/artifact-scan/aggregate.js — the verdict reducer
2
+ // (task 1002545, goal 1000055).
3
+ //
4
+ // THE LINE THAT MAKES THE VERDICT ARITHMETIC RATHER THAN AN OPINION. No model
5
+ // ever writes a verdict: this reducer takes the MAXIMUM of the floor's floor,
6
+ // the panel's outcome, the tier clamp, and the coverage cap — each computed in
7
+ // code from evidence — and any verdict-shaped field a lens returns is ignored
8
+ // by construction (the reducer never reads one). An artifact whose whole
9
+ // purpose is to talk the scanner into saying "clean" cannot succeed here,
10
+ // because there is no sentence it could produce that this arithmetic reads.
11
+ //
12
+ // Monotone by design: every input can only RAISE the verdict, never lower it.
13
+
14
+ 'use strict';
15
+
16
+ // Same rank floor.js exports — duplicated as the module's shared source of
17
+ // truth is floor.js; requiring it here keeps the two in lockstep.
18
+ const { VERDICT_RANK } = require('./floor');
19
+
20
+ const VERDICTS = Object.freeze(['clean', 'caution', 'dangerous']);
21
+
22
+ // Tiers mirror scan-store.js TIERS. 'low' is the cheapest tier: floor + one
23
+ // triage read, no full panel — static analysis that shallow can never clear an
24
+ // artifact, so 'low' clamps to caution and can NEVER reach clean.
25
+ const TIER_CLAMP = Object.freeze({
26
+ low: 'caution',
27
+ default: 'clean',
28
+ high: 'clean',
29
+ xhigh: 'clean',
30
+ max: 'clean',
31
+ });
32
+
33
+ // A lens that did not finish cleanly contributes caution, never silence: an
34
+ // errored/timed-out/budget-exhausted lens means its surface went UNREVIEWED.
35
+ const LENS_OK = 'ok';
36
+ const LENS_PROBLEM_STATUSES = Object.freeze(['error', 'timeout', 'budget_exhausted']);
37
+
38
+ const rank = (v) => VERDICT_RANK[v] ?? VERDICT_RANK.dangerous; // unknown → worst
39
+ const maxVerdict = (a, b) => (rank(a) >= rank(b) ? a : b);
40
+
41
+ // Panel outcome — computed from FINDINGS ONLY. The severity arithmetic is the
42
+ // same as the floor's: any critical → dangerous, any finding → caution, none →
43
+ // clean. A lens's own `verdict` field (or any other model-authored summary
44
+ // judgment) is deliberately never read.
45
+ function panelOutcome(lenses = []) {
46
+ let out = 'clean';
47
+ for (const lens of lenses) {
48
+ const findings = Array.isArray(lens && lens.findings) ? lens.findings : [];
49
+ for (const f of findings) {
50
+ out = maxVerdict(out, f && f.severity === 'critical' ? 'dangerous' : 'caution');
51
+ }
52
+ }
53
+ return out;
54
+ }
55
+
56
+ // Lens health — 'clean' only when a real panel ran to completion. Fails
57
+ // closed on every degenerate shape:
58
+ // - any lens missing, errored, timed out, or budget-exhausted → caution;
59
+ // - a status-ok lens whose findings is not an array (malformed payload —
60
+ // its findings were LOST, not absent) → caution;
61
+ // - `panelRequired` (a tier whose clamp permits clean) with ZERO ok lenses →
62
+ // caution. An absent panel is the maximal case of "silence", and without
63
+ // this the one input that means "the deep review never happened at all"
64
+ // was the only input in this file that failed open (review finding).
65
+ function lensHealth(lenses = [], { panelRequired = false } = {}) {
66
+ let okLenses = 0;
67
+ for (const lens of lenses) {
68
+ if (!lens || lens.status !== LENS_OK || !Array.isArray(lens.findings)) return 'caution';
69
+ okLenses++;
70
+ }
71
+ if (panelRequired && okLenses === 0) return 'caution';
72
+ return 'clean';
73
+ }
74
+
75
+ // Coverage cap — static text analysis cannot clear what it never read.
76
+ // Anything unread or unreadable holds the verdict at caution or above:
77
+ // skipped files/bytes, findings flagged unscannable (MCP commands, remote
78
+ // servers), and declared dependencies the install would fetch.
79
+ function coverageCap({ floorReport = {}, declaredDependencies = null } = {}) {
80
+ const cov = floorReport.coverage || {};
81
+ const skipped = Array.isArray(cov.files_skipped) ? cov.files_skipped.length : 0;
82
+ const findings = Array.isArray(floorReport.findings) ? floorReport.findings : [];
83
+ const unscannable = findings.filter((f) => f && f.detail && f.detail.unscannable === true).length;
84
+ const deps = declaredDependencies != null
85
+ ? Number(declaredDependencies) || 0
86
+ : findings.filter((f) => f && f.code === 'dependencies_unfetched').length;
87
+ const reasons = [];
88
+ if (skipped > 0) reasons.push(`${skipped} file(s) skipped unread`);
89
+ if (unscannable > 0) reasons.push(`${unscannable} unscannable surface(s) (off-box code)`);
90
+ if (deps > 0) reasons.push('declared dependencies never fetched');
91
+ return { verdict: reasons.length > 0 ? 'caution' : 'clean', reasons };
92
+ }
93
+
94
+ // The reducer. Returns the computed verdict plus each component, so the report
95
+ // can show its arithmetic — an auditable max(), not a narrative.
96
+ function reduceVerdict({ floorReport = {}, lenses = [], tier = 'low', declaredDependencies = null } = {}) {
97
+ const floor = VERDICTS.includes(floorReport.floor_verdict) ? floorReport.floor_verdict : 'caution';
98
+ const panel = panelOutcome(lenses);
99
+ // Own-property lookup so a hostile tier string can never reach the clamp
100
+ // through the prototype chain; unknown tier → caution, fail closed.
101
+ const clamp = Object.prototype.hasOwnProperty.call(TIER_CLAMP, tier) ? TIER_CLAMP[tier] : 'caution';
102
+ // A tier whose clamp permits 'clean' is a tier that RUNS a panel — so on
103
+ // those tiers an empty panel is a failure, not a pass.
104
+ const health = lensHealth(lenses, { panelRequired: clamp === 'clean' });
105
+ const coverage = coverageCap({ floorReport, declaredDependencies });
106
+
107
+ let verdict = 'clean';
108
+ for (const component of [floor, panel, health, clamp, coverage.verdict]) {
109
+ verdict = maxVerdict(verdict, component);
110
+ }
111
+ return {
112
+ verdict,
113
+ components: {
114
+ floor,
115
+ panel,
116
+ lens_health: health,
117
+ tier_clamp: clamp,
118
+ coverage_cap: coverage.verdict,
119
+ },
120
+ coverage_reasons: coverage.reasons,
121
+ };
122
+ }
123
+
124
+ module.exports = {
125
+ VERDICTS,
126
+ TIER_CLAMP,
127
+ LENS_OK,
128
+ LENS_PROBLEM_STATUSES,
129
+ panelOutcome,
130
+ lensHealth,
131
+ coverageCap,
132
+ reduceVerdict,
133
+ };
@@ -0,0 +1,53 @@
1
+ // modules/security/artifact-scan/detectors/capabilities.js — the plain
2
+ // capability inventory (task 1002544).
3
+ //
4
+ // PRINTED, NEVER SCORED. The inventory answers "what CAN this artifact do"
5
+ // (spawn processes, touch the filesystem, read env, eval code…) so the human
6
+ // and the panel read the report against an accurate power budget. It emits no
7
+ // findings and never contributes to the verdict floor: capability is not
8
+ // guilt, and scoring it here would double-count the detectors that DO grade
9
+ // specific behaviors.
10
+
11
+ 'use strict';
12
+
13
+ const CAPABILITY_PATTERNS = Object.freeze([
14
+ ['process-spawn', /\bchild_process\b|\bexecSync?\s*\(|\bspawnSync?\s*\(|\bexecFile\s*\(|Start-Process|\bsubprocess\b/],
15
+ ['filesystem', /\brequire\s*\(\s*['"](?:node:)?fs['"]|\bfrom\s+['"](?:node:)?fs['"]|\breadFileSync?\s*\(|\bwriteFileSync?\s*\(/],
16
+ ['env-read', /\bprocess\.env\b|\bos\.environ\b|\$env:/],
17
+ ['dynamic-code', /\beval\s*\(|new\s+Function\s*\(|\bvm\.(?:runInContext|runInNewContext|Script)\b/],
18
+ ['native-addon', /\.node['"]|\bnode-gyp\b|binding\.gyp|\bffi\b/],
19
+ ['network', /\bfetch\s*\(|\bhttps?\.(?:request|get)\s*\(|\bnet\.connect|XMLHttpRequest|WebSocket|\baxios\b|\bcurl\s+|\bwget\s+/],
20
+ ['crypto', /\brequire\s*\(\s*['"](?:node:)?crypto['"]|\bfrom\s+['"](?:node:)?crypto['"]/],
21
+ ]);
22
+
23
+ const SCRIPT_EXTENSIONS = Object.freeze(['.sh', '.bash', '.ps1', '.psm1', '.cmd', '.bat']);
24
+
25
+ const MAX_EXAMPLE_FILES = 5;
26
+
27
+ // Returns the inventory: [{ capability, count, files: first-5 paths }].
28
+ // Shell/batch scripts are inventoried by extension — their presence means
29
+ // "there is code here meant to be executed by a shell", whatever it says.
30
+ function inventory(corpus) {
31
+ const byCapability = new Map();
32
+ const add = (capability, path) => {
33
+ if (!byCapability.has(capability)) byCapability.set(capability, []);
34
+ byCapability.get(capability).push(path);
35
+ };
36
+
37
+ for (const file of corpus.files) {
38
+ const lower = file.path.toLowerCase();
39
+ if (SCRIPT_EXTENSIONS.some((ext) => lower.endsWith(ext))) add('shell-scripts', file.path);
40
+ if (file.text === null) continue;
41
+ for (const [capability, re] of CAPABILITY_PATTERNS) {
42
+ if (re.test(file.text)) add(capability, file.path);
43
+ }
44
+ }
45
+
46
+ return [...byCapability.entries()]
47
+ .map(([capability, files]) => ({ capability, count: files.length, files: files.slice(0, MAX_EXAMPLE_FILES) }))
48
+ .sort((a, b) => a.capability.localeCompare(b.capability));
49
+ }
50
+
51
+ // Constants exported as the white-box surface: the reducer/report stages and
52
+ // tests read them to stay in lockstep with what the inventory actually covers.
53
+ module.exports = { inventory, CAPABILITY_PATTERNS, SCRIPT_EXTENSIONS };
@@ -0,0 +1,41 @@
1
+ // modules/security/artifact-scan/detectors/config-obfuscation.js — obfuscation
2
+ // aimed at a reader who skims the raw config bytes (task 1002544).
3
+ //
4
+ // Runs ONCE over every JSON config in the bundle (not per-key like the
5
+ // individual detectors' parse/grep cross-checks), so an escaped-key finding is
6
+ // emitted a single time per file rather than once per detector that touches it.
7
+ //
8
+ // A JSON config KEY written with backslash escapes (`"postinstall"`,
9
+ // `"postinstall"`) has no legitimate purpose: npm and the harness decode
10
+ // it to the real key and act on it, while a human — and a naive grep — sees
11
+ // something else. That mismatch is the whole point of the trick, so its mere
12
+ // presence is a finding. (The duplicate-key SHADOWING case is separately caught
13
+ // by each detector's escape-decoding grepParseDisagreement.)
14
+
15
+ 'use strict';
16
+
17
+ const { finding, basename, escapedConfigKeyCount } = require('./shared');
18
+
19
+ function isJsonConfig(p) {
20
+ const name = basename(p);
21
+ if (!name.endsWith('.json')) return false;
22
+ return name === 'package.json' || name === '.claude.json' || name.startsWith('settings')
23
+ || name === '.mcp.json' || name === 'mcp.json' || name === 'claude_desktop_config.json'
24
+ || name === 'managed-settings.json' || p.includes('.claude/') || p.includes('.claude\\');
25
+ }
26
+
27
+ function detect(corpus) {
28
+ const findings = [];
29
+ for (const file of corpus.files) {
30
+ if (file.text === null || !isJsonConfig(file.path)) continue;
31
+ const escaped = escapedConfigKeyCount(file.text);
32
+ if (escaped > 0) {
33
+ findings.push(finding('obfuscated_config_key', 'high', file.path,
34
+ `${escaped} config key(s) use backslash escapes — the harness decodes them to real keys while a skim of the raw bytes reads something else.`,
35
+ { escaped_key_count: escaped }));
36
+ }
37
+ }
38
+ return findings;
39
+ }
40
+
41
+ module.exports = { detect, isJsonConfig };
@@ -0,0 +1,112 @@
1
+ // modules/security/artifact-scan/detectors/credentials.js — references to
2
+ // credential stores (task 1002544).
3
+ //
4
+ // The pattern list is CURATED AND NAMED, not clever: each entry is a specific
5
+ // place secrets live — including our own ~/.config/otb/gds-session.json and
6
+ // the Windows stores — spelled the way code spells it. A hit means "this
7
+ // artifact's text names a credential store", nothing more; whether it READS it
8
+ // is runtime behavior a static floor cannot see, and the finding says so.
9
+ //
10
+ // Patterns are tiered by SIGNAL, because the co-occurrence critical (floor.js)
11
+ // must not cry wolf:
12
+ // FILE — a concrete on-disk secret store path (.aws/credentials, id_rsa,
13
+ // gds-session.json). Co-located with a network sink this is the
14
+ // strong read-then-send shape, so ONLY file-class hits arm the
15
+ // co-occurrence critical.
16
+ // ENV — an environment-variable NAME (GITHUB_TOKEN, NPM_TOKEN). Ordinary
17
+ // tooling reads these next to a fetch() constantly, so an env hit is
18
+ // a medium on its own and never on its own arms the critical.
19
+
20
+ 'use strict';
21
+
22
+ const { finding, lineAt } = require('./shared');
23
+
24
+ const SEP = String.raw`[\\/]+`;
25
+
26
+ // [label, regex, class]. Regexes are case-insensitive and separator-agnostic.
27
+ // The SSH pattern is written WITHOUT `.*` before an alternation — a greedy
28
+ // `.*(?:_rsa|_ed25519)` backtracks catastrophically on a long non-matching
29
+ // line (ReDoS) and would hang the floor before any report is persisted; a
30
+ // bounded `[^\\/\n]*` cannot.
31
+ const CREDENTIAL_PATTERNS = Object.freeze([
32
+ ['our own GDS CLI session token', new RegExp(String.raw`gds-session\.json|\.config${SEP}otb`, 'i'), 'file'],
33
+ ['SSH private key', new RegExp(String.raw`\.ssh${SEP}[^\\/\n]*(?:_rsa|_ed25519|_ecdsa)|\bid_rsa\b|\bid_ed25519\b|\bid_ecdsa\b`, 'i'), 'file'],
34
+ ['AWS credentials file', new RegExp(String.raw`\.aws${SEP}credentials`, 'i'), 'file'],
35
+ ['netrc', new RegExp(String.raw`(?:^|[^\w.])\.?_?netrc\b`, 'i'), 'file'],
36
+ ['git stored credentials', new RegExp(String.raw`\.git-credentials`, 'i'), 'file'],
37
+ ['GitHub CLI hosts file', new RegExp(String.raw`\.config${SEP}gh${SEP}hosts\.yml`, 'i'), 'file'],
38
+ ['kubeconfig file', new RegExp(String.raw`\.kube${SEP}config`, 'i'), 'file'],
39
+ ['Docker credential store', new RegExp(String.raw`\.docker${SEP}config\.json`, 'i'), 'file'],
40
+ ['browser credential/cookie store', new RegExp(String.raw`Login Data|moz_logins|\blogins\.json\b|User Data${SEP}Default`, 'i'), 'file'],
41
+ ['cloud metadata endpoint', metadataEndpointRegex(), 'file'],
42
+ // The negative lookbehind is load-bearing: without it, `.env` matches inside
43
+ // `process.env`, misclassifying every env-var access as a credential FILE
44
+ // read and arming the co-occurrence critical on ordinary Node code.
45
+ ['generic .env secret file', new RegExp(String.raw`(?<![\w.])\.env(?:\.local|\.production|\.development)?\b`, 'i'), 'file'],
46
+ // ENV class — variable NAMES, not files. Medium, never arms co-occurrence.
47
+ ['AWS secret env var', /AWS_SECRET_ACCESS_KEY|AWS_SESSION_TOKEN/, 'env'],
48
+ ['npm auth env var', /\b_authToken\b|\bNPM_TOKEN\b/, 'env'],
49
+ ['GitHub token env var', /\bGH_TOKEN\b|\bGITHUB_TOKEN\b/, 'env'],
50
+ ['kubeconfig env var', /\bKUBECONFIG\b/, 'env'],
51
+ // Windows / macOS secret-store API calls — execution surface, treated file-class.
52
+ ['Windows Credential Manager', /\bcmdkey\b|\bvaultcmd\b|\bCredRead\b|\bCredEnumerate\b|Windows\.Security\.Credentials|PasswordVault/i, 'file'],
53
+ ['Windows DPAPI', /CryptUnprotectData|ProtectedData\.Unprotect/i, 'file'],
54
+ ['macOS Keychain', /security\s+find-(?:generic|internet)-password|\bKeychain\b/, 'file'],
55
+ ]);
56
+
57
+ // The cloud-metadata endpoint in every form git/Node can reach it: dotted,
58
+ // decimal, hex, octal, IPv6, and the AWS/GCP hostnames. A literal-string match
59
+ // (169.254.169.254 only) is defeated by writing the same address as the integer
60
+ // 2852039166 or 0xa9fea9fe — both of which resolve to the same host.
61
+ function metadataEndpointRegex() {
62
+ return new RegExp([
63
+ '169\\.254\\.169\\.254', // dotted
64
+ '\\b2852039166\\b', // decimal
65
+ '0xa9fea9fe', // hex
66
+ '0251\\.0376\\.0251\\.0376', // dotted octal
67
+ '\\[?fd00:ec2::254\\]?', // AWS IPv6
68
+ 'metadata\\.google\\.internal',
69
+ '\\binstance-data\\b',
70
+ 'metadata\\.azure\\.com',
71
+ ].join('|'), 'i');
72
+ }
73
+
74
+ // Structured per-file hits for the floor's co-occurrence join. Each hit carries
75
+ // its class so floor.js can arm the critical on file-class hits only.
76
+ //
77
+ // Memoized per corpus object: detect() and floor.coOccurrence() both need this,
78
+ // and re-running every regex over the whole tree twice per scan is pure waste.
79
+ // The corpus is immutable for the life of a scan, so a WeakMap keyed on it is a
80
+ // safe cache that also lets the object be GC'd when the scan ends.
81
+ const _hitsCache = new WeakMap();
82
+ function perFileHits(corpus) {
83
+ const cached = _hitsCache.get(corpus);
84
+ if (cached) return cached;
85
+ const hits = new Map(); // path -> [{ label, line, klass }]
86
+ for (const file of corpus.files) {
87
+ if (file.text === null) continue;
88
+ for (const [label, re, klass] of CREDENTIAL_PATTERNS) {
89
+ const m = re.exec(file.text);
90
+ if (m) {
91
+ if (!hits.has(file.path)) hits.set(file.path, []);
92
+ hits.get(file.path).push({ label, line: lineAt(file.text, m.index), klass });
93
+ }
94
+ }
95
+ }
96
+ _hitsCache.set(corpus, hits);
97
+ return hits;
98
+ }
99
+
100
+ function detect(corpus) {
101
+ const findings = [];
102
+ for (const [path, fileHits] of perFileHits(corpus)) {
103
+ for (const h of fileHits) {
104
+ findings.push(finding('credential_store_reference', h.klass === 'file' ? 'high' : 'medium', path,
105
+ `Names a credential store (${h.label}) at line ${h.line} — whether it is read is runtime behavior this static pass cannot see.`,
106
+ { store: h.label, line: h.line, klass: h.klass }));
107
+ }
108
+ }
109
+ return findings;
110
+ }
111
+
112
+ module.exports = { detect, perFileHits, CREDENTIAL_PATTERNS };
@@ -0,0 +1,115 @@
1
+ // modules/security/artifact-scan/detectors/dependencies.js — the declared
2
+ // dependency list and registry configuration (task 1002544).
3
+ //
4
+ // The scan never fetches dependencies, so every declared dependency is code
5
+ // the install would pull that NOBODY here read — that alone caps coverage.
6
+ // Within the list, specs are graded by how much the install outcome can drift
7
+ // from what a reviewer might have vetted: a git/http/file spec fetches from an
8
+ // arbitrary place (high), a floating spec ('*', 'latest', ranges) means the
9
+ // registry decides the bytes at install time (wildcards medium, ranges low —
10
+ // ranges are ecosystem-normal). A .npmrc registry override redirects EVERY
11
+ // package to a server the artifact's author chose — the classic dependency-
12
+ // confusion delivery vehicle (high).
13
+
14
+ 'use strict';
15
+
16
+ const { finding, excerpt, basename, parseJsonStrict } = require('./shared');
17
+
18
+ const DEP_SECTIONS = Object.freeze([
19
+ 'dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies',
20
+ ]);
21
+
22
+ function specSeverity(spec) {
23
+ const s = String(spec).trim();
24
+ // Protocol / shorthand sources that fetch from an arbitrary place, not the
25
+ // registry version the reviewer might have vetted.
26
+ if (/^(?:git\+|git:|github:|gitlab:|bitbucket:|https?:|file:|link:|portal:|workspace:|patch:)/i.test(s)) {
27
+ return { severity: 'high', kind: 'non-registry source' };
28
+ }
29
+ // `npm:pkg@range` alias — the installed package is NOT the one this key names.
30
+ if (/^npm:/i.test(s)) return { severity: 'high', kind: 'aliased package' };
31
+ // Bare `user/repo` (optionally `#ref`) with no protocol is GitHub shorthand.
32
+ if (/^[\w.-]+\/[\w.-]+(?:#.+)?$/.test(s) && !/^[\d]/.test(s)) {
33
+ return { severity: 'high', kind: 'github shorthand' };
34
+ }
35
+ if (s === '' || s === '*' || s === 'latest' || s === 'next' || /^[x*]$/i.test(s)) {
36
+ return { severity: 'medium', kind: 'wildcard' };
37
+ }
38
+ if (/^(?:\^|~|>=?|<=?)/.test(s) || /\.(x|\*)/i.test(s) || /\s*\|\|\s*/.test(s) || /\s-\s/.test(s)) {
39
+ return { severity: 'low', kind: 'floating range' };
40
+ }
41
+ return null; // pinned exact version — inventoried via the declared count, not a finding
42
+ }
43
+
44
+ function detect(corpus) {
45
+ const findings = [];
46
+ for (const file of corpus.files) {
47
+ if (file.text === null) continue;
48
+ const name = basename(file.path);
49
+
50
+ if (name === 'package.json') {
51
+ const parsed = parseJsonStrict(file.text);
52
+ // No config_unparseable here (asymmetric with hooks/mcp on purpose):
53
+ // npm-lifecycle.js already emits it for every unparseable package.json,
54
+ // and this detector fires on the SAME files — a second copy would only
55
+ // inflate the count.
56
+ if (!parsed.ok || !parsed.value || typeof parsed.value !== 'object') continue;
57
+ let declared = 0;
58
+ for (const section of DEP_SECTIONS) {
59
+ const deps = parsed.value[section];
60
+ if (!deps || typeof deps !== 'object' || Array.isArray(deps)) continue;
61
+ for (const [dep, spec] of Object.entries(deps)) {
62
+ declared++;
63
+ const graded = specSeverity(spec);
64
+ if (graded) {
65
+ findings.push(finding('dependency_floating_spec', graded.severity, file.path,
66
+ `${section}.${excerpt(dep, 80)} = "${excerpt(String(spec), 80)}" (${graded.kind}) — the installed bytes are decided at install time, not here.`,
67
+ { section, dep: excerpt(dep, 80), spec: excerpt(String(spec), 80), kind: graded.kind }));
68
+ }
69
+ }
70
+ }
71
+ if (declared > 0) {
72
+ findings.push(finding('dependencies_unfetched', 'medium', file.path,
73
+ `${declared} declared dependencies were never fetched or read — the install would pull code nobody here reviewed.`,
74
+ { declared, unscannable: true }));
75
+ }
76
+ const overrides = parsed.value.overrides || parsed.value.resolutions;
77
+ if (overrides && typeof overrides === 'object') {
78
+ findings.push(finding('dependency_overrides', 'medium', file.path,
79
+ `package.json pins transitive dependencies via overrides/resolutions — the tree the lockfile implies is not the tree npm resolves.`,
80
+ null));
81
+ }
82
+ const pubReg = parsed.value.publishConfig && parsed.value.publishConfig.registry;
83
+ if (typeof pubReg === 'string') {
84
+ findings.push(finding('registry_override', 'high', file.path,
85
+ `publishConfig.registry points at ${excerpt(pubReg)}.`, { registry: excerpt(pubReg) }));
86
+ }
87
+ }
88
+
89
+ if (name === '.npmrc' || name === 'npmrc') {
90
+ // .npmrc is INI-ish; the floor greps line-wise rather than trusting any
91
+ // parser — every line is checked raw, so there is no parse/grep gap.
92
+ const lines = file.text.split(/\r?\n/);
93
+ for (let i = 0; i < lines.length; i++) {
94
+ const line = lines[i].trim();
95
+ if (/^(?:@[^:]+:)?registry\s*=/.test(line)) {
96
+ findings.push(finding('registry_override', 'high', file.path,
97
+ `.npmrc redirects package resolution (line ${i + 1}): ${excerpt(line)} — the dependency-confusion delivery vehicle.`,
98
+ { line: i + 1, text: excerpt(line) }));
99
+ }
100
+ if (/_authToken|_auth\s*=|always-auth/.test(line)) {
101
+ findings.push(finding('npmrc_auth_config', 'medium', file.path,
102
+ `.npmrc carries auth configuration (line ${i + 1}): ${excerpt(line)}.`,
103
+ { line: i + 1 }));
104
+ }
105
+ if (/^ignore-scripts\s*=\s*false/.test(line)) {
106
+ findings.push(finding('npmrc_scripts_enabled', 'high', file.path,
107
+ `.npmrc explicitly re-enables install scripts (line ${i + 1}).`, { line: i + 1 }));
108
+ }
109
+ }
110
+ }
111
+ }
112
+ return findings;
113
+ }
114
+
115
+ module.exports = { detect, specSeverity, DEP_SECTIONS };