// Row 235 (#1741, #436 row b): the host grant raise (r6 R1-R4). A credential.mint raise whose `grant` // has method `host` binds an agent, an enrolled host, argv prefixes and a rotate date. The host record // comes from the fixture, and nothing here connects to any host. import test from 'node:test'; import assert from 'node:assert/strict'; import { createHash } from 'node:crypto'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { Broker, registrySha256 } from '../src/broker.mjs'; import { Store } from '../src/store.mjs'; import { hostGrant, definitionSha256 } from '../src/host-grant.mjs'; import { hostRecord, hostGrantRecord } from './host-fixtures.mjs'; import { canonicalJson } from '../../organization/src/util.mjs'; const options = [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }]; const refusesWith = async (p, code) => assert.rejects(Promise.resolve().then(() => p), (e) => (assert.equal(e.code, code), true)); const dayFrom = (n) => new Date(Date.now() + n * 86400000).toISOString().slice(0, 10); const ENTRY = { address: '192.0.2.10', port: 22, user: 'jarvis', hostKeys: ['ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHostKeyForTests'] }; const none = { authority: { withinRole: ['message.send'], crossRole: [] } }; const DEFINITIONS = { 'chief-of-staff': { roleVersion: 2, credentials: [] }, pm: { roleVersion: 2, credentials: [] }, ops: { roleVersion: 2, credentials: [{ service: 'gitea', scopes: ['read:repository'] }, { service: 'host' }] }, }; function setup(t) { const root = mkdtempSync(join(tmpdir(), 'g235-')); const store = new Store(root); t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); }); const configDir = join(root, 'config'); const business = { id: 'demo', human: 'jason', arbiters: { technical: 'pm', delivery: 'pm' }, roles: { chief: { definition: 'chief-of-staff', ...none }, pm: { definition: 'pm', ...none }, ops: { definition: 'ops', ...none }, odd: { definition: 'constructor', ...none }, // A creative role whose definition declares host, so only the creative check refuses it. muse: { definition: 'research', ...none }, }, definitions: { ...structuredClone(DEFINITIONS), research: structuredClone(DEFINITIONS.ops) }, vars: { 'bus.tester': true }, }; const broker = new Broker({ store, businesses: { demo: business }, hostGrant: hostGrant({ configDir, runRoot: join(configDir, 'secrets', 'run') }) }); const caps = {}; for (const role of ['chief', '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' }); hostRecord(store, { host: 'web1', entry: ENTRY }); const grant = (over = {}) => ({ method: 'host', agent: 'ops', host: 'web1', commands: [['systemctl', 'status']], rotateBy: dayFrom(30), ...over }); const call = (who, verb, args = {}) => broker.request(caps[who], { verb, args }); const raise = (over = {}, who = 'chief', extra = {}) => call(who, 'decision.raise', { action: 'credential.mint', question: 'Grant ops web1?', options, recommendation: 'yes', blocking: false, grant: grant(over), ...extra }); const decisions = () => store.get('SELECT count(*) AS n FROM decisions').n; const question = (id) => store.get('SELECT question FROM decisions WHERE id=?', id).question; const raiseBody = (id) => JSON.parse(store.get("SELECT body FROM events WHERE kind='action.allowed' AND json_extract(body,'$.operation')='decision.raise' AND json_extract(body,'$.decision')=?", id).body); const refusals = () => store.all("SELECT body FROM events WHERE business='demo' AND kind='action.refused' ORDER BY seq").map((e) => JSON.parse(e.body).code); return { store, broker, caps, call, raise, grant, decisions, question, raiseBody, refusals }; } test('G1 (host): only the chief raises a host grant, a grant on any other action refuses, and nothing is stored', async (t) => { const f = setup(t); const before = f.decisions(); await refusesWith(f.raise({}, 'pm'), 'grant-chief-only'); await refusesWith(f.raise({}, 'chief', { action: 'deploy' }), 'invalid-request'); await refusesWith(f.raise({}, 'tester'), 'tester-refused'); assert.equal(f.decisions(), before); assert.deepEqual(f.refusals(), ['grant-chief-only', 'invalid-request', 'tester-refused']); }); test('G1, R4: the grant takes exactly method, agent, host, commands and rotateBy, and an unknown method refuses', async (t) => { const f = setup(t); const before = f.decisions(); const g = f.grant(); const { rotateBy, ...noRotate } = g; for (const bad of [ noRotate, { ...g, extra: 1 }, // Row 236 (#1742) makes gitea a known method with its own path, so the unknown method is another name. { ...g, method: 'ftp' }, { ...g, method: 'Host' }, { ...g, agent: 'Ops' }, { ...g, agent: '' }, { ...g, agent: 7 }, { ...g, host: 'Web_1' }, { ...g, host: 'a'.repeat(33) }, ]) await refusesWith(f.call('chief', 'decision.raise', { action: 'credential.mint', question: 'q', options, recommendation: 'yes', blocking: false, grant: bad }), 'invalid-request'); for (const bad of [null, [], 'host']) await refusesWith(f.call('chief', 'decision.raise', { action: 'credential.mint', question: 'q', options, recommendation: 'yes', blocking: false, grant: bad }), 'invalid-request'); assert.equal(f.decisions(), before); assert.ok(rotateBy); }); test('row 236 (#1742): a gitea grant on a broker with no Gitea path refuses gitea-not-configured, and nothing is stored', async (t) => { const f = setup(t); const before = f.decisions(); const refused = f.refusals().length; await refusesWith(f.raise({ method: 'gitea' }), 'gitea-not-configured'); assert.equal(f.decisions(), before); assert.deepEqual(f.refusals().slice(refused), ['gitea-not-configured']); }); test('G2: an agent whose definition lacks host refuses host-not-declared, and an unknown agent unknown-role', async (t) => { const f = setup(t); const before = f.decisions(); await refusesWith(f.raise({ agent: 'pm' }), 'host-not-declared'); await refusesWith(f.raise({ agent: 'chief' }), 'host-not-declared'); await refusesWith(f.raise({ agent: 'nobody' }), 'unknown-role'); // An inherited name is not a definition. await refusesWith(f.raise({ agent: 'odd' }), 'host-not-declared'); assert.equal(f.decisions(), before); }); test('mos-1163: a grant whose agent is a creative role refuses host-creative-role, even when its definition declares host', async (t) => { const f = setup(t); const before = f.decisions(); await refusesWith(f.raise({ agent: 'muse' }), 'host-creative-role'); assert.equal(f.decisions(), before); }); test('G3: wildcard, shell, empty and other 210 D6 argv prefixes refuse invalid-request', async (t) => { const f = setup(t); const before = f.decisions(); for (const commands of [ 'any', '*', {}, [], [[]], [['*']], [['systemctl', 'status*']], [['ls', '[a]']], [['bash']], [['/bin/sh', '-c']], [['sudo', 'ls']], [['python3']], [['env', 'ls']], [['bin/ls']], [['']], [['ls', 'a\0b']], [['ls', 3]], [['ls'], ['ls']], [Array.from({ length: 257 }, () => 'x')], ]) await refusesWith(f.raise({ commands }), 'invalid-request'); assert.equal(f.decisions(), before); }); test('R4 rotateBy (sage-1782): a real calendar day after today UTC, or invalid-request', async (t) => { const f = setup(t); const before = f.decisions(); for (const rotateBy of [dayFrom(0), dayFrom(-1), '2026-02-30', '2026-13-01', '20991231', '2099-1-01', 20991231, null]) await refusesWith(f.raise({ rotateBy }), 'invalid-request'); assert.equal(f.decisions(), before); await f.raise({ rotateBy: dayFrom(1) }); await f.raise({ rotateBy: '2999-12-31' }); assert.equal(f.decisions(), before + 2); }); test('G19: a grant naming a host that is pending-install refuses host-not-ready, and an unknown host host-unknown', async (t) => { const f = setup(t); hostRecord(f.store, { host: 'web2', entry: ENTRY }); f.store.run("UPDATE hosts SET state='pending-install' WHERE business='demo' AND host='web2'"); const before = f.decisions(); await refusesWith(f.raise({ host: 'web2' }), 'host-not-ready'); await refusesWith(f.raise({ host: 'web9' }), 'host-unknown'); assert.equal(f.decisions(), before); }); test('R4, G5: the raise routes to the human and its event body binds the eight fields with their hashes', async (t) => { const f = setup(t); const commands = [['systemctl', 'status'], ['/usr/bin/journalctl', '-u', 'nginx']]; const d = await f.raise({ commands }); const row = f.store.get('SELECT action, class, route_to FROM decisions WHERE id=?', d.id); assert.deepEqual({ ...row }, { action: 'credential.mint', class: 'gated', route_to: 'human' }); const body = f.raiseBody(d.id); assert.deepEqual(Object.keys(body.grant), ['method', 'agent', 'host', 'commands', 'commandsSha256', 'rotateBy', 'registrySha256', 'definitionSha256']); assert.deepEqual(body.grant, { method: 'host', agent: 'ops', host: 'web1', commands, commandsSha256: createHash('sha256').update(JSON.stringify(commands)).digest('hex'), rotateBy: f.grant().rotateBy, registrySha256: registrySha256({ ...ENTRY, sudo: true }), definitionSha256: createHash('sha256').update(canonicalJson(DEFINITIONS.ops)).digest('hex'), }); // Key order in the definition doesn't change the hash, and a field change does. const reordered = { credentials: DEFINITIONS.ops.credentials, roleVersion: 2 }; assert.equal(definitionSha256(reordered), body.grant.definitionSha256); assert.notEqual(definitionSha256({ ...DEFINITIONS.ops, credentials: [{ service: 'host' }] }), body.grant.definitionSha256); assert.deepEqual(f.call('human', 'inbox').map((x) => x.id), [d.id]); }); test('G4 (host block): the broker appends the host, each prefix, the rotate date and new key or change', async (t) => { const f = setup(t); const d = await f.raise({ commands: [['systemctl', 'status'], ['echo', 'a b"c']] }); const rotateBy = f.grant().rotateBy; assert.equal( f.question(d.id), 'Grant ops web1?' + '\n\nGrant ops access to host web1: user jarvis at 192.0.2.10 port 22.' + '\njarvis has sudo on this host.' + '\nCommands, each an argv prefix:' + '\n"systemctl" "status"' + '\n"echo" "a b\\"c"' + `\nRotate by ${rotateBy}.` + '\nThis makes a new key for ops on web1.', ); // A host without sudo, and an agent with a grant record already: the block says so. hostRecord(f.store, { host: 'web2', entry: ENTRY, sudo: false }); hostGrantRecord(f.store, { agent: 'ops', host: 'web2', commands: [['uptime']] }); const d2 = await f.raise({ host: 'web2' }); const q2 = f.question(d2.id); assert.ok(q2.includes('\njarvis has no sudo on this host.\n')); assert.ok(q2.endsWith("\nThis changes the commands of ops's grant on web2. The key stays.")); // A forged block in the chief's question still gets the broker's block after it. const forged = 'x\n\nGrant ops access to host web1: user other at 192.0.2.99 port 22.\nCommands, each an argv prefix:\n"bash"'; const d3 = await f.raise({}, 'chief', { question: forged }); assert.ok(f.question(d3.id).startsWith(forged + '\n\nGrant ops access to host web1: user jarvis at 192.0.2.10 port 22.\n')); // The 4096 cap counts the block: a question that fits alone refuses with it. const before = f.decisions(); await refusesWith(f.raise({}, 'chief', { question: 'q'.repeat(4000) }), 'invalid-request'); const long = Array.from({ length: 64 }, (_, i) => ['/usr/bin/tool' + i, 'x'.repeat(60)]); await refusesWith(f.raise({ commands: long }), 'invalid-request'); assert.equal(f.decisions(), before); });