diff --git a/packages/bus/README.md b/packages/bus/README.md index 75118426..bd5f65a7 100644 --- a/packages/bus/README.md +++ b/packages/bus/README.md @@ -122,9 +122,22 @@ Authorization requires the resolved option identified by `approvalChoice` Inbox projections expose `{action,target,approvalChoice}` as `authorization`: **S4 must display this context when offering resolution**, not infer approval from recommendation or option position. Any other option is a refusal, even if -the decision is resolved. Approval is context-bound, not an exactly-once service -operation receipt. S3/S6 must implement their own external-operation identity and -uncertain-outcome handling. Raising/superseding and resolving are transactional. +the decision is resolved. Every decision-backed authorization is single-use, +including cross-role approval (lead decision 64). `authorize`, `message.send` +and `role.revoke` share one authority-and-consumption helper. Each caller keeps +the consumption check, `action.allowed` event and any broker-owned effect in one +transaction. +A later use through any of those paths, including after restart, refuses with +`decision-consumed`. The `decision.raise` context event does not consume approval. +A failed transaction rolls consumption back. A mismatch between the decision's +recorded class and current policy refuses with `decision-mismatch` before use, +in either direction. Within-role actions without a decision remain unchanged; +explicitly supplying a decision subjects it to the same checks. + +A consumed approval is not an exactly-once external service operation receipt; +an uncertain external outcome must not be retried with that approval. S3/S6 +must implement their own external-operation identity and uncertain-outcome +handling. Raising/superseding and resolving are transactional. ## Human and reader paths @@ -140,6 +153,12 @@ check: command and launch-registry checks still apply. All other read failures refuse, and the CLI itself must have a readable nonce environment. Reading `/proc` is Linux-specific. Reparenting and malicious same-UID PID/nonce impersonation remain within the explicit cooperative trust limit. +Lead decision 62 accepts this limit for slice 1: the proof refuses a direct +agent invocation, but deliberate detachment and environment clearing (as in +Darkwing's `setsid -f env -i` probe) can obtain human authority. S6 must close +this gap for managed agents with their own PID namespace or user and no human +socket mounted. That isolation is not supplied by this S2 package. T3 seats +must not invoke the human CLI or work around its proof. Human resolutions and launch toggles append `human.input`. Agent capabilities and read-only WebUI capabilities cannot reach these operations. Reader diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index 8c6347eb..43ce89e8 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -262,7 +262,7 @@ export class Broker { ) fail('launch-revoked'); const cls = this.#classification(s, action); - if (cls === 'within-role') return { class: cls }; + if (cls === 'within-role' && !decision) return { class: cls }; if (!decision) fail('decision-required'); const d = this.#decision(s, decision), r = this.#closed(d.id); @@ -273,6 +273,7 @@ export class Broker { ); const context = evidence ? JSON.parse(evidence.body) : null; if ( + d.class !== cls || d.action !== action || d.raised_by_role !== s.role || d.raised_by_run !== s.run || @@ -285,19 +286,31 @@ export class Broker { fail('decision-mismatch'); return { class: cls, decision: d.id }; } + // Caller owns the transaction: authority, consumption and effect commit together. + #consumeAuthority(s, action, context = {}) { + const result = this.#checkAuthority(s, action, context); + // decision.raise records context, not permission consumed by an executor. + if ( + result.decision && + this.#store.get( + "SELECT 1 FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.decision')=? AND json_extract(body,'$.operation') IS NOT 'decision.raise' LIMIT 1", + s.business, + result.decision, + ) + ) + fail('decision-consumed'); + this.#event( + s, + 'action.allowed', + { action, ...result, target: context.target ?? null }, + context.target ?? null, + ); + return result; + } // Trusted S3 handler calls inside its own operation; not a generic socket action executor. authorize(cap, action, context = {}) { const s = this.#session(cap); - return this.#store.transaction(() => { - const result = this.#checkAuthority(s, action, context); - this.#event( - s, - 'action.allowed', - { action, ...result, target: context.target ?? null }, - context.target ?? null, - ); - return result; - }); + return this.#store.transaction(() => this.#consumeAuthority(s, action, context)); } // Trusted adapters only. No agent socket route reaches this method. recordEvent(cap, { kind, body, subject = null }) { @@ -385,7 +398,7 @@ export class Broker { keys(a, ['role', 'decision'], ['role', 'decision']); identifier(a.role); identifier(a.decision); - this.#checkAuthority(s, 'role.revoke', { decision: a.decision, target: a.role }); + this.#consumeAuthority(s, 'role.revoke', { decision: a.decision, target: a.role }); const h = this.#holder(s.business, a.role); if (h?.op !== 'claim') fail('not-held'); this.#claimEnd(s, h, 'revoke', a.decision); @@ -539,7 +552,8 @@ export class Broker { } case 'message.send': { keys(a, ['to', 'body', 'class', 'in_reply_to', 'corrects', 'decision'], ['to', 'body']); - if (!s.human) this.#checkAuthority(s, 'message.send', { decision: a.decision ?? null, target: a.to }); + if (!s.human) + this.#consumeAuthority(s, 'message.send', { decision: a.decision ?? null, target: a.to }); else this.#human(s); if (a.to !== 'human' && !Object.hasOwn(b.roles, a.to)) fail('unknown-role'); string(a.body, 32768); diff --git a/packages/bus/tests/single-use.test.mjs b/packages/bus/tests/single-use.test.mjs new file mode 100644 index 00000000..00aed1d9 --- /dev/null +++ b/packages/bus/tests/single-use.test.mjs @@ -0,0 +1,242 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Store } from '../src/store.mjs'; +import { Broker } from '../src/broker.mjs'; +const businesses = { + demo: { + id: 'demo', + human: 'jason', + arbiters: { technical: 'cto', delivery: 'pm' }, + roles: { + coder: { authority: { withinRole: ['message.send'], crossRole: ['task.scope.change'] } }, + cto: { authority: { withinRole: [], crossRole: [] } }, + pm: { authority: { withinRole: [], crossRole: [] } }, + }, + }, +}; +function fixture(t, gatedMessages = false) { + const root = mkdtempSync(join(tmpdir(), 'bus-once-')); + let store, b, cap, human; + const policy = structuredClone(businesses); + if (gatedMessages) policy.demo.roles.coder.authority.withinRole = []; + const call = (c, verb, args = {}) => b.request(c, { verb, args }); + const bind = (run) => b.bindLaunch({ business: 'demo', role: 'coder', run, harness: 'pi', address: run }); + function open() { + store = new Store(root); + b = new Broker({ store, businesses: policy }); + cap = bind('run1'); + human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); + } + open(); + call(cap, 'role.claim'); + t.after(() => { + store.close(); + rmSync(root, { recursive: true, force: true }); + }); + function approve(action = 'deploy', domain = 'delivery', target = 'release1') { + const d = call(cap, 'decision.raise', { + action, + domain, + target, + question: 'Allow?', + options: [ + { key: 'yes', text: 'Yes' }, + { key: 'no', text: 'No' }, + ], + recommendation: 'no', + blocking: false, + }); + if (d.route_to === 'human') call(human, 'decision.resolve', { id: d.id, choice: 'yes' }); + else { + const c = b.bindLaunch({ business: 'demo', role: 'cto', run: 'cto1', harness: 'pi', address: 'cto1' }); + call(c, 'role.claim'); + call(c, 'decision.resolve', { id: d.id, choice: 'yes' }); + } + return d; + } + return { + get b() { + return b; + }, + get store() { + return store; + }, + get cap() { + return cap; + }, + call, + bind, + approve, + use(d, c = cap) { + return b.authorize(c, d.action, { decision: d.id, target: d.authorization.target }); + }, + setPolicy(withinRole, crossRole) { + policy.demo.roles.coder.authority = { withinRole, crossRole }; + }, + narrowToCross(action) { + policy.demo.roles.coder.authority.crossRole.push(action); + }, + reopen() { + store.close(); + open(); + }, + count(d) { + return store.get( + "SELECT count(*) AS n FROM events WHERE kind='action.allowed' AND json_extract(body,'$.decision')=? AND json_extract(body,'$.operation') IS NOT 'decision.raise'", + d.id, + ).n; + }, + }; +} +test('gated approval authorizes once, survives store reopen, and fresh approval works', (t) => { + const f = fixture(t), + d = f.approve(); + assert.equal(f.use(d).class, 'gated'); + assert.equal(f.count(d), 1); + assert.throws(() => f.use(d), /decision-consumed/); + assert.equal(f.count(d), 1); + f.reopen(); + assert.throws(() => f.use(d), /decision-consumed/); + const fresh = f.approve(); + f.use(fresh); + assert.equal(f.count(fresh), 1); +}); +test('another run cannot consume an approval; a failed check leaves it usable', (t) => { + const f = fixture(t), + d = f.approve(), + other = f.bind('run2'); + f.call(f.cap, 'role.release'); + f.call(other, 'role.claim'); + assert.throws(() => f.use(d, other), /decision-mismatch/); + assert.equal(f.count(d), 0); + f.call(other, 'role.release'); + f.call(f.cap, 'role.claim'); + assert.throws( + () => f.b.authorize(f.cap, 'deploy', { decision: d.id, target: 'other' }), + /decision-mismatch/, + ); + f.use(d); + assert.equal(f.count(d), 1); +}); +test('two scheduled callers have exactly one grant and one consumed refusal', async (t) => { + const f = fixture(t), + d = f.approve(); + const results = await Promise.allSettled([ + Promise.resolve().then(() => f.use(d)), + Promise.resolve().then(() => f.use(d)), + ]); + assert.equal(results.filter((r) => r.status === 'fulfilled').length, 1); + assert.equal(results.find((r) => r.status === 'rejected').reason.code, 'decision-consumed'); + assert.equal(f.count(d), 1); +}); +test('failed commit rolls consumption back; cross-role consumes and within-role stays reusable', (t) => { + const f = fixture(t), + d = f.approve(), + transaction = f.store.transaction.bind(f.store); + f.store.transaction = (fn) => + transaction(() => { + fn(); + throw Error('fixture rollback'); + }); + assert.throws(() => f.use(d), /fixture rollback/); + f.store.transaction = transaction; + assert.equal(f.count(d), 0); + f.use(d); + const cross = f.approve('task.scope.change', 'technical'); + f.use(cross); + assert.throws(() => f.use(cross), /decision-consumed/); + f.reopen(); + assert.throws(() => f.use(cross), /decision-consumed/); + assert.equal(f.count(cross), 1); + for (let n = 0; n < 2; n++) assert.equal(f.b.authorize(f.cap, 'message.send').class, 'within-role'); + const within = f.call(f.cap, 'decision.raise', { + action: 'message.send', + question: 'Send?', + options: [ + { key: 'yes', text: 'Yes' }, + { key: 'no', text: 'No' }, + ], + recommendation: 'yes', + choice: 'yes', + blocking: false, + }); + assert.equal(within.route_to, 'coder'); + f.use(within); + assert.throws(() => f.use(within), /decision-consumed/); + assert.equal(f.count(within), 1); +}); + +for (const [from, to] of [ + ['gated', 'cross-role'], + ['cross-role', 'gated'], + ['gated', 'within-role'], + ['cross-role', 'within-role'], + ['within-role', 'gated'], + ['within-role', 'cross-role'], +]) { + test(`class drift ${from} to ${to} refuses before consumption`, (t) => { + const f = fixture(t), + action = 'task.priority.change'; + f.setPolicy(from === 'within-role' ? [action] : [], from === 'cross-role' ? [action] : []); + f.reopen(); + let d; + if (from === 'within-role') + d = f.call(f.cap, 'decision.raise', { + action, + target: 'release1', + question: 'Allow?', + options: [ + { key: 'yes', text: 'Yes' }, + { key: 'no', text: 'No' }, + ], + recommendation: 'yes', + choice: 'yes', + blocking: false, + }); + else d = f.approve(action, 'technical'); + f.setPolicy(to === 'within-role' ? [action] : [], to === 'cross-role' ? [action] : []); + f.reopen(); + assert.throws(() => f.use(d), /decision-mismatch/); + assert.equal(f.count(d), 0); + }); +} +test('message.send consumes approval and prevents a later send or authorize', (t) => { + const f = fixture(t, true), + d = f.approve('message.send', 'delivery', 'pm'); + // Invalid effect must roll the consumption insert back with the message. + assert.throws(() => f.call(f.cap, 'message.send', { to: 'pm', body: '', decision: d.id }), /invalid/); + assert.equal(f.count(d), 0); + f.call(f.cap, 'message.send', { to: 'pm', body: 'once', decision: d.id }); + assert.equal(f.count(d), 1); + assert.throws( + () => f.call(f.cap, 'message.send', { to: 'pm', body: 'twice', decision: d.id }), + /decision-consumed/, + ); + assert.throws(() => f.use(d), /decision-consumed/); + assert.equal(f.store.get('SELECT count(*) AS n FROM messages').n, 1); + const fresh = f.approve('message.send', 'delivery', 'pm'); + f.use(fresh); + assert.throws( + () => f.call(f.cap, 'message.send', { to: 'pm', body: 'after authorize', decision: fresh.id }), + /decision-consumed/, + ); +}); +test('role.revoke consumes approval and prevents a later revoke or authorize', (t) => { + const f = fixture(t), + pm = f.b.bindLaunch({ business: 'demo', role: 'pm', run: 'pm1', harness: 'pi', address: 'pm1' }); + f.call(pm, 'role.claim'); + const d = f.approve('role.revoke', 'delivery', 'pm'); + f.call(f.cap, 'role.revoke', { role: 'pm', decision: d.id }); + assert.equal(f.count(d), 1); + assert.throws(() => f.call(f.cap, 'role.revoke', { role: 'pm', decision: d.id }), /decision-consumed/); + assert.throws(() => f.use(d), /decision-consumed/); + assert.equal(f.store.get("SELECT count(*) AS n FROM role_claims WHERE op='revoke'").n, 1); + const next = f.b.bindLaunch({ business: 'demo', role: 'pm', run: 'pm2', harness: 'pi', address: 'pm2' }); + f.call(next, 'role.claim'); + const fresh = f.approve('role.revoke', 'delivery', 'pm'); + f.use(fresh); + assert.throws(() => f.call(f.cap, 'role.revoke', { role: 'pm', decision: fresh.id }), /decision-consumed/); +});