row 298: Untested scope evidence, exact commands, spent narrowing and CLI coverage (#1804)

This commit is contained in:
jason.woltje committed 2026-10-23 14:22:36 -05:00
1 parent 7cea739de3
commit 574ece13c1
15 files changed
+536 -49

No files matched your search

+2
View File
@@ -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: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-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.
+141
View File
@@ -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.
@@ -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
+81
View File
@@ -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()
+2
View File
@@ -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-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) | 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-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.
+2
View File
@@ -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.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 <user> <tag>` 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.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 <user> <tag>` 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 <user>` 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}`. | | `hosts.sweep` | `{host}`; the human or the tester (r6 X3). Runs `list <user>` 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.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.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). | | `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). |
+39 -16
View File
@@ -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 // 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. // 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); 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, // Row 298 (#1804, Q228): grantScope(business) reads {value, revision} atomically from the
// unchecked; the bus runtime reads it live, so the human's write counts at the next raise. Without // organization file. Without a configured reader no scope can authorize a grant.
// it the broker's own copy decides. this.#grantScope = grantScope ?? (() => ({ value: undefined, revision: null }));
this.#grantScope = grantScope ?? ((business) => this.#business(business).launch?.grantScope);
// Row 194 (#1692, D2.3): roleFile(name) loads role definition `name` from the roles directory as it // 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. // stands, for the hire checks. Without it a hire refuses not-configured.
this.#roleFile = roleFile; 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. // Row 298: the grant.scope.used record of scope close seq. Gitea and host grants only.
#scopeUsed(business, decision, seq, grant) { #scopeUsed(business, decision, seq, grant) {
if (grant.method !== 'gitea' && grant.method !== 'host') return false; if (grant.method !== 'gitea' && grant.method !== 'host') return false;
return Boolean(this.#store.get( const row = this.#store.get(
`SELECT 1 FROM events WHERE business=? AND ${GRANT_EVIDENCE} AND json_extract(body,'$.action')='grant.scope.used' `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`, AND json_extract(body,'$.grantDecision')=? AND json_extract(body,'$.decisionSeq')=? AND json_extract(body,'$.method')=? LIMIT 1`,
business, decision, seq, grant.method, 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 // Row 298: the live organization snapshot. Missing/bad scope covers nothing. An unreadable
// empty scope, which covers nothing, so every grant waits for the human (brief item 1). // organization file or missing revision cannot authorize a scope close either.
#scopeNow(business) { #scopeNow(business) {
try { try {
return grantScopeOf(this.#grantScope(business)).scope; const snapshot = this.#grantScope(business);
return { ...grantScopeOf(snapshot?.value), revision: snapshot?.revision ?? null };
} catch { } 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 // 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 // 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 // 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); 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; if (d?.route_to !== 'human' || this.#closed(d.id)) return view;
const raise = this.#raised(s.business, d.id); 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; if (!raise?.grant || !grantInScope(scope, raise.grant)) return view;
const seq = Number( const seq = Number(
this.#store.run( this.#store.run(
@@ -1585,7 +1595,7 @@ export class Broker {
); );
this.#grantEvent(s.business, { this.#grantEvent(s.business, {
action: 'grant.scope.used', grantDecision: d.id, decisionSeq: seq, method: raise.grant.method, 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); }, d.id);
// As the resolve's: each executor rechecks all of this itself, so a rollback starts nothing. // 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)); 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. // 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. // A narrowed scope ends it scope-changed, and the human's Approve is the way back.
#scopeRecheck(business, decision, g) { #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, // 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. // no role or run, and a three-string owner subject.
@@ -2912,10 +2922,22 @@ export class Broker {
} catch {} } catch {}
return code; 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; let checked;
try { 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) { } catch (e) {
const code = e instanceof BusError ? e.code : 'gitea-failed'; const code = e instanceof BusError ? e.code : 'gitea-failed';
if (code !== 'not-authorized' && code !== 'decision-consumed') { if (code !== 'not-authorized' && code !== 'decision-consumed') {
@@ -2925,6 +2947,7 @@ export class Broker {
} }
return code; return code;
} }
if (checked.scopeChanged) return 'scope-changed';
return await this.#giteaSteps(business, decision, g, signal, log, api, meta, checked, stop); return await this.#giteaSteps(business, decision, g, signal, log, api, meta, checked, stop);
} finally { } finally {
this.#giteaKeys.release(business, agent, signal); this.#giteaKeys.release(business, agent, signal);
+5 -4
View File
@@ -369,11 +369,12 @@ export async function startBroker({
return false; return false;
} }
}, },
// Row 298 (#1804, Q228): launch.grantScope as the file stands now, unchecked. The broker checks // Row 298 (#1804, Q228): one read binds launch.grantScope to the organization revision.
// it; a throw here (an unreadable file) is the empty scope there. // A missing organization reader or unreadable file cannot authorize an automatic grant.
grantScope: (id) => { grantScope: (id) => {
const b = settings ? settings.current(id) : businesses[id]; if (!settings) return { value: undefined, revision: null };
return b?.launch?.grantScope; 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. // Row 194 (#1692, D4.1, B8): bus.tester as the file sets it now; an unreadable file is off.
testerOn: (id) => { testerOn: (id) => {
@@ -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);
});
+94 -12
View File
@@ -11,14 +11,14 @@ import { createHash } from 'node:crypto';
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'; import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; 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 { Credentials } from '../src/credentials.mjs';
import { giteaGrant, giteaKeys } from '../src/gitea-grant.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 { Store } from '../src/store.mjs';
import { checkGrantScope, grantScopeOf } from '../../business/src/vocabulary.mjs'; import { checkGrantScope, grantScopeOf } from '../../business/src/vocabulary.mjs';
import { fakeGiteaApi } from './fake-gitea-api.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 USER = 'demo-coder';
const ENTRY = { address: '192.0.2.10', port: 22, user: 'jarvis', hostKeys: ['ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHostKeyForTests'] }; 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' }] }, ops: { roleVersion: 2, credentials: [{ service: 'host' }] },
}; };
const GITEA = { gitea: { agents: ['coder'], projects: ['app'] } }; 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); const REVISION = 'a'.repeat(64);
async function until(fn, ms = 10000) { async function until(fn, ms = 10000) {
const end = Date.now() + ms; const end = Date.now() + ms;
@@ -97,18 +97,28 @@ async function setup(t, opts = {}) {
} }
return { revision: REVISION }; 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({ const broker = new Broker({
store, store,
businesses: { demo: business }, businesses: { demo: business },
secretCheck: (value) => credentials.assertClean(value), secretCheck: (value) => credentials.assertClean(value),
giteaGrant: grantApi, giteaGrant: grantApi,
giteaKeys: giteaKeys({ credentials, storeRoot }), giteaKeys: giteaKeys({ credentials, storeRoot }),
hostGrant: hostGrant({ configDir, runRoot: join(configDir, 'secrets', 'run') }), hostGrant: hostApi,
hostKeys: opts.hostKeys ? grantKeys({ credentials, storeRoot }) : null,
organization, 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 = {}; 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 }); caps[role] = broker.bindLaunch({ business: 'demo', role, run: role + '-run', harness: 'pi', address: role });
broker.request(caps[role], { verb: 'role.claim' }); 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 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 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'"); 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) => { 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', 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, 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'); 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); 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 d = await f.raiseGitea();
const [close] = f.closes(d.id); const [close] = f.closes(d.id);
assert.deepEqual([close.op, close.by, close.choice, close.via], ['resolved', 'grant-scope', 'yes', 'scope']); 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'); const inbox = await f.call('human', 'inbox');
assert.ok(!JSON.stringify(inbox).includes(d.id), 'the decision is not in the human inbox'); assert.ok(!JSON.stringify(inbox).includes(d.id), 'the decision is not in the human inbox');
await until(() => f.ended(d.id)); 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'); assert.equal(f.ended(d.id).action, 'grant.applied');
const used = f.grantEvents().find((e) => e.action === 'grant.scope.used'); const used = f.grantEvents().find((e) => e.action === 'grant.scope.used');
assert.deepEqual(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.deepEqual(f.spent(d.id), ['grant.gitea']);
assert.equal(f.record().decision, d.id); assert.equal(f.record().decision, d.id);
// The spend is row 236's: a second start finds it consumed. // 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' }); const out = await f.call('human', 'grants.revoke', { service: 'gitea', agent: 'coder', reason: 'test' });
assert.deepEqual(out.serviceLeft, []); assert.deepEqual(out.serviceLeft, []);
assert.ok(f.grantEvents().some((e) => e.action === 'grant.revoked' && e.agent === 'coder')); 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.deepEqual([f.ended(d.id).action, f.ended(d.id).code], ['grant.failed', 'scope-changed']);
assert.equal(f.record(), undefined); assert.equal(f.record(), undefined);
assert.deepEqual(f.gitea.tokens(USER) ?? [], []); 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) => { 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 f = await setup(t, { scope });
const d = await f.raiseHost(); const d = await f.raiseHost();
assert.equal(f.closes(d.id)[0].via, 'scope'); 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'); assert.equal(f.closes(d.id)[0].via, 'scope');
await until(() => f.ended(d.id)); 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.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), []);
}); });
+1 -1
View File
@@ -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. // 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. // 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. // 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(source, /\bimport\s*\(/);
assert.doesNotMatch(readFileSync(new URL('../src/landing.mjs', import.meta.url), 'utf8'), /\bimport\b/); assert.doesNotMatch(readFileSync(new URL('../src/landing.mjs', import.meta.url), 'utf8'), /\bimport\b/);
+7 -12
View File
@@ -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 // 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. // (seat/src/sandbox.mjs RUNNER_FILES) and the runner loads broker.mjs.
// //
// Shape: { gitea?: { agents, projects }, host?: { agents, hosts, commands?, commandPrefixes? } }. // Shape: { gitea?: { agents, projects }, host?: { agents, hosts, commands } }.
// Each list is distinct and nonempty. A host entry needs commands or commandPrefixes. A commands // Each list is distinct and nonempty. A commands entry covers a requested prefix equal to it,
// entry covers a requested prefix equal to it, token for token. A commandPrefixes entry also covers a // token for token and with equal length. No entry matches part of a token, so
// requested prefix that extends it with more whole tokens. No entry matches part of a token, so
// ["ls"] never covers ["lsblk"]. Enrollment (host-enroll) is never in scope. {} is the empty scope. // ["ls"] never covers ["lsblk"]. Enrollment (host-enroll) is never in scope. {} is the empty scope.
export const GRANT_SCOPE_EMPTY = Object.freeze({}); export const GRANT_SCOPE_EMPTY = Object.freeze({});
const SCOPE_IDS = (value) => const SCOPE_IDS = (value) =>
@@ -333,15 +332,12 @@ export function checkGrantScope(value) {
} }
if (value.host !== undefined) { if (value.host !== undefined) {
const h = value.host; 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 (!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 (!SCOPE_ARGVS(h.commands)) return null;
if (h.commands !== undefined && !SCOPE_ARGVS(h.commands)) return null;
if (h.commandPrefixes !== undefined && !SCOPE_ARGVS(h.commandPrefixes)) return null;
out.host = { out.host = {
agents: [...h.agents], hosts: [...h.hosts], agents: [...h.agents], hosts: [...h.hosts],
...(h.commands !== undefined ? { commands: h.commands.map((argv) => [...argv]) } : {}), commands: h.commands.map((argv) => [...argv]),
...(h.commandPrefixes !== undefined ? { commandPrefixes: h.commandPrefixes.map((argv) => [...argv]) } : {}),
}; };
} }
return out; return out;
@@ -366,11 +362,10 @@ export function grantInScope(scope, body) {
if (body?.method === "host") { if (body?.method === "host") {
const h = scope.host; const h = scope.host;
const same = (a, b) => a.length === b.length && a.every((x, i) => x === b[i]); 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) && return Boolean(h) && h.agents.includes(body.agent) && h.hosts.includes(body.host) &&
Array.isArray(body.commands) && body.commands.length > 0 && Array.isArray(body.commands) && body.commands.length > 0 &&
body.commands.every((argv) => 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; return false;
} }
+4 -4
View File
@@ -15,7 +15,7 @@ const roots = [];
after(() => roots.forEach(root => rmSync(root, { recursive: true, force: true }))); after(() => roots.forEach(root => rmSync(root, { recursive: true, force: true })));
const GITEA = { gitea: { agents: ["coder"], projects: ["app", "lib"] } }; 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 gitea = (over = {}) => ({ method: "gitea", agent: "coder", target: { projects: ["app"] }, ...over });
const host = (commands, over = {}) => ({ method: "host", agent: "ops", host: "web1", commands, ...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"] } }, { 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: ["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"], 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)); 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. // 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", "nginx", "--now"]])), false);
assert.equal(grantInScope(HOST, host([["systemctl", "status"]])), 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"]])), 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([["lsblk"]])), false);
assert.equal(grantInScope(HOST, host([["ls"], ["lsblk"]])), false); assert.equal(grantInScope(HOST, host([["ls"], ["lsblk"]])), false);
assert.equal(grantInScope(HOST, host([["ls"]], { host: "web2" })), false); assert.equal(grantInScope(HOST, host([["ls"]], { host: "web2" })), false);
+31
View File
@@ -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 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. 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) ## Access (FED R5, #414)
``` ```
+74
View File
@@ -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, []);
}
});