From 574ece13c1c3ac0c3a5bc50701f92b2000fa74b3 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Fri, 23 Oct 2026 14:22:36 -0500 Subject: [PATCH] row 298: Untested scope evidence, exact commands, spent narrowing and CLI coverage (#1804) --- BUILD-LOG.md | 2 + agents/boyd/work/queue-298/BUILD.md | 141 ++++++++++++++++++ agents/boyd/work/queue-298/MANIFEST.sha256 | 13 ++ agents/boyd/work/queue-298/mutants.py | 81 ++++++++++ docs/SESSIONS.md | 2 + packages/bus/README.md | 2 + packages/bus/src/broker.mjs | 55 +++++-- packages/bus/src/runtime.mjs | 9 +- .../bus/tests/grant-scope-runtime.test.mjs | 40 +++++ packages/bus/tests/grant-scope.test.mjs | 106 +++++++++++-- packages/bus/tests/project-runtime.test.mjs | 2 +- packages/business/src/vocabulary.mjs | 19 +-- packages/business/tests/grant-scope.test.mjs | 8 +- packages/cli/README.md | 31 ++++ packages/cli/tests/grants-scope.test.mjs | 74 +++++++++ 15 files changed, 536 insertions(+), 49 deletions(-) create mode 100644 agents/boyd/work/queue-298/BUILD.md create mode 100644 agents/boyd/work/queue-298/MANIFEST.sha256 create mode 100644 agents/boyd/work/queue-298/mutants.py create mode 100644 packages/bus/tests/grant-scope-runtime.test.mjs create mode 100644 packages/cli/tests/grants-scope.test.mjs diff --git a/BUILD-LOG.md b/BUILD-LOG.md index 700f7a7d1..1bf19e166 100644 --- a/BUILD-LOG.md +++ b/BUILD-LOG.md @@ -5411,3 +5411,5 @@ Row 299 (573, #1806, Gander, Filbert): before, the P10 band test timed a fault r 2026-10-23T17:29:46Z | Gyro Vikunja571 sage-3542 Connections amendment | Branch gyro/editor-connections-design cut exactly from landed PRD1.17 30e6bb680; read REQ-LIB-5 in that PRD. Replaced stale J-b waits in parent and W4. Proposed W8 owns current authorized Markdown/DOCX links/backlinks list, labels, versioned refresh, leakage refusals and fixture tests. Build order under mos-1470/comment961 is W7 closed routing candidate, W8 landing, then W7 human first-real-import opening. No REQ-LIB-8 condition or acceptance change. Q234/mos-1363/S0 M1-M14 and post-10-27/MVP priority fixed. Static diff, twelve brief sections and append-only checks pass. No repo Node, tests, build, browser, queue mutation or live action. Fenton reviews after authorized docs branch push; Sage lands on approval. 2026-10-23T17:32:37Z | Gyro sage-3544 fenton-0016 N1/N2 delta | Before: W7 Gate/final real-conversion list omit the W8 build-order cross-reference and W8 human-view filtering cites agent settings. After: both locations explicitly put W8 landing before human opening as mos-1470 build order, not acceptance; comment/tracked-deletion links are not current story links and never appear in the human Connections view regardless of agent settings. New commit atop approved 5af060fb5, no amend. Static diff and append-only checks pass. Docs only, no repo Node/tests/live action. Fenton checks only this delta. + +2026-10-23 | Boyd | Row 298 (#1804, Q228), before implementation completion | Took over Megavolt 7a141269c on boyd/row298 at 45dca69f6 under mos-1496/1502. Approved D1-D6 govern human-only grants.scope.set, empty fallback, in-scope close/evidence and spend recheck. All inherited code UNTESTED; no Node/tests until Sage CLEAR. Plan and static gaps recorded in agents/boyd/work/queue-298/BUILD.md; no live grant or credential access. diff --git a/agents/boyd/work/queue-298/BUILD.md b/agents/boyd/work/queue-298/BUILD.md new file mode 100644 index 000000000..5aeb74938 --- /dev/null +++ b/agents/boyd/work/queue-298/BUILD.md @@ -0,0 +1,141 @@ +# Row 298 (#1804, Q228): chief-of-staff grant scope + +Owner Boyd (48a3ae38), reviewer Dewey. Branch `boyd/row298`, base `45dca69f6`. +Assignment: mos-1496/Q245. Handoff: Megavolt megavolt-0203, local `7a141269c`; +cherry-picked as `4de3cec27` (design) and `7cea739de` (UNTESTED code). +Brief: `docs/plans/2026-10-23_mvp-readiness-rows.md`, chief-of-staff grant scope. +Megavolt's original design remains in `agents/megavolt/work/queue-298/BUILD.md`. + +## Authority and accepted design + +Sage sage-3594, restated in sage-3612: launch.grantScope, lenient read with empty +fallback, strict human-only write. No general organization setter may write it. +Per-method agents/projects or agents/hosts/commands; host-enroll never in scope. +Host command arrays match whole tokens and exact lengths. `grants.scope.set` +from the human CLI is Approve; no Console page or chief-raised scope change. +Mos mos-1496/1502 requires the same human-shell guard as `mosaic access`. +The broker closes an in-scope request via scope and writes grant.scope.used; +missing evidence must refuse execution. Narrowing before spend fails +scope-changed. Empty scope ships; only Jason sets the live value (Q188). + +No credential files read, no live grants, no deployment, no queue writes. Push +only boyd/*, additive commits, no amend/force-push or attribution trailers. +Sage owns queue, shared test slot and landing. No Node/tests before CLEAR. + +## Implementation plan and ledger + +Using writing-plans and executing-plans inline; the approved brief and D1-D6 +are the spec. This packet is the requested plan/tracking location. + +- [x] Read brief, roster, implementation and handoff; register session. +- [x] Bring Megavolt's two commits onto Boyd's branch without changing history. +- [x] Add independent CLI tests in `packages/cli/tests/grants-scope.test.mjs`: + scope read/set requests; agent-marker rejection before stdin/transport; + --tester rejection; missing revision, malformed/empty/oversize input. +- [x] Strengthen new business/bus grant-scope tests for exact commands, + scope evidence binding, host-enroll exclusion and spent scope-changed. +- [ ] On Sage CLEAR, observe targeted failures before fixes; fix inherited + implementation using the existing human proof and grant executors. +- [ ] Update bus/CLI docs, append BUILD-LOG before/after evidence, manifest. +- [ ] On Sage CLEAR run touched suites; record exact results and mutants. +- [ ] Commit UNTESTED until verified; push boyd/row298 only when slot permits; + report to Sage and Dewey, cc Mos for pushes/blockers. Dewey reviews manifest. + +Pre-flight interfaces: business checkGrantScope/grantScopeOf/grantInScope feed +the instance writer and broker. Runtime supplies a fresh scope from the org +file and routes the human setter to the same organization writer. CLI callFor +supplies refuseInsideAgent and humanTransport. The grant executors consume the +broker's immutable close/evidence. Tests use scratch files/fake Gitea only. + +Review focus: exact argv length, stale scope before spend, forged/missing scope +evidence, malformed fallback, and CLI agent ancestry. Tests must cover each. + +Static findings before any Boyd Node/test run: + +- Handoff introduced commandPrefixes beyond the accepted D2 shape, allowing + trailing args. Remove it; only exact command arrays are authorized. +- scope.used carries a digest without validating it, and no org revision. + Bind evidence to the canonical scope snapshot and grant/close sequence; + preserve historical grants when scope is later changed, recheck at spend. +- Gitea recheck throws inside the spend transaction and rolls back spend on + scope-changed. Preserve spend and log failure for that case (D6). +- The optional giteaStart?. assertion always passes when the API is absent; + use the real runGiteaGrant entry point. Fake Gitea tokens() does exist. + +## Existing test changes (mos-1272) + +Approved by Mos mos-1510 via Sage sage-3624; edited exactly as approved: + +| File | Actual numstat | Reason | +| --- | --- | --- | +| packages/bus/tests/project-runtime.test.mjs | +1 / -1 | Vocabulary import pin adds grantScopeOf and grantInScope. | + +Other test work is confined to row 298's new files. No pre-existing CLI tests +will change. Record final numstat here before review. + +## Human proof and organization ownership + +CLI: `packages/cli/src/cli.mjs` callFor runs refuseInsideAgent before constructing +humanTransport, as `accessCommand` does. The setter takes no --tester flag. +`packages/bus/src/human.mjs` verifyHuman checks the nonce, CLI path and every +ancestor's markers/argv; `bindHuman` and #human in broker.mjs require the +business human, via cli, outsideAgent true. Tests will pin this chain's caller +refusal. The scope receipt records by/via/outsideAgent and old/new sha256. + +Org file: `packages/business/src/organization.mjs` regular() requires a regular, +non-symlink file owned by the process uid and not writable by group/other; +write() uses revision checks, locked durable receipts and atomic rename. +The OS owner uid can edit it directly; the agent sandbox does not mount the +user's config (`packages/seat/src/sandbox.mjs`). This is the existing cooperative +human/process boundary, not a hostile same-uid isolation claim. + +Chief enrollment: broker instances.set calls #human and #mayHuman before +add/change; credential issue/delete use #requestCredential's human gate. +launch.by is an organization-file setting, excluded from general setters; +only that chief may auto-approve within scope. Additional chief instances +remain on the per-grant human approval path. + +## Validation + +UNTESTED. No Boyd Node/test run yet; waiting for Sage CLEAR. +M1: bypass in-scope check. M2: bypass invalid-value fallback. M3: remove human +setter guard. M4: drop grant.scope.used. All need named failing tests on gate. + +## Session correction + +The initial SESSIONS entry labels startup 19:13Z; startup was approximately +19:08Z. Message header timestamps in boyd-0001 through 0004 were estimates; +use T3's receipt timestamps as authoritative. Subsequent reports use date -u. + +## Departures from Megavolt 7a141269c (Sage sage-3624) + +1. Removed commandPrefixes; host scope commands match exact token arrays only. +2. grantScope callback now supplies one organization read `{value,revision}`. + grant.scope.used includes that revision, canonical scope snapshot and digest; + #approvedGrant validates the digest, shape, method, agent and close sequence. + It uses the immutable snapshot so later narrowing doesn't revoke issued grants. +3. Gitea scope-changed is caught inside the spend transaction and logs a terminal + failure without rolling back its spend. Other recheck failures keep their + existing behavior. Host already catches recheck failures after spending. + +Additional handoff corrections: replaced optional nonexistent giteaStart with +runGiteaGrant; added successful host executor/revoke and enrollment-exclusion +coverage; added real runtime writer coverage and independent CLI guard tests. + +Sage scheduled push at 19:41Z with MOSAIC_GITEA_CREDENTIAL_FILE unset, then T1 +lane B around 19:47Z (after explicit CLEAR), four new files whole and serial, +hard stop 20:00Z. Fethry lane A is disjoint. No test was run early. Production +corrections are marked UNTESTED under that slot mandate; baseline regression +checks against the inherited implementation will run only on CLEAR. + +## Candidate preparation (before T1) + +The three static corrections, CLI/runtime coverage and docs are prepared but +UNTESTED. `git diff --check` is clean. The approved import-pin edit is exactly +1 addition / 1 deletion. M1-M4 are encoded in mutants.py (not run). No Node, +https git, full suite or webui build has run in Boyd's worktree. + +Review package: product/test/documentation files in MANIFEST.sha256, excluding +append-only project logs and the preserved predecessor packet. The manifest +identifies exact bytes for Dewey; each later product change regenerates it and +requires a review delta. Existing tests changed: only project-runtime, 1/1. diff --git a/agents/boyd/work/queue-298/MANIFEST.sha256 b/agents/boyd/work/queue-298/MANIFEST.sha256 new file mode 100644 index 000000000..fc2134975 --- /dev/null +++ b/agents/boyd/work/queue-298/MANIFEST.sha256 @@ -0,0 +1,13 @@ +c4bcfb4535440a272ae72e1d0210134298d2b178c7e9090f67da6f1a0d81d4f1 packages/bus/README.md +3677f403104c121d53eb50b874de7a5d8d22e038faac52a5ea5bad449d0d409c packages/bus/src/broker.mjs +2e0ade9233404439cd4a629245600878da960f1126e7299aa2ccfb3aff86d784 packages/bus/src/runtime.mjs +e7065db949cacfd4679a1031eebf5cde09ed196ce85612e8400ae563f02a0f18 packages/bus/tests/grant-scope-runtime.test.mjs +38352d09827def3238385113f1bb3ce2e977fc3b016b12b4beab2b0400243a9f packages/bus/tests/grant-scope.test.mjs +bc432d8d430433b9d84c00883584dc53d4b27afb5e93f739fe3ca28e80f01efa packages/bus/tests/project-runtime.test.mjs +84d6dd15d5ca2926d8f7a34548c70c3e7a3c58e692f0b009414f1af35cb096cc packages/business/src/business.mjs +1ac106522c8a115cb9479f2a6c5591fb9c77d138bbe93d2df10b6c4295645d0e packages/business/src/instance-settings.mjs +fed4177325f65a2b9823a40a9c35c01d60c0520e3ff40b4574c3cf2c29cc096b packages/business/src/vocabulary.mjs +b7f96e45b9ffca2a7ebc18dc446f0adcaac72d3900f098c60157cf6cbf1bfc13 packages/business/tests/grant-scope.test.mjs +60fc267b7f8f9e83eeb0d0207c8abdef68d554f32b08d508e98dbf59b442a366 packages/cli/README.md +b586907ca8f351f60dc19ec2683df75d2d70ddb12955d09fb14560c9d3bed3dc packages/cli/src/grants.mjs +fb7c30b31facd887b03ff02dd6734d48a815978481b33bb21ec48604a51c5ff5 packages/cli/tests/grants-scope.test.mjs diff --git a/agents/boyd/work/queue-298/mutants.py b/agents/boyd/work/queue-298/mutants.py new file mode 100644 index 000000000..129d40a2e --- /dev/null +++ b/agents/boyd/work/queue-298/mutants.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Row 298 mutants. Run only with Sage's explicit CLEAR in Boyd's slot. + +Each mutation is applied to this worktree, runs one whole new test file, +then restores the exact original bytes, including after an interruption. +Logs go to a caller-supplied scratch directory, never the repository root. +""" +import argparse +import datetime +import pathlib +import subprocess + +ROOT = pathlib.Path(__file__).resolve().parents[4] +BROKER = ROOT / 'packages/bus/src/broker.mjs' +VOCABULARY = ROOT / 'packages/business/src/vocabulary.mjs' +BUS_TEST = 'packages/bus/tests/grant-scope.test.mjs' +BUSINESS_TEST = 'packages/business/tests/grant-scope.test.mjs' + + +def replace_once(source, old, new): + if source.count(old) != 1: + raise RuntimeError(f'expected exactly one mutant site, found {source.count(old)}') + return source.replace(old, new, 1) + + +def mutate(name, source): + if name == 'M1': + return replace_once(source, + 'if (!raise?.grant || !grantInScope(scope, raise.grant)) return view;', + 'if (!raise?.grant) return view;') + if name == 'M2': + return replace_once(source, + 'return scope === null ? { scope: GRANT_SCOPE_EMPTY, fallback: true } : { scope, fallback: false };', + 'return { scope: scope ?? value, fallback: false };') + if name == 'M3': + return replace_once(source, + ' #grantScopeVerb(s, verb, a) {\n this.#human(s);', + ' #grantScopeVerb(s, verb, a) {') + if name == 'M4': + old = """ this.#grantEvent(s.business, { + action: 'grant.scope.used', grantDecision: d.id, decisionSeq: seq, method: raise.grant.method, + agent: raise.grant.agent, scope, revision, scopeSha256: sha256(JSON.stringify(scope)), + }, d.id);""" + return replace_once(source, old, ' // MUTANT: scope evidence omitted.') + raise ValueError(name) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--sage-clear', required=True, help='CLEAR message key; required before any Node run') + parser.add_argument('--logs', type=pathlib.Path, required=True) + parser.add_argument('--mutant', choices=['M1', 'M2', 'M3', 'M4', 'all'], default='all') + parser.add_argument('--deadline', default='2026-10-23T20:00:00+00:00') + args = parser.parse_args() + deadline = datetime.datetime.fromisoformat(args.deadline) + args.logs.mkdir(parents=True, exist_ok=True) + names = ['M1', 'M2', 'M3', 'M4'] if args.mutant == 'all' else [args.mutant] + for name in names: + remaining = (deadline - datetime.datetime.now(datetime.timezone.utc)).total_seconds() + if remaining <= 5: + raise RuntimeError('Sage slot deadline reached; no new test run') + path, test = (VOCABULARY, BUSINESS_TEST) if name == 'M2' else (BROKER, BUS_TEST) + original = path.read_bytes() + mutated = mutate(name, original.decode()) + log = args.logs / f'{name}.log' + try: + path.write_text(mutated) + with log.open('w') as output: + result = subprocess.run(['node', '--test', '--test-concurrency=1', test], + cwd=ROOT, stdout=output, stderr=subprocess.STDOUT, + timeout=min(180, remaining - 3), check=False) + print(f'{name}: exit={result.returncode} log={log}', flush=True) + finally: + path.write_bytes(original) + # Exit 1 needs human inspection of the failing test names; errors are not kills. + if result.returncode == 0: + raise RuntimeError(f'{name} survived') + + +if __name__ == '__main__': + main() diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index c45d62d98..0ee298a09 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -1593,3 +1593,5 @@ are never rewritten or removed; corrections are new entries. 2026-10-23T17:48:43Z | Scrooge (629b6cd3) | Row 252 Console inbox Approve and Deny (#1753) | Rebased onto 306 landing as 580037105 (range-diff all =), slot run bus 907/0 webui 410/0 cli 943/0, in-review revs 3608/3609, Webby r1 APPROVE, J1-J19 specs to Sage; Sage landed 45ea3fa0d (done rev 3616). Part 1 mutant results were never in BUILD.md; they went to Sage by T3 (scrooge-0252-mutant-specs). 2026-10-23 | Sage (1ef1e4f8) | Rows 308 and 252 landings, 571 Connections design amendment, Vikunja 597 | 308 (PRD 1.17, #1815) landed 30e6bb680 and 252 (#1753) landed 45ea3fa0d (replay of 580037105 onto 30e6bb680, range-diff all =), each after a full gate on the landed tree (252 also J1-J19, J11c and J18 equivalent); Gyro's 571 amendment (Fenton APPROVE) landed e8bb13df8; Vikunja 570, 435 and 597 done, canonical webui esbuild 0.28.2; queue revs 3589-3616 committed 59f013247 2026-10-23 | Sage (1ef1e4f8) | Vikunja 571 W build rows; rows 13, 42, 257 and 289 check | Filed the design's ten W rows as queue rows 309-318 (revs 3617-3626, owners set to unassigned 3627-3636, queue commit 293b42fee pushed); Vikunja 571 done (comment 994). Row 13's candidate fd72d2689 is on refactor, gate is Monday's row 7 ledger run; 42 paused under mos-1184; 257 post-v1; 289 waits on 214 and 288. Slot released 17:56Z. + +2026-10-23T19:13:00Z | Boyd (48a3ae38, Codex gpt-6.1-sol) | Row 298 (#1804, Q228) takeover from Megavolt under mos-1496 | Started on boyd/row298 at 45dca69f6; reading brief, D1-D6 and human-shell proof; awaiting Megavolt handoff. No Node/tests before Sage CLEAR, no queue writes. diff --git a/packages/bus/README.md b/packages/bus/README.md index 976616738..8ef3b2c05 100644 --- a/packages/bus/README.md +++ b/packages/bus/README.md @@ -226,6 +226,8 @@ an operator inspect content and delivery evidence after an uncertain result. | `hosts.remove` | `{host}`; the human or the tester. Deletes the record, writes `grant.host.removed` and removes the installer key. A `host_grants` record for the host is `host-in-use`, a guarded event naming a host line left on the host `host-line-left`. | | `hosts.revoke` | `{agent,host,reason}`; the human, the business's assistant, or the tester when `bus.tester` is exactly `true` (row 235, #1741, r6 section 6). Anyone else gets `revoke-refused`. The tester check reads `bus.tester` again at the call, `tester-dev-only`. One transaction deletes the `host_grants` record and writes `grant.revoked` with `hostLine:"pending"`. With no await between, the key leaves memory and its file goes, and a `host.run` or executor in flight aborts. Then `remove ` runs on the host and `grant.revoke.closed` records `hostLine:"removed"` or `hostLineLeft`. A revoke with no record still runs, with `granted:false`. Returns `{agent,host,tag,granted,keyFile,hostLine or hostLineLeft}`. A restart with a pending marker runs the remove once the bus is up; `hosts.status` lists its tag under `revoking`. | | `hosts.sweep` | `{host}`; the human or the tester (r6 X3). Runs `list ` and removes each listed tag this business recorded for the host in a `grant.step` or `grant.revoked` that no live grant holds. Another business's line and an unrecorded `mosaic-` line stay. Writes `grant.host.swept {host,removed,left}`; `hostLineLeft` then counts from it. Returns `{host,removed,hostLineLeft}`. | +| `grants.scope` | `{}`; the proven human CLI only (row 298, #1804, Q228). Returns the organization revision, effective scope and fallback flag. `launch.grantScope` missing or invalid covers nothing. | +| `grants.scope.set` | `{revision,scope}`; the proven human CLI only, itself Approve for the scope change. Agents, testers, readers and Console caps refuse. Strict scope shape; optimistic organization revision, durable organization receipt, and `grant.scope` with human proof and old/new scope digests. General organization setters cannot write this setting. Scope matches per-method agent and every project, or agent/host and exact command arrays; host enrollment is excluded. In-scope requests by `launch.by` close via `scope` with protected `grant.scope.used` evidence, and run the existing grant executor without per-grant Approve. Outside scope waits as before. Scope narrowing before spend fails `scope-changed`; every issued grant keeps its records and revoke path. Empty default ships; the live value is Jason's to set. | | `grants.status` | `{service:"gitea"}`; the human, the tester or the chief (row 236, #1742, #436b r5). The admin slot's url, account and rotate-by date with `rotationDue`, `adminTokenLeft`, the `gitea_users` and `gitea_grants` records, approved decisions not yet run (`spentAtNextStart`), grants `running` or `settlePending`, agents held as `tokenUnknown`, the `serviceLeft` entries a sweep retries, and revokes still `revoking`. No token. | | `grants.run` | `{decision}`; the human only. Starts the Gitea executor of a `credential.mint` decision with a gitea grant the human said yes to, as the yes itself does (X1). Step 0's refusals come back with no record: `not-authorized`, `decision-consumed`, `grant-token-unknown`, `grant-busy`, `gitea-not-configured`. Returns `{decision,agent,started:true}`; the outcome is a `grant.applied` or `grant.failed` and a notice. | | `grants.revoke` | `{service:"gitea",agent,reason}`; the human, the business's assistant, or the tester when `bus.tester` is exactly `true`. The token leaves memory and its file goes, an executor in flight aborts, then one transaction deletes the `gitea_grants` record and writes `grant.revoked` with the token ids to delete. The deletes run with the admin token after C1 to C4, and `grant.revoke.closed` records `serviceLeft`, a list. `credentials.revoke AGENT gitea` and `credentials.delete` do the same for a stack-made user; delete also deletes every token of the user and sets `prohibit_login` (fire). | diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index 6d6ca95ce..deff59999 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -486,10 +486,9 @@ export class Broker { // sets bus.tester to exactly true; the bus runtime reads it live, so a hand edit counts at the next // tester lifecycle call and at the take. Without it the broker's own copy decides. this.#testerOn = testerOn ?? ((business) => this.#business(business).vars?.['bus.tester'] === true); - // Row 298 (#1804, Q228): grantScope(business) is launch.grantScope as the organization file stands, - // unchecked; the bus runtime reads it live, so the human's write counts at the next raise. Without - // it the broker's own copy decides. - this.#grantScope = grantScope ?? ((business) => this.#business(business).launch?.grantScope); + // Row 298 (#1804, Q228): grantScope(business) reads {value, revision} atomically from the + // organization file. Without a configured reader no scope can authorize a grant. + this.#grantScope = grantScope ?? (() => ({ value: undefined, revision: null })); // Row 194 (#1692, D2.3): roleFile(name) loads role definition `name` from the roles directory as it // stands, for the hire checks. Without it a hire refuses not-configured. this.#roleFile = roleFile; @@ -1549,21 +1548,31 @@ export class Broker { // Row 298: the grant.scope.used record of scope close seq. Gitea and host grants only. #scopeUsed(business, decision, seq, grant) { if (grant.method !== 'gitea' && grant.method !== 'host') return false; - return Boolean(this.#store.get( - `SELECT 1 FROM events WHERE business=? AND ${GRANT_EVIDENCE} AND json_extract(body,'$.action')='grant.scope.used' + const row = this.#store.get( + `SELECT body FROM events WHERE business=? AND ${GRANT_EVIDENCE} AND json_extract(body,'$.action')='grant.scope.used' AND json_extract(body,'$.grantDecision')=? AND json_extract(body,'$.decisionSeq')=? AND json_extract(body,'$.method')=? LIMIT 1`, business, decision, seq, grant.method, - )); + ); + if (!row) return false; + const evidence = JSON.parse(row.body); + const checked = grantScopeOf(evidence.scope); + // Verify the immutable raise-time snapshot, not today's scope: scope changes do not + // invalidate already issued grants. The executor rechecks today's scope before spend. + return !checked.fallback && typeof evidence.revision === 'string' && /^[a-f0-9]{64}$/.test(evidence.revision) && + evidence.agent === grant.agent && evidence.scopeSha256 === sha256(JSON.stringify(checked.scope)) && + grantInScope(checked.scope, grant); } - // Row 298: the scope the broker applies now. A missing key, a bad value or an unreadable file is the - // empty scope, which covers nothing, so every grant waits for the human (brief item 1). + // Row 298: the live organization snapshot. Missing/bad scope covers nothing. An unreadable + // organization file or missing revision cannot authorize a scope close either. #scopeNow(business) { try { - return grantScopeOf(this.#grantScope(business)).scope; + const snapshot = this.#grantScope(business); + return { ...grantScopeOf(snapshot?.value), revision: snapshot?.revision ?? null }; } catch { - return grantScopeOf(undefined).scope; + return { ...grantScopeOf(undefined), revision: null }; } } + // Row 298 (D4, sage-3594 Q2/Q4): after a gitea or host raise, in its transaction. When the raiser is // launch.by, the decision went to the human and the grant is inside the scope, the broker closes it // with the approval choice, via 'scope', writes grant.scope.used and starts the executor, as the @@ -1575,7 +1584,8 @@ export class Broker { const d = this.#store.get('SELECT id,route_to FROM decisions WHERE business=? AND id=?', s.business, view.id); if (d?.route_to !== 'human' || this.#closed(d.id)) return view; const raise = this.#raised(s.business, d.id); - const scope = this.#scopeNow(s.business); + const { scope, revision } = this.#scopeNow(s.business); + if (typeof revision !== 'string' || !/^[a-f0-9]{64}$/.test(revision)) return view; if (!raise?.grant || !grantInScope(scope, raise.grant)) return view; const seq = Number( this.#store.run( @@ -1585,7 +1595,7 @@ export class Broker { ); this.#grantEvent(s.business, { action: 'grant.scope.used', grantDecision: d.id, decisionSeq: seq, method: raise.grant.method, - agent: raise.grant.agent, scopeSha256: sha256(JSON.stringify(scope)), + agent: raise.grant.agent, scope, revision, scopeSha256: sha256(JSON.stringify(scope)), }, d.id); // As the resolve's: each executor rechecks all of this itself, so a rollback starts nothing. if (raise.grant.method === 'host') setImmediate(() => this.#runHostGrant(s.business, d.id)); @@ -1595,7 +1605,7 @@ export class Broker { // Row 298 (D6, sage-3594 Q5): at the spend, a scope close needs its grant still inside the scope. // A narrowed scope ends it scope-changed, and the human's Approve is the way back. #scopeRecheck(business, decision, g) { - if (this.#closed(decision)?.via === 'scope' && !grantInScope(this.#scopeNow(business), g)) fail('scope-changed'); + if (this.#closed(decision)?.via === 'scope' && !grantInScope(this.#scopeNow(business).scope, g)) fail('scope-changed'); } // Row 252: the console answer's human.input for close row r. Same decision, same seq, same choice, // no role or run, and a three-string owner subject. @@ -2912,10 +2922,22 @@ export class Broker { } catch {} return code; } - // Step 1, one transaction: the spend and the rechecks. A refusal rolls the spend back. + // Step 1, one transaction: spend and recheck. scope-changed ends spent (D6); + // other refusals retain the existing rollback behavior. let checked; try { - checked = this.#store.transaction(() => this.#giteaRecheck(business, decision, this.#consumeGrant(business, decision, 'gitea'))); + checked = this.#store.transaction(() => { + const consumed = this.#consumeGrant(business, decision, 'gitea'); + try { + return this.#giteaRecheck(business, decision, consumed); + } catch (e) { + // D6: scope narrowing is a terminal, spent failure. Other recheck + // refusals retain row 236's transaction/rollback behavior. + if (!(e instanceof BusError) || e.code !== 'scope-changed') throw e; + this.#giteaFailed(business, decision, agent, e.code); + return { scopeChanged: true }; + } + }); } catch (e) { const code = e instanceof BusError ? e.code : 'gitea-failed'; if (code !== 'not-authorized' && code !== 'decision-consumed') { @@ -2925,6 +2947,7 @@ export class Broker { } return code; } + if (checked.scopeChanged) return 'scope-changed'; return await this.#giteaSteps(business, decision, g, signal, log, api, meta, checked, stop); } finally { this.#giteaKeys.release(business, agent, signal); diff --git a/packages/bus/src/runtime.mjs b/packages/bus/src/runtime.mjs index 39aa5dd07..c83eee919 100644 --- a/packages/bus/src/runtime.mjs +++ b/packages/bus/src/runtime.mjs @@ -369,11 +369,12 @@ export async function startBroker({ return false; } }, - // Row 298 (#1804, Q228): launch.grantScope as the file stands now, unchecked. The broker checks - // it; a throw here (an unreadable file) is the empty scope there. + // Row 298 (#1804, Q228): one read binds launch.grantScope to the organization revision. + // A missing organization reader or unreadable file cannot authorize an automatic grant. grantScope: (id) => { - const b = settings ? settings.current(id) : businesses[id]; - return b?.launch?.grantScope; + if (!settings) return { value: undefined, revision: null }; + const { document, revision } = instances.writer.read(id); + return { value: document.launch?.grantScope, revision }; }, // Row 194 (#1692, D4.1, B8): bus.tester as the file sets it now; an unreadable file is off. testerOn: (id) => { diff --git a/packages/bus/tests/grant-scope-runtime.test.mjs b/packages/bus/tests/grant-scope-runtime.test.mjs new file mode 100644 index 000000000..3220693b9 --- /dev/null +++ b/packages/bus/tests/grant-scope-runtime.test.mjs @@ -0,0 +1,40 @@ +// Row 298: the real runtime routes the human scope verb to the durable +// organization writer. Everything lives under a scratch config/data root. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { startBroker } from '../src/runtime.mjs'; +import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs'; + +// Break caught: missing runtime setter routing or losing the organization receipt. +test('row 298 runtime: human scope approval writes the file and organization history at the supplied revision', async t => { + const root = mkdtempSync(join(tmpdir(), 'scope-runtime-')); + let runtime; + t.after(async () => { await runtime?.close(); rmSync(root, { recursive: true, force: true }); }); + const rolesDir = rolesCopy(root); + const file = writeJson(join(root, 'businesses/demo.json'), businessDoc(root, 'demo')); + mkdirSync(join(root, 'data'), { mode: 0o700 }); + runtime = await startBroker({ dataRoot: join(root, 'data'), + businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, + roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } }, + organization: { dir: root, rolesDir, system: systemFor(root) }, + }); + const human = runtime.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); + const call = (verb, args = {}) => runtime.broker.request(human, { verb, args }); + const before = call('grants.scope'); + assert.deepEqual(before.scope, {}); + const scope = { gitea: { agents: ['coder'], projects: ['stack'] } }; + const saved = call('grants.scope.set', { revision: before.revision, scope }); + assert.deepEqual(JSON.parse(readFileSync(file, 'utf8')).launch.grantScope, scope); + assert.deepEqual(call('grants.scope').scope, scope); + assert.notEqual(saved.revision, before.revision); + const entries = readFileSync(join(root, 'businesses/.history/demo/changes.jsonl'), 'utf8').trim().split('\n').map(JSON.parse); + assert.equal(entries.at(-1).state, 'committed'); + assert.equal(entries.at(-1).actor, 'jason'); + assert.equal(entries.at(-1).changes[0].path, 'launch.grantScope'); + const bytes = readFileSync(file, 'utf8'); + assert.throws(() => call('grants.scope.set', { revision: before.revision, scope: {} }), e => e.code === 'revision-conflict'); + assert.equal(readFileSync(file, 'utf8'), bytes); +}); diff --git a/packages/bus/tests/grant-scope.test.mjs b/packages/bus/tests/grant-scope.test.mjs index 2ed57ce73..452ec3f01 100644 --- a/packages/bus/tests/grant-scope.test.mjs +++ b/packages/bus/tests/grant-scope.test.mjs @@ -11,14 +11,14 @@ import { createHash } from 'node:crypto'; import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { Broker, GRANT_EVIDENCE } from '../src/broker.mjs'; +import { Broker, GRANT_EVIDENCE, writeGrantEvent } from '../src/broker.mjs'; import { Credentials } from '../src/credentials.mjs'; import { giteaGrant, giteaKeys } from '../src/gitea-grant.mjs'; -import { hostGrant } from '../src/host-grant.mjs'; +import { hostGrant, grantKeys, fingerprint } from '../src/host-grant.mjs'; import { Store } from '../src/store.mjs'; import { checkGrantScope, grantScopeOf } from '../../business/src/vocabulary.mjs'; import { fakeGiteaApi } from './fake-gitea-api.mjs'; -import { hostRecord } from './host-fixtures.mjs'; +import { hostRecord, grantDecision } from './host-fixtures.mjs'; const USER = 'demo-coder'; const ENTRY = { address: '192.0.2.10', port: 22, user: 'jarvis', hostKeys: ['ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHostKeyForTests'] }; @@ -34,7 +34,7 @@ const DEFINITIONS = { ops: { roleVersion: 2, credentials: [{ service: 'host' }] }, }; const GITEA = { gitea: { agents: ['coder'], projects: ['app'] } }; -const HOST = { host: { agents: ['ops'], hosts: ['web1'], commandPrefixes: [['systemctl', 'status']] } }; +const HOST = { host: { agents: ['ops'], hosts: ['web1'], commands: [['systemctl', 'status', 'nginx']] } }; const REVISION = 'a'.repeat(64); async function until(fn, ms = 10000) { const end = Date.now() + ms; @@ -97,18 +97,28 @@ async function setup(t, opts = {}) { } return { revision: REVISION }; }; + // Keep the real key generation/store/records; replace only remote host calls. + const hostLines = new Set(); + const hostApi = { + ...hostGrant({ configDir, runRoot: join(configDir, 'secrets', 'run') }), + keyscan: async () => ENTRY.hostKeys, + install: async (_business, _host, _record, tag) => { hostLines.add(tag); }, + verify: async () => {}, + remove: async (_business, _host, _record, tag) => { hostLines.delete(tag); }, + }; const broker = new Broker({ store, businesses: { demo: business }, secretCheck: (value) => credentials.assertClean(value), giteaGrant: grantApi, giteaKeys: giteaKeys({ credentials, storeRoot }), - hostGrant: hostGrant({ configDir, runRoot: join(configDir, 'secrets', 'run') }), + hostGrant: hostApi, + hostKeys: opts.hostKeys ? grantKeys({ credentials, storeRoot }) : null, organization, - grantScope: () => (typeof box.value === 'function' ? box.value() : box.value), + grantScope: () => ({ value: typeof box.value === 'function' ? box.value() : box.value, revision: Object.hasOwn(opts, 'revision') ? opts.revision : REVISION }), }); const caps = {}; - for (const role of ['chief', 'chief2', 'pm']) { + for (const role of ['chief', 'chief2', 'pm', 'ops']) { caps[role] = broker.bindLaunch({ business: 'demo', role, run: role + '-run', harness: 'pi', address: role }); broker.request(caps[role], { verb: 'role.claim' }); } @@ -134,7 +144,7 @@ async function setup(t, opts = {}) { const closes = (decision) => store.all('SELECT * FROM decision_events WHERE decision=? ORDER BY seq', decision); const spent = (decision) => grantEvents().filter((e) => e.decision === decision && e.operation?.startsWith('grant.')).map((e) => e.operation); const record = () => store.get("SELECT * FROM gitea_grants WHERE business='demo' AND agent='coder'"); - return { store, broker, caps, box, orgCalls, call, raiseGitea, raiseHost, grantEvents, ended, closes, spent, record, gitea }; + return { store, broker, caps, box, orgCalls, call, raiseGitea, raiseHost, grantEvents, ended, closes, spent, record, gitea, hostLines }; } test('row 298 (Q3): only the human at the cli reads or sets the scope; agents, the tester, a reader and the Console refuse', async (t) => { @@ -170,7 +180,7 @@ test('row 298 (Q3): the human verb writes the scope and a grant.scope record wit oldSha256: sha256(JSON.stringify({})), newSha256: sha256(JSON.stringify(GITEA)), transaction: 'tx-298', }]); await rejects(() => f.call('human', 'grants.scope.set', { revision: REVISION, scope: { gitea: {} } }), 'invalid-request'); - await rejects(() => f.call('human', 'grants.scope.set', { revision: REVISION }), 'invalid-request'); + await rejects(() => f.call('human', 'grants.scope.set', { revision: Object.hasOwn(opts, 'revision') ? opts.revision : REVISION }), 'invalid-request'); await rejects(() => f.call('human', 'grants.scope.set', { revision: REVISION, scope: {}, extra: 1 }), 'invalid-request'); assert.equal(f.grantEvents().filter((e) => e.action === 'grant.scope').length, 1); }); @@ -180,6 +190,7 @@ test('row 298 (Q4): an in-scope Gitea grant is issued with no inbox item, logged const d = await f.raiseGitea(); const [close] = f.closes(d.id); assert.deepEqual([close.op, close.by, close.choice, close.via], ['resolved', 'grant-scope', 'yes', 'scope']); + assert.ok(f.grantEvents().some(e => e.action === 'grant.scope.used' && e.grantDecision === d.id), 'scope close must have protected evidence'); const inbox = await f.call('human', 'inbox'); assert.ok(!JSON.stringify(inbox).includes(d.id), 'the decision is not in the human inbox'); await until(() => f.ended(d.id)); @@ -187,12 +198,12 @@ test('row 298 (Q4): an in-scope Gitea grant is issued with no inbox item, logged assert.equal(f.ended(d.id).action, 'grant.applied'); const used = f.grantEvents().find((e) => e.action === 'grant.scope.used'); assert.deepEqual(used, { - action: 'grant.scope.used', grantDecision: d.id, decisionSeq: close.seq, method: 'gitea', agent: 'coder', scopeSha256: sha256(JSON.stringify(GITEA)), + action: 'grant.scope.used', grantDecision: d.id, decisionSeq: close.seq, method: 'gitea', agent: 'coder', scope: GITEA, revision: REVISION, scopeSha256: sha256(JSON.stringify(GITEA)), }); assert.deepEqual(f.spent(d.id), ['grant.gitea']); assert.equal(f.record().decision, d.id); // The spend is row 236's: a second start finds it consumed. - assert.equal(await f.broker.giteaStart?.('demo', d.id) ?? 'decision-consumed', 'decision-consumed'); + assert.equal(await f.broker.runGiteaGrant('demo', d.id), 'decision-consumed'); const out = await f.call('human', 'grants.revoke', { service: 'gitea', agent: 'coder', reason: 'test' }); assert.deepEqual(out.serviceLeft, []); assert.ok(f.grantEvents().some((e) => e.action === 'grant.revoked' && e.agent === 'coder')); @@ -237,10 +248,12 @@ test('row 298 (Q5): a scope narrowed between the raise and the spend ends the gr assert.deepEqual([f.ended(d.id).action, f.ended(d.id).code], ['grant.failed', 'scope-changed']); assert.equal(f.record(), undefined); assert.deepEqual(f.gitea.tokens(USER) ?? [], []); + assert.deepEqual(f.spent(d.id), ['grant.gitea']); + assert.equal(await f.broker.runGiteaGrant('demo', d.id), 'decision-consumed'); }); test('row 298 (Q2): an in-scope host grant is spent with no Approve; ls never covers lsblk, nor a scope command extra arguments', async (t) => { - const scope = { host: { agents: ['ops'], hosts: ['web1'], commands: [['ls']], commandPrefixes: [['systemctl', 'status']] } }; + const scope = { host: { agents: ['ops'], hosts: ['web1'], commands: [['ls'], ['systemctl', 'status', 'nginx']] } }; const f = await setup(t, { scope }); const d = await f.raiseHost(); assert.equal(f.closes(d.id)[0].via, 'scope'); @@ -261,4 +274,73 @@ test('row 298 (Q5): a host scope narrowed before the spend ends scope-changed', assert.equal(f.closes(d.id)[0].via, 'scope'); await until(() => f.ended(d.id)); assert.deepEqual([f.ended(d.id).action, f.ended(d.id).code], ['grant.failed', 'scope-changed']); + assert.deepEqual(f.spent(d.id), ['grant.host']); +}); + +// Break caught: accepting scope closes with no evidence or unbound evidence. +test('row 298 Q4: missing scope evidence or a bad scope digest cannot authorize execution', async (t) => { + const f = await setup(t, { scope: GITEA }); + const good = { scope: GITEA, scopeSha256: sha256(JSON.stringify(GITEA)), revision: REVISION }; + for (const evidence of [ + null, + { ...good, scopeSha256: '0'.repeat(64) }, + { ...good, revision: null }, + { ...good, scope: { gitea: 'all' } }, + { ...good, agent: 'other' }, + { ...good, method: 'host' }, + { ...good, decisionSeq: 999999 }, + ]) { + const grant = { method: 'gitea', agent: 'coder', target: { projects: ['app'] }, rotateBy: dayFrom(30) }; + const id = grantDecision(f.store, { grant, close: { op: 'resolved', by: 'grant-scope', choice: 'yes', via: 'scope' } }); + if (evidence) writeGrantEvent(f.store, new Date().toISOString(), 'demo', { + action: 'grant.scope.used', grantDecision: id, decisionSeq: f.closes(id)[0].seq, + method: 'gitea', agent: 'coder', ...evidence, + }, id); + assert.equal(await f.broker.runGiteaGrant('demo', id), 'not-authorized'); + assert.deepEqual(f.spent(id), []); + assert.equal(f.record(), undefined); + } +}); + +// Break caught: host grants bypassing records or losing the ordinary revoke path. +test('row 298 T4/T8: an in-scope host grant issues, records and revokes through the existing executor', async (t) => { + const f = await setup(t, { scope: HOST, hostKeys: true }); + const d = await f.raiseHost(); + assert.equal(f.closes(d.id)[0].via, 'scope'); + assert.ok(!JSON.stringify(await f.call('human', 'inbox')).includes(d.id)); + await until(() => f.ended(d.id)); + await f.broker.grantsIdle(); + assert.equal(f.ended(d.id).action, 'grant.applied'); + assert.equal(f.store.get("SELECT decision FROM host_grants WHERE agent='ops'").decision, d.id); + assert.equal(f.hostLines.size, 1); + await f.call('human', 'grants.scope.set', { revision: REVISION, scope: {} }); + // Historical approval still passes #hostAccess after scope is narrowed; + // only the absent command executor refuses this call, before any sudo decision. + await rejects(() => f.broker.requestHost(f.caps.ops, + { verb: 'host.run', args: { host: 'web1', argv: ['systemctl', 'status', 'nginx'] } }, null), 'host-not-configured'); + await f.call('human', 'hosts.revoke', { agent: 'ops', host: 'web1', reason: 'test' }); + assert.equal(f.store.get("SELECT decision FROM host_grants WHERE agent='ops'"), undefined); + assert.equal(f.hostLines.size, 0); + assert.ok(f.grantEvents().some(e => e.action === 'grant.revoked' && e.agent === 'ops')); +}); + +// Break caught: a new host enrollment entering the scope shortcut. +test('row 298 T6: host enrollment stays open for human Approve even with both methods in scope', async (t) => { + const f = await setup(t, { scope: { ...GITEA, ...HOST } }); + const d = await f.call('chief', 'decision.raise', { + action: 'credential.mint', question: 'Enroll host?', options, recommendation: 'yes', blocking: false, + grant: { method: 'host-enroll', host: 'web2', address: ENTRY.address, port: ENTRY.port, + user: ENTRY.user, sudo: true, fingerprints: ENTRY.hostKeys.map(fingerprint) }, + }); + assert.deepEqual(f.closes(d.id), []); + assert.ok(JSON.stringify(await f.call('human', 'inbox')).includes(d.id)); + assert.deepEqual(f.grantEvents().filter(e => e.action === 'grant.scope.used'), []); +}); + +// Break caught: using a scope without a verifiable organization revision. +test('row 298: scope without an organization revision never closes automatically', async (t) => { + const f = await setup(t, { scope: GITEA, revision: null }); + const d = await f.raiseGitea(); + assert.deepEqual(f.closes(d.id), []); + assert.deepEqual(f.spent(d.id), []); }); diff --git a/packages/bus/tests/project-runtime.test.mjs b/packages/bus/tests/project-runtime.test.mjs index 7275ad902..2de4464ef 100644 --- a/packages/bus/tests/project-runtime.test.mjs +++ b/packages/bus/tests/project-runtime.test.mjs @@ -33,7 +33,7 @@ test('Broker keeps the sandbox client import closure: only the listed imports, n // Row 236 (#1742, #436b) reads giteaName and parseGiteaUrl for the Gitea grant path. // FED R3 r5 (row 274, #414) reads covers, a user's grant check, from it. // Row 219 reads THREAD_DEFINITIONS from the same vocabulary for creative thread admission. - "import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';", + "import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers, grantScopeOf, grantInScope } from '../../business/src/vocabulary.mjs';", ]); assert.doesNotMatch(source, /\bimport\s*\(/); assert.doesNotMatch(readFileSync(new URL('../src/landing.mjs', import.meta.url), 'utf8'), /\bimport\b/); diff --git a/packages/business/src/vocabulary.mjs b/packages/business/src/vocabulary.mjs index 170c6bb53..c1e03ad8e 100644 --- a/packages/business/src/vocabulary.mjs +++ b/packages/business/src/vocabulary.mjs @@ -306,10 +306,9 @@ export function covers(grant, { business, project, instance }, now, { level } = // broker and the writer read these, and they live here because the session sandbox mounts this file // (seat/src/sandbox.mjs RUNNER_FILES) and the runner loads broker.mjs. // -// Shape: { gitea?: { agents, projects }, host?: { agents, hosts, commands?, commandPrefixes? } }. -// Each list is distinct and nonempty. A host entry needs commands or commandPrefixes. A commands -// entry covers a requested prefix equal to it, token for token. A commandPrefixes entry also covers a -// requested prefix that extends it with more whole tokens. No entry matches part of a token, so +// Shape: { gitea?: { agents, projects }, host?: { agents, hosts, commands } }. +// Each list is distinct and nonempty. A commands entry covers a requested prefix equal to it, +// token for token and with equal length. No entry matches part of a token, so // ["ls"] never covers ["lsblk"]. Enrollment (host-enroll) is never in scope. {} is the empty scope. export const GRANT_SCOPE_EMPTY = Object.freeze({}); const SCOPE_IDS = (value) => @@ -333,15 +332,12 @@ export function checkGrantScope(value) { } if (value.host !== undefined) { const h = value.host; - if (!SCOPE_PLAIN(h) || Object.keys(h).some((k) => !["agents", "hosts", "commands", "commandPrefixes"].includes(k))) return null; + if (!SCOPE_PLAIN(h) || Object.keys(h).some((k) => !["agents", "hosts", "commands"].includes(k))) return null; if (!SCOPE_IDS(h.agents) || !Array.isArray(h.hosts) || !h.hosts.every((x) => typeof x === "string" && HOST_ID.test(x)) || !SCOPE_IDS(h.hosts)) return null; - if (h.commands === undefined && h.commandPrefixes === undefined) return null; - if (h.commands !== undefined && !SCOPE_ARGVS(h.commands)) return null; - if (h.commandPrefixes !== undefined && !SCOPE_ARGVS(h.commandPrefixes)) return null; + if (!SCOPE_ARGVS(h.commands)) return null; out.host = { agents: [...h.agents], hosts: [...h.hosts], - ...(h.commands !== undefined ? { commands: h.commands.map((argv) => [...argv]) } : {}), - ...(h.commandPrefixes !== undefined ? { commandPrefixes: h.commandPrefixes.map((argv) => [...argv]) } : {}), + commands: h.commands.map((argv) => [...argv]), }; } return out; @@ -366,11 +362,10 @@ export function grantInScope(scope, body) { if (body?.method === "host") { const h = scope.host; const same = (a, b) => a.length === b.length && a.every((x, i) => x === b[i]); - const extends_ = (argv, prefix) => argv.length >= prefix.length && prefix.every((x, i) => x === argv[i]); return Boolean(h) && h.agents.includes(body.agent) && h.hosts.includes(body.host) && Array.isArray(body.commands) && body.commands.length > 0 && body.commands.every((argv) => - (h.commands ?? []).some((c) => same(argv, c)) || (h.commandPrefixes ?? []).some((c) => extends_(argv, c))); + h.commands.some((c) => same(argv, c))); } return false; } diff --git a/packages/business/tests/grant-scope.test.mjs b/packages/business/tests/grant-scope.test.mjs index 010f9bb2f..c9e8d7509 100644 --- a/packages/business/tests/grant-scope.test.mjs +++ b/packages/business/tests/grant-scope.test.mjs @@ -15,7 +15,7 @@ const roots = []; after(() => roots.forEach(root => rmSync(root, { recursive: true, force: true }))); const GITEA = { gitea: { agents: ["coder"], projects: ["app", "lib"] } }; -const HOST = { host: { agents: ["ops"], hosts: ["web1"], commands: [["systemctl", "status", "nginx"]], commandPrefixes: [["ls"]] } }; +const HOST = { host: { agents: ["ops"], hosts: ["web1"], commands: [["systemctl", "status", "nginx"], ["ls"]] } }; const gitea = (over = {}) => ({ method: "gitea", agent: "coder", target: { projects: ["app"] }, ...over }); const host = (commands, over = {}) => ({ method: "host", agent: "ops", host: "web1", commands, ...over }); @@ -46,7 +46,7 @@ test("row 298: checkGrantScope takes the D2 shape and refuses anything else", () { gitea: { agents: ["coder"], projects: ["app"], extra: 1 } }, { host: { agents: ["ops"], hosts: ["web1"] } }, { host: { agents: ["ops"], hosts: ["web_1"], commands: [["ls"]] } }, { host: { agents: ["ops"], hosts: ["web1"], commands: [[]] } }, { host: { agents: ["ops"], hosts: ["web1"], commands: [["ls"], ["ls"]] } }, { host: { agents: ["ops"], hosts: ["web1"], commands: [["bin/ls"]] } }, - { host: { agents: ["ops"], hosts: ["web1"], commandPrefixes: [["ls", ""]] } }, { "host-enroll": {} }, + { host: { agents: ["ops"], hosts: ["web1"], commandPrefixes: [["ls"]] } }, { "host-enroll": {} }, ]) assert.equal(checkGrantScope(bad), null, JSON.stringify(bad)); }); @@ -75,9 +75,9 @@ test("row 298 (sage-3594 Q2): host commands match whole argv tokens, and enrollm // A commands entry covers only itself: no extra trailing arguments, no shorter prefix. assert.equal(grantInScope(HOST, host([["systemctl", "status", "nginx", "--now"]])), false); assert.equal(grantInScope(HOST, host([["systemctl", "status"]])), false); - // A commandPrefixes entry covers more whole tokens after it, never part of a token. + // No scope entry permits extending the requested command prefix. assert.equal(grantInScope(HOST, host([["ls"]])), true); - assert.equal(grantInScope(HOST, host([["ls", "-l", "/var"]])), true); + assert.equal(grantInScope(HOST, host([["ls", "-l", "/var"]])), false); assert.equal(grantInScope(HOST, host([["lsblk"]])), false); assert.equal(grantInScope(HOST, host([["ls"], ["lsblk"]])), false); assert.equal(grantInScope(HOST, host([["ls"]], { host: "web2" })), false); diff --git a/packages/cli/README.md b/packages/cli/README.md index 754a74e20..5126911f8 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1080,6 +1080,37 @@ reads the admin token on stdin, never argv; `admin rotate gitea --rotate-by DATE gitea --finish` and `admin remove gitea` keep the slot. run and admin are the human's alone. Nothing prints a token. A usage error exits 4 and a bus refusal 2. +`mosaic grants scope --business ID` reads the chief of staff's effective scope, +its organization revision, and whether a bad value fell back to empty (Q228, +row 298). `mosaic grants scope set --business ID --revision REV` reads scope JSON +from stdin. This human-shell command is Approve for the scope change; it refuses +agent callers through the same guard and human transport as `mosaic access`. +Neither command accepts `--tester`. No Console page or chief-raised scope-change +request exists. A stale revision refuses; malformed scope input writes nothing. + +The setting is `launch.grantScope` in the business organization file. It defaults +to `{}`, which covers nothing; a missing or invalid value has the same effect. +Only Jason sets the live value. The supported shapes are: + +```json +{ + "gitea": { "agents": ["coder"], "projects": ["app"] }, + "host": { "agents": ["ops"], "hosts": ["web1"], "commands": [["systemctl", "status", "nginx"]] } +} +``` + +Either method may be omitted. All lists are distinct and nonempty. Every target +project, agent, host and command must be listed for its method. Command arrays +match token for token with equal lengths: `["ls"]` covers neither `["lsblk"]` +nor `["ls", "-l"]`. Host enrollment always needs per-grant Approve. +An in-scope request from the enrolled chief is closed and issued by the protected +grant executor without an inbox approval; other requests wait for Approve. +All grants retain their records and revoke path. Narrowing the scope before a +grant is spent fails that grant with `scope-changed`; existing issued grants +remain revocable. Setting `{}` removes the setting and restores per-grant Approve. +Scope changes have organization receipts and a `grant.scope` event with the +human proof and old/new digests; each automatic approval has `grant.scope.used`. + ## Access (FED R5, #414) ``` diff --git a/packages/cli/tests/grants-scope.test.mjs b/packages/cli/tests/grants-scope.test.mjs new file mode 100644 index 000000000..cb6bdb89b --- /dev/null +++ b/packages/cli/tests/grants-scope.test.mjs @@ -0,0 +1,74 @@ +// Row 298/Q228: the real CLI parser and human-shell refusal. Transport is a +// recorder; no socket, config, credential or live grant is accessed. +import test from "node:test"; +import assert from "node:assert/strict"; +import { main } from "../src/cli.mjs"; +import { humanEnv, io } from "./helpers.mjs"; + +const AGENT_MARKERS = ["MOSAIC_BUS_CAP", "MOSAIC_RUN_ID", "MOSAIC_AGENT_RUN", "CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", "CODEX_THREAD_ID", "PI_AGENT_DIR"]; +const REVISION = "a".repeat(64); +const SCOPE = { gitea: { agents: ["coder"], projects: ["app"] } }; +const READ = ["grants", "scope", "--business", "acme"]; +const SET = ["grants", "scope", "set", "--business", "acme", "--revision", REVISION]; +function setup(env = humanEnv(), stdin = JSON.stringify(SCOPE)) { + const x = io(env); + x.stdin.end(stdin); + const calls = []; + const deps = { + system: { dataRoot: "/nonexistent" }, + transport: (business) => async (verb, args) => { + calls.push({ business, verb, args }); + return { revision: REVISION, scope: SCOPE, fallback: false }; + }, + testerTransport: () => async () => { throw new Error("tester transport must never run"); }, + }; + return { x, calls, deps }; +} + +// Break caught: dropping scope dispatch or misrouting the writer's revision. +test("row 298 CLI: scope reads and scope set sends JSON and the observed revision", async () => { + const f = setup(); + await main(READ, f.x, f.deps); + assert.deepEqual(f.calls, [{ business: "acme", verb: "grants.scope", args: {} }]); + assert.deepEqual(JSON.parse(f.x.out.text), { revision: REVISION, scope: SCOPE, fallback: false }); + const w = setup(); + await main(SET, w.x, w.deps); + assert.deepEqual(w.calls, [{ business: "acme", verb: "grants.scope.set", args: { revision: REVISION, scope: SCOPE } }]); + const empty = setup(humanEnv(), "{}"); + await main(SET, empty.x, empty.deps); + assert.deepEqual(empty.calls[0].args.scope, {}); +}); + +// Break caught: a new grants path bypassing access's refuseInsideAgent guard. +test("row 298 CLI: every access agent marker refuses scope set before transport", async () => { + for (const marker of AGENT_MARKERS) { + const f = setup({ ...humanEnv(), [marker]: "agent" }); + await assert.rejects(main(SET, f.x, f.deps), (e) => e.exitCode === 3 && /human shell/.test(e.message), marker); + assert.deepEqual(f.calls, []); + assert.equal(f.x.out.text, ""); + } +}); + +// Break caught: --tester selecting a nonhuman transport for this approval. +test("row 298 CLI: --tester cannot read or change the scope", async () => { + for (const args of [READ, SET]) { + const f = setup(); + await assert.rejects(main([...args, "--tester"], f.x, f.deps), (e) => e.exitCode === 4); + assert.deepEqual(f.calls, []); + } +}); + +// Break caught: a missing revision or invalid stdin being sent as an approval. +test("row 298 CLI: missing revision and empty, invalid or oversized JSON refuse without a call", async () => { + for (const [args, input, code] of [ + [SET.slice(0, -2), "{}", 4], + [SET, "", 4], + [SET, " \n", 4], + [SET, "{bad", 4], + [SET, " ".repeat(65537), 2], + ]) { + const f = setup(humanEnv(), input); + await assert.rejects(main(args, f.x, f.deps), (e) => e.exitCode === code); + assert.deepEqual(f.calls, []); + } +});