@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.
- package/.bongos-core.json +547 -197
- 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/bin/bongos.js +33 -7
- 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/0137-upstream-publish-policy-manual-owner-gated.md +1 -1
- 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/0161-publish-on-merge.md +34 -0
- package/docs/adr/README.md +9 -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 +181 -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 +6 -2
- 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/migrations/core_205_versions_track_default.sql +28 -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 +99 -69
- 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/bump-version.js +76 -0
- 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 +77 -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 +5 -5
- 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 +96 -13
- 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_cli.mjs +91 -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/bump_version.mjs +71 -0
- 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 +147 -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 +1 -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/patch_gate_ordering.mjs +102 -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
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
// tests/governance_abuse_matrix.mjs — the abuse matrix (BV1.R112, ADR 0151).
|
|
2
|
+
//
|
|
3
|
+
// Mechanically proves the safety guarantee the governance module asserts in
|
|
4
|
+
// prose: NO configuration surface can grant a `system: true` (trust-boundary)
|
|
5
|
+
// permission — deploy, protected-path, rank-set, grade-bypass, cross-tier,
|
|
6
|
+
// principals — to a custom role or a low-trust builder, and the out-of-band
|
|
7
|
+
// substrate still blocks the action EVEN IF a grant row were forced into the DB.
|
|
8
|
+
//
|
|
9
|
+
// The matrix is enumerated from the catalog, never sampled: every vector below
|
|
10
|
+
// loops over catalog.systemKeys() (and every registry surface), so a system
|
|
11
|
+
// permission added tomorrow is covered the day it lands.
|
|
12
|
+
//
|
|
13
|
+
// Grant vectors (every one must refuse, server-side, before any DB write):
|
|
14
|
+
// V1 HTTP: PUT a system permission onto a custom role
|
|
15
|
+
// V2 HTTP: PUT any grant onto a seeded rank-role (immutable)
|
|
16
|
+
// V3 HTTP: assign a seeded rank-role to a builder (rank is the only writer)
|
|
17
|
+
// V4 HTTP: mass-assignment on role create (is_system / permissions in body)
|
|
18
|
+
// V5 HTTP: every write vector 403s outright for a caller without
|
|
19
|
+
// governance.manage (the low-trust builder's own attempt)
|
|
20
|
+
// V6 config: a role template proposing system keys is stripped; a seeded
|
|
21
|
+
// role's template grants are dropped wholesale; env overrides labels only
|
|
22
|
+
// V7 session-scope: an allow-list naming a system or principal key THROWS
|
|
23
|
+
//
|
|
24
|
+
// The wall behind the wall (a grant row forced past every vector above):
|
|
25
|
+
// V8 a forced principal grant resolves to NOTHING (effectivePermissions)
|
|
26
|
+
// V9 a box-scoped session narrows away every forced system grant
|
|
27
|
+
// V10 no route consumes the substrate-enforced keys via requirePermission —
|
|
28
|
+
// a forced deploy/protected-path/ssh grant admits nothing in-app
|
|
29
|
+
// V11 the substrate blocks a sub-floor builder on EVERY protected surface,
|
|
30
|
+
// and is structurally grant-blind (dependency-free — it cannot even
|
|
31
|
+
// reach the resolver, a DB, or the network)
|
|
32
|
+
// V12 seed integrity: no rank-role below archon holds a system key; no
|
|
33
|
+
// rank-role at all holds a principal key
|
|
34
|
+
//
|
|
35
|
+
// DB-free (the governance_routes.mjs pattern): real router + real
|
|
36
|
+
// requirePermission over a stub `governance` port, db sibling monkeypatched.
|
|
37
|
+
//
|
|
38
|
+
// Run: node tests/governance_abuse_matrix.mjs
|
|
39
|
+
|
|
40
|
+
import { strict as assert } from 'node:assert';
|
|
41
|
+
import { test, after } from 'node:test';
|
|
42
|
+
import http from 'node:http';
|
|
43
|
+
import fs from 'node:fs';
|
|
44
|
+
import path from 'node:path';
|
|
45
|
+
import { createRequire } from 'node:module';
|
|
46
|
+
import { fileURLToPath } from 'node:url';
|
|
47
|
+
|
|
48
|
+
const require = createRequire(import.meta.url);
|
|
49
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
50
|
+
const api = require(path.join(ROOT, 'src', 'module-api.js'));
|
|
51
|
+
const seams = require(path.join(ROOT, 'src', 'module-seams.js'));
|
|
52
|
+
const catalog = require(path.join(ROOT, 'modules', 'governance', 'catalog.js'));
|
|
53
|
+
const govConfig = require(path.join(ROOT, 'modules', 'governance', 'config.js'));
|
|
54
|
+
const sessionScopes = require(path.join(ROOT, 'modules', 'governance', 'session-scopes.js'));
|
|
55
|
+
const resolver = require(path.join(ROOT, 'modules', 'governance', 'resolver.js'));
|
|
56
|
+
const govDb = require(path.join(ROOT, 'modules', 'governance', 'db.js'));
|
|
57
|
+
const ppc = require(path.join(ROOT, 'src', 'bongos', 'permission-path-check.js'));
|
|
58
|
+
const rrc = require(path.join(ROOT, 'src', 'bongos', 'route-rank-check.js'));
|
|
59
|
+
const { attachFail } = require(path.join(ROOT, 'src', 'bongos', 'middleware', 'error-envelope.js'));
|
|
60
|
+
const express = require('express');
|
|
61
|
+
|
|
62
|
+
const SYSTEM_KEYS = catalog.systemKeys();
|
|
63
|
+
const SEEDED_ROLES = catalog.RANK_ORDER; // xenos/thetes/metic/archon
|
|
64
|
+
|
|
65
|
+
// The keys whose enforcement lives OUTSIDE the app entirely (git pre-push hook,
|
|
66
|
+
// grader pre-pass, post-push main audit, credential placement — ADR 0043). The
|
|
67
|
+
// task names deploy + protected-path; infra.ssh is the same substrate class.
|
|
68
|
+
const SUBSTRATE_KEYS = ['deploy.push_main', 'path.protected.modify', 'infra.ssh'];
|
|
69
|
+
|
|
70
|
+
// ===========================================================================
|
|
71
|
+
// The matrix rows — sanity on what is being enumerated
|
|
72
|
+
// ===========================================================================
|
|
73
|
+
|
|
74
|
+
test('the matrix enumerates a non-empty system set including the named powers', () => {
|
|
75
|
+
assert.ok(SYSTEM_KEYS.length > 0, 'no system keys — the matrix would be vacuous');
|
|
76
|
+
for (const k of SUBSTRATE_KEYS) {
|
|
77
|
+
assert.ok(SYSTEM_KEYS.includes(k), `${k} must be system:true`);
|
|
78
|
+
}
|
|
79
|
+
for (const k of SYSTEM_KEYS) {
|
|
80
|
+
assert.equal(catalog.isGrantable(k), false, `${k} must be non-grantable`);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('every system permission floors at archon or is a principal (never seeded lower)', () => {
|
|
85
|
+
for (const k of SYSTEM_KEYS) {
|
|
86
|
+
const p = catalog.byKey(k);
|
|
87
|
+
assert.ok(p.floor === 'archon' || p.floor === null,
|
|
88
|
+
`${k} floors at "${p.floor}" — a system permission must never seed below archon`);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// ===========================================================================
|
|
93
|
+
// The HTTP vectors — real router, stub port, monkeypatched db
|
|
94
|
+
// ===========================================================================
|
|
95
|
+
|
|
96
|
+
const calls = [];
|
|
97
|
+
const stub = {
|
|
98
|
+
getRoleByKey: async (k) => (SEEDED_ROLES.includes(k)
|
|
99
|
+
? { role_key: k, label: k, is_system: true, seeded_rank: k }
|
|
100
|
+
: { role_key: k, label: k, is_system: false, seeded_rank: null }),
|
|
101
|
+
createRole: async (a) => ({ role_key: a.roleKey, label: a.label, description: a.description ?? null, is_system: false, seeded_rank: null, created_by: a.createdBy, created_at: 't' }),
|
|
102
|
+
grantRolePermission: async () => true,
|
|
103
|
+
revokeRolePermission: async () => true,
|
|
104
|
+
assignRole: async () => true,
|
|
105
|
+
unassignRole: async () => true,
|
|
106
|
+
listRolesWithGrants: async () => [],
|
|
107
|
+
listAssignments: async () => [],
|
|
108
|
+
};
|
|
109
|
+
const REAL = {};
|
|
110
|
+
for (const k of Object.keys(stub)) {
|
|
111
|
+
REAL[k] = govDb[k];
|
|
112
|
+
govDb[k] = (...args) => { calls.push([k, ...args]); return stub[k](...args); };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// The attacker holds the MOST privileged legitimate session — an Archon with
|
|
116
|
+
// governance.manage. The matrix must hold even against the admin misusing the
|
|
117
|
+
// API, not just against a low-trust caller (V5 covers that one).
|
|
118
|
+
const realRequireBuilder = api.requireBuilder;
|
|
119
|
+
api.requireBuilder = (req, res, next) => { req.builder = { id: 42, rank: 'archon' }; next(); };
|
|
120
|
+
|
|
121
|
+
function grant(keys) {
|
|
122
|
+
seams._reset();
|
|
123
|
+
seams.registerProvider('governance', { resolveBuilderPermissions: async () => keys });
|
|
124
|
+
}
|
|
125
|
+
grant(['governance.manage']);
|
|
126
|
+
|
|
127
|
+
const buildGovernanceRouter = require(path.join(ROOT, 'modules', 'governance', 'routes', 'governance.js'));
|
|
128
|
+
const app = express();
|
|
129
|
+
app.use(express.json());
|
|
130
|
+
app.use(attachFail);
|
|
131
|
+
app.use(buildGovernanceRouter());
|
|
132
|
+
const server = http.createServer(app);
|
|
133
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
134
|
+
const BASE = `http://127.0.0.1:${server.address().port}`;
|
|
135
|
+
|
|
136
|
+
after(() => {
|
|
137
|
+
server.close();
|
|
138
|
+
api.requireBuilder = realRequireBuilder;
|
|
139
|
+
for (const k of Object.keys(REAL)) govDb[k] = REAL[k];
|
|
140
|
+
seams._reset();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
async function call(method, p, body) {
|
|
144
|
+
calls.length = 0;
|
|
145
|
+
const res = await fetch(`${BASE}${p}`, {
|
|
146
|
+
method,
|
|
147
|
+
headers: body === undefined ? {} : { 'content-type': 'application/json' },
|
|
148
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
149
|
+
});
|
|
150
|
+
let json = null;
|
|
151
|
+
try { json = await res.json(); } catch { /* no body */ }
|
|
152
|
+
return { status: res.status, json };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
test('V1: EVERY system permission is refused onto a custom role, before the db', async () => {
|
|
156
|
+
for (const k of SYSTEM_KEYS) {
|
|
157
|
+
const r = await call('PUT', `/governance/roles/custom-role/permissions/${k}`);
|
|
158
|
+
assert.equal(r.status, 400, `${k} must be refused`);
|
|
159
|
+
assert.equal(r.json.error.code, 'permission_not_grantable', k);
|
|
160
|
+
assert.equal(calls.filter(([c]) => c === 'grantRolePermission').length, 0,
|
|
161
|
+
`${k}: the refusal must land BEFORE any db write`);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('V2: EVERY seeded rank-role refuses EVERY grant (system and operational alike)', async () => {
|
|
166
|
+
for (const role of SEEDED_ROLES) {
|
|
167
|
+
for (const k of [...SYSTEM_KEYS, 'idea.triage']) {
|
|
168
|
+
const r = await call('PUT', `/governance/roles/${role}/permissions/${k}`);
|
|
169
|
+
assert.equal(r.status, 403, `${role} ← ${k} must be refused`);
|
|
170
|
+
assert.equal(r.json.error.code, 'seeded_role_immutable', `${role} ← ${k}`);
|
|
171
|
+
assert.equal(calls.filter(([c]) => c === 'grantRolePermission').length, 0);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('V3: EVERY seeded rank-role refuses assignment (rank is the only writer — R95b)', async () => {
|
|
177
|
+
for (const role of SEEDED_ROLES) {
|
|
178
|
+
const r = await call('POST', '/governance/assignments', { builder_id: 7, role_key: role });
|
|
179
|
+
assert.equal(r.status, 403, `assigning seeded role ${role} must be refused`);
|
|
180
|
+
assert.equal(r.json.error.code, 'seeded_role_assignment_forbidden', role);
|
|
181
|
+
assert.equal(calls.filter(([c]) => c === 'assignRole').length, 0);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('V4: mass-assignment on role create is refused outright (strict schema)', async () => {
|
|
186
|
+
const r = await call('POST', '/governance/roles', {
|
|
187
|
+
role_key: 'sneaky-role',
|
|
188
|
+
label: 'Sneaky',
|
|
189
|
+
is_system: true,
|
|
190
|
+
seeded_rank: 'archon',
|
|
191
|
+
permissions: SYSTEM_KEYS,
|
|
192
|
+
});
|
|
193
|
+
assert.equal(r.status, 400, 'undeclared fields must be rejected, not silently dropped');
|
|
194
|
+
assert.equal(r.json.error.code, 'validation_failed');
|
|
195
|
+
assert.equal(calls.length, 0, 'nothing reached the db');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('V4: a clean role create is never system and carries no grants', async () => {
|
|
199
|
+
const r = await call('POST', '/governance/roles', { role_key: 'sneaky-role', label: 'Sneaky' });
|
|
200
|
+
assert.equal(r.status, 201);
|
|
201
|
+
assert.equal(r.json.role.is_system, false, 'a created role is never system');
|
|
202
|
+
assert.equal(r.json.role.seeded_rank, null);
|
|
203
|
+
assert.deepEqual(r.json.role.permissions, [], 'creation carries no grants');
|
|
204
|
+
const c = calls.find(([k]) => k === 'createRole');
|
|
205
|
+
assert.deepEqual(Object.keys(c[1]).sort(), ['createdBy', 'description', 'label', 'roleKey'],
|
|
206
|
+
'nothing beyond the four whitelisted fields reaches the db');
|
|
207
|
+
assert.equal(calls.filter(([k]) => k === 'grantRolePermission').length, 0);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('V5: a caller without governance.manage cannot reach ANY write vector at all', async () => {
|
|
211
|
+
grant(catalog.grantableKeys().filter((k) => k !== 'governance.manage'));
|
|
212
|
+
for (const [method, p, body] of [
|
|
213
|
+
['POST', '/governance/roles', { role_key: 'x-role', label: 'X' }],
|
|
214
|
+
['PUT', '/governance/roles/custom-role/permissions/deploy.push_main', undefined],
|
|
215
|
+
['DELETE', '/governance/roles/custom-role/permissions/idea.triage', undefined],
|
|
216
|
+
['POST', '/governance/assignments', { builder_id: 7, role_key: 'custom-role' }],
|
|
217
|
+
['DELETE', '/governance/assignments/7/custom-role', undefined],
|
|
218
|
+
]) {
|
|
219
|
+
const r = await call(method, p, body);
|
|
220
|
+
assert.equal(r.status, 403, `${method} ${p} must deny — even holding every operational grant`);
|
|
221
|
+
assert.equal(r.json.error.code, 'permission_forbidden', `${method} ${p}`);
|
|
222
|
+
}
|
|
223
|
+
grant(['governance.manage']);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// ===========================================================================
|
|
227
|
+
// V6 — the config vector (a checkout-writable file must grant nothing)
|
|
228
|
+
// ===========================================================================
|
|
229
|
+
|
|
230
|
+
test('V6: a config template proposing EVERY system key resolves with all of them stripped', () => {
|
|
231
|
+
const resolved = govConfig.resolveGovernanceConfig({
|
|
232
|
+
instance: { roles: { 'evil-template': { label: 'Evil', permissions: [...SYSTEM_KEYS, 'no.such.key', 'idea.triage'] } } },
|
|
233
|
+
env: {},
|
|
234
|
+
});
|
|
235
|
+
const evil = resolved.roles['evil-template'];
|
|
236
|
+
assert.deepEqual([...evil.permissions], ['idea.triage'],
|
|
237
|
+
'only the operational key survives; every system + unknown key is stripped');
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test('V6: a seeded rank-role template cannot re-grant — its permissions drop wholesale', () => {
|
|
241
|
+
const resolved = govConfig.resolveGovernanceConfig({
|
|
242
|
+
instance: { roles: { thetes: { label: 'Upgraded', permissions: ['idea.triage', ...SYSTEM_KEYS] } } },
|
|
243
|
+
env: {},
|
|
244
|
+
});
|
|
245
|
+
assert.deepEqual([...resolved.roles.thetes.permissions], [],
|
|
246
|
+
'a seeded role takes NO permissions from config, operational ones included');
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test('V6: the env layer relabels only — it cannot add a permission or a role', () => {
|
|
250
|
+
// The role must exist in the merged config for the relabel to land (an env
|
|
251
|
+
// var can only RELABEL a declared role) — mirror the neutral starter's shape.
|
|
252
|
+
const resolved = govConfig.resolveGovernanceConfig({
|
|
253
|
+
neutral: { roles: { metic: { label: 'Metic' } } },
|
|
254
|
+
instance: {},
|
|
255
|
+
env: { OTB_GOVERNANCE_ROLE_LABELS: 'metic=Overlord,ghost-role=Ghost' },
|
|
256
|
+
});
|
|
257
|
+
assert.equal(resolved.roles.metic.label, 'Overlord', 'relabeling works');
|
|
258
|
+
assert.equal(resolved.roles['ghost-role'], undefined, 'an env var cannot mint a role');
|
|
259
|
+
assert.deepEqual([...resolved.roles.metic.permissions], [],
|
|
260
|
+
'the relabeled seeded role still takes no grants from config or env');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// ===========================================================================
|
|
264
|
+
// V7 — the session-scope vector (an allow-list can never name system power)
|
|
265
|
+
// ===========================================================================
|
|
266
|
+
|
|
267
|
+
test('V7: a session-scope allow-list naming ANY system key throws at validation', () => {
|
|
268
|
+
for (const k of SYSTEM_KEYS) {
|
|
269
|
+
assert.throws(
|
|
270
|
+
() => sessionScopes.validateAgainstCatalog([
|
|
271
|
+
sessionScopes.normalizeScope({ source: 'evil', permissions: [k] }),
|
|
272
|
+
]),
|
|
273
|
+
new RegExp(k.replace(/\./g, '\\.')),
|
|
274
|
+
`a scope allowing ${k} must be refused at load`,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test('V7: the LIVE session-scope registry carries no system or principal key', () => {
|
|
280
|
+
for (const scope of sessionScopes.SESSION_SCOPES) {
|
|
281
|
+
for (const k of scope.permissions) {
|
|
282
|
+
assert.equal(catalog.isSystem(k), false, `scope "${scope.source}" allows system key ${k}`);
|
|
283
|
+
assert.equal(catalog.isPrincipalKey(k), false, `scope "${scope.source}" allows principal key ${k}`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// ===========================================================================
|
|
289
|
+
// V8–V9 — a grant row FORCED past every write-side wall
|
|
290
|
+
// ===========================================================================
|
|
291
|
+
|
|
292
|
+
test('V8: a forced principal grant resolves to nothing (structurally dropped)', () => {
|
|
293
|
+
const forced = resolver.effectivePermissions([...catalog.principalKeys(), 'task.ship']);
|
|
294
|
+
assert.deepEqual([...forced], ['task.ship'],
|
|
295
|
+
'principal keys confer nothing through the role axis no matter how the row got written');
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test('V9: a box-scoped session narrows away EVERY forced system grant', () => {
|
|
299
|
+
const forcedEverything = catalog.permissionKeys(); // as if every key were granted
|
|
300
|
+
const boxHolds = resolver.narrowToSessionScope(forcedEverything, 'box');
|
|
301
|
+
for (const k of boxHolds) {
|
|
302
|
+
assert.equal(catalog.isSystem(k), false, `a box token surfaced system key ${k}`);
|
|
303
|
+
}
|
|
304
|
+
assert.deepEqual([...resolver.narrowToSessionScope(forcedEverything, 'not-a-source')], [],
|
|
305
|
+
'an unrecognised source resolves to nothing, never to "full"');
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// ===========================================================================
|
|
309
|
+
// V10 — no route consumes the substrate keys (a forced grant is inert in-app)
|
|
310
|
+
// ===========================================================================
|
|
311
|
+
|
|
312
|
+
test('V10: no route gates on deploy/protected-path/ssh via requirePermission', () => {
|
|
313
|
+
const files = rrc.discoverRouteFiles();
|
|
314
|
+
assert.ok(files.length > 0, 'no route files discovered');
|
|
315
|
+
for (const { abs, rel } of files) {
|
|
316
|
+
const text = fs.readFileSync(abs, 'utf8');
|
|
317
|
+
for (const m of text.matchAll(/requirePermission\(([^)]*)\)/g)) {
|
|
318
|
+
for (const k of SUBSTRATE_KEYS) {
|
|
319
|
+
assert.ok(!m[1].includes(k),
|
|
320
|
+
`${rel} gates on ${k} — that key is substrate-enforced (ADR 0043); an app route consuming it would make a forced grant spendable`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// ===========================================================================
|
|
327
|
+
// V11 — the substrate holds, and cannot even see a grant
|
|
328
|
+
// ===========================================================================
|
|
329
|
+
|
|
330
|
+
// A concrete file that matches a registry glob, so every surface is exercised.
|
|
331
|
+
function sampleFileFor(glob) {
|
|
332
|
+
if (glob.endsWith('/')) return `${glob}sample.js`;
|
|
333
|
+
return glob.replace(/\*\*/g, 'deep/nested').replace(/\*/g, 'sample');
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
test('V11: EVERY protected surface blocks a builder below its floor — grants have no input', () => {
|
|
337
|
+
assert.ok(ppc.PROTECTED_SURFACES.length > 0, 'empty protected-surface registry');
|
|
338
|
+
for (const s of ppc.PROTECTED_SURFACES) {
|
|
339
|
+
const file = sampleFileFor(s.glob);
|
|
340
|
+
assert.ok(ppc.matchProtected([file]).length > 0, `synthesized ${file} must match ${s.glob}`);
|
|
341
|
+
const floorTier = ppc.RANK_TIER[s.floor];
|
|
342
|
+
assert.ok(floorTier > ppc.RANK_TIER.xenos, `${s.glob} floors at ${s.floor} — nothing sits below it to block`);
|
|
343
|
+
const attacker = Object.keys(ppc.RANK_TIER).find((r) => ppc.RANK_TIER[r] === floorTier - 1);
|
|
344
|
+
const result = ppc.checkPermissionPaths({ changedFiles: [file], builderRank: attacker });
|
|
345
|
+
assert.equal(result.hardFail, true, `${attacker} touching ${file} must hard-fail`);
|
|
346
|
+
assert.ok(result.findings.some((f) => f.file === file && f.severity === 'blocker'),
|
|
347
|
+
`${file}: a blocker finding must name the file`);
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test('V11: every protected surface maps to a system (non-grantable) permission', () => {
|
|
352
|
+
for (const s of ppc.PROTECTED_SURFACES) {
|
|
353
|
+
assert.equal(catalog.isSystem(s.permission), true,
|
|
354
|
+
`${s.glob} maps to ${s.permission}, which is not system:true — an Archon could hand a custom role the substrate's power`);
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test('V11: the substrate checker is structurally grant-blind (node builtins only)', () => {
|
|
359
|
+
const src = fs.readFileSync(path.join(ROOT, 'src', 'bongos', 'permission-path-check.js'), 'utf8');
|
|
360
|
+
const required = [...src.matchAll(/require\((['"])([^'"]+)\1\)/g)].map((m) => m[2]);
|
|
361
|
+
assert.ok(required.length > 0, 'expected the checker to require node builtins');
|
|
362
|
+
for (const spec of required) {
|
|
363
|
+
assert.ok(spec.startsWith('node:'),
|
|
364
|
+
`permission-path-check.js requires "${spec}" — it must stay dependency-free so it CANNOT consult the resolver, a DB, or the network (a forced grant has no channel in)`);
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
// ===========================================================================
|
|
369
|
+
// V12 — seed integrity (the compat seed can never leak system power downward)
|
|
370
|
+
// ===========================================================================
|
|
371
|
+
|
|
372
|
+
test('V12: no rank-role below archon holds a system key; none holds a principal key', () => {
|
|
373
|
+
for (const rank of SEEDED_ROLES) {
|
|
374
|
+
const seed = catalog.RANK_ROLE_SEED[rank];
|
|
375
|
+
for (const k of seed) {
|
|
376
|
+
assert.equal(catalog.isPrincipalKey(k), false, `${rank} seeds principal key ${k}`);
|
|
377
|
+
if (rank !== 'archon') {
|
|
378
|
+
assert.equal(catalog.isSystem(k), false, `${rank} seeds system key ${k}`);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
const archonSystem = catalog.RANK_ROLE_SEED.archon.filter((k) => catalog.isSystem(k)).sort();
|
|
383
|
+
const expected = SYSTEM_KEYS.filter((k) => catalog.byKey(k).floor === 'archon').sort();
|
|
384
|
+
assert.deepEqual(archonSystem, expected,
|
|
385
|
+
'archon holds exactly the archon-floored system keys — principals stay out of every rank-role');
|
|
386
|
+
});
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// tests/governance_fold.mjs — the session-load permission FOLD (task 1002556).
|
|
2
|
+
//
|
|
3
|
+
// DB-free. requirePermission used to cost a second round-trip on every gated
|
|
4
|
+
// request (requireBuilder loads the session, then resolveHeldPermissions hits
|
|
5
|
+
// the governance tables). The fold has db-kernel's lookupSession aggregate the
|
|
6
|
+
// builder's raw grant keys in the SAME query; the gate then runs the pure
|
|
7
|
+
// catalog-drop (the port's effectivePermissions) with no second trip.
|
|
8
|
+
//
|
|
9
|
+
// What must hold, and what this file pins:
|
|
10
|
+
// • the fold is an OPTIMIZATION, never an authority change — same verdicts,
|
|
11
|
+
// catalog-drop included, as the DB resolve given the same rows;
|
|
12
|
+
// • every unexpected shape FALLS BACK to the DB resolve (no raw keys on the
|
|
13
|
+
// session / an older port without the pure half / a throwing pure half) —
|
|
14
|
+
// the fold can only ever remove a round-trip, never a denial;
|
|
15
|
+
// • fail-closed posture is untouched: a missing/malformed port still denies
|
|
16
|
+
// even when raw keys are present (authority must be ESTABLISHED, ADR 0016).
|
|
17
|
+
//
|
|
18
|
+
// The real-DB / real-HTTP complement is tests/governance_live_proof.mjs, which
|
|
19
|
+
// exercises the folded lookupSession end-to-end on a live stack.
|
|
20
|
+
import assert from 'node:assert/strict';
|
|
21
|
+
import { test } from 'node:test';
|
|
22
|
+
import { createRequire } from 'node:module';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
|
|
26
|
+
const require = createRequire(import.meta.url);
|
|
27
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
28
|
+
const auth = require(path.join(ROOT, 'src', 'bongos', 'auth.js'));
|
|
29
|
+
const seams = require(path.join(ROOT, 'src', 'module-seams.js'));
|
|
30
|
+
const resolver = require(path.join(ROOT, 'modules', 'governance', 'resolver.js'));
|
|
31
|
+
const { attachFail } = require(path.join(ROOT, 'src', 'bongos', 'middleware', 'error-envelope.js'));
|
|
32
|
+
|
|
33
|
+
function fakeRes() {
|
|
34
|
+
const res = {
|
|
35
|
+
_status: null,
|
|
36
|
+
_json: null,
|
|
37
|
+
status(code) { this._status = code; return this; },
|
|
38
|
+
json(body) { this._json = body; return this; },
|
|
39
|
+
};
|
|
40
|
+
attachFail({}, res, () => {});
|
|
41
|
+
return res;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// One gate run against a stubbed port. `port` null = unregistered.
|
|
45
|
+
async function run(req, port) {
|
|
46
|
+
seams._reset();
|
|
47
|
+
if (port !== null) seams.registerProvider('governance', port);
|
|
48
|
+
const res = fakeRes();
|
|
49
|
+
let nexted = false;
|
|
50
|
+
await auth.requirePermission('idea.triage')(req, res, () => { nexted = true; });
|
|
51
|
+
seams._reset();
|
|
52
|
+
return { res, nexted };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const BUILDER = { id: 7, rank: 'thetes' };
|
|
56
|
+
// A port whose DB half counts calls and whose pure half is the REAL catalog-drop
|
|
57
|
+
// (resolver.effectivePermissions is DB-free), so fold verdicts are the shipped ones.
|
|
58
|
+
function countingPort(dbKeys, extra = {}) {
|
|
59
|
+
const port = {
|
|
60
|
+
dbCalls: 0,
|
|
61
|
+
resolveBuilderPermissions: async function count() { port.dbCalls += 1; return dbKeys; },
|
|
62
|
+
effectivePermissions: resolver.effectivePermissions,
|
|
63
|
+
...extra,
|
|
64
|
+
};
|
|
65
|
+
return port;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
test('FAST PATH: raw keys on the request admit with ZERO resolver round-trips', async () => {
|
|
69
|
+
const port = countingPort(['idea.triage']);
|
|
70
|
+
const { nexted } = await run({ builder: BUILDER, rawPermissionKeys: ['idea.triage'] }, port);
|
|
71
|
+
assert.equal(nexted, true, 'the folded grant admits');
|
|
72
|
+
assert.equal(port.dbCalls, 0, 'the DB resolve must not run — that is the whole point');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('the catalog-drop still applies on the fold: a junk raw key admits nothing', async () => {
|
|
76
|
+
const port = countingPort(['idea.triage']);
|
|
77
|
+
const { res, nexted } = await run(
|
|
78
|
+
{ builder: BUILDER, rawPermissionKeys: ['idea.triage.definitely-not-a-key'] }, port,
|
|
79
|
+
);
|
|
80
|
+
assert.equal(nexted, false, 'an undefined catalog key must not admit');
|
|
81
|
+
assert.equal(res._status, 403);
|
|
82
|
+
assert.equal(port.dbCalls, 0, 'denial came from the pure path, not a fallback');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('no raw keys on the request → the DB resolve runs exactly as before', async () => {
|
|
86
|
+
const port = countingPort(['idea.triage']);
|
|
87
|
+
const { nexted } = await run({ builder: BUILDER, rawPermissionKeys: null }, port);
|
|
88
|
+
assert.equal(nexted, true);
|
|
89
|
+
assert.equal(port.dbCalls, 1, 'fallback = the pre-fold single DB resolve');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('an older port without the pure half → DB fallback, not a crash or deny', async () => {
|
|
93
|
+
const port = countingPort(['idea.triage'], { effectivePermissions: undefined });
|
|
94
|
+
const { nexted } = await run({ builder: BUILDER, rawPermissionKeys: ['idea.triage'] }, port);
|
|
95
|
+
assert.equal(nexted, true);
|
|
96
|
+
assert.equal(port.dbCalls, 1);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('a throwing pure half → DB fallback still answers', async () => {
|
|
100
|
+
const port = countingPort(['idea.triage'], { effectivePermissions: () => { throw new Error('boom'); } });
|
|
101
|
+
const { nexted } = await run({ builder: BUILDER, rawPermissionKeys: ['idea.triage'] }, port);
|
|
102
|
+
assert.equal(nexted, true);
|
|
103
|
+
assert.equal(port.dbCalls, 1);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('fail-closed is untouched: raw keys present but NO port still 403s', async () => {
|
|
107
|
+
const { res, nexted } = await run({ builder: BUILDER, rawPermissionKeys: ['idea.triage'] }, null);
|
|
108
|
+
assert.equal(nexted, false, 'authority must be ESTABLISHED — raw keys alone admit nothing');
|
|
109
|
+
assert.equal(res._status, 403);
|
|
110
|
+
assert.equal(res._json.error.code, 'permission_forbidden');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('the kernel shape guard: the real fragment embeds; a malformed one degrades to the base query', () => {
|
|
114
|
+
const dbKernel = require(path.join(ROOT, 'src', 'bongos', 'db-kernel.js'));
|
|
115
|
+
// A statement separator (the misregistration/injection shape) is refused —
|
|
116
|
+
// and refusal does NOT latch, so a later good registration still lands.
|
|
117
|
+
seams._reset();
|
|
118
|
+
seams.registerProvider('session-grants-sql', {
|
|
119
|
+
selectFragment: `, (SELECT 1) AS raw_permission_keys; DROP TABLE builders --`,
|
|
120
|
+
});
|
|
121
|
+
assert.equal(dbKernel.sessionPermsSelect(), '', 'a fragment with a statement separator is refused');
|
|
122
|
+
// The REAL registered fragment passes the guard verbatim.
|
|
123
|
+
seams._reset();
|
|
124
|
+
const buildGovernanceRouter = require(path.join(ROOT, 'modules', 'governance', 'routes', 'governance.js'));
|
|
125
|
+
buildGovernanceRouter();
|
|
126
|
+
const real = seams.resolveOptional('session-grants-sql').selectFragment;
|
|
127
|
+
assert.equal(dbKernel.sessionPermsSelect(), real, 'the shipped fragment embeds unchanged');
|
|
128
|
+
seams._reset();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('the module registers the capability-named SQL seam the kernel embeds', () => {
|
|
132
|
+
// The kernel resolves 'session-grants-sql' and stays module-agnostic (ADR 0083
|
|
133
|
+
// §Decision #4); the governance route factory is what registers it. Pin the
|
|
134
|
+
// registration and the column contract the kernel query depends on.
|
|
135
|
+
seams._reset();
|
|
136
|
+
const buildGovernanceRouter = require(path.join(ROOT, 'modules', 'governance', 'routes', 'governance.js'));
|
|
137
|
+
buildGovernanceRouter();
|
|
138
|
+
const provider = seams.resolveOptional('session-grants-sql');
|
|
139
|
+
assert.ok(provider && typeof provider.selectFragment === 'string', 'fragment provider registered');
|
|
140
|
+
assert.match(provider.selectFragment, /AS raw_permission_keys/, 'yields the column auth.js reads');
|
|
141
|
+
assert.match(provider.selectFragment, /s\.builder_id/, 'correlates on the session query alias');
|
|
142
|
+
seams._reset();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('parity: the fold and the DB resolve return the same verdict for the same rows', async () => {
|
|
146
|
+
// Same raw rows through both paths — including a junk key and a duplicate,
|
|
147
|
+
// the two shapes the fold's array_agg(DISTINCT) and the resolve differ on
|
|
148
|
+
// mechanically but must agree on semantically.
|
|
149
|
+
const rows = ['idea.triage', 'idea.triage', 'blocker.review', 'not.a.key'];
|
|
150
|
+
const foldPort = countingPort([]);
|
|
151
|
+
const dbPort = countingPort(rows);
|
|
152
|
+
const fold = await run({ builder: BUILDER, rawPermissionKeys: rows }, foldPort);
|
|
153
|
+
const db = await run({ builder: BUILDER, rawPermissionKeys: null }, dbPort);
|
|
154
|
+
assert.equal(fold.nexted, db.nexted, 'same admit/deny either way');
|
|
155
|
+
assert.equal(fold.nexted, true);
|
|
156
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// tests/governance_fold_bench.mjs — the task-1002556 benchmark, re-runnable.
|
|
2
|
+
//
|
|
3
|
+
// Measures end-to-end latency of a requireBuilder + requirePermission route
|
|
4
|
+
// (GET /governance/roles) against a LIVE server, the way the fold was graded:
|
|
5
|
+
// point it at a stack before and after and compare. Asserts only route health
|
|
6
|
+
// (every request 200) — the numbers are the deliverable, printed for the
|
|
7
|
+
// operator to diff.
|
|
8
|
+
//
|
|
9
|
+
// Captured runs, docker-compose sandbox, 2026-08-06 (n=300 after 30 warmup):
|
|
10
|
+
// two-trip baseline (pre-fold main): mean 3.36ms p50 3.30 p95 4.01 p99 5.24
|
|
11
|
+
// folded (session-load fold): mean 3.02ms p50 2.99 p95 3.55 p99 3.74
|
|
12
|
+
// One DB round-trip's worth on a local TCP socket; the delta scales with real
|
|
13
|
+
// DB latency and applies to every migrated permission gate. The companion
|
|
14
|
+
// statement-log check (see the task's ship notes) showed the folded run issues
|
|
15
|
+
// ONE session query (carrying raw_permission_keys) and no resolver query.
|
|
16
|
+
//
|
|
17
|
+
// Integration-class (needs a live server + a session): SELF-SKIPS (exit 0)
|
|
18
|
+
// without GDS_BASE_URL + BENCH_TOKEN; listed in run-unit-tests.js's INTEGRATION
|
|
19
|
+
// set so the DB-free lane never runs it.
|
|
20
|
+
//
|
|
21
|
+
// Run: GDS_BASE_URL=http://localhost:3000 BENCH_TOKEN=<archon session token> \
|
|
22
|
+
// node tests/governance_fold_bench.mjs
|
|
23
|
+
// BENCH_N (default 300) / BENCH_WARMUP (default 30) tune the sample.
|
|
24
|
+
|
|
25
|
+
import { strict as assert } from 'node:assert';
|
|
26
|
+
|
|
27
|
+
const BASE = (process.env.GDS_BASE_URL || '').replace(/\/$/, '');
|
|
28
|
+
const TOKEN = process.env.BENCH_TOKEN || '';
|
|
29
|
+
if (!BASE || !TOKEN) {
|
|
30
|
+
console.log('governance_fold_bench.mjs SKIPPED — set GDS_BASE_URL and BENCH_TOKEN (a session token whose builder holds governance.manage).');
|
|
31
|
+
process.exit(0);
|
|
32
|
+
}
|
|
33
|
+
const N = Number(process.env.BENCH_N) || 300;
|
|
34
|
+
const WARMUP = Number(process.env.BENCH_WARMUP) || 30;
|
|
35
|
+
|
|
36
|
+
const times = [];
|
|
37
|
+
for (let i = 0; i < WARMUP + N; i++) {
|
|
38
|
+
const t0 = process.hrtime.bigint();
|
|
39
|
+
const res = await fetch(`${BASE}/api/gds/governance/roles`, {
|
|
40
|
+
headers: { authorization: `Bearer ${TOKEN}` },
|
|
41
|
+
});
|
|
42
|
+
await res.json();
|
|
43
|
+
const ms = Number(process.hrtime.bigint() - t0) / 1e6;
|
|
44
|
+
assert.equal(res.status, 200, `request ${i} returned ${res.status} — the bench needs a healthy gated route`);
|
|
45
|
+
if (i >= WARMUP) times.push(ms);
|
|
46
|
+
}
|
|
47
|
+
times.sort((a, b) => a - b);
|
|
48
|
+
const pct = (p) => times[Math.floor(times.length * p)].toFixed(2);
|
|
49
|
+
const mean = (times.reduce((a, b) => a + b, 0) / times.length).toFixed(2);
|
|
50
|
+
console.log(`governance_fold_bench: n=${N} mean=${mean}ms p50=${pct(0.5)}ms p95=${pct(0.95)}ms p99=${pct(0.99)}ms`);
|