@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.
- package/.bongos-core.json +515 -195
- package/.claude/skills/builder-box/SKILL.md +1 -1
- package/.claude/skills/builder-claim/SKILL.md +1 -1
- package/.claude/skills/goal-create/SKILL.md +1 -1
- package/.claude/skills/idea-triage/SKILL.md +1 -1
- package/.claude/skills/planning-session/SKILL.md +5 -5
- package/.claude/skills/scan-before-install/SKILL.md +165 -0
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +63 -41
- package/clients/bongos-client/index.cjs +63 -41
- package/clients/bongos-client/index.d.ts +78 -44
- package/clients/bongos-client/index.mjs +63 -41
- package/docs/adr/0020-security-model-public-stub.md +1 -1
- package/docs/adr/0097-one-active-claim-per-session-and-worktree-binding.md +2 -0
- package/docs/adr/0144-devbox-rehome-onto-cloudbongos-plane.md +1 -1
- package/docs/adr/0151-governance-permissions-as-atom-ranks-as-roles.md +2 -1
- package/docs/adr/0155-adopt-private-repo-widen-oauth-scope.md +51 -0
- package/docs/adr/0156-user-set-display-name-wins.md +62 -0
- package/docs/adr/0157-archon-is-rank-and-identity-only.md +93 -0
- package/docs/adr/0158-choke-point-verdict-taxonomy.md +174 -0
- package/docs/adr/0159-merge-gate-reconciliation.md +88 -0
- package/docs/adr/0160-main-audit-repo-verifiable-caller.md +77 -0
- package/docs/adr/README.md +8 -0
- package/docs/api/openapi.json +1209 -233
- package/docs/api-reference.md +61 -46
- package/docs/architecture.md +1 -1
- package/docs/canonical-permissions.md +61 -16
- package/docs/design/vanilla-hall-ui-redesign-scope.md +1 -1
- package/docs/file-map.md +4 -1
- package/docs/onboarding/diagrams/assertions.json +3 -3
- package/docs/recipes/core-release-pipeline.md +179 -0
- package/docs/recipes/packaging-the-core.md +2 -0
- package/docs/recipes/parallel-goal-waves.md +1 -3
- package/docs/recipes/private-npm-distribution.md +2 -0
- package/docs/recipes/upgrading-the-core.md +2 -0
- package/migrations/core_197_display_name_source.sql +56 -0
- package/migrations/core_204_rekey_active_claim_per_session_worktree.sql +77 -0
- package/modules/autonomy/routes/autonomy.js +1 -1
- package/modules/builder-settings/db.js +7 -1
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +63 -41
- package/modules/dev-box/routes/box.js +11 -9
- package/modules/discord/auth.js +3 -1
- package/modules/discord/routes/discord.js +8 -2
- package/modules/discord/ship-broadcast.js +48 -1
- package/modules/governance/catalog.js +70 -20
- package/modules/governance/db.js +102 -0
- package/modules/governance/migrations/governance_006_version_create.sql +38 -0
- package/modules/governance/migrations/governance_007_delegate_archon_ops_to_metic.sql +72 -0
- package/modules/governance/routes/governance.js +235 -6
- package/modules/grading/grader-workers/index.js +18 -9
- package/modules/hall-ui/public/gate.js +6 -2
- package/modules/hall-ui/public/goals-page.js +6 -2
- package/modules/hall-ui/public/governance.css +100 -0
- package/modules/hall-ui/public/governance.html +77 -0
- package/modules/hall-ui/public/governance.js +353 -0
- package/modules/hall-ui/public/harbor.js +14 -8
- package/modules/hall-ui/public/instances-panel.js +3 -1
- package/modules/hall-ui/public/new-project-wizard.js +6 -0
- package/modules/hall-ui/public/people.html +51 -0
- package/modules/hall-ui/public/people.js +100 -0
- package/modules/hall-ui/public/profile.html +2 -1
- package/modules/hall-ui/public/profile.js +9 -3
- package/modules/hall-ui/public/sessions.js +4 -2
- package/modules/hall-ui/public/shell.js +28 -7
- package/modules/hall-ui/public/task-detail.js +6 -3
- package/modules/hall-ui/public/watch.js +17 -9
- package/modules/ideas/blockers.js +44 -7
- package/modules/ideas/routes/blockers.js +12 -7
- package/modules/ideas/routes/inbox.js +2 -2
- package/modules/lifecycle/db.js +84 -17
- package/modules/lifecycle/github-push.js +37 -10
- package/modules/lifecycle/publish-reconciler.js +128 -31
- package/modules/lifecycle/routes/claims.js +10 -4
- package/modules/lifecycle/routes/dependencies.js +2 -2
- package/modules/lifecycle/routes/done-when.js +4 -4
- package/modules/lifecycle/routes/gate-approvals.js +9 -3
- package/modules/lifecycle/routes/goals.js +5 -5
- package/modules/lifecycle/routes/tasks.js +16 -16
- package/modules/lifecycle/routes/versions.js +13 -2
- package/modules/lifecycle/ship-card.js +38 -7
- package/modules/memory/routes/memory.js +2 -2
- package/modules/onboarding/routes/access-requests.js +3 -3
- package/modules/platform-identity/routes/projects.js +10 -6
- package/modules/platform-identity/routes/scouting.js +2 -2
- package/modules/provisioning/migrations/provisioning_004_onboard_mode.sql +30 -0
- package/modules/provisioning/provisioning.js +24 -6
- package/modules/provisioning/routes/provisioning.js +24 -6
- package/modules/security/artifact-scan/aggregate.js +133 -0
- package/modules/security/artifact-scan/detectors/capabilities.js +53 -0
- package/modules/security/artifact-scan/detectors/config-obfuscation.js +41 -0
- package/modules/security/artifact-scan/detectors/credentials.js +112 -0
- package/modules/security/artifact-scan/detectors/dependencies.js +115 -0
- package/modules/security/artifact-scan/detectors/hooks.js +116 -0
- package/modules/security/artifact-scan/detectors/injection.js +118 -0
- package/modules/security/artifact-scan/detectors/mcp.js +102 -0
- package/modules/security/artifact-scan/detectors/network.js +67 -0
- package/modules/security/artifact-scan/detectors/npm-lifecycle.js +67 -0
- package/modules/security/artifact-scan/detectors/permissions.js +91 -0
- package/modules/security/artifact-scan/detectors/shared.js +219 -0
- package/modules/security/artifact-scan/fence-local.js +99 -0
- package/modules/security/artifact-scan/fetch.js +330 -0
- package/modules/security/artifact-scan/floor.js +319 -0
- package/modules/security/artifact-scan/limits.js +66 -0
- package/modules/security/artifact-scan/manifest.js +203 -0
- package/modules/security/artifact-scan/mirror.js +158 -0
- package/modules/security/artifact-scan/quarantine.js +149 -0
- package/modules/security/artifact-scan/rebuild-spec.js +120 -0
- package/modules/security/artifact-scan/report.js +155 -0
- package/modules/security/artifact-scan/sanitize.js +317 -0
- package/modules/security/migrations/security_001_tool_scans.sql +145 -0
- package/modules/security/module.json +2 -2
- package/modules/security/routes/reports.js +11 -7
- package/modules/security/routes/scans.js +115 -0
- package/modules/security/scan-store.js +214 -0
- package/modules/security/tests/aggregate.mjs +283 -0
- package/modules/security/tests/artifact_scan.mjs +591 -0
- package/modules/security/tests/floor.mjs +549 -0
- package/modules/security/tests/mirror.mjs +350 -0
- package/modules/security/tests/sanitize_rebuild.mjs +499 -0
- package/modules/security/tests/scan_store.mjs +250 -0
- package/modules/security/tests/tool_scans_migration.mjs +116 -0
- package/modules/sessions/routes/sessions.js +6 -5
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/create.html +13 -3
- package/scripts/gds/abandon.js +1 -1
- package/scripts/gds/audit-authorship.sh +6 -0
- package/scripts/gds/box.js +1 -1
- package/scripts/gds/claim.js +15 -0
- package/scripts/gds/cli-lib.js +16 -4
- package/scripts/gds/gating.js +28 -13
- package/scripts/gds/gen-api-docs.js +1 -1
- package/scripts/gds/goal.js +234 -0
- package/scripts/gds/init.js +70 -15
- package/scripts/gds/main-audit.js +80 -11
- package/scripts/gds/mirror-redact.js +6 -0
- package/scripts/gds/newcomer-floor.js +2 -2
- package/scripts/gds/provision.js +230 -94
- package/scripts/gds/run-unit-tests.js +9 -1
- package/scripts/gds/sandbox-stage.js +66 -9
- package/scripts/gds/search-ingest-md.js +1 -0
- package/scripts/gds/sequence.js +67 -8
- package/scripts/gds/ship.js +183 -50
- package/scripts/hall-preview/server.js +1 -1
- package/scripts/migrate.sh +99 -14
- package/src/bongos/auth.js +129 -27
- package/src/bongos/db-kernel.js +66 -5
- package/src/bongos/db.js +6 -7
- package/src/bongos/github-repos.js +19 -7
- package/src/bongos/goal-scope-check.js +1 -1
- package/src/bongos/module-scope-map.js +3 -1
- package/src/bongos/permission-path-check.js +1 -1
- package/src/bongos/route-rank-check.js +73 -30
- package/src/bongos/routes/audit-log.js +4 -2
- package/src/bongos/routes/auth.js +8 -4
- package/src/bongos/routes/backup.js +7 -4
- package/src/bongos/routes/builders.js +23 -11
- package/src/bongos/routes/github.js +15 -4
- package/src/bongos/routes/llm-cache.js +5 -2
- package/src/bongos/routes/modules.js +9 -5
- package/src/bongos/routes/security.js +21 -12
- package/src/bongos/serve-internal.js +22 -7
- package/src/module-api.js +86 -1
- package/src/platform-server.js +14 -0
- package/tests/api_client.mjs +18 -2
- package/tests/api_path_404.mjs +3 -0
- package/tests/art_stage.mjs +2 -1
- package/tests/atlas.mjs +32 -4
- package/tests/auth_admission_gate.mjs +17 -5
- package/tests/auth_error_brand.mjs +41 -21
- package/tests/auth_resolve_failure.mjs +155 -0
- package/tests/blocker_link.mjs +88 -0
- package/tests/bongos_downloads.mjs +2 -1
- package/tests/box_dns_repoint.mjs +2 -0
- package/tests/box_ship_permissions.mjs +2 -0
- package/tests/box_terminal_ssrf.mjs +23 -17
- package/tests/claim_error_surface.mjs +23 -0
- package/tests/claude_materialize.mjs +4 -0
- package/tests/cli_box_scope.mjs +40 -0
- package/tests/cloud_bongos_pack.mjs +34 -4
- package/tests/conductor.mjs +2 -1
- package/tests/criterion_authoring_gate.mjs +28 -5
- package/tests/currency_label.mjs +10 -1
- package/tests/deploy_instance.mjs +2 -0
- package/tests/devbox_downloads.mjs +2 -1
- package/tests/display_name.mjs +95 -6
- package/tests/eviction_policy.mjs +2 -1
- package/tests/file_map.mjs +10 -0
- package/tests/first_admin_bootstrap.mjs +10 -2
- package/tests/first_run_engine_check.mjs +2 -1
- package/tests/fitness.mjs +26 -6
- package/tests/gate_approvals.mjs +15 -2
- package/tests/github_push_land_proof.mjs +50 -4
- package/tests/github_repos_helpers.mjs +30 -5
- package/tests/github_token_store.mjs +23 -0
- package/tests/goal_abuse_matrix.mjs +13 -3
- package/tests/goal_cli.mjs +89 -0
- package/tests/governance_abuse_matrix.mjs +386 -0
- package/tests/governance_fold.mjs +156 -0
- package/tests/governance_fold_bench.mjs +50 -0
- package/tests/governance_live_proof.mjs +201 -0
- package/tests/governance_page_gates.mjs +21 -9
- package/tests/governance_parity.mjs +11 -2
- package/tests/governance_require_permission.mjs +125 -1
- package/tests/governance_routes.mjs +334 -0
- package/tests/grader.mjs +7 -3
- package/tests/hall_nav.mjs +14 -4
- package/tests/helpers.mjs +73 -0
- package/tests/hook_core_resolve.mjs +2 -1
- package/tests/host_topology_skips.mjs +97 -0
- package/tests/infra_exec_paths.mjs +14 -4
- package/tests/init.mjs +128 -0
- package/tests/known_failing_ratchet.mjs +89 -0
- package/tests/lifecycle-card.mjs +2 -1
- package/tests/main_audit.mjs +72 -1
- package/tests/memory_isolation.mjs +56 -9
- package/tests/migrate_root_resolution.mjs +124 -1
- package/tests/mirror_redact.mjs +19 -0
- package/tests/module_api.mjs +3 -1
- package/tests/module_loader.mjs +4 -0
- package/tests/modules.mjs +35 -7
- package/tests/modules_route.mjs +29 -5
- package/tests/onboarding_observability.mjs +49 -11
- package/tests/onboarding_route_signals.mjs +1 -0
- package/tests/one_active_claim_per_session.mjs +80 -0
- package/tests/pre_compact.mjs +2 -1
- package/tests/preview_server.mjs +2 -1
- package/tests/profile_route.mjs +1 -0
- package/tests/provision.mjs +170 -0
- package/tests/provisioning_onboard_mode.mjs +180 -0
- package/tests/publish_manifest.mjs +16 -3
- package/tests/publish_reconciler.mjs +181 -10
- package/tests/rank_tier_single_source.mjs +205 -0
- package/tests/recall_hints.mjs +2 -1
- package/tests/repo_map.mjs +4 -0
- package/tests/route_permission_check.mjs +14 -5
- package/tests/rubric_fixtures.mjs +3 -0
- package/tests/run_routine_two_root.mjs +2 -0
- package/tests/sandbox_stage.mjs +69 -7
- package/tests/search_ingest.mjs +7 -0
- package/tests/security_docs.mjs +28 -6
- package/tests/sequence.mjs +64 -0
- package/tests/session-cards.mjs +2 -1
- package/tests/session_start_freshness.mjs +2 -1
- package/tests/ship_broadcast_server.mjs +63 -1
- package/tests/ship_card.mjs +66 -0
- package/tests/ship_ci_deploy.mjs +66 -1
- package/tests/ship_smoke_gate.mjs +76 -0
- package/tests/versions_route.mjs +15 -0
- package/tests/workflow_exec_paths.mjs +16 -4
- package/tests/worktree_guard.mjs +2 -1
|
@@ -11,6 +11,11 @@ const { asyncHandler, validateOrRespond } = api;
|
|
|
11
11
|
// 'building'; 'shipped'/'frozen' are end states reached via the close flow.
|
|
12
12
|
const VALID_VERSION_STATUSES = new Set(['planning', 'building', 'shipped', 'frozen']);
|
|
13
13
|
|
|
14
|
+
// versions.track CHECK (migration 003) — NOT NULL with no DB default, so the
|
|
15
|
+
// create path must always supply one. 'product' is the caller-facing default:
|
|
16
|
+
// an instance seeding its first version is seeding its product track.
|
|
17
|
+
const VALID_VERSION_TRACKS = new Set(['product', 'internal']);
|
|
18
|
+
|
|
14
19
|
module.exports = function buildVersionsRouter() {
|
|
15
20
|
const router = express.Router();
|
|
16
21
|
|
|
@@ -62,11 +67,12 @@ module.exports = function buildVersionsRouter() {
|
|
|
62
67
|
// no hand-written seed-*.js script. Archon-only: creating a version is a
|
|
63
68
|
// plan/scope mutation, same gate as POST /tasks (ADR 0016).
|
|
64
69
|
// rank: archon — creates a version + its done-when criteria.
|
|
65
|
-
router.post('/versions', auth.requireBuilder, auth.
|
|
70
|
+
router.post('/versions', auth.requireBuilder, auth.requirePermission('version.create'), asyncHandler('POST /versions', async (req, res) => {
|
|
66
71
|
if (validateOrRespond(req, res, {
|
|
67
72
|
id: { required: true, type: 'string', maxLength: 64, minLength: 1 },
|
|
68
73
|
name: { required: true, type: 'string', maxLength: 200, minLength: 1 },
|
|
69
74
|
status: { type: 'string', maxLength: 32 },
|
|
75
|
+
track: { type: 'string', maxLength: 16 },
|
|
70
76
|
done_when: { type: 'string', maxLength: 8000 },
|
|
71
77
|
scope_doc_path: { type: 'string', maxLength: 256 },
|
|
72
78
|
criteria: { type: 'array', maxItems: 100 },
|
|
@@ -81,6 +87,11 @@ module.exports = function buildVersionsRouter() {
|
|
|
81
87
|
return res.fail('bad_status', 400, { valid: Array.from(VALID_VERSION_STATUSES) });
|
|
82
88
|
}
|
|
83
89
|
|
|
90
|
+
const track = body.track || 'product';
|
|
91
|
+
if (!VALID_VERSION_TRACKS.has(track)) {
|
|
92
|
+
return res.fail('bad_track', 400, { valid: Array.from(VALID_VERSION_TRACKS) });
|
|
93
|
+
}
|
|
94
|
+
|
|
84
95
|
if (await db.getVersion(id)) {
|
|
85
96
|
return res.fail('version_exists', { status: 409, message: `Version '${id}' already exists.` });
|
|
86
97
|
}
|
|
@@ -104,7 +115,7 @@ module.exports = function buildVersionsRouter() {
|
|
|
104
115
|
let version, created;
|
|
105
116
|
try {
|
|
106
117
|
({ version, criteria: created } = await db.createVersion({
|
|
107
|
-
id, name, status,
|
|
118
|
+
id, name, status, track,
|
|
108
119
|
doneWhen: body.done_when ?? null,
|
|
109
120
|
scopeDocPath: body.scope_doc_path ?? null,
|
|
110
121
|
criteria,
|
|
@@ -195,7 +195,21 @@ function safeBrand() {
|
|
|
195
195
|
// Stage-specific field selection lives HERE; the renderers are generic.
|
|
196
196
|
function buildCardModel(input = {}) {
|
|
197
197
|
const stage = input.stage || stageFor(input.postGradeStatus);
|
|
198
|
-
const
|
|
198
|
+
const baseCfg = STAGES[stage] || STAGES.failed;
|
|
199
|
+
// task 1002541: did the land actually bail? `input.land` is the ship chain's
|
|
200
|
+
// outcome ({ ok, pending, reason, nextStep }). A task sits at 'confirmed' both
|
|
201
|
+
// when it IS landing on its own (pending — CI/the reconciler will finish it)
|
|
202
|
+
// and when the merge gave up. Only the second is a lie worth shouting about,
|
|
203
|
+
// so this needs an explicit reason — never a bare "not ok".
|
|
204
|
+
// ABSENT `land` (every pre-existing caller, and breadcrumbs written by older
|
|
205
|
+
// versions) ⇒ false ⇒ the card renders exactly as it did before.
|
|
206
|
+
const land = input.land || null;
|
|
207
|
+
const landBailed = !!(stage === 'confirmed' && land && land.ok === false && !land.pending && land.reason);
|
|
208
|
+
// A bailed land is not "confirmed — landing". Recolor the banner to match the
|
|
209
|
+
// truth; the underlying STAGES entry is left untouched (it's frozen + shared).
|
|
210
|
+
const cfg = landBailed
|
|
211
|
+
? { ...baseCfg, tone: 'warning', icon: 'ti-alert-triangle', statusWord: 'Not landed', headline: (id) => `Task #${id} confirmed — NOT landed` }
|
|
212
|
+
: baseCfg;
|
|
199
213
|
const brand = input.brand || safeBrand();
|
|
200
214
|
const taskId = input.taskId;
|
|
201
215
|
const grade = input.grade || {};
|
|
@@ -285,11 +299,20 @@ function buildCardModel(input = {}) {
|
|
|
285
299
|
deployCommit = input.deploy && input.deploy.commit ? String(input.deploy.commit) : null;
|
|
286
300
|
deployText = deployCommit ? `live · ${deployCommit}` : 'live';
|
|
287
301
|
} else if (stage === 'confirmed') {
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
|
|
302
|
+
// task 1002541: this line used to be unconditional, driven by the STATUS
|
|
303
|
+
// alone — so a merge that bailed still reported "landing automatically".
|
|
304
|
+
// Nothing was landing, no PR existed, and under ADR 0120 (pay on land) the
|
|
305
|
+
// builder was unpaid: the failure announced itself as success, which is
|
|
306
|
+
// why these strands survived for days. Say what actually happened.
|
|
307
|
+
if (landBailed) {
|
|
308
|
+
deployText = `NOT landed — ${land.reason}`;
|
|
309
|
+
} else {
|
|
310
|
+
// The server lands confirmed tasks automatically (publish-reconciler +
|
|
311
|
+
// merge driver + ADR 0082 resolver); /merge-mode is a rare manual
|
|
312
|
+
// fallback, kept as the card's optional 'Land it' override — so the fact
|
|
313
|
+
// states the automatic reality rather than implying a required command.
|
|
314
|
+
deployText = 'landing automatically';
|
|
315
|
+
}
|
|
293
316
|
} else {
|
|
294
317
|
deployText = 'not deployed';
|
|
295
318
|
}
|
|
@@ -300,12 +323,20 @@ function buildCardModel(input = {}) {
|
|
|
300
323
|
addFact('', changed);
|
|
301
324
|
if (gradeText) addFact('grade', gradeText, { cls: gradeCls });
|
|
302
325
|
if (stage === 'shipped') addFact('live', deployCommit, { mono: true });
|
|
326
|
+
// The reason can be a full sentence — too long for a fact chip, so the chip
|
|
327
|
+
// carries the alarm and the reason rides in its tooltip (the Deploy row above
|
|
328
|
+
// always shows it in full).
|
|
329
|
+
else if (landBailed) addFact('', 'NOT landed', { cls: 'cc-bad', title: land.reason });
|
|
303
330
|
else addFact('', deployText);
|
|
304
331
|
addFact('', usageText, usageTitle ? { title: usageTitle } : {});
|
|
305
332
|
if (creditsText) addFact('', creditsText);
|
|
306
333
|
}
|
|
307
334
|
|
|
308
|
-
|
|
335
|
+
// task 1002541: a bail knows the precise recovery command (re-run the ship,
|
|
336
|
+
// re-auth, pass --db-only…), which beats the stage's generic '/merge-mode'.
|
|
337
|
+
const action = (landBailed && land.nextStep)
|
|
338
|
+
? { kind: 'prompt', label: 'Land it', cmd: land.nextStep }
|
|
339
|
+
: nextActionFor(stage, { taskId, discipline: input.discipline, sandboxUrl: input.sandboxUrl });
|
|
309
340
|
|
|
310
341
|
return {
|
|
311
342
|
stage, cfg, brand, taskId,
|
|
@@ -556,7 +556,7 @@ module.exports = function buildMemoryRouter() {
|
|
|
556
556
|
router.get(
|
|
557
557
|
'/memory/builders/:builderId/files',
|
|
558
558
|
auth.requireBuilder,
|
|
559
|
-
auth.
|
|
559
|
+
auth.requirePermission('memory.read.any'),
|
|
560
560
|
readRateLimit,
|
|
561
561
|
async (req, res) => {
|
|
562
562
|
const targetId = Number(req.params.builderId);
|
|
@@ -607,7 +607,7 @@ module.exports = function buildMemoryRouter() {
|
|
|
607
607
|
router.get(
|
|
608
608
|
'/memory/builders/:builderId/file',
|
|
609
609
|
auth.requireBuilder,
|
|
610
|
-
auth.
|
|
610
|
+
auth.requirePermission('memory.read.any'),
|
|
611
611
|
readRateLimit,
|
|
612
612
|
async (req, res) => {
|
|
613
613
|
const targetId = Number(req.params.builderId);
|
|
@@ -26,7 +26,7 @@ const express = require('express');
|
|
|
26
26
|
// core file a module may require, ADR 0083); approval-broadcast travels WITH this
|
|
27
27
|
// admission route (its sole consumer) and is an intra-module require.
|
|
28
28
|
const api = require('../../../src/module-api');
|
|
29
|
-
const auth = { requireBuilder: api.requireBuilder,
|
|
29
|
+
const auth = { requireBuilder: api.requireBuilder, requirePermission: api.requirePermission };
|
|
30
30
|
const { pool } = api;
|
|
31
31
|
const { validateOrRespond, parseId } = api;
|
|
32
32
|
const approvalBroadcast = require('../approval-broadcast');
|
|
@@ -145,7 +145,7 @@ module.exports = function buildAccessRequestsRouter() {
|
|
|
145
145
|
// GET /access-requests?status=pending — Archon queue. Defaults to pending;
|
|
146
146
|
// pass ?status=invited|dismissed for the resolved history.
|
|
147
147
|
// rank: archon — the queue exposes would-be builders' usernames.
|
|
148
|
-
router.get('/access-requests', auth.requireBuilder, auth.
|
|
148
|
+
router.get('/access-requests', auth.requireBuilder, auth.requirePermission('access_request.review'), async (req, res) => {
|
|
149
149
|
const status = req.query.status || 'pending';
|
|
150
150
|
if (!['pending', 'invited', 'dismissed'].includes(status)) {
|
|
151
151
|
return res.fail('bad_status', 400);
|
|
@@ -174,7 +174,7 @@ module.exports = function buildAccessRequestsRouter() {
|
|
|
174
174
|
// (the only sign-in gate — no repo access needed); 'dismissed' = declined /
|
|
175
175
|
// spam / dup. The row stays as the audit trail either way.
|
|
176
176
|
// rank: archon — same gate as the rest of the roster management surface.
|
|
177
|
-
router.patch('/access-requests/:id', auth.requireBuilder, auth.
|
|
177
|
+
router.patch('/access-requests/:id', auth.requireBuilder, auth.requirePermission('access_request.review'), async (req, res) => {
|
|
178
178
|
const id = parseId(req, res, { code: 'bad_request_id' });
|
|
179
179
|
if (id === null) return;
|
|
180
180
|
if (validateOrRespond(req, res, {
|
|
@@ -14,13 +14,17 @@ const express = require('express');
|
|
|
14
14
|
const api = require('../../../src/module-api');
|
|
15
15
|
const pi = require('../platform-identity');
|
|
16
16
|
|
|
17
|
-
const { pool, requireBuilder,
|
|
17
|
+
const { pool, requireBuilder, requirePermission, validateOrRespond, parseId, corsPublicGet } = api;
|
|
18
18
|
|
|
19
19
|
module.exports = function projectsRoutes() {
|
|
20
20
|
const router = express.Router();
|
|
21
21
|
|
|
22
|
-
// rank: archon — the full curation list (includes unfeatured + owner attribution).
|
|
23
|
-
|
|
22
|
+
// rank: metic+archon — the full curation list (includes unfeatured + owner attribution).
|
|
23
|
+
// BV1.R105 left this on requireRank because `project.curate` was declared over the
|
|
24
|
+
// writes + GET /scouting, not over this read. ADR 0157 extends that atom's guards to
|
|
25
|
+
// name GET /projects and lowers its floor archon→metic, so the mapping now exists and
|
|
26
|
+
// the audience is the working rank.
|
|
27
|
+
router.get('/projects', requireBuilder, requirePermission('project.curate'), async (_req, res) => {
|
|
24
28
|
try {
|
|
25
29
|
res.json({ projects: await pi.listAllProjects({ pool }) });
|
|
26
30
|
} catch (err) {
|
|
@@ -31,7 +35,7 @@ module.exports = function projectsRoutes() {
|
|
|
31
35
|
|
|
32
36
|
// rank: archon — create / update a catalog entry (manual seed for hand-wired
|
|
33
37
|
// instances; federated projects auto-catalogue at registration).
|
|
34
|
-
router.post('/projects', requireBuilder,
|
|
38
|
+
router.post('/projects', requireBuilder, requirePermission('project.curate'), async (req, res) => {
|
|
35
39
|
if (validateOrRespond(req, res, {
|
|
36
40
|
origin: { required: true, type: 'string', maxLength: 512 },
|
|
37
41
|
name: { required: true, type: 'string', maxLength: 200 },
|
|
@@ -57,7 +61,7 @@ module.exports = function projectsRoutes() {
|
|
|
57
61
|
// by github_login and requires the invitee to already have a platform account (they
|
|
58
62
|
// build on some project); inviting someone entirely new to the platform (GitHub-id
|
|
59
63
|
// resolution) is a follow-up.
|
|
60
|
-
router.post('/projects/invite', requireBuilder,
|
|
64
|
+
router.post('/projects/invite', requireBuilder, requirePermission('project.curate'), async (req, res) => {
|
|
61
65
|
if (validateOrRespond(req, res, {
|
|
62
66
|
client_id: { required: true, type: 'string', maxLength: 200 },
|
|
63
67
|
github_login: { required: true, type: 'string', maxLength: 200 },
|
|
@@ -76,7 +80,7 @@ module.exports = function projectsRoutes() {
|
|
|
76
80
|
});
|
|
77
81
|
|
|
78
82
|
// rank: archon — the feature / unfeature control (the ADR 0141 §5 curation gate).
|
|
79
|
-
router.patch('/projects/:id/featured', requireBuilder,
|
|
83
|
+
router.patch('/projects/:id/featured', requireBuilder, requirePermission('project.curate'), async (req, res) => {
|
|
80
84
|
const id = parseId(req, res, { code: 'bad_project_id', positiveInt: true });
|
|
81
85
|
if (id === null) return;
|
|
82
86
|
if (validateOrRespond(req, res, { featured: { required: true, type: 'boolean' } })) return;
|
|
@@ -12,7 +12,7 @@ const express = require('express');
|
|
|
12
12
|
const api = require('../../../src/module-api');
|
|
13
13
|
const pi = require('../platform-identity');
|
|
14
14
|
|
|
15
|
-
const { pool, requireBuilder,
|
|
15
|
+
const { pool, requireBuilder, requirePermission, parsePagination } = api;
|
|
16
16
|
|
|
17
17
|
module.exports = function scoutingRoutes() {
|
|
18
18
|
const router = express.Router();
|
|
@@ -20,7 +20,7 @@ module.exports = function scoutingRoutes() {
|
|
|
20
20
|
// rank: archon — the internal talent directory across every project. The Archon
|
|
21
21
|
// gate here is the load-bearing protection ("NOT shown to builders"): a non-Archon
|
|
22
22
|
// is 403'd before any cross-builder data is read.
|
|
23
|
-
router.get('/scouting', requireBuilder,
|
|
23
|
+
router.get('/scouting', requireBuilder, requirePermission('project.curate'), async (req, res) => {
|
|
24
24
|
const { limit } = parsePagination(req, { defaultLimit: 100, maxLimit: 500 });
|
|
25
25
|
try {
|
|
26
26
|
const builders = await pi.listScoutingRollup({ limit, pool });
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
-- provisioning_004_onboard_mode.sql — record HOW an instance was onboarded:
|
|
2
|
+
-- greenfield (a fresh empty repo) vs adopt (layer onto a repo the owner already has).
|
|
3
|
+
-- ADR 0121 / task 1002562, BONGOS-V1 goal 32.
|
|
4
|
+
--
|
|
5
|
+
-- WHY THIS COLUMN EXISTS. `bongos init --adopt` (ADR 0121, tasks 2042-2046) built the
|
|
6
|
+
-- LOCAL half of brownfield onboarding, and `GET /provisioning/onboard-plan?mode=…`
|
|
7
|
+
-- branches the checklist TEXT the user is shown — but the mode was never PERSISTED, so
|
|
8
|
+
-- the control-plane runner had no way to know. scripts/gds/provision.js therefore ran
|
|
9
|
+
-- the greenfield scaffold unconditionally: `git init` a fresh tree, commit, and push
|
|
10
|
+
-- `HEAD:main`. Against a repo that already has history that is an unrelated-history
|
|
11
|
+
-- non-fast-forward and git rejects it, so adopting an existing repo could never
|
|
12
|
+
-- succeed. This column is what lets the runner take the layering path instead.
|
|
13
|
+
--
|
|
14
|
+
-- DEFAULT 'greenfield' is the correct backfill: every instance provisioned before this
|
|
15
|
+
-- migration went through the greenfield scaffold by construction (there was no other
|
|
16
|
+
-- path), so no existing row is mislabelled.
|
|
17
|
+
--
|
|
18
|
+
-- Widening/adding is ADDITIVE — a new nullable-with-default column plus a CHECK that
|
|
19
|
+
-- only constrains the new column. No existing row changes and no data is lost, so this
|
|
20
|
+
-- is safe under the module-owned-migration rule (ADR 0083 §Decision #5). Idempotent:
|
|
21
|
+
-- safe to re-run.
|
|
22
|
+
ALTER TABLE provisioning_instances
|
|
23
|
+
ADD COLUMN IF NOT EXISTS onboard_mode text NOT NULL DEFAULT 'greenfield';
|
|
24
|
+
|
|
25
|
+
ALTER TABLE provisioning_instances DROP CONSTRAINT IF EXISTS provisioning_instances_onboard_mode_check;
|
|
26
|
+
ALTER TABLE provisioning_instances ADD CONSTRAINT provisioning_instances_onboard_mode_check
|
|
27
|
+
CHECK (onboard_mode IN ('greenfield', 'adopt'));
|
|
28
|
+
|
|
29
|
+
INSERT INTO schema_migrations (version) VALUES ('provisioning_004_onboard_mode')
|
|
30
|
+
ON CONFLICT (version) DO NOTHING;
|
|
@@ -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,
|
|
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.
|
|
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.
|
|
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.
|
|
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 };
|