From 7a141269c12ef0642aab0eac23122a8576f987da Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Fri, 23 Oct 2026 14:11:35 -0500 Subject: [PATCH] row 298 (#1804): UNTESTED handoff to Boyd (mos-1502). launch.grantScope shape, fallback and match (vocabulary.mjs), the one writer (instance-settings grantScope), broker grants.scope/grants.scope.set gated by #human alone, in-scope close via 'scope' with grant.scope.used, scope-changed recheck, cli grants scope|scope set, business and bus tests. No node run; project-runtime.test.mjs import pin not yet updated; docs and BUILD.md not updated --- packages/bus/src/broker.mjs | 106 +++++++- packages/bus/src/runtime.mjs | 10 + packages/bus/tests/grant-scope.test.mjs | 264 +++++++++++++++++++ packages/business/src/business.mjs | 5 +- packages/business/src/instance-settings.mjs | 31 ++- packages/business/src/vocabulary.mjs | 75 ++++++ packages/business/tests/grant-scope.test.mjs | 124 +++++++++ packages/cli/src/grants.mjs | 34 ++- 8 files changed, 637 insertions(+), 12 deletions(-) create mode 100644 packages/bus/tests/grant-scope.test.mjs create mode 100644 packages/business/tests/grant-scope.test.mjs diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index d5e7f18fd..6d6ca95ce 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -4,7 +4,7 @@ import { initializeControl, processingStatus } from '../../processing-control/sr import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs'; import { loadRequirements } from '../../tasks/src/requirements.mjs'; import { MERGE, LandingRefusal, readLanding } from './landing.mjs'; -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'; // Kept closed while S1 lands; its vocabulary is the integration authority. export const ACTIONS = Object.freeze([ @@ -290,6 +290,9 @@ export function writeGrantEvent(store, at, business, body, subject = null) { ); return eid; } +// Row 298 (#1804, Q228): the by of a close the chief's grant scope makes, with via 'scope'. No session +// closes as it, and #approvedGrant takes it only with the grant.scope.used record of that close. +const SCOPE_BY = 'grant-scope'; export const HOSTS_VERBS = Object.freeze(['hosts.status', 'hosts.check', 'hosts.remove', 'hosts.revoke', 'hosts.sweep']); // Row 236 (#1742, #436b r5): the Gitea grant path's verbs. run and admin.* are the human's alone. export const GRANTS_VERBS = Object.freeze([ @@ -412,6 +415,7 @@ export class Broker { #revokeUndo = []; #testerOn; #roleFile; + #grantScope; // Row 236 (#436b): giteaGrant and giteaKeys from gitea-grant.mjs, or null, and the remote halves of // section 5's restart rows and of revokes a restart cut short, for reconcileGitea. #giteaGrant; @@ -439,11 +443,12 @@ export class Broker { #clock = 0; #processing; // credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use(). - constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null, accessStamp = null, compactions = null }) { + constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, grantScope = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null, accessStamp = null, compactions = null }) { if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business'); // Row 194 b15 (sage-2501): the function-argument checks come before anything reads the store. if (harnessOn !== null && typeof harnessOn !== 'function') throw new TypeError('harnessOn must be a function'); if (testerOn !== null && typeof testerOn !== 'function') throw new TypeError('testerOn must be a function'); + if (grantScope !== null && typeof grantScope !== 'function') throw new TypeError('grantScope must be a function'); if (roleFile !== null && typeof roleFile !== 'function') throw new TypeError('roleFile must be a function'); if (access !== null && typeof access !== 'function') throw new TypeError('access must be a function'); if (accessStamp !== null && typeof accessStamp !== 'function') throw new TypeError('accessStamp must be a function'); @@ -481,6 +486,10 @@ 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 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; @@ -1527,12 +1536,67 @@ export class Broker { const raise = this.#raised(business, decision); if (d?.action !== 'credential.mint' || d.route_to !== 'human' || !raise?.grant) return null; const r = this.#closed(decision); - if (r?.op !== 'resolved' || r.choice !== raise.approvalChoice || r.by !== this.#business(business).human) return null; + if (r?.op !== 'resolved' || r.choice !== raise.approvalChoice) return null; + // Row 298 (#1804, Q228, sage-3594 Q4): a scope close counts only with its own grant.scope.used, + // which only #scopeClose writes, under the grant actor. + if (r.via === 'scope') return r.by === SCOPE_BY && this.#scopeUsed(business, decision, r.seq, raise.grant) ? raise.grant : null; + if (r.by !== this.#business(business).human) return null; // Row 252 (#1753, design section 3, J14): a console close counts only with its own human.input, // which only the broker writes (recordEvent reserves the kind). if (!(r.via === 'console' ? this.#consoleInput(business, decision, r) : r.via === 'cli')) return null; return raise.grant; } + // 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' + AND json_extract(body,'$.grantDecision')=? AND json_extract(body,'$.decisionSeq')=? AND json_extract(body,'$.method')=? LIMIT 1`, + business, decision, seq, grant.method, + )); + } + // 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). + #scopeNow(business) { + try { + return grantScopeOf(this.#grantScope(business)).scope; + } catch { + return grantScopeOf(undefined).scope; + } + } + // 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 + // human's Approve does. The spend, the records and the revoke are row 236's and 235's, unchanged. + // Anything else is left in the inbox. launch.by is a hand edit, so a chief-of-staff instance the + // tester or an instances.set adds never raises in scope (Q6). + #scopeClose(s, view) { + if (s.role !== this.#business(s.business).launch?.by) return view; + 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); + if (!raise?.grant || !grantInScope(scope, raise.grant)) return view; + const seq = Number( + this.#store.run( + 'INSERT INTO decision_events(decision,at,op,by,choice,note,via) VALUES(?,?,?,?,?,?,?)', + d.id, this.#now(), 'resolved', SCOPE_BY, raise.approvalChoice, null, 'scope', + ).lastInsertRowid, + ); + 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)), + }, 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)); + if (raise.grant.method === 'gitea') setImmediate(() => this.#startGitea(s.business, d.id)); + return this.#decisionView(this.#decision(s, d.id)); + } + // 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'); + } // 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. #consoleInput(business, decision, r) { @@ -1765,6 +1829,7 @@ export class Broker { } // r6 X2 step 1's checks after the spend, R4's binding again. Throws the code that ends the grant. #hostGrantRecheck(business, decision, g) { + this.#scopeRecheck(business, decision, g); if (!this.#hostGrant || !this.#hostKeys) fail('host-not-configured'); const definition = this.#hostDefinition(business, g.agent); if (this.#hostGrant.definitionSha256(definition) !== g.definitionSha256) fail('decision-mismatch'); @@ -2565,7 +2630,7 @@ export class Broker { if (!this.#giteaGrant) fail('gitea-not-configured'); const g = this.#giteaGrant.giteaGrantCheck(a.grant, this.#now().slice(0, 10)); return this.#store.transaction(() => { - const result = this.#dispatch(s, 'decision.raise', a, null, this.#giteaGrantBinding(s.business, g)); + const result = this.#scopeClose(s, this.#dispatch(s, 'decision.raise', a, null, this.#giteaGrantBinding(s.business, g))); this.#secretCheck(result); return result; }); @@ -2574,7 +2639,7 @@ export class Broker { if (a.grant?.method === 'host') { const g = this.#hostGrant.hostGrantCheck(a.grant, this.#now().slice(0, 10)); return this.#store.transaction(() => { - const result = this.#dispatch(s, 'decision.raise', a, null, this.#hostGrantBinding(s.business, g)); + const result = this.#scopeClose(s, this.#dispatch(s, 'decision.raise', a, null, this.#hostGrantBinding(s.business, g))); this.#secretCheck(result); return result; }); @@ -2696,6 +2761,7 @@ export class Broker { } // Section 4 step 1's checks after the spend. Throws the code that ends the grant. #giteaRecheck(business, decision, g) { + this.#scopeRecheck(business, decision, g); const { b, role, definition } = this.#giteaRole(business, g.agent); if (this.#giteaGrant.definitionSha256(definition) !== g.definitionSha256) fail('decision-mismatch'); if (this.#giteaGrant.identitySha256(role, b.projects) !== g.identitySha256) fail('decision-mismatch'); @@ -4669,6 +4735,8 @@ export class Broker { if (CREDENTIAL_VERBS.includes(request.verb)) return this.#requestCredential(s, request.verb, structuredClone(args)); if (request.verb === 'instance.hire' || request.verb === 'instance.fire') return this.#chiefLifecycle(s, request.verb, structuredClone(args)); + if (request.verb === 'grants.scope' || request.verb === 'grants.scope.set') + return this.#grantScopeVerb(s, request.verb, structuredClone(args)); if (["limits", "limits.set", "organization.set", "organization.revision", "plugins", "plugins.set", "instances", "instances.set", "routes", "routes.set", "veto.set"].includes(request.verb)) { // Row 123 (#1617, REQ-COS-1, REQ-LIMIT-1): organization.set is limits.set for a role whose // file lists organization.change. It takes the same keys, runs the same write path and @@ -4766,6 +4834,34 @@ export class Broker { throw this.#refused(s, e, request); } } + // Row 298 (#1804, Q228, sage-3594 Q3): the chief's grant scope, read and written by the human at the + // cli alone, and the write is the human's Approve of the change. #human is the one check, so an + // agent, the chief, the tester and a reader refuse human-required (the Console's cap never gets + // here). bindHuman binds only the business's human. The writer is the organization writer's + // grantScope; ORGANIZATION_KEYS doesn't name grantScope, so organization.set and limits.set can't + // write it. Outside the store transaction, as the organization branch. + #grantScopeVerb(s, verb, a) { + this.#human(s); + if (!this.#organization) fail('not-configured'); + if (verb === 'grants.scope') keys(a, []); + else keys(a, ['revision', 'scope'], ['revision', 'scope']); + let result; + try { + result = this.#organization(s.business, verb, a, s.human); + } catch (e) { + this.#organizationFail(e, verb); + } + this.#secretCheck(result); + // The change's record: the human proof bindHuman checked, and the old and new scopes' sha256. + // The writer's changes.jsonl entry holds the values. + if (verb === 'grants.scope.set') + this.#store.transaction(() => this.#grantEvent(s.business, { + action: 'grant.scope', by: s.human, via: s.via, outsideAgent: s.outsideAgent === true, + oldSha256: sha256(JSON.stringify(result.old)), newSha256: sha256(JSON.stringify(result.scope)), + transaction: result.transaction ?? null, + })); + return result; + } // Row 199 (#1697, design section 6). The token of an issue comes in args from the human's stdin; the // secret check above has refused one the bus already holds, a refusal records its code alone, and // nothing returned carries it. Outside the store transaction, as the organization branch. diff --git a/packages/bus/src/runtime.mjs b/packages/bus/src/runtime.mjs index d02d85158..39aa5dd07 100644 --- a/packages/bus/src/runtime.mjs +++ b/packages/bus/src/runtime.mjs @@ -323,6 +323,8 @@ export async function startBroker({ if(verb==='instances')return instances.read(id,actor); // Row 65 (mos-0840): the Console's routing table. if(verb==='routes')return settings.routes(id); + // Row 298 (#1804, Q228): the chief's grant scope as the file stands, the fallback shown. + if(verb==='grants.scope')return instances.grantScopeRead(id); // Row 194 (#1692, V5): a delete's checks at the verb, with no write and no reload. if(verb==='instances.check')return instances.checkDelete(id,args.instance,actor); // Row 194 b6 (webby-0094 B1): a fire's checks against the file, before step A. @@ -334,6 +336,8 @@ export async function startBroker({ else if(verb==='plugins.set')result=plugins.set(id,args,actor); // Row 194 (#1692, V6): the human's keep and noHire lists, through the instance writer. else if(verb==='veto.set')result=instances.veto(id,args,actor); + // Row 298: the human's grant scope, through the instance writer. + else if(verb==='grants.scope.set')result=instances.grantScope(id,args,actor); // Row 194 (D2.1, D2.2): the chief's hire and fire, with the reason in the change context. else if(verb==='instance.hire')result=instances.hire(id,args,{actor,context}); else if(verb==='instance.fire')result=instances.fire(id,{name:args.name},{actor,context}); @@ -365,6 +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. + grantScope: (id) => { + const b = settings ? settings.current(id) : businesses[id]; + return b?.launch?.grantScope; + }, // Row 194 (#1692, D4.1, B8): bus.tester as the file sets it now; an unreadable file is off. testerOn: (id) => { try { diff --git a/packages/bus/tests/grant-scope.test.mjs b/packages/bus/tests/grant-scope.test.mjs new file mode 100644 index 000000000..2ed57ce73 --- /dev/null +++ b/packages/bus/tests/grant-scope.test.mjs @@ -0,0 +1,264 @@ +// Row 298 (#1804, Q228): the chief of staff's grant scope at the broker. The human's grants.scope and +// grants.scope.set (sage-3594 Q3), the in-scope close via 'scope' with grant.scope.used (Q4), the +// spend recheck's scope-changed (Q5), and the out-of-scope raise that still waits for the human. +// Gitea grants run against the fake Gitea in fake-gitea-api.mjs on 127.0.0.1; host grants stop at the +// spend, as no host key store is configured, and nothing connects to a host. Every token is a fake the +// fake made and none is printed. The organization writer is a fake here; the real one is +// packages/business/tests/grant-scope.test.mjs. +import test from 'node:test'; +import assert from 'node:assert/strict'; +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 { Credentials } from '../src/credentials.mjs'; +import { giteaGrant, giteaKeys } from '../src/gitea-grant.mjs'; +import { hostGrant } 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'; + +const USER = 'demo-coder'; +const ENTRY = { address: '192.0.2.10', port: 22, user: 'jarvis', hostKeys: ['ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHostKeyForTests'] }; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const sha256 = (x) => createHash('sha256').update(x).digest('hex'); +const dayFrom = (n) => new Date(Date.now() + n * 86400000).toISOString().slice(0, 10); +const options = [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }]; +const none = { authority: { withinRole: ['message.send'], crossRole: [] } }; +const DEFINITIONS = { + 'chief-of-staff': { roleVersion: 2, credentials: [] }, + pm: { roleVersion: 2, credentials: [] }, + coder: { roleVersion: 2, credentials: [{ service: 'gitea' }] }, + ops: { roleVersion: 2, credentials: [{ service: 'host' }] }, +}; +const GITEA = { gitea: { agents: ['coder'], projects: ['app'] } }; +const HOST = { host: { agents: ['ops'], hosts: ['web1'], commandPrefixes: [['systemctl', 'status']] } }; +const REVISION = 'a'.repeat(64); +async function until(fn, ms = 10000) { + const end = Date.now() + ms; + while (!fn()) { + if (Date.now() > end) throw new Error('timed out'); + await sleep(20); + } +} +const rejects = (p, code) => assert.rejects(Promise.resolve().then(p), (e) => (assert.equal(e.code, code), true)); + +// opts.scope: the value grantScope answers, or a function called at each read. +async function setup(t, opts = {}) { + const dir = mkdtempSync(join(tmpdir(), 'g298-')); + t.after(() => rmSync(dir, { recursive: true, force: true })); + const gitea = await fakeGiteaApi(t, join(dir, 'tls')); + gitea.addUser('acme'); + gitea.addRepo('acme', 'app'); + gitea.addRepo('acme', 'lib'); + const configDir = join(dir, 'cfg'); + mkdirSync(configDir, { mode: 0o700 }); + const storeRoot = join(configDir, 'secrets', 'agents'); + mkdirSync(join(dir, 'data'), { mode: 0o700 }); + const store = new Store(join(dir, 'data')); + const credentials = new Credentials({ references: {}, storeRoot, dataRoot: join(dir, 'data') }); + t.after(() => { + credentials.close(); + store.close(); + }); + const business = { + id: 'demo', + human: 'jason', + arbiters: { technical: 'pm', delivery: 'pm' }, + roles: { + chief: { definition: 'chief-of-staff', ...none }, + // A second chief-of-staff instance, as an instances.set or the tester could add. It isn't launch.by. + chief2: { definition: 'chief-of-staff', ...none }, + pm: { definition: 'pm', ...none }, + coder: { definition: 'coder', ...none, identity: { name: 'Coder', email: 'coder@example.test', gitea: { url: gitea.url, user: USER } }, publish: ['app', 'lib'] }, + ops: { definition: 'ops', ...none }, + }, + definitions: structuredClone(DEFINITIONS), + projects: Object.fromEntries(['app', 'lib'].map((p) => [p, { remote: `${gitea.url}/acme/${p}` }])), + launch: { by: 'chief', instances: ['pm', 'coder', 'ops'] }, + vars: { 'bus.tester': true }, + }; + const grantApi = giteaGrant({ configDir, ca: gitea.ca, timeoutMs: 5000 }); + grantApi.writeAdmin('demo', gitea.adminToken, { url: gitea.url, account: 'mosaic-grant', rotateBy: dayFrom(60), setAt: '2026-10-21T00:00:00.000Z' }); + // The fake organization writer: the scope in a box, checked as the real writer checks it. + const box = { value: opts.scope }; + const orgCalls = []; + const organization = (id, verb, args, actor) => { + orgCalls.push([verb, actor]); + if (verb === 'grants.scope') return { business: id, revision: REVISION, ...grantScopeOf(box.value) }; + if (verb === 'grants.scope.set') { + const scope = checkGrantScope(args.scope); + if (scope === null) throw Object.assign(new Error('scope is not a grant scope'), { code: 'invalid-request' }); + const old = grantScopeOf(box.value).scope; + box.value = scope; + return { business: id, scope, old, transaction: 'tx-298' }; + } + return { revision: REVISION }; + }; + 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') }), + organization, + grantScope: () => (typeof box.value === 'function' ? box.value() : box.value), + }); + const caps = {}; + for (const role of ['chief', 'chief2', 'pm']) { + caps[role] = broker.bindLaunch({ business: 'demo', role, run: role + '-run', harness: 'pi', address: role }); + broker.request(caps[role], { verb: 'role.claim' }); + } + caps.human = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); + caps.tester = broker.bindTester({ business: 'demo' }); + caps.reader = broker.bindReader({ business: 'demo' }); + caps.console = broker.bindConsole({ business: 'demo', human: 'jason', subject: { provider: 'p', iss: 'https://idp.example.test', sub: 's' } }); + hostRecord(store, { host: 'web1', entry: ENTRY }); + const call = (who, verb, args = {}) => broker.request(caps[who], { verb, args }); + const raiseGitea = (over = {}, who = 'chief') => + call(who, 'decision.raise', { + action: 'credential.mint', question: 'Grant coder Gitea?', options, recommendation: 'yes', blocking: false, + grant: { method: 'gitea', agent: 'coder', target: { projects: ['app'] }, rotateBy: dayFrom(30), ...over }, + }); + const raiseHost = (over = {}, who = 'chief') => + call(who, 'decision.raise', { + action: 'credential.mint', question: 'Grant ops web1?', options, recommendation: 'yes', blocking: false, + grant: { method: 'host', agent: 'ops', host: 'web1', commands: [['systemctl', 'status', 'nginx']], rotateBy: dayFrom(30), ...over }, + }); + const grantEvents = () => + store.all(`SELECT body FROM events WHERE business='demo' AND ${GRANT_EVIDENCE} ORDER BY seq`).map((e) => JSON.parse(e.body)); + const ended = (decision) => grantEvents().findLast((e) => e.grantDecision === decision && ['grant.applied', 'grant.failed'].includes(e.action)); + 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 }; +} + +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) => { + const f = await setup(t); + for (const verb of ['grants.scope', 'grants.scope.set']) { + const args = verb === 'grants.scope' ? {} : { revision: REVISION, scope: GITEA }; + for (const who of ['chief', 'chief2', 'pm', 'tester']) await rejects(() => f.call(who, verb, args), 'human-required'); + await rejects(() => f.call('reader', verb, args), 'read-only'); + await rejects(() => f.call('console', verb, args), 'human-required'); + } + assert.deepEqual(f.orgCalls, []); + assert.equal(f.box.value, undefined); + assert.deepEqual(f.grantEvents().filter((e) => e.action === 'grant.scope'), []); +}); + +test('row 298 (Q1): organization.set and limits.set refuse a grantScope key, and nothing is written', async (t) => { + const f = await setup(t); + await rejects(() => f.call('human', 'limits.set', { revision: REVISION, grantScope: GITEA }), 'invalid-request'); + await rejects(() => f.call('chief', 'organization.set', { revision: REVISION, grantScope: GITEA }), 'invalid-request'); + assert.deepEqual(f.orgCalls, []); + assert.equal(f.box.value, undefined); +}); + +test('row 298 (Q3): the human verb writes the scope and a grant.scope record with the human proof and both sha256', async (t) => { + const f = await setup(t); + assert.deepEqual(await f.call('human', 'grants.scope'), { business: 'demo', revision: REVISION, scope: {}, fallback: false }); + const r = await f.call('human', 'grants.scope.set', { revision: REVISION, scope: GITEA }); + assert.deepEqual(r.scope, GITEA); + assert.deepEqual(f.orgCalls, [['grants.scope', 'jason'], ['grants.scope.set', 'jason']]); + const changes = f.grantEvents().filter((e) => e.action === 'grant.scope'); + assert.deepEqual(changes, [{ + action: 'grant.scope', by: 'jason', via: 'cli', outsideAgent: true, + 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: REVISION, scope: {}, extra: 1 }), 'invalid-request'); + assert.equal(f.grantEvents().filter((e) => e.action === 'grant.scope').length, 1); +}); + +test('row 298 (Q4): an in-scope Gitea grant is issued with no inbox item, logged with 236 records, and revocable', async (t) => { + const f = await setup(t, { scope: GITEA }); + 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']); + 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)); + await f.broker.giteaIdle(); + 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)), + }); + 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'); + 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')); + assert.equal(f.record(), undefined); +}); + +test('row 298: an out-of-scope Gitea grant waits for the human, whose Approve still issues it', async (t) => { + const f = await setup(t, { scope: GITEA }); + const d = await f.raiseGitea({ target: { projects: ['app', 'lib'] } }); + 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'), []); + await f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' }); + await until(() => f.ended(d.id)); + await f.broker.giteaIdle(); + assert.equal(f.ended(d.id).action, 'grant.applied'); +}); + +test('row 298: with no scope, a bad scope or an unreadable file, an in-scope-looking grant waits', async (t) => { + const bad = { gitea: { agents: ['coder'], projects: ['app'], note: 'not a key' } }; + for (const scope of [undefined, bad, () => { throw new Error('unreadable'); }]) { + const f = await setup(t, { scope }); + const d = await f.raiseGitea(); + assert.deepEqual(f.closes(d.id), [], String(scope)); + assert.deepEqual(f.grantEvents().filter((e) => e.action === 'grant.scope.used'), []); + } +}); + +test('row 298 (Q6): a chief-of-staff instance that is not launch.by never raises in scope', async (t) => { + const f = await setup(t, { scope: GITEA }); + const d = await f.raiseGitea({}, 'chief2'); + assert.deepEqual(f.closes(d.id), []); +}); + +test('row 298 (Q5): a scope narrowed between the raise and the spend ends the grant scope-changed', async (t) => { + let reads = 0; + const f = await setup(t, { scope: () => (reads++ === 0 ? GITEA : {}) }); + const d = await f.raiseGitea(); + assert.equal(f.closes(d.id)[0].via, 'scope'); + await until(() => f.ended(d.id)); + await f.broker.giteaIdle(); + 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) ?? [], []); +}); + +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 f = await setup(t, { scope }); + const d = await f.raiseHost(); + assert.equal(f.closes(d.id)[0].via, 'scope'); + await until(() => f.ended(d.id)); + // The spend is row 235's. No host key store is configured, so the recheck after it ends here. + assert.deepEqual(f.spent(d.id), ['grant.host']); + assert.deepEqual([f.ended(d.id).action, f.ended(d.id).code], ['grant.failed', 'host-not-configured']); + for (const commands of [[['lsblk']], [['ls', '-l']], [['systemctl', 'restart']]]) { + const w = await f.raiseHost({ commands }); + assert.deepEqual(f.closes(w.id), [], JSON.stringify(commands)); + } +}); + +test('row 298 (Q5): a host scope narrowed before the spend ends scope-changed', async (t) => { + let reads = 0; + const f = await setup(t, { scope: () => (reads++ === 0 ? HOST : {}) }); + const d = await f.raiseHost(); + 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']); +}); diff --git a/packages/business/src/business.mjs b/packages/business/src/business.mjs index e07a363e3..dce1ceab7 100644 --- a/packages/business/src/business.mjs +++ b/packages/business/src/business.mjs @@ -175,7 +175,7 @@ function checkLaunch(launch, roles, definitions, file) { const where = `${file} launch`; requireObject(launch, where); if (Object.hasOwn(launch,"max")) refuse(`${where}.max needs migration: mosaic limits migrate --file FILE --models FILE`); - rejectUnknownKeys(launch, ["by", "instances", "models", "limits", "threadIdleSeconds", "account", "keep", "noHire"], where); + rejectUnknownKeys(launch, ["by", "instances", "models", "limits", "threadIdleSeconds", "account", "keep", "noHire", "grantScope"], where); requireId(launch.by, `${where}.by`); if (!Object.hasOwn(roles, launch.by)) refuse(`${where}.by names ${launch.by}, which the business file doesn't declare`); const launcher = definitions[roles[launch.by].definition]; @@ -212,6 +212,9 @@ function checkLaunch(launch, roles, definitions, file) { return { by: launch.by, instances, models, limits, threadIdleSeconds, ...(account ? { account } : {}), ...(keep ? { keep } : {}), ...(noHire ? { noHire } : {}), + // Row 298 (#1804, Q228): the chief's grant scope passes through unchecked. A bad value must not + // refuse the whole file; the broker's reader (grantScopeOf) falls back to the empty scope instead. + ...(launch.grantScope !== undefined ? { grantScope: structuredClone(launch.grantScope) } : {}), }; } diff --git a/packages/business/src/instance-settings.mjs b/packages/business/src/instance-settings.mjs index 5074eab7b..9b2fc0a66 100644 --- a/packages/business/src/instance-settings.mjs +++ b/packages/business/src/instance-settings.mjs @@ -14,11 +14,12 @@ import { resolveInstance } from "./resolve.mjs"; import { loadRole } from "./role.mjs"; import { refuse } from "./errors.mjs"; import { isPlainObject, canonicalJson } from "./util.mjs"; -import { CREATIVE_DEFINITIONS, END_PROTECTED, HOST_SERVICE, ID_PATTERN } from "./vocabulary.mjs"; +import { CREATIVE_DEFINITIONS, END_PROTECTED, HOST_SERVICE, ID_PATTERN, checkGrantScope, grantScopeOf } from "./vocabulary.mjs"; const SET_KEYS = ["revision", "instance", "op", "definition", "vars", "launch", "tracker"]; const VETO_KEYS = ["revision", "op", "name"]; const VETO_OPS = ["keep", "unkeep", "nohire", "hire"]; +const SCOPE_KEYS = ["revision", "scope"]; const id = value => typeof value === "string" && ID_PATTERN.test(value); const revisionOk = value => typeof value === "string" && /^[a-f0-9]{64}$/.test(value); // Row 194 (D2.2, N3): the vars a hire may set, the ones the chief can already set on an instance. @@ -302,6 +303,32 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r return commit(business, old, next, { revision: args.revision, actor }); } + // Row 298 (#1804, Q228): the chief's grant scope as the file stands. scope is the one the broker + // applies, so a bad value reads as {} with fallback true. + function grantScopeRead(business) { + const { document, revision } = writer.read(business); + if (!document.launch) throw new OrganizationError("organization-launch-not-configured"); + return { business, revision, ...grantScopeOf(document.launch.grantScope) }; + } + + // Row 298: args { revision, scope }. Human-only at the broker (grants.scope.set), and the only + // writer of launch.grantScope: SET_KEYS and the organization.set keys don't name it. The scope is + // checked strictly here; {} removes the key. old is what the broker applied before the write, + // the fallback included. + function grantScope(business, args, actor) { + if (!isPlainObject(args) || Object.keys(args).some(key => !SCOPE_KEYS.includes(key))) throw new OrganizationError("invalid-request"); + if (!revisionOk(args.revision)) throw new OrganizationError("invalid-request"); + const scope = checkGrantScope(args.scope); + if (scope === null) throw new OrganizationError("invalid-request", "scope is not a grant scope"); + const old = writer.read(business).document; + if (!old.launch) throw new OrganizationError("organization-launch-not-configured"); + const next = structuredClone(old); + if (Object.keys(scope).length === 0) delete next.launch.grantScope; + else next.launch.grantScope = scope; + const before = grantScopeOf(old.launch.grantScope).scope; + return { ...commit(business, old, next, { revision: args.revision, actor }), scope, old: before }; + } + // Row 194 (#1692, D2.2): the chief's hire, after the broker's identity, grant, allowlist and // hireable checks. args { name, definition, vars? }. A new name is added and listed. A fired name // (declared, not listed) is listed again only with its own definition and no vars. The writer @@ -419,5 +446,5 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r return { ...read(business, actor), transaction: receipt.transaction, restart: false }; } - return { writer, read, set, checkDelete, checkFire, veto, hire, fire }; + return { writer, read, set, checkDelete, checkFire, veto, hire, fire, grantScope, grantScopeRead }; } diff --git a/packages/business/src/vocabulary.mjs b/packages/business/src/vocabulary.mjs index 4b686f56a..170c6bb53 100644 --- a/packages/business/src/vocabulary.mjs +++ b/packages/business/src/vocabulary.mjs @@ -299,3 +299,78 @@ export function covers(grant, { business, project, instance }, now, { level } = if (!LEVELS.includes(grant.level) || LEVELS.indexOf(grant.level) > LEVELS.indexOf(level)) return false; return true; } + +// Row 298 (#1804, Q228): the chief of staff's grant scope, launch.grantScope in the organization +// file. Only the human writes it (grants.scope.set). A Gitea or host grant the enrolled chief raises +// inside it is issued with no per-grant Approve; anything else waits for the human as before. The +// 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 +// ["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) => + Array.isArray(value) && value.length > 0 && value.length <= 256 && + value.every((x) => typeof x === "string" && ID_PATTERN.test(x)) && new Set(value).size === value.length; +const SCOPE_ARGVS = (value) => + Array.isArray(value) && value.length > 0 && value.length <= 256 && value.every(validArgv) && + new Set(value.map((argv) => JSON.stringify(argv))).size === value.length; +const SCOPE_PLAIN = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype; + +// The scope, rebuilt in a fixed key order so JSON.stringify of it is stable, or null when the value +// isn't one. The writer refuses null; the readers fall back (grantScopeOf). +export function checkGrantScope(value) { + if (!SCOPE_PLAIN(value) || Object.keys(value).some((k) => k !== "gitea" && k !== "host")) return null; + const out = {}; + if (value.gitea !== undefined) { + const g = value.gitea; + if (!SCOPE_PLAIN(g) || Object.keys(g).some((k) => k !== "agents" && k !== "projects")) return null; + if (!SCOPE_IDS(g.agents) || !SCOPE_IDS(g.projects)) return null; + out.gitea = { agents: [...g.agents], projects: [...g.projects] }; + } + 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_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; + 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]) } : {}), + }; + } + return out; +} + +// What the readers use: a missing key is the empty scope, and a bad value falls back to it, with +// fallback true. The empty scope puts every grant in front of the human, so a bad value fails closed. +export function grantScopeOf(value) { + if (value === undefined) return { scope: GRANT_SCOPE_EMPTY, fallback: false }; + const scope = checkGrantScope(value); + return scope === null ? { scope: GRANT_SCOPE_EMPTY, fallback: true } : { scope, fallback: false }; +} + +// Whether a raise's grant body is inside a checked scope. Only gitea and host bodies can be. +export function grantInScope(scope, body) { + if (body?.method === "gitea") { + const g = scope.gitea; + return Boolean(g) && g.agents.includes(body.agent) && + Array.isArray(body.target?.projects) && body.target.projects.length > 0 && + body.target.projects.every((p) => g.projects.includes(p)); + } + 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))); + } + return false; +} diff --git a/packages/business/tests/grant-scope.test.mjs b/packages/business/tests/grant-scope.test.mjs new file mode 100644 index 000000000..010f9bb2f --- /dev/null +++ b/packages/business/tests/grant-scope.test.mjs @@ -0,0 +1,124 @@ +// Row 298 (#1804, Q228): the chief of staff's grant scope, launch.grantScope. The shape check, the +// readers' fallback, the match rules (sage-3594 Q2) and the one writer. The broker's verbs, the +// in-scope close and the executors are bus tests (packages/bus/tests/grant-scope.test.mjs). +import test, { after } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { instanceSettings } from "../src/instance-settings.mjs"; +import { launchSettings } from "../src/launch-settings.mjs"; +import { loadBusiness } from "../src/business.mjs"; +import { checkGrantScope, grantInScope, grantScopeOf } from "../src/vocabulary.mjs"; +import { businessDoc, rolesCopy, systemFor, tmp, writeJson } from "./helpers.mjs"; + +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 gitea = (over = {}) => ({ method: "gitea", agent: "coder", target: { projects: ["app"] }, ...over }); +const host = (commands, over = {}) => ({ method: "host", agent: "ops", host: "web1", commands, ...over }); + +function fixture(mutate) { + const root = tmp(); + roots.push(root); + const rolesDir = rolesCopy(root); + const doc = businessDoc(root, "acme"); + mutate?.(doc); + const dir = join(root, "config"); + const file = writeJson(join(dir, "businesses", "acme.json"), doc); + const settings = instanceSettings({ dir, rolesDir, system: systemFor(root) }); + const limits = launchSettings({ dir, rolesDir, system: systemFor(root) }); + const onDisk = () => JSON.parse(readFileSync(file, "utf8")); + const log = () => readFileSync(settings.writer.logFile("acme"), "utf8").trim().split("\n").map(line => JSON.parse(line)); + const revision = () => settings.read("acme").revision; + return { root, dir, rolesDir, file, settings, limits, onDisk, log, revision }; +} + +test("row 298: checkGrantScope takes the D2 shape and refuses anything else", () => { + assert.deepEqual(checkGrantScope({}), {}); + assert.deepEqual(checkGrantScope(GITEA), GITEA); + assert.deepEqual(checkGrantScope(HOST), HOST); + assert.deepEqual(checkGrantScope({ host: { agents: ["ops"], hosts: ["web1"], commands: [["uptime"]] } }).host.commands, [["uptime"]]); + for (const bad of [ + null, [], "x", { other: {} }, { gitea: [] }, { gitea: { agents: ["coder"] } }, { gitea: { agents: [], projects: ["app"] } }, + { gitea: { agents: ["coder", "coder"], projects: ["app"] } }, { gitea: { agents: ["Coder"], projects: ["app"] } }, + { 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": {} }, + ]) + assert.equal(checkGrantScope(bad), null, JSON.stringify(bad)); +}); + +test("row 298: a missing scope is empty and a bad one falls back to empty, which covers nothing", () => { + assert.deepEqual(grantScopeOf(undefined), { scope: {}, fallback: false }); + assert.deepEqual(grantScopeOf(GITEA), { scope: GITEA, fallback: false }); + const bad = { gitea: { agents: ["coder"], projects: ["app"], extra: true } }; + assert.deepEqual(grantScopeOf(bad), { scope: {}, fallback: true }); + assert.equal(grantInScope(grantScopeOf(bad).scope, gitea()), false); + assert.equal(grantInScope({}, gitea()), false); + assert.equal(grantInScope({}, host([["ls"]])), false); +}); + +test("row 298: a Gitea grant is in scope only when its agent and every project are listed", () => { + assert.equal(grantInScope(GITEA, gitea()), true); + assert.equal(grantInScope(GITEA, gitea({ target: { projects: ["app", "lib"] } })), true); + assert.equal(grantInScope(GITEA, gitea({ target: { projects: ["app", "old"] } })), false); + assert.equal(grantInScope(GITEA, gitea({ agent: "research" })), false); + assert.equal(grantInScope(GITEA, gitea({ target: { projects: [] } })), false); + assert.equal(grantInScope(HOST, gitea()), false); +}); + +test("row 298 (sage-3594 Q2): host commands match whole argv tokens, and enrollment is never in scope", () => { + assert.equal(grantInScope(HOST, host([["systemctl", "status", "nginx"]])), true); + // 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. + assert.equal(grantInScope(HOST, host([["ls"]])), true); + assert.equal(grantInScope(HOST, host([["ls", "-l", "/var"]])), true); + assert.equal(grantInScope(HOST, host([["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"]], { agent: "coder" })), false); + assert.equal(grantInScope(HOST, { method: "host-enroll", host: "web1" }), false); + assert.equal(grantInScope({ ...HOST, "host-enroll": true }, { method: "host-enroll", host: "web1" }), false); +}); + +test("row 298: a bad grantScope in the file still loads, and the read shows the fallback", () => { + const f = fixture(doc => { doc.launch.grantScope = { gitea: "everything" }; }); + const business = loadBusiness("acme", { dir: f.dir, rolesDir: f.rolesDir }); + assert.deepEqual(business.launch.grantScope, { gitea: "everything" }); + assert.deepEqual(f.settings.grantScopeRead("acme"), { business: "acme", revision: f.revision(), scope: {}, fallback: true }); +}); + +test("row 298: the writer checks the scope, logs the change and removes the key for {}", () => { + const f = fixture(); + assert.deepEqual(f.settings.grantScopeRead("acme").scope, {}); + const r = f.settings.grantScope("acme", { revision: f.revision(), scope: GITEA }, "jason"); + assert.deepEqual(r.scope, GITEA); + assert.deepEqual(r.old, {}); + assert.ok(typeof r.transaction === "string" && r.transaction.length > 0); + assert.deepEqual(f.onDisk().launch.grantScope, GITEA); + assert.equal(f.log().at(-1).actor, "jason"); + const back = f.settings.grantScope("acme", { revision: f.revision(), scope: {} }, "jason"); + assert.deepEqual(back.old, GITEA); + assert.equal(Object.hasOwn(f.onDisk().launch, "grantScope"), false); + const bytes = readFileSync(f.file, "utf8"); + assert.throws(() => f.settings.grantScope("acme", { revision: f.revision(), scope: { gitea: {} } }, "jason"), e => e.code === "invalid-request"); + assert.throws(() => f.settings.grantScope("acme", { revision: f.revision(), scope: GITEA, extra: 1 }, "jason"), e => e.code === "invalid-request"); + assert.throws(() => f.settings.grantScope("acme", { revision: "0".repeat(64), scope: GITEA }, "jason"), e => e.code === "revision-conflict"); + assert.throws(() => f.settings.grantScope("acme", { revision: f.revision(), scope: {} }, "jason"), e => e.code === "organization-no-change"); + assert.equal(readFileSync(f.file, "utf8"), bytes); +}); + +test("row 298 (sage-3594 Q1): the general writers can't write grantScope", () => { + const f = fixture(); + const bytes = readFileSync(f.file, "utf8"); + assert.throws(() => f.limits.set("acme", { revision: f.revision(), grantScope: GITEA }, "jason")); + assert.throws(() => f.limits.set("acme", { revision: f.revision(), grantScope: GITEA }, "chief-of-staff.chief")); + assert.throws(() => f.settings.set("acme", { revision: f.revision(), instance: "coder", op: "change", grantScope: GITEA }, "jason"), e => e.code === "invalid-request"); + assert.throws(() => f.settings.veto("acme", { revision: f.revision(), op: "keep", name: "coder", grantScope: GITEA }, "jason"), e => e.code === "invalid-request"); + assert.equal(readFileSync(f.file, "utf8"), bytes); +}); diff --git a/packages/cli/src/grants.mjs b/packages/cli/src/grants.mjs index 23318dd1b..839deb5d3 100644 --- a/packages/cli/src/grants.mjs +++ b/packages/cli/src/grants.mjs @@ -2,16 +2,20 @@ // starts the executor of a decision the human said yes to, and admin set, rotate and remove keep the // admin slot; those are the human's alone. status is the human's, the tester's or the chief's; revoke // and sweep the human's or, with --tester, the tester's. admin set reads the admin token on stdin, never -// argv, and nothing here prints a token: the answers carry ids and names only. +// argv, and nothing here prints a token: the answers carry ids and names only. Row 298 (#1804, Q228): +// scope reads the chief of staff's grant scope and scope set writes it from JSON on stdin; both are +// the human's, and the set is the human's Approve of the change. import { CliError } from "./errors.mjs"; export const GRANTS_USAGE = "mosaic grants status gitea [--json] [--tester] [--business ID] | run DECISION [--business ID] | " + "revoke AGENT gitea --reason R [--tester] [--business ID] | sweep gitea [--tester] [--business ID] | " + "admin set gitea --url URL --account NAME --rotate-by DATE [--business ID] | " + - "admin rotate gitea --rotate-by DATE | admin rotate gitea --finish | admin remove gitea"; + "admin rotate gitea --rotate-by DATE | admin rotate gitea --finish | admin remove gitea | " + + "scope [--business ID] | scope set --revision REV [--business ID] (the scope JSON on stdin)"; const MAX_TOKEN = 16384; -const VALUES = ["--business", "--reason", "--url", "--account", "--rotate-by"]; +const MAX_SCOPE = 65536; +const VALUES = ["--business", "--reason", "--url", "--account", "--rotate-by", "--revision"]; function parse(args) { const out = { positional: [], flags: new Set(), values: {} }; @@ -40,6 +44,21 @@ async function readToken(stdin) { return text; } +// Row 298: the whole of stdin, one JSON scope. {} is the empty scope. +async function readScope(stdin) { + let text = ""; + for await (const chunk of stdin) { + text += chunk; + if (text.length > MAX_SCOPE) throw new CliError("refused: the scope on stdin is too long", 2); + } + if (!text.trim()) throw new CliError("refused: no scope on stdin; pipe the scope JSON, {} for none", 4); + try { + return JSON.parse(text); + } catch { + throw new CliError("refused: the scope on stdin is not JSON", 4); + } +} + // Each shape: the positional count after the verb, the flags and values it takes and needs. const SHAPES = { status: { n: 1, flags: ["--json", "--tester"], need: [], may: ["--business"] }, @@ -49,6 +68,8 @@ const SHAPES = { "admin set": { n: 1, flags: [], need: ["--url", "--account", "--rotate-by"], may: ["--business"] }, "admin rotate": { n: 1, flags: ["--finish"], need: [], may: ["--rotate-by", "--business"] }, "admin remove": { n: 1, flags: [], need: [], may: ["--business"] }, + scope: { n: 0, flags: [], need: [], may: ["--business"] }, + "scope set": { n: 0, flags: [], need: ["--revision"], may: ["--business"] }, }; const lines = (s) => { @@ -71,6 +92,9 @@ export async function grantsCommand(args, io, callFor) { if (verb === "admin") { verb = `admin ${rest[0] ?? ""}`; rest = rest.slice(1); + } else if (verb === "scope" && rest[0] === "set") { + verb = "scope set"; + rest = rest.slice(1); } // Own keys only, so `constructor` is a usage error, not a verb. const shape = Object.hasOwn(SHAPES, verb ?? "") ? SHAPES[verb] : null; @@ -79,7 +103,7 @@ export async function grantsCommand(args, io, callFor) { [...parsed.flags].some((f) => !shape.flags.includes(f)) || Object.keys(parsed.values).some((v) => !shape.need.includes(v) && !shape.may.includes(v)) || shape.need.some((v) => !Object.hasOwn(parsed.values, v)) || - (verb !== "run" && rest.at(-1) !== "gitea") || + (verb !== "run" && verb !== "scope" && verb !== "scope set" && rest.at(-1) !== "gitea") || (verb === "admin rotate" && parsed.flags.has("--finish") === Object.hasOwn(parsed.values, "--rotate-by")) ) throw new CliError(GRANTS_USAGE, 4); @@ -93,6 +117,8 @@ export async function grantsCommand(args, io, callFor) { else if (verb === "sweep") request = ["grants.sweep", { service: "gitea" }]; else if (verb === "admin set") request = ["grants.admin.set", { service: "gitea", url: v["--url"], account: v["--account"], rotateBy: v["--rotate-by"], token: await readToken(io.stdin) }]; + else if (verb === "scope") request = ["grants.scope", {}]; + else if (verb === "scope set") request = ["grants.scope.set", { revision: v["--revision"], scope: await readScope(io.stdin) }]; else if (verb === "admin rotate") request = ["grants.admin.rotate", parsed.flags.has("--finish") ? { service: "gitea", finish: true } : { service: "gitea", rotateBy: v["--rotate-by"] }]; else request = ["grants.admin.remove", { service: "gitea" }];