@bongos/core 1.19.644 → 1.19.646
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 +21 -21
- package/.claude/skills/grade-recover/SKILL.md +4 -2
- package/docs/module-api-changelog.md +4 -0
- package/modules/ideas/routes/inbox.js +13 -3
- package/modules/ideas/routing.js +20 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/claude-materialize.js +14 -1
- package/scripts/gds/ship-flow.js +27 -1
- package/scripts/gds/ship-honesty.js +53 -0
- package/src/bongos/auth.js +59 -6
- package/src/module-api.js +1 -1
- package/tests/auth_page_gate.mjs +58 -0
- package/tests/claude_materialize.mjs +30 -0
- package/tests/filing_abuse_matrix.mjs +53 -1
- package/tests/government_require_permission.mjs +103 -0
- package/tests/ship_cannot_lie.mjs +103 -1
package/.bongos-core.json
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.646",
|
|
6
|
+
"core_contract": "1.19.646",
|
|
7
|
+
"source_commit": "9648a16f755cae6136582c0d0ca4930a566a11af",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-10T18:10:26.956Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 470,
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
19
|
"file_count": 2600,
|
|
20
|
-
"tree_sha256": "
|
|
20
|
+
"tree_sha256": "69d3719087a6b6e7fb7c1a0d4889c6bf272f0258a7d7cc72458179525775f6df",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/backlog-review/SKILL.md",
|
|
@@ -172,7 +172,7 @@
|
|
|
172
172
|
{
|
|
173
173
|
"path": ".claude/skills/grade-recover/SKILL.md",
|
|
174
174
|
"mode": "0000644",
|
|
175
|
-
"sha256": "
|
|
175
|
+
"sha256": "4dbbf04c19558b33332d9ba1bb8747198ded19e1a3af06ef72ede58ee4eb95ad"
|
|
176
176
|
},
|
|
177
177
|
{
|
|
178
178
|
"path": ".claude/skills/grader-health/SKILL.md",
|
|
@@ -2772,7 +2772,7 @@
|
|
|
2772
2772
|
{
|
|
2773
2773
|
"path": "docs/module-api-changelog.md",
|
|
2774
2774
|
"mode": "0000644",
|
|
2775
|
-
"sha256": "
|
|
2775
|
+
"sha256": "f4ceea78bdb8f6ba5fec64415aed58610a8ab011d3a8b550e517515904c8cfce"
|
|
2776
2776
|
},
|
|
2777
2777
|
{
|
|
2778
2778
|
"path": "docs/modules-contract.md",
|
|
@@ -5547,12 +5547,12 @@
|
|
|
5547
5547
|
{
|
|
5548
5548
|
"path": "modules/ideas/routes/inbox.js",
|
|
5549
5549
|
"mode": "0000644",
|
|
5550
|
-
"sha256": "
|
|
5550
|
+
"sha256": "b3df5f0d4273f44af60a7e09a8bef026a0f02085c7823c7912c70d067e55e127"
|
|
5551
5551
|
},
|
|
5552
5552
|
{
|
|
5553
5553
|
"path": "modules/ideas/routing.js",
|
|
5554
5554
|
"mode": "0000644",
|
|
5555
|
-
"sha256": "
|
|
5555
|
+
"sha256": "da653f475d6c91db8e84bff46c797bb68b61e48d561eacf7bfd228a119ac7266"
|
|
5556
5556
|
},
|
|
5557
5557
|
{
|
|
5558
5558
|
"path": "modules/ideas/templates.js",
|
|
@@ -7722,12 +7722,12 @@
|
|
|
7722
7722
|
{
|
|
7723
7723
|
"path": "package-lock.json",
|
|
7724
7724
|
"mode": "0000644",
|
|
7725
|
-
"sha256": "
|
|
7725
|
+
"sha256": "8def408bd91446b93b180b2696465b9bedaf1f8ebe15c671cd4f66a41f6b03da"
|
|
7726
7726
|
},
|
|
7727
7727
|
{
|
|
7728
7728
|
"path": "package.json",
|
|
7729
7729
|
"mode": "0000644",
|
|
7730
|
-
"sha256": "
|
|
7730
|
+
"sha256": "62ee3c0059ddc570ef2fef4f2288b7714d9eb95f64d390f67720eb7d07c92cd5"
|
|
7731
7731
|
},
|
|
7732
7732
|
{
|
|
7733
7733
|
"path": "public-docs/index.html",
|
|
@@ -7982,7 +7982,7 @@
|
|
|
7982
7982
|
{
|
|
7983
7983
|
"path": "scripts/gds/claude-materialize.js",
|
|
7984
7984
|
"mode": "0000644",
|
|
7985
|
-
"sha256": "
|
|
7985
|
+
"sha256": "cf333976f3c8fc9cfa1af57a121e5ee1f826d40fe7461bf1e32f939a59523663"
|
|
7986
7986
|
},
|
|
7987
7987
|
{
|
|
7988
7988
|
"path": "scripts/gds/claude-md-template.js",
|
|
@@ -8862,7 +8862,7 @@
|
|
|
8862
8862
|
{
|
|
8863
8863
|
"path": "scripts/gds/ship-flow.js",
|
|
8864
8864
|
"mode": "0000644",
|
|
8865
|
-
"sha256": "
|
|
8865
|
+
"sha256": "9853e719e4a695cc52ec9089298d05163210ed3b51ca9eceb7553bc0ed9c65bd"
|
|
8866
8866
|
},
|
|
8867
8867
|
{
|
|
8868
8868
|
"path": "scripts/gds/ship-git.js",
|
|
@@ -8882,7 +8882,7 @@
|
|
|
8882
8882
|
{
|
|
8883
8883
|
"path": "scripts/gds/ship-honesty.js",
|
|
8884
8884
|
"mode": "0000644",
|
|
8885
|
-
"sha256": "
|
|
8885
|
+
"sha256": "29116265032b48115c31df794aa4689e7ce3f5eaeef152ea1ca2390dc2078c39"
|
|
8886
8886
|
},
|
|
8887
8887
|
{
|
|
8888
8888
|
"path": "scripts/gds/ship-io.js",
|
|
@@ -9217,7 +9217,7 @@
|
|
|
9217
9217
|
{
|
|
9218
9218
|
"path": "src/bongos/auth.js",
|
|
9219
9219
|
"mode": "0000644",
|
|
9220
|
-
"sha256": "
|
|
9220
|
+
"sha256": "88e734e42d8fd15ba24d75cafcc611de90e16362200e198e9a9bc4ee825bfbaf"
|
|
9221
9221
|
},
|
|
9222
9222
|
{
|
|
9223
9223
|
"path": "src/bongos/cascade-dispatch.js",
|
|
@@ -9482,7 +9482,7 @@
|
|
|
9482
9482
|
{
|
|
9483
9483
|
"path": "src/module-api.js",
|
|
9484
9484
|
"mode": "0000644",
|
|
9485
|
-
"sha256": "
|
|
9485
|
+
"sha256": "bb3a06612cea7d25b04efabda8659ed8a71b66859bab279f9b4e8a1e5dbadff3"
|
|
9486
9486
|
},
|
|
9487
9487
|
{
|
|
9488
9488
|
"path": "src/module-loader/catalog.js",
|
|
@@ -9742,7 +9742,7 @@
|
|
|
9742
9742
|
{
|
|
9743
9743
|
"path": "tests/auth_page_gate.mjs",
|
|
9744
9744
|
"mode": "0000644",
|
|
9745
|
-
"sha256": "
|
|
9745
|
+
"sha256": "8011ca574306df12ae1e046e6a37ca9e779b29ad26d2bfc0508ae8d6b9f5a0b2"
|
|
9746
9746
|
},
|
|
9747
9747
|
{
|
|
9748
9748
|
"path": "tests/auth_resolve_failure.mjs",
|
|
@@ -10067,7 +10067,7 @@
|
|
|
10067
10067
|
{
|
|
10068
10068
|
"path": "tests/claude_materialize.mjs",
|
|
10069
10069
|
"mode": "0000644",
|
|
10070
|
-
"sha256": "
|
|
10070
|
+
"sha256": "b0474476af85716848114d345f4be67beb2e01ad3ceab36a25d474c3e9e536c5"
|
|
10071
10071
|
},
|
|
10072
10072
|
{
|
|
10073
10073
|
"path": "tests/clawback_confirmed_backlog.mjs",
|
|
@@ -10502,7 +10502,7 @@
|
|
|
10502
10502
|
{
|
|
10503
10503
|
"path": "tests/filing_abuse_matrix.mjs",
|
|
10504
10504
|
"mode": "0000644",
|
|
10505
|
-
"sha256": "
|
|
10505
|
+
"sha256": "815f2fa2ffd6e7a721507852d43b0490115a0b15393670f6035626bdf000e99c"
|
|
10506
10506
|
},
|
|
10507
10507
|
{
|
|
10508
10508
|
"path": "tests/first_admin_bootstrap.mjs",
|
|
@@ -10902,7 +10902,7 @@
|
|
|
10902
10902
|
{
|
|
10903
10903
|
"path": "tests/government_require_permission.mjs",
|
|
10904
10904
|
"mode": "0000644",
|
|
10905
|
-
"sha256": "
|
|
10905
|
+
"sha256": "8ae3eaaf0b7d9a0e93d58eda3d877abe4f59bed25ffbcdc4e42a67497398debf"
|
|
10906
10906
|
},
|
|
10907
10907
|
{
|
|
10908
10908
|
"path": "tests/government_resolver.mjs",
|
|
@@ -12517,7 +12517,7 @@
|
|
|
12517
12517
|
{
|
|
12518
12518
|
"path": "tests/ship_cannot_lie.mjs",
|
|
12519
12519
|
"mode": "0000644",
|
|
12520
|
-
"sha256": "
|
|
12520
|
+
"sha256": "d8d99b6cd465b04eaf13790b0bdfe9a76ce36d77660407d3a2c6a17ac7852e8c"
|
|
12521
12521
|
},
|
|
12522
12522
|
{
|
|
12523
12523
|
"path": "tests/ship_card.mjs",
|
|
@@ -58,12 +58,14 @@ The panel ran and rejected the work. Recover by fixing, not by re-rolling:
|
|
|
58
58
|
1. **Group `issues[]` by severity, then by worker.** Nit/minor findings are advisory by rubric policy — they cannot have caused the fail on their own, so do not start there. The fail came from blocker/major findings (or a gating axis under threshold).
|
|
59
59
|
2. **For each blocker/major finding, add a checked-in `tests/*.mjs` case** that reproduces it, then fix until green. The grader treats throwaway verification as unverified — the committed test is what makes the fix real.
|
|
60
60
|
3. **Fix real findings once; do not chase re-worded restatements** of a point you already answered (answer it in the commit or with a tracked task ref instead).
|
|
61
|
-
4. Commit on the claim branch — the grader reads committed history — then:
|
|
61
|
+
4. Commit on the claim branch — the grader reads committed history — then re-grade, saying how each finding was answered:
|
|
62
62
|
|
|
63
63
|
```bash
|
|
64
|
-
node scripts/gds/ship.js <id> --regrade
|
|
64
|
+
node scripts/gds/ship.js <id> --regrade --response-file response.md
|
|
65
65
|
```
|
|
66
66
|
|
|
67
|
+
**`--response` is how you answer a finding; `--notes` is not** (task 1003702). The panel is given the task, your value summary, the changed files and the diff — it never reads ship notes, on a re-grade or on the first ship. `--response` rides to the panel fenced as the prior round's answer, so an already-answered finding cannot re-veto on re-wording alone. Passing `--notes` used to be accepted and silently dropped; it is now refused with this same guidance. Evidence itself still belongs in the **diff** — a committed test is what makes a fix real (step 2), and the panel can read every changed file.
|
|
68
|
+
|
|
67
69
|
### Not recoverable here — `REFUSED (authorization)`
|
|
68
70
|
|
|
69
71
|
A grade block naming protected surfaces above your rank is an authorization wall, not a quality verdict: no re-grade at your rank can land that diff as-is. Split the protected files out of the diff and re-ship the rest, or hand the task to a builder at/above the floor the refusal names.
|
|
@@ -1737,5 +1737,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1737
1737
|
landed since 1.19.642 with no explicit bump. run 34509123612. (task 1002620)
|
|
1738
1738
|
1.19.644 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1739
1739
|
landed since 1.19.643 with no explicit bump. run 34509682128. (task 1002620)
|
|
1740
|
+
1.19.645 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1741
|
+
landed since 1.19.644 with no explicit bump. run 34511775606. (task 1002620)
|
|
1742
|
+
1.19.646 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1743
|
+
landed since 1.19.645 with no explicit bump. run 34512629504. (task 1002620)
|
|
1740
1744
|
---------------------------------------------------------------------------
|
|
1741
1745
|
```
|
|
@@ -39,6 +39,9 @@ const inbox = require('../inbox');
|
|
|
39
39
|
// and the deterministic scorer (R142) — both pure, both walk the declaration.
|
|
40
40
|
const { validateFieldValues, templateFor, fieldOrder } = require('../templates');
|
|
41
41
|
const { completenessScore, weakestSectionPayload, scoreFieldValues } = require('../completeness');
|
|
42
|
+
// The trusted tier, spelled once (task 1003197 (D)) — routing.js owns METIC_PLUS
|
|
43
|
+
// and already names 'criterion-proposal' in NEVER_ROUTE_KINDS.
|
|
44
|
+
const { mayProposeCriterion, METIC_PLUS } = require('../routing');
|
|
42
45
|
// task 1003208: structured logging (pino via the doorway) — was console.*.
|
|
43
46
|
const log = api.logger('ideas');
|
|
44
47
|
const { validateOrRespond, LIMITS, parseId } = api;
|
|
@@ -93,9 +96,16 @@ module.exports = function buildInboxRouter() {
|
|
|
93
96
|
})) return;
|
|
94
97
|
const body = req.body || {};
|
|
95
98
|
if (body.kind === 'criterion-proposal') {
|
|
96
|
-
|
|
97
|
-
if (rank && rank !== 'metic' && rank
|
|
98
|
-
|
|
99
|
+
// FAIL CLOSED (task 1003197 (D)). This was inline and read
|
|
100
|
+
// `if (rank && rank !== 'metic' && …)`, so an ABSENT rank ADMITTED. The
|
|
101
|
+
// predicate now sits with the one trusted-tier definition (routing.js), pure
|
|
102
|
+
// and denying by default. Still a rank test rather than
|
|
103
|
+
// requirePermission('idea.triage'): the gate is conditional on the request
|
|
104
|
+
// BODY, and requirePermission is route-level middleware that cannot be mounted
|
|
105
|
+
// per-payload — which is why that atom's catalog `guards` note names only
|
|
106
|
+
// PATCH /inbox/:id. Same audience either way; idea.triage floors at metic.
|
|
107
|
+
if (!mayProposeCriterion(req.builder?.rank)) {
|
|
108
|
+
return res.fail('rank_forbidden', 403, { required: [...METIC_PLUS], actual: req.builder?.rank ?? null, reason: "kind='criterion-proposal' is a planning-session write; Metic+ only." });
|
|
99
109
|
}
|
|
100
110
|
}
|
|
101
111
|
// Full Idea capture (task 1002919 / BV1.R143). Only 'full' means anything
|
package/modules/ideas/routing.js
CHANGED
|
@@ -49,6 +49,25 @@ const NEVER_ROUTE_KINDS = new Set(['criterion-proposal', 'decision']);
|
|
|
49
49
|
// separate, load-bearing gates (R05), and this decision is subordinate to both.
|
|
50
50
|
const METIC_PLUS = new Set(['metic', 'archon']);
|
|
51
51
|
|
|
52
|
+
// Who may FILE a kind='criterion-proposal' (V3.R87 / #306) — the same trusted tier,
|
|
53
|
+
// read from the one definition above rather than a second copy. A proposal reshapes
|
|
54
|
+
// the next version's scope-truth, so it is a trusted write even though ordinary
|
|
55
|
+
// filing is open to everyone; POST /inbox asks this so a Xenos cannot slip one in by
|
|
56
|
+
// raw curl after the /planning-session skill refuses to run for them.
|
|
57
|
+
//
|
|
58
|
+
// FAIL-CLOSED (task 1003197 (D)). The route held this inline as
|
|
59
|
+
// `if (rank && rank !== 'metic' && …)`, so an ABSENT rank ADMITTED. Unreachable
|
|
60
|
+
// today — builders.rank is NOT NULL behind an enum CHECK and requireBuilder 401s
|
|
61
|
+
// first — but the gate was leaning on a schema invariant it never stated, and an
|
|
62
|
+
// unknown rank is not evidence of authority. Case-insensitive but NOT trimmed:
|
|
63
|
+
// 'METIC ' is a hostile shape, not a rank. Non-strings stringify and refuse rather
|
|
64
|
+
// than throwing on .toLowerCase(). Lives here beside NEVER_ROUTE_KINDS, which
|
|
65
|
+
// already names this same kind, and keeps the trusted tier spelled ONCE
|
|
66
|
+
// (tests/filing_abuse_matrix.mjs holds both lines).
|
|
67
|
+
function mayProposeCriterion(rank) {
|
|
68
|
+
return METIC_PLUS.has(String(rank ?? '').toLowerCase());
|
|
69
|
+
}
|
|
70
|
+
|
|
52
71
|
// A Full Idea (grade='full') is the Board Room's subject — it is ratified by
|
|
53
72
|
// peers (goal 1000069), not converted into a task by its own filer. 'spark' and
|
|
54
73
|
// 'quick' carry no such meaning and route normally.
|
|
@@ -292,4 +311,5 @@ module.exports = {
|
|
|
292
311
|
NEVER_ROUTE_KINDS,
|
|
293
312
|
HOMELESS_ROUTING_VECTORS,
|
|
294
313
|
METIC_PLUS,
|
|
314
|
+
mayProposeCriterion,
|
|
295
315
|
};
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.646",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.646",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.646",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
|
@@ -431,7 +431,20 @@ function materializeClaude({ coreRoot, instanceDir, dryRun = false, overwrite =
|
|
|
431
431
|
landedNow[name] = { module: mod.key, source: `.claude/skills/${name}`, coreShipped: true };
|
|
432
432
|
}
|
|
433
433
|
}
|
|
434
|
-
|
|
434
|
+
// A name the CORE ships now is the core's, whatever an older manifest said — a core
|
|
435
|
+
// skill can take over a name a module once owned, and step 1 copies it in on this very
|
|
436
|
+
// run. Without this guard 1c deleted it again immediately (task 1003818, a regression
|
|
437
|
+
// in 1003632's own first cut). materializeModuleSkillsIntoCore states the same rule for
|
|
438
|
+
// the checkout side via its `tracked(name)` check; this is the instance-side twin.
|
|
439
|
+
//
|
|
440
|
+
// The `!skipSkills.has(name)` half is load-bearing, not belt-and-braces: when a DISABLED
|
|
441
|
+
// module declares a core-shipped name, step 1 SKIPS the copy (that is the task 2103b
|
|
442
|
+
// exclusion), so the instance's dir really is stale and must still be withdrawn. Keeping
|
|
443
|
+
// it on the strength of "the core ships this name" would resurrect exactly the bug 1c
|
|
444
|
+
// exists to fix. Deliberately asked of the CORE source tree rather than the roster, so a
|
|
445
|
+
// name no enabled module declares any more is still recognised as legitimately sourced.
|
|
446
|
+
const coreShipsNow = (name) => fs.existsSync(path.join(skillsSrc, name)) && !skipSkills.has(name);
|
|
447
|
+
const withdrawable = Object.keys(landedPreviously).filter((n) => SKILL_NAME_RE.test(n) && !landedNow[n] && !coreShipsNow(n));
|
|
435
448
|
if (!overwrite) {
|
|
436
449
|
// adopt mode never clobbers what the owner already has; withdrawing is a clobber.
|
|
437
450
|
if (withdrawable.length) summary.notes.push(`${withdrawable.length} skill(s) of a now-disabled module were left in place (adopt mode does not withdraw): ${withdrawable.sort().join(', ')}.`);
|
package/scripts/gds/ship-flow.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
'use strict';
|
|
10
10
|
|
|
11
11
|
const { cliClient, requireSession, arg, detectWorktreeName } = require('./cli-lib');
|
|
12
|
-
const { resolveProse, markShipProgress, smokeGateMessages } = require('./ship-honesty.js');
|
|
12
|
+
const { resolveProse, markShipProgress, smokeGateMessages, proseFlagPresent, regradeNotesRefusal } = require('./ship-honesty.js');
|
|
13
13
|
const { classifyEmptyShip } = require('../../modules/lifecycle/ship-preflight');
|
|
14
14
|
const { scopeForModule, isModuleKey } = require('../../src/bongos/module-scope-map');
|
|
15
15
|
const { matchOne } = require('../../src/bongos/path-match');
|
|
@@ -684,6 +684,16 @@ async function regradeMain() {
|
|
|
684
684
|
console.error('usage: node scripts/gds/ship.js <task-id> --regrade [--summary-file <path|-> | --summary "..."] [--response-file <path|-> | --response "..."] [--no-merge] [--force] [--verified] [--base <ref>]');
|
|
685
685
|
process.exit(2);
|
|
686
686
|
}
|
|
687
|
+
// task 1003702: refuse --notes rather than swallowing it. The reasoning — why
|
|
688
|
+
// a refusal and not a persist — is in ship-honesty.js above regradeNotesRefusal
|
|
689
|
+
// and is not repeated here. What is load-bearing at THIS call site is the
|
|
690
|
+
// POSITION: before the preflight and before any grade spend, because a refusal
|
|
691
|
+
// that first costs a 4-worker panel run is barely better than the silence it
|
|
692
|
+
// replaces. tests/ship_cannot_lie.mjs pins that order.
|
|
693
|
+
if (proseFlagPresent(args, 'notes')) {
|
|
694
|
+
for (const line of regradeNotesRefusal(taskId)) console.error(line);
|
|
695
|
+
process.exit(2);
|
|
696
|
+
}
|
|
687
697
|
const regradeProse = resolveProse(args, ['summary', 'response']);
|
|
688
698
|
const summaryArg = regradeProse.summary;
|
|
689
699
|
// task 1002662: the builder's response to the prior round's findings — how
|
|
@@ -753,6 +763,17 @@ async function regradeMain() {
|
|
|
753
763
|
// re-grade is the FIRST time this task actually ships. Fall back to the
|
|
754
764
|
// task's stored value_summary so the merge commit + Discord #ship-news post
|
|
755
765
|
// carry meaningful text even when --summary is omitted.
|
|
766
|
+
//
|
|
767
|
+
// AND IT NEVER REACHES THE TASK ROW — task 1003702's acceptance 3 asked this
|
|
768
|
+
// question; the answer is a real, separate defect, filed as task 1003817.
|
|
769
|
+
// `summary` from here feeds the grader prompt, the merge commit message and
|
|
770
|
+
// the PR title/body, and NOTHING writes it back to tasks.value_summary. All
|
|
771
|
+
// three candidate write paths were read and ruled out: publish-branch accepts
|
|
772
|
+
// value_summary but uses it only for PR text; PATCH /tasks/:id does not accept
|
|
773
|
+
// the field at all; the claim-resolve route is the sole writer and a re-grade
|
|
774
|
+
// has no claim (which is why claimLike is synthesized above). Fixing it needs
|
|
775
|
+
// a server route change, so it is deliberately NOT folded into this CLI fix.
|
|
776
|
+
// tests/ship_cannot_lie.mjs pins both halves of that so the answer cannot rot.
|
|
756
777
|
const summary = summaryArg || task.value_summary || '';
|
|
757
778
|
|
|
758
779
|
// The grader helpers want a claim-like object for touches[] (preflight's
|
|
@@ -848,6 +869,11 @@ async function regradeMain() {
|
|
|
848
869
|
card: {
|
|
849
870
|
title: task.title,
|
|
850
871
|
valueSummary: summary,
|
|
872
|
+
// Empty by design, not by omission (task 1003702). A re-grade takes no
|
|
873
|
+
// --notes at all now — it is refused above — so there are none from THIS
|
|
874
|
+
// round to show. The first ship's notes are the durable record and live on
|
|
875
|
+
// the resolved claim; re-printing them here would suggest this round
|
|
876
|
+
// produced them.
|
|
851
877
|
notes: '',
|
|
852
878
|
files: committedFiles,
|
|
853
879
|
baseline: graderBaseline,
|
|
@@ -143,8 +143,61 @@ function smokeGateMessages(taskId) {
|
|
|
143
143
|
};
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
+
// ---- --regrade must not swallow --notes (task 1003702) ----------------------
|
|
147
|
+
//
|
|
148
|
+
// THE THIRD WAY TO LOSE THE BUILDER'S PROSE, and the reason this lives in the
|
|
149
|
+
// prose-loss file rather than beside regradeMain. `--regrade` resolves only
|
|
150
|
+
// `summary` and `response`, so `--notes` parsed as an unknown flag, produced no
|
|
151
|
+
// error, and vanished. A builder answering a "no evidence in the notes" blocker
|
|
152
|
+
// passed it again in --notes, saw the ship run, and got the identical finding
|
|
153
|
+
// back marked unanswered — with nothing anywhere saying why.
|
|
154
|
+
//
|
|
155
|
+
// AND THE FIX IS A REFUSAL, NOT A PERSIST, because the premise the bug was filed
|
|
156
|
+
// under is wrong in the builder's favour: the panel NEVER reads ship notes, on a
|
|
157
|
+
// first ship or a re-grade. modules/grading/grader-prompt.js buildPrompt() puts
|
|
158
|
+
// the task row, the VALUE SUMMARY, the changed files, the diffstat and the diff
|
|
159
|
+
// in the prompt and nothing else, and the workers get the same bundle. So
|
|
160
|
+
// storing the notes would have fixed the silence and none of the substance: the
|
|
161
|
+
// builder would pass --notes, watch it save, and STILL get "unanswered" — a
|
|
162
|
+
// worse failure, because now it looks like it worked.
|
|
163
|
+
//
|
|
164
|
+
// The two channels that do reach the panel are named in the refusal: --response
|
|
165
|
+
// (fenced into the prior-round block, task 1002662) and the DIFF itself, which
|
|
166
|
+
// is why the workaround that actually worked on task 1003698 was committing the
|
|
167
|
+
// evidence as a file. Nothing is written and nothing is lost: the first ship's
|
|
168
|
+
// notes stay the durable ledger record, which is the one thing --notes on a
|
|
169
|
+
// re-grade could legitimately have meant.
|
|
170
|
+
function proseFlagPresent(args, name) {
|
|
171
|
+
const list = Array.isArray(args) ? args : [];
|
|
172
|
+
return list.some((a) => a === `--${name}` || String(a).startsWith(`--${name}=`)
|
|
173
|
+
|| a === `--${name}-file` || String(a).startsWith(`--${name}-file=`));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Pure so the refusal's WORDING is testable — a message that fails to name the
|
|
177
|
+
// alternative is the same dead end with an error code on it.
|
|
178
|
+
function regradeNotesRefusal(taskId) {
|
|
179
|
+
return [
|
|
180
|
+
'--regrade does not take --notes, and your notes have NOT been saved or graded.',
|
|
181
|
+
'',
|
|
182
|
+
'The grader never reads ship notes — not on a re-grade, and not on the first ship.',
|
|
183
|
+
'The panel is given the task, your VALUE SUMMARY, the changed files and the diff.',
|
|
184
|
+
'So notes cannot answer a finding, and silently keeping them would only look like it had.',
|
|
185
|
+
'',
|
|
186
|
+
'Put the evidence where the panel can actually see it:',
|
|
187
|
+
` --response "..." answers the prior round's findings; it rides to the panel fenced.`,
|
|
188
|
+
` --response-file <path> the safe form for prose (a shell eats backticks in an inline value).`,
|
|
189
|
+
' the DIFF commit the proof — a test, a dry-run log, a session-log file.',
|
|
190
|
+
' The panel reads the committed diff and can Read every changed file.',
|
|
191
|
+
'',
|
|
192
|
+
` node scripts/gds/ship.js ${taskId} --regrade --response-file notes.md`,
|
|
193
|
+
'',
|
|
194
|
+
"The first ship's notes stay the durable record; nothing you wrote earlier was lost.",
|
|
195
|
+
];
|
|
196
|
+
}
|
|
197
|
+
|
|
146
198
|
module.exports = {
|
|
147
199
|
resolveProse, markShipProgress, fatalRecoveryLines, smokeGateMessages,
|
|
200
|
+
proseFlagPresent, regradeNotesRefusal,
|
|
148
201
|
_resetForTest: () => { shipProgress = { stage: 'preflight', taskId: null }; },
|
|
149
202
|
_peekForTest: () => shipProgress,
|
|
150
203
|
};
|
package/src/bongos/auth.js
CHANGED
|
@@ -117,10 +117,15 @@ async function resolveSession(req) {
|
|
|
117
117
|
//
|
|
118
118
|
// A SESSION-SCOPE CAN ONLY EVER NARROW. Effective authority is (what the builder's
|
|
119
119
|
// roles confer) ∩ (what this session's scope allows), with the allow-list
|
|
120
|
-
// DENY-BY-DEFAULT. The
|
|
121
|
-
// (resolveSessionPermissions
|
|
122
|
-
//
|
|
123
|
-
//
|
|
120
|
+
// DENY-BY-DEFAULT. The governance resolver COMPOSES that intersection
|
|
121
|
+
// (resolveSessionPermissions), but nothing on the request path reads it — it is
|
|
122
|
+
// exported on the `government` port and exercised by
|
|
123
|
+
// tests/government_session_scope.mjs, and that is the whole of its live use. The
|
|
124
|
+
// ENFORCEMENT is the kernel's own route-reachability projection below: "is this
|
|
125
|
+
// route inside the session's scope at all", run by requireBuilder and by
|
|
126
|
+
// runPageGate. This comment used to say the permission gates read the resolver's
|
|
127
|
+
// composed answer; they never have, and the next reviewer should not lose an hour
|
|
128
|
+
// to that ghost (task 1003197 (B)). The projection is deliberately the ROLE-FREE
|
|
124
129
|
// half — folding the builder's roles in here would change who can reach the
|
|
125
130
|
// bootstrap routes (a builder on a rank with no seeded governance role resolves to
|
|
126
131
|
// zero permissions by design — see tests/government_parity.mjs Part D), and R99 is
|
|
@@ -352,11 +357,15 @@ function signInWithReturn(req) {
|
|
|
352
357
|
// unauthorized request is bounced before the file is ever served (curl-proof,
|
|
353
358
|
// JS-disabled-proof). The client-side link-hiding in the hall is cosmetic UX.
|
|
354
359
|
//
|
|
355
|
-
// Each gate asks
|
|
360
|
+
// Each gate asks THREE questions, in order:
|
|
356
361
|
// 1. IDENTITY — is there a live session? (token from the gds_session cookie /
|
|
357
362
|
// legacy pms_session / Bearer, looked up fresh against builder_sessions).
|
|
358
363
|
// No → 302 into the web sign-in flow.
|
|
359
|
-
// 2.
|
|
364
|
+
// 2. SCOPE — is a page inside this SESSION's scope at all? A narrowing scope
|
|
365
|
+
// (a box-baked token) reaches no page, whatever its builder's rank, exactly as
|
|
366
|
+
// requireBuilder denies it every route that declared nothing (task 1003197).
|
|
367
|
+
// No → 302 into the web sign-in flow: the remedy is a different session.
|
|
368
|
+
// 3. AUTHORITY — does the builder HOLD the page's catalog permission, resolved
|
|
360
369
|
// per request through the `governance` port exactly as requirePermission
|
|
361
370
|
// does? No → 302 to an UNGATED hall page.
|
|
362
371
|
//
|
|
@@ -504,6 +513,24 @@ async function runPageGate(name, req, res, next) {
|
|
|
504
513
|
res.redirect(302, signInWithReturn(req));
|
|
505
514
|
return;
|
|
506
515
|
}
|
|
516
|
+
// SECURITY (task 1003197 (A)): SESSION-SCOPE, the same deny-by-default check
|
|
517
|
+
// requireBuilder runs above — the two auth paths must not diverge. resolveSession
|
|
518
|
+
// accepts a Bearer token with NO source filter, so before this a box-baked token
|
|
519
|
+
// could fetch a gated page SHELL (/builders, /watch, the tab shells) whenever its
|
|
520
|
+
// builder's rank held the page's permission. Every data endpoint underneath
|
|
521
|
+
// re-gates through requireBuilder and 403s box_scope, so the exposure was shells
|
|
522
|
+
// only — but a shell is still a surface a token living outside the ADR 0016 trust
|
|
523
|
+
// boundary was never meant to reach. Page routes declare no capability, so
|
|
524
|
+
// scopeCovers refuses EVERY narrowing scope here (and an unrecognised source with
|
|
525
|
+
// it), while an unscoped cli/web session passes untouched.
|
|
526
|
+
//
|
|
527
|
+
// Sign-in, not `deniedTo`: unlike the authority branch below, the remedy is a
|
|
528
|
+
// DIFFERENT SESSION — /builder-reauth mints a normal one — not a different page,
|
|
529
|
+
// and a box session has no browser cookie to loop the sign-in flow with.
|
|
530
|
+
if (!scopeCovers(sessionScopeFor(session.source), req.sessionScopeNeeds)) {
|
|
531
|
+
res.redirect(302, signInWithReturn(req));
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
507
534
|
const held = await resolveHeldPermissions(session.builder_id, session.raw_permission_keys);
|
|
508
535
|
if (held === null || !held.has(permission)) {
|
|
509
536
|
// The rank door said no. A page whose data route admits a SECOND audience
|
|
@@ -759,6 +786,32 @@ function requirePermission(...perms) {
|
|
|
759
786
|
const builderId = req.builder && req.builder.id;
|
|
760
787
|
if (!builderId) return deny(null);
|
|
761
788
|
if (required.length === 0) return deny(null);
|
|
789
|
+
// R100 (task 1003197 (C)): an ownership-scoped key has NO unconditional answer.
|
|
790
|
+
// It is parameterized by a concrete row this gate was never handed, so admitting
|
|
791
|
+
// on `held.has(key)` would collapse `.own` into `.any` — precisely the
|
|
792
|
+
// degradation the ownership axis exists to prevent. The resolver's
|
|
793
|
+
// builderHasPermissions refuses these for the same reason (resolver.js); mirror
|
|
794
|
+
// it so the kernel gate can never be the softer of the two doors. ZERO call
|
|
795
|
+
// sites pass an own-scoped key today (full enumeration, task 1003197) — this
|
|
796
|
+
// guards the future mis-authored gate that would otherwise be silently
|
|
797
|
+
// unconditional. The route wants authorizeOwned() in its handler, which cannot
|
|
798
|
+
// be called without naming the owner. LOUD, because this is a wiring bug in the
|
|
799
|
+
// route, not a fact about the caller.
|
|
800
|
+
const governance = seams.resolveOptional('government');
|
|
801
|
+
if (governance && typeof governance.isOwnershipScoped === 'function') {
|
|
802
|
+
const owned = required.filter((key) => {
|
|
803
|
+
try { return governance.isOwnershipScoped(key); } catch { return false; }
|
|
804
|
+
});
|
|
805
|
+
if (owned.length > 0) {
|
|
806
|
+
require('./logger').logger.child({ src: 'auth' })
|
|
807
|
+
.error({ keys: owned }, 'requirePermission: an ownership-scoped key cannot gate a route unconditionally — the handler must call authorizeOwned() with the row\'s owner');
|
|
808
|
+
return res.fail('permission_forbidden', {
|
|
809
|
+
status: 403,
|
|
810
|
+
message: `this route is gated on ownership-scoped permission${owned.length === 1 ? '' : 's'} ${owned.join(', ')}, which cannot be answered without a resource owner — denied fail-closed`,
|
|
811
|
+
details: { required, held: null, floors: null },
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
}
|
|
762
815
|
// Shared with the page gates (R107): null = authority could not be established
|
|
763
816
|
// (no port / malformed port / a throwing resolver) and is always a denial.
|
|
764
817
|
// req.rawPermissionKeys rides the fold (task 1002556) when the session query
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.646'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
package/tests/auth_page_gate.mjs
CHANGED
|
@@ -99,6 +99,64 @@ await t('session lookup error → fails CLOSED (redirect, next() NOT called)', a
|
|
|
99
99
|
'must redirect to sign-in on a lookup error (fail closed)');
|
|
100
100
|
});
|
|
101
101
|
|
|
102
|
+
// ---- SESSION-SCOPE on the page path (task 1003197 (A)) ----------------------
|
|
103
|
+
//
|
|
104
|
+
// resolveSession accepts a Bearer token with no source filter, so before this the
|
|
105
|
+
// page gate never asked the question requireBuilder asks: is a page inside this
|
|
106
|
+
// SESSION's scope at all? A box-baked token (which lives on the box, outside the
|
|
107
|
+
// ADR 0016 trust boundary) could therefore fetch a gated page SHELL whenever its
|
|
108
|
+
// builder's rank held the page permission. The stub above resolves the permission
|
|
109
|
+
// for EVERY builder, which is what makes these assertions about scope alone.
|
|
110
|
+
|
|
111
|
+
console.log('\nrequireBuilderPage — session scope:');
|
|
112
|
+
|
|
113
|
+
await t('box-scoped session → 302 to sign-in even though its builder holds the permission', async () => {
|
|
114
|
+
nextSession = { builder_id: 3, github_login: 'boxbot', rank: 'archon', status: 'active', source: 'box' };
|
|
115
|
+
lookupThrows = false;
|
|
116
|
+
const req = { headers: { authorization: 'Bearer boxtoken' } }; // a page route declares no scope
|
|
117
|
+
const res = fakeRes();
|
|
118
|
+
let nexted = false;
|
|
119
|
+
await auth.requireBuilderPage(req, res, () => { nexted = true; });
|
|
120
|
+
assert.equal(nexted, false, 'a box session must not be served a gated page shell');
|
|
121
|
+
assert.ok(res._redirect && res._redirect.loc === '/api/gds/auth/web/start',
|
|
122
|
+
'the remedy is a different session (/builder-reauth), so sign-in — not deniedTo');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
await t('a source the registry does not declare → denied, never treated as unscoped', async () => {
|
|
126
|
+
nextSession = { builder_id: 4, github_login: 'mystery', rank: 'archon', status: 'active', source: 'not-a-scope' };
|
|
127
|
+
const req = { headers: { authorization: 'Bearer whoknows' } };
|
|
128
|
+
const res = fakeRes();
|
|
129
|
+
let nexted = false;
|
|
130
|
+
await auth.requireBuilderPage(req, res, () => { nexted = true; });
|
|
131
|
+
assert.equal(nexted, false, 'an unrecognised scope must fail closed, exactly as requireBuilder does');
|
|
132
|
+
assert.ok(res._redirect && res._redirect.loc === '/api/gds/auth/web/start');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
await t('cli and web sessions are untouched by the scope check', async () => {
|
|
136
|
+
for (const source of ['cli', 'web']) {
|
|
137
|
+
nextSession = { builder_id: 1, github_login: 'someone', rank: 'xenos', status: 'active', source };
|
|
138
|
+
const req = { headers: { cookie: 'gds_session=validtoken' } };
|
|
139
|
+
const res = fakeRes();
|
|
140
|
+
let nexted = false;
|
|
141
|
+
await auth.requireBuilderPage(req, res, () => { nexted = true; });
|
|
142
|
+
assert.equal(nexted, true, `an unscoped ${source} session must still reach the page`);
|
|
143
|
+
assert.equal(res._redirect, null);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
await t('a legacy session with no source field still reaches the page', async () => {
|
|
148
|
+
// The same invariant tests/box_scope_session.mjs pins for requireBuilder: absent
|
|
149
|
+
// source resolves to the UNSCOPED scope, so this gate can never lock out a real
|
|
150
|
+
// browser session minted before `source` was recorded.
|
|
151
|
+
nextSession = { builder_id: 2, github_login: 'legacy', rank: 'metic', status: 'active' };
|
|
152
|
+
const req = { headers: { cookie: 'gds_session=validtoken' } };
|
|
153
|
+
const res = fakeRes();
|
|
154
|
+
let nexted = false;
|
|
155
|
+
await auth.requireBuilderPage(req, res, () => { nexted = true; });
|
|
156
|
+
assert.equal(nexted, true, 'absent source must never trip the page scope gate');
|
|
157
|
+
assert.equal(res._redirect, null);
|
|
158
|
+
});
|
|
159
|
+
|
|
102
160
|
seams._reset(); // leave the seam registry as we found it (the governance_* suites' pattern)
|
|
103
161
|
|
|
104
162
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
@@ -318,6 +318,36 @@ t('materializeClaude: a skill the module STOPPED declaring is withdrawn too', ()
|
|
|
318
318
|
for (const d of [core, inst]) rmSync(d, { recursive: true, force: true });
|
|
319
319
|
});
|
|
320
320
|
|
|
321
|
+
t('materializeClaude: a CORE skill that took over a module-owned name is NOT withdrawn — and a disabled module\'s core-shipped name still is (task 1003818)', () => {
|
|
322
|
+
// The regression this pins: step 1 copies the core's dir in, and 1c deleted it again in
|
|
323
|
+
// the SAME run, because no enabled module declared the name any more. The core-checkout
|
|
324
|
+
// twin has always guarded this via tracked() ("a core skill can take over a name a
|
|
325
|
+
// module once owned"); the instance side shipped without it in task 1003632.
|
|
326
|
+
const { core, inst, modDir } = withdrawFixture();
|
|
327
|
+
m.materializeClaude({ coreRoot: core, instanceDir: inst, dryRun: false }); // alpha lands, recorded
|
|
328
|
+
|
|
329
|
+
// the module stops declaring alpha; the core now ships its own
|
|
330
|
+
writeFileSync(join(modDir, 'module.json'), JSON.stringify({ key: 'foo', default: true, contributes: { skills: ['beta'] } }));
|
|
331
|
+
mkdirSync(join(core, '.claude', 'skills', 'alpha'), { recursive: true });
|
|
332
|
+
writeFileSync(join(core, '.claude', 'skills', 'alpha', 'SKILL.md'), '---\nname: alpha\n---\nCORE OWNS THIS NOW\n');
|
|
333
|
+
const kept = m.materializeClaude({ coreRoot: core, instanceDir: inst, dryRun: false });
|
|
334
|
+
assert.deepEqual(kept.withdrawnSkills, [], 'a name the core ships now is the core\'s, whatever the manifest said');
|
|
335
|
+
assert.match(readFileSync(join(inst, '.claude', 'skills', 'alpha', 'SKILL.md'), 'utf8'), /CORE OWNS THIS NOW/, 'and it is the core copy that survives');
|
|
336
|
+
|
|
337
|
+
// THE OTHER HALF, which the guard must not break: a DISABLED module declaring a
|
|
338
|
+
// core-shipped name means step 1 skips the copy, so the instance dir IS stale.
|
|
339
|
+
const inst2 = mkdtempSync(join(tmpdir(), 'wd-inst-'));
|
|
340
|
+
writeFileSync(join(modDir, 'module.json'), JSON.stringify({ key: 'foo', default: true, contributes: { skills: ['alpha', 'beta'] } }));
|
|
341
|
+
m.materializeClaude({ coreRoot: core, instanceDir: inst2, dryRun: false });
|
|
342
|
+
assert.ok(existsSync(join(inst2, '.claude', 'skills', 'alpha')), 'landed while enabled');
|
|
343
|
+
mkdirSync(join(inst2, 'config'), { recursive: true });
|
|
344
|
+
writeFileSync(join(inst2, 'config', 'modules.json'), JSON.stringify({ modules: { foo: false } }));
|
|
345
|
+
const gone = m.materializeClaude({ coreRoot: core, instanceDir: inst2, dryRun: false });
|
|
346
|
+
assert.ok(gone.withdrawnSkills.includes('alpha'), 'a disabled module\'s core-shipped name is still withdrawn — step 1 skipped it, so the dir is stale');
|
|
347
|
+
assert.ok(!existsSync(join(inst2, '.claude', 'skills', 'alpha')));
|
|
348
|
+
for (const d of [core, inst, inst2]) rmSync(d, { recursive: true, force: true });
|
|
349
|
+
});
|
|
350
|
+
|
|
321
351
|
t('materializeClaude: a HAND-WRITTEN skill is never withdrawn, even when a disabled module declares its name', () => {
|
|
322
352
|
const { core, inst } = withdrawFixture();
|
|
323
353
|
// The owner wrote .claude/skills/alpha themselves; no materialize ever landed it,
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
// F17 get a keyword-guessed 'bug' from #ideas to mint work → cannot: vector not trusted
|
|
37
37
|
// F18 forge/omit the vector to reach the homeless-bug door → fail-closed, an inbox row
|
|
38
38
|
// F19 file a homeless NON-bug hoping for a free home → inbox; only bugs get one
|
|
39
|
+
// F20 file a criterion-proposal with no/blank/padded rank → refused, fail-closed
|
|
39
40
|
// P1 a Metic+ files a bug in an ordinary goal → 'ready' (the C1 grant)
|
|
40
41
|
// P2 a Metic+ files into protected scope → allowed, floored to metic
|
|
41
42
|
// P3 a Metic+ files a HOMELESS bug from a trusted vector → 'ready', in the maintenance goal
|
|
@@ -76,7 +77,7 @@ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
|
76
77
|
const read = (rel) => readFileSync(path.join(ROOT, rel), 'utf8');
|
|
77
78
|
|
|
78
79
|
const routing = require('../modules/ideas/routing.js');
|
|
79
|
-
const { decideLanding, authorizeRoutedLanding, routedPriority, PROTECTED_SCOPE_FLOOR } = routing;
|
|
80
|
+
const { decideLanding, authorizeRoutedLanding, routedPriority, PROTECTED_SCOPE_FLOOR, mayProposeCriterion } = routing;
|
|
80
81
|
const bugs = require('../modules/discord/discord-bugs.js');
|
|
81
82
|
const { rateLimitPerHour } = bugs;
|
|
82
83
|
const { scopeIncludesProtected, isModuleKey } = require('../src/bongos/module-scope-map.js');
|
|
@@ -477,6 +478,57 @@ await test('[pin] no OTHER module file mints a task from a filing without a row
|
|
|
477
478
|
`a new filing vector must be added to KNOWN_FILING_VECTORS and given a matrix row: ${unaudited.join(', ')}`);
|
|
478
479
|
});
|
|
479
480
|
|
|
481
|
+
// =========================================================================
|
|
482
|
+
// F20 — the criterion-proposal door (task 1003197 (D))
|
|
483
|
+
// =========================================================================
|
|
484
|
+
//
|
|
485
|
+
// Filing is open to everyone, but kind='criterion-proposal' reshapes the next
|
|
486
|
+
// version's scope-truth, so POST /inbox holds a Metic+ door in front of it. That
|
|
487
|
+
// door was written `if (rank && rank !== 'metic' && …)`: an ABSENT rank walked
|
|
488
|
+
// straight through. Unreachable today (builders.rank is NOT NULL behind an enum
|
|
489
|
+
// CHECK, and requireBuilder 401s first) — but it was a gate leaning on a schema
|
|
490
|
+
// invariant it never stated, which is the same shape as every "unreachable" bug
|
|
491
|
+
// that later became reachable. The predicate is now pure and denies by default.
|
|
492
|
+
|
|
493
|
+
await test('[F20] the criterion-proposal door refuses every sub-Metic shape, absent rank included', () => {
|
|
494
|
+
for (const rank of SUB_METIC) {
|
|
495
|
+
assert.equal(mayProposeCriterion(rank), false,
|
|
496
|
+
`rank ${JSON.stringify(rank)} must not be able to file a criterion proposal`);
|
|
497
|
+
}
|
|
498
|
+
// Named explicitly, because these three are the bug: they are the shapes the old
|
|
499
|
+
// `rank &&` guard ADMITTED, and a list-driven loop can lose a case silently.
|
|
500
|
+
for (const falsy of [null, undefined, '']) {
|
|
501
|
+
assert.equal(mayProposeCriterion(falsy), false,
|
|
502
|
+
'an unknown rank is not evidence of authority — the gate must fail CLOSED');
|
|
503
|
+
}
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
await test('[F20] it still admits Metic and Archon, in any casing', () => {
|
|
507
|
+
for (const rank of TRUSTED) {
|
|
508
|
+
assert.equal(mayProposeCriterion(rank), true,
|
|
509
|
+
`${JSON.stringify(rank)} is a trusted filer and must keep the door`);
|
|
510
|
+
}
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
await test('[F20] a padded rank is a hostile shape, not a rank — no trimming', () => {
|
|
514
|
+
// 'METIC ' appears in SUB_METIC on purpose. Trimming would WIDEN the audience,
|
|
515
|
+
// so the predicate lowercases and nothing else.
|
|
516
|
+
for (const padded of ['metic ', ' metic', 'archon ', '\tarchon']) {
|
|
517
|
+
assert.equal(mayProposeCriterion(padded), false);
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
await test('[F20] the route reads the domain predicate — no second copy of the door', () => {
|
|
522
|
+
const routeSrc = read('modules/ideas/routes/inbox.js');
|
|
523
|
+
const code = routeSrc.split('\n').filter((l) => !l.trim().startsWith('//')).join('\n');
|
|
524
|
+
assert.match(code, /mayProposeCriterion\(req\.builder\?\.rank\)/,
|
|
525
|
+
'the route must ask routing.js, so the fail-closed posture cannot drift');
|
|
526
|
+
assert.match(code, /require\('\.\.\/routing'\)/,
|
|
527
|
+
'and reach it by import, never by re-spelling the tier');
|
|
528
|
+
assert.ok(!/rank\s*&&\s*rank\s*!==/.test(code),
|
|
529
|
+
'the admits-on-falsy guard must not come back');
|
|
530
|
+
});
|
|
531
|
+
|
|
480
532
|
await test('[pin] there is ONE trusted-tier definition, and #bugs no longer keeps a copy', () => {
|
|
481
533
|
// This pin used to assert that two spellings of "Metic+" AGREED — routing.js and
|
|
482
534
|
// discord-bugs.js each carried one. R11 deleted the second, which is the better
|
|
@@ -403,3 +403,106 @@ test('every modules/*/routes gate is migrated, bar the one documented holdout',
|
|
|
403
403
|
'a modules/*/routes gate still calls requireRank — migrate it to requirePermission, ' +
|
|
404
404
|
'or add it to RETAINED with a "Still requireRank after BV1.R105" note explaining why');
|
|
405
405
|
});
|
|
406
|
+
|
|
407
|
+
// ---------------------------------------------------------------------------
|
|
408
|
+
// the OWNERSHIP axis cannot gate a route (task 1003197 (C))
|
|
409
|
+
// ---------------------------------------------------------------------------
|
|
410
|
+
//
|
|
411
|
+
// `held.has('box.manage.own')` is true for anyone whose rank carries the atom, so a
|
|
412
|
+
// gate mounted on an ownership-scoped key would admit UNCONDITIONALLY — `.own`
|
|
413
|
+
// collapsed into `.any`, the exact degradation the axis exists to prevent, because
|
|
414
|
+
// the gate is never handed the row whose owner is the other half of the question.
|
|
415
|
+
// The resolver's builderHasPermissions refuses these already; the kernel gate must
|
|
416
|
+
// not be the softer door. Zero call sites pass one today — this is the guard on the
|
|
417
|
+
// future mis-authored gate.
|
|
418
|
+
//
|
|
419
|
+
// `run` above stubs a port with resolveBuilderPermissions ONLY, so this needs its
|
|
420
|
+
// own runner that also stubs isOwnershipScoped (the live port always carries it).
|
|
421
|
+
async function runOwned(gate, req, { resolve, isOwnershipScoped }) {
|
|
422
|
+
seams._reset();
|
|
423
|
+
seams.registerProvider('government', { resolveBuilderPermissions: resolve, isOwnershipScoped });
|
|
424
|
+
const res = fakeRes();
|
|
425
|
+
let nexted = false;
|
|
426
|
+
await gate(req, res, () => { nexted = true; });
|
|
427
|
+
seams._reset();
|
|
428
|
+
return { res, nexted };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const OWNED_KEY = catalog.permissionKeys().find((k) => catalog.isOwnershipScoped(k));
|
|
432
|
+
|
|
433
|
+
test('the catalog still declares at least one ownership-scoped key', () => {
|
|
434
|
+
// If the axis were ever emptied, the assertions below would pass vacuously.
|
|
435
|
+
assert.ok(OWNED_KEY, 'expected an ownership-scoped atom in the catalog (e.g. box.manage.own)');
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test('an ownership-scoped key 403s even when the builder HOLDS it', async () => {
|
|
439
|
+
const { res, nexted } = await runOwned(
|
|
440
|
+
auth.requirePermission(OWNED_KEY), { builder: BUILDER },
|
|
441
|
+
{ resolve: holding(OWNED_KEY), isOwnershipScoped: catalog.isOwnershipScoped },
|
|
442
|
+
);
|
|
443
|
+
assert.equal(nexted, false, 'holding an own-scoped atom is not an unconditional admit');
|
|
444
|
+
assert.equal(res._status, 403);
|
|
445
|
+
assert.equal(res._json.error.code, 'permission_forbidden');
|
|
446
|
+
assert.match(res._json.error.message, /ownership-scoped/,
|
|
447
|
+
'the denial must name the reason — a route wiring bug, not a missing grant');
|
|
448
|
+
assert.equal(res._json.error.details.held, null,
|
|
449
|
+
'never echo the held set on this branch: the denial is about the gate, not the caller');
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
test('one own-scoped key in a multi-key gate poisons the whole gate', async () => {
|
|
453
|
+
const { res, nexted } = await runOwned(
|
|
454
|
+
auth.requirePermission('idea.triage', OWNED_KEY), { builder: BUILDER },
|
|
455
|
+
{ resolve: holding('idea.triage', OWNED_KEY), isOwnershipScoped: catalog.isOwnershipScoped },
|
|
456
|
+
);
|
|
457
|
+
assert.equal(nexted, false, 'the gate must refuse as a whole — a partial answer is not an answer');
|
|
458
|
+
assert.equal(res._status, 403);
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
test('an ordinary key still passes with isOwnershipScoped present on the port', async () => {
|
|
462
|
+
// The guard must be inert for every real gate in the tree.
|
|
463
|
+
const { res, nexted } = await runOwned(
|
|
464
|
+
auth.requirePermission('idea.triage'), { builder: BUILDER },
|
|
465
|
+
{ resolve: holding('idea.triage'), isOwnershipScoped: catalog.isOwnershipScoped },
|
|
466
|
+
);
|
|
467
|
+
assert.equal(nexted, true);
|
|
468
|
+
assert.equal(res._status, null);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
test('a throwing isOwnershipScoped does not admit and does not crash the gate', async () => {
|
|
472
|
+
// Fall through to the ordinary held check rather than 500 — but a key the caller
|
|
473
|
+
// does not hold must still deny, so the outcome is never an admit-by-error.
|
|
474
|
+
const { res, nexted } = await runOwned(
|
|
475
|
+
auth.requirePermission('government.manage'), { builder: BUILDER },
|
|
476
|
+
{ resolve: holding('idea.triage'), isOwnershipScoped: () => { throw new Error('catalog blew up'); } },
|
|
477
|
+
);
|
|
478
|
+
assert.equal(nexted, false);
|
|
479
|
+
assert.equal(res._status, 403);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test('no route in the tree is gated on an ownership-scoped key today', () => {
|
|
483
|
+
// The enumeration the fix was written against, kept honest. A new gate mounted on
|
|
484
|
+
// an own-scoped atom would now 403 at runtime; this fails at BUILD time instead,
|
|
485
|
+
// naming the file, so nobody ships a route that can only ever deny.
|
|
486
|
+
const owned = catalog.permissionKeys().filter((k) => catalog.isOwnershipScoped(k));
|
|
487
|
+
const dirs = [path.join(ROOT, 'src', 'bongos', 'routes'), path.join(ROOT, 'modules')];
|
|
488
|
+
const offenders = [];
|
|
489
|
+
const walk = (dir) => {
|
|
490
|
+
if (!fs.existsSync(dir)) return;
|
|
491
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
492
|
+
const full = path.join(dir, entry.name);
|
|
493
|
+
if (entry.isDirectory()) { if (entry.name !== 'node_modules') walk(full); continue; }
|
|
494
|
+
if (!entry.name.endsWith('.js')) continue;
|
|
495
|
+
const text = fs.readFileSync(full, 'utf8');
|
|
496
|
+
for (const key of owned) {
|
|
497
|
+
// A gate is a call: requirePermission('<key>'), in any of its spellings.
|
|
498
|
+
if (new RegExp(`requirePermission\\s*\\([^)]*['"\`]${key.replace(/\./g, '\\.')}['"\`]`).test(text)) {
|
|
499
|
+
offenders.push(`${path.relative(ROOT, full)} :: ${key}`);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
};
|
|
504
|
+
dirs.forEach(walk);
|
|
505
|
+
assert.deepEqual(offenders, [],
|
|
506
|
+
'requirePermission cannot answer an ownership-scoped key — the handler must call ' +
|
|
507
|
+
'authorizeOwned() with the row\'s owner instead');
|
|
508
|
+
});
|
|
@@ -14,13 +14,20 @@
|
|
|
14
14
|
// lands the task at 'completed' before the grade runs, and 'completed' is
|
|
15
15
|
// ALSO what a grade-failed task looks like — so a bare `fatal: <stack>`
|
|
16
16
|
// left nobody able to tell how far it got.
|
|
17
|
+
//
|
|
18
|
+
// (3) --regrade must not swallow --notes. Same shape as (1): prose handed to
|
|
19
|
+
// ship.js that reaches nothing, silently (task 1003702).
|
|
17
20
|
import { strict as assert } from 'node:assert';
|
|
18
21
|
import { test } from 'node:test';
|
|
19
22
|
import { createRequire } from 'node:module';
|
|
23
|
+
import { readFileSync } from 'node:fs';
|
|
20
24
|
|
|
21
25
|
const require = createRequire(import.meta.url);
|
|
22
26
|
const { argText } = require('../scripts/gds/cli-lib.js');
|
|
23
|
-
const {
|
|
27
|
+
const {
|
|
28
|
+
fatalRecoveryLines, markShipProgress, _peekForTest,
|
|
29
|
+
proseFlagPresent, regradeNotesRefusal,
|
|
30
|
+
} = require('../scripts/gds/ship-honesty.js');
|
|
24
31
|
|
|
25
32
|
// Injected IO so no case touches the filesystem.
|
|
26
33
|
const io = {
|
|
@@ -166,3 +173,98 @@ test('markShipProgress is the single writer, and ship.js re-exports the reader',
|
|
|
166
173
|
assert.equal(typeof shipCli.fatalRecoveryLines, 'function', 'ship.js still exposes it');
|
|
167
174
|
assert.equal(shipCli.fatalRecoveryLines, fatalRecoveryLines, 'same function, not a copy');
|
|
168
175
|
});
|
|
176
|
+
|
|
177
|
+
// ── (3) --regrade refuses --notes instead of swallowing it (task 1003702) ────
|
|
178
|
+
//
|
|
179
|
+
// The third way to lose the builder's prose, and the same incident shape as (1):
|
|
180
|
+
// prose handed to ship.js that reaches nothing, with no error. --regrade
|
|
181
|
+
// resolved only summary + response, so --notes parsed as an unknown flag and
|
|
182
|
+
// vanished; a builder answering a "no evidence in the notes" blocker passed it
|
|
183
|
+
// again there and got the identical finding back marked unanswered.
|
|
184
|
+
|
|
185
|
+
test('every spelling of --notes is caught, and nothing else is', () => {
|
|
186
|
+
// All four forms argText accepts. Catching only the bare flag would leave
|
|
187
|
+
// --notes-file — the form the ship docs actively recommend for prose — silent,
|
|
188
|
+
// which is the exact defect wearing different clothes.
|
|
189
|
+
for (const argv of [
|
|
190
|
+
['1', '--regrade', '--notes', 'x'],
|
|
191
|
+
['1', '--regrade', '--notes=x'],
|
|
192
|
+
['1', '--regrade', '--notes-file', 'n.md'],
|
|
193
|
+
['1', '--regrade', '--notes-file=n.md'],
|
|
194
|
+
]) {
|
|
195
|
+
assert.equal(proseFlagPresent(argv, 'notes'), true, `caught: ${argv.join(' ')}`);
|
|
196
|
+
}
|
|
197
|
+
// Not over-eager: the flags a re-grade legitimately takes must still pass, and
|
|
198
|
+
// a flag that merely CONTAINS the word is a different flag.
|
|
199
|
+
for (const argv of [
|
|
200
|
+
['1', '--regrade'],
|
|
201
|
+
['1', '--regrade', '--response-file', 'r.md'],
|
|
202
|
+
['1', '--regrade', '--summary', 'x'],
|
|
203
|
+
['1', '--regrade', '--no-notes-please'],
|
|
204
|
+
['1', '--regrade', '--response', 'my --notes were wrong'],
|
|
205
|
+
]) {
|
|
206
|
+
assert.equal(proseFlagPresent(argv, 'notes'), false, `not caught: ${argv.join(' ')}`);
|
|
207
|
+
}
|
|
208
|
+
// Generic over the flag name — it is not a --notes special case.
|
|
209
|
+
assert.equal(proseFlagPresent(['--response-file', 'r.md'], 'response'), true);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('THE ACCEPTANCE: the refusal names where the evidence must go instead', () => {
|
|
213
|
+
// A non-zero exit that does not say what to do is the same dead end with an
|
|
214
|
+
// error code on it. The refusal has to carry the working channel, or a builder
|
|
215
|
+
// reads "not supported" and goes back to putting evidence nowhere.
|
|
216
|
+
const text = regradeNotesRefusal(1003698).join('\n');
|
|
217
|
+
assert.match(text, /--response/, 'names the channel that reaches the panel');
|
|
218
|
+
assert.match(text, /--response-file/, 'and its file form, the safe one for prose');
|
|
219
|
+
assert.match(text, /diff/i, 'and the diff, which is what the panel actually reads');
|
|
220
|
+
assert.match(text, /ship\.js 1003698 --regrade/, 'a runnable command with the real task id');
|
|
221
|
+
// It must not be reassuring about the thing that did not happen.
|
|
222
|
+
assert.match(text, /NOT been saved/, 'says plainly that the notes went nowhere');
|
|
223
|
+
// And it must not claim the notes were merely "not supported yet" — the
|
|
224
|
+
// substantive fact is that the grader never reads ship notes at all, so a
|
|
225
|
+
// builder does not go looking for a flag that would make them count.
|
|
226
|
+
assert.match(text, /never reads ship notes/, 'explains WHY, not just that it refused');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test('the refusal is wired into the regrade path, before any grade spend', () => {
|
|
230
|
+
// Wiring proof (the (1) lesson: a helper nothing calls is not a fix). Also
|
|
231
|
+
// pins the ORDER — a refusal that costs a 4-worker panel run first is barely
|
|
232
|
+
// better than the silence, so the guard must precede the preflight.
|
|
233
|
+
const src = readFileSync(new URL('../scripts/gds/ship-flow.js', import.meta.url), 'utf8');
|
|
234
|
+
const regrade = src.slice(src.indexOf('async function regradeMain'));
|
|
235
|
+
const guard = regrade.indexOf("proseFlagPresent(args, 'notes')");
|
|
236
|
+
assert.ok(guard !== -1, 'regradeMain consults the guard');
|
|
237
|
+
const preflight = regrade.indexOf('preflight(claimLike');
|
|
238
|
+
assert.ok(preflight !== -1 && guard < preflight, 'it refuses before the preflight and the panel');
|
|
239
|
+
// The usage line must not advertise a flag the path refuses.
|
|
240
|
+
const usage = regrade.slice(0, regrade.indexOf('resolveProse'));
|
|
241
|
+
assert.ok(!/--notes/.test(usage.replace(/proseFlagPresent[^\n]*/g, '')
|
|
242
|
+
.replace(/\/\/[^\n]*/g, '')), 'usage does not offer --notes');
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test('ACCEPTANCE 3: --summary reaches the GRADER on a re-grade but never the task row', () => {
|
|
246
|
+
// Task 1003702's third acceptance asked whether --summary reaches the stored
|
|
247
|
+
// value_summary on a re-grade. It does NOT, and that is a separate defect
|
|
248
|
+
// filed as task 1003817 (it needs a server route change; this task's fix is
|
|
249
|
+
// CLI-only). Pinned here rather than left in prose for the reason this whole
|
|
250
|
+
// file exists: the panel reads the DIFF, so an answer that lives only in ship
|
|
251
|
+
// notes is an answer nobody can check — which is the very bug task 1003702 is.
|
|
252
|
+
const src = readFileSync(new URL('../scripts/gds/ship-flow.js', import.meta.url), 'utf8');
|
|
253
|
+
const regrade = src.slice(src.indexOf('async function regradeMain'));
|
|
254
|
+
const code = regrade.replace(/\/\/[^\n]*/g, ''); // comments describe, they do not write
|
|
255
|
+
|
|
256
|
+
// Half one — the threading is intact, so nothing regressed: --summary still
|
|
257
|
+
// falls back to the stored value and still reaches the grader and the card.
|
|
258
|
+
assert.match(code, /const summary = summaryArg \|\| task\.value_summary/,
|
|
259
|
+
'the stored summary is still the fallback when --summary is omitted');
|
|
260
|
+
assert.match(code, /gradeAndRecord\(\{[\s\S]{0,200}?summary,/,
|
|
261
|
+
'the summary still reaches the grader prompt');
|
|
262
|
+
assert.match(code, /valueSummary: summary/, 'and the completion card');
|
|
263
|
+
|
|
264
|
+
// Half two — and there is no writer. A `value_summary:` KEY would be a request
|
|
265
|
+
// payload; the legitimate read is `task.value_summary`, dot-access. If someone
|
|
266
|
+
// later adds the write (task 1003817), this assertion is what tells them to
|
|
267
|
+
// update the comment above `const summary` rather than leave it lying.
|
|
268
|
+
assert.ok(!/[^.]\bvalue_summary\s*:/.test(code),
|
|
269
|
+
'regradeMain writes value_summary nowhere — see task 1003817');
|
|
270
|
+
});
|