fix(authority): close chief grant and root fire review gaps (#1873)

This commit was merged in pull request #1930.
This commit is contained in:
jason.woltje committed 2026-11-11 04:19:33 -06:00
1 parent 89205a6163
commit 5e93356cfa
7 files changed
+162 -23

No files matched your search

+37 -9
View File
@@ -1558,7 +1558,10 @@ export class Broker {
#copyRefused(b, instance) { #copyRefused(b, instance) {
const copy = b.copies && Object.hasOwn(b.copies, instance) ? b.copies[instance] : null; const copy = b.copies && Object.hasOwn(b.copies, instance) ? b.copies[instance] : null;
if (b.roles[instance]?.unbound?.length) throw new BusError('credential-unbound', `${instance} has no binding for ${b.roles[instance].unbound.join(', ')}`); if (b.roles[instance]?.unbound?.length) throw new BusError('credential-unbound', `${instance} has no binding for ${b.roles[instance].unbound.join(', ')}`);
if(copy?.level==='root' && this.#hires?.rootActive && !this.#hires.rootActive(copy.name))fail('instance-fired'); if(copy?.level==='root') {
const refusal=this.#hires?.rootRefusal ? this.#hires.rootRefusal(copy.name) : this.#hires?.rootActive && !this.#hires.rootActive(copy.name) ? 'instance-fired' : null;
if(refusal)fail(refusal);
}
if (!copy || copy.state === 'instance') return; if (!copy || copy.state === 'instance') return;
if (copy.state === 'unbound') throw new BusError('credential-unbound', `${instance} has no binding in ${b.id} for ${copy.missing.join(', ')}`); if (copy.state === 'unbound') throw new BusError('credential-unbound', `${instance} has no binding in ${b.id} for ${copy.missing.join(', ')}`);
throw new BusError(copy.project === null ? 'copy-refused' : 'project-refused', `${instance} can't launch in ${b.id}: ${copy.reason}`); throw new BusError(copy.project === null ? 'copy-refused' : 'project-refused', `${instance} can't launch in ${b.id}: ${copy.reason}`);
@@ -6316,13 +6319,24 @@ export class Broker {
} }
async #raiseHire(s, input, immediate) { async #raiseHire(s, input, immediate) {
const b=this.#business(s.business); const b=this.#business(s.business);
// Change is a replacement composition and uses the existing role.hire grant.
const chiefAllowed=()=>{
this.#agent(s);
if(s.role!==this.#business(s.business).launch?.by)fail('role-not-chief');
if(!['hire','change','fire'].includes(input?.op))fail('invalid-request');
if(this.#classification(s,input.op==='fire'?'role.fire':'role.hire')!=='within-role')fail('not-authorized');
};
if(immediate) { const c=this.#hireCaller(s); if(!c.rights.includes('org.hire'))fail('right-required'); } if(immediate) { const c=this.#hireCaller(s); if(!c.rights.includes('org.hire'))fail('right-required'); }
else { this.#agent(s); if(s.role!==b.launch?.by)fail('role-not-chief'); } else chiefAllowed();
if(!this.#hires)fail('not-configured'); if(!this.#hires)fail('not-configured');
const did=randomUUID(), at=this.#now(), expiresAt=new Date(Date.parse(at)+7*86400000).toISOString(); const did=randomUUID(), at=this.#now(), expiresAt=new Date(Date.parse(at)+7*86400000).toISOString();
const p=await this.#hires.prepare(s.business,structuredClone(input),{id:did,expiresAt}); const p=await this.#hires.prepare(s.business,structuredClone(input),{id:did,expiresAt});
// Recheck the chief after asynchronous preparation; a released run cannot raise. // Recheck the chief after asynchronous preparation; a released run cannot raise.
if(!immediate) {this.#agent(s);if(s.role!==this.#business(s.business).launch?.by)fail('role-not-chief');} if(!immediate)chiefAllowed();
else {
const caller=this.#hireCaller(s);
if(((p.flagged.length || p.credentialLines.length) && !caller.owner) || (p.level==='root' && !caller.instanceOwner))fail('hire-owner-required');
}
const action=`role.${p.op}`; const action=`role.${p.op}`;
this.#store.transaction(()=>{ this.#store.transaction(()=>{
const raiser=immediate?'human':s.role; const raiser=immediate?'human':s.role;
@@ -6334,23 +6348,21 @@ export class Broker {
return {...this.#decisionView(this.#decision(s,did)),hire:p}; return {...this.#decisionView(this.#decision(s,did)),hire:p};
} }
async #hireConsole(s,verb,a) { async #hireConsole(s,verb,a) {
const caller=this.#hireCaller(s); const caller={...this.#hireCaller(s),now:()=>Math.max(Date.now(),this.#clock)};
keys(a,verb==='hire.preview.console'?['id','adjust']:['id','choice','digest','adjust'],verb==='hire.preview.console'?['id']:['id','choice','digest']); keys(a,verb==='hire.preview.console'?['id','adjust']:['id','choice','digest','adjust'],verb==='hire.preview.console'?['id']:['id','choice','digest']);
identifier(a.id); identifier(a.id);
const d=this.#decision(s,a.id), raise=this.#raised(s.business,d.id); const d=this.#decision(s,a.id), raise=this.#raised(s.business,d.id);
let p=raise?.hire; let p=raise?.hire;
if(!['role.hire','role.fire','role.change'].includes(d.action) || !p)fail('decision-not-eligible'); if(!['role.hire','role.fire','role.change'].includes(d.action) || !p)fail('decision-not-eligible');
if(this.#closed(d.id))fail('decision-closed'); const closed=this.#closed(d.id);
if(closed)fail(closed.by==='expiry'?'decision-expired':'decision-closed');
if(this.#expireHire(s,d,p))fail('decision-expired');
if(!caller.rights.includes('org.hire'))fail('right-required'); if(!caller.rights.includes('org.hire'))fail('right-required');
if(a.adjust!==undefined) { if(a.adjust!==undefined) {
if(!this.#hires?.preview)fail('not-configured'); if(!this.#hires?.preview)fail('not-configured');
p=await this.#hires.preview(s.business,d.id,{...caller,adjust:a.adjust}); p=await this.#hires.preview(s.business,d.id,{...caller,adjust:a.adjust});
} }
if((p.flagged.length || p.credentialLines.length) && !caller.owner || p.level==='root' && !caller.instanceOwner)fail('hire-owner-required'); if((p.flagged.length || p.credentialLines.length) && !caller.owner || p.level==='root' && !caller.instanceOwner)fail('hire-owner-required');
if(Date.parse(p.expiresAt)<=Date.now()) {
this.#store.transaction(()=>{this.#store.run('INSERT INTO decision_events(decision,at,op,by,via) VALUES(?,?,?,?,?)',d.id,this.#now(),'withdrawn','expiry','broker');this.#event(s,'action.allowed',{operation:'hire.expired',decision:d.id},d.id);});
fail('decision-expired');
}
if(verb==='hire.preview.console')return {id:d.id,business:s.business,question:d.question,kind:'hire',options:JSON.parse(d.options),...p}; if(verb==='hire.preview.console')return {id:d.id,business:s.business,question:d.question,kind:'hire',options:JSON.parse(d.options),...p};
if(a.digest!==p.digest)fail('decision-stale'); if(a.digest!==p.digest)fail('decision-stale');
if(a.choice==='yes') { if(!this.#hires)fail('not-configured'); await this.#hires.check(s.business,d.id,{...caller,digest:p.digest,adjust:a.adjust}); } if(a.choice==='yes') { if(!this.#hires)fail('not-configured'); await this.#hires.check(s.business,d.id,{...caller,digest:p.digest,adjust:a.adjust}); }
@@ -6375,6 +6387,20 @@ export class Broker {
throw e; throw e;
} }
} }
#expireHire(s,d,p,insideTransaction=false) {
if(!p || Date.parse(p.expiresAt)>Math.max(Date.now(),this.#clock) || !Number.isFinite(Date.parse(p.expiresAt)))return false;
const close=()=>{
if(this.#closed(d.id))return;
this.#store.run('INSERT INTO decision_events(decision,at,op,by,via) VALUES(?,?,?,?,?)',d.id,this.#now(),'withdrawn','expiry','broker');
this.#event({business:s.business,role:null,run:null},'action.allowed',{operation:'hire.expired',decision:d.id},d.id);
};
if(insideTransaction)close();else this.#store.transaction(close);
return true;
}
#expireHires(s) {
for(const d of this.#store.all(`SELECT d.* FROM decisions d WHERE business=? AND action IN ('role.hire','role.fire','role.change') AND NOT EXISTS(SELECT 1 FROM decision_events x WHERE x.decision=d.id AND x.op IN ${CLOSED})`,s.business))
this.#expireHire(s,d,this.#raised(s.business,d.id)?.hire,true);
}
async #stopCopyApproved(s,p) { async #stopCopyApproved(s,p) {
const instance=p.level==='project'?`${p.name}.${p.project}`:p.name; const instance=p.level==='project'?`${p.name}.${p.project}`:p.name;
const b=this.#business(s.business); const b=this.#business(s.business);
@@ -7817,6 +7843,7 @@ export class Broker {
const allowed = verb === 'trail' ? ['subject'] : verb === 'tasks' ? TASK_ARGS : []; const allowed = verb === 'trail' ? ['subject'] : verb === 'tasks' ? TASK_ARGS : [];
keys(a, allowed, verb === 'trail' ? ['subject'] : []); keys(a, allowed, verb === 'trail' ? ['subject'] : []);
if (!s.human && !s.reader) this.#agent(s); if (!s.human && !s.reader) this.#agent(s);
if (verb === 'inbox')this.#expireHires(s);
if (verb === 'inbox') if (verb === 'inbox')
return this.#store return this.#store
.all( .all(
@@ -7862,6 +7889,7 @@ export class Broker {
case 'decisions': { case 'decisions': {
if (!s.human && !s.reader) fail('human-required'); if (!s.human && !s.reader) fail('human-required');
keys(a, ['list'], ['list']); keys(a, ['list'], ['list']);
this.#expireHires(s);
if (a.list === 'roles') if (a.list === 'roles')
return this.#store return this.#store
.all( .all(
+3
View File
@@ -10,6 +10,9 @@ const group = (codes, rail, nextStep) => {
} }
}; };
group("definition-shared hire-resolution-required hire-owner-required decision-expired right-required", "R5/R8/R9: digest-bound hire approval by a permitted person", "Use the hire Console path with org.hire. Flagged, credential and root decisions need their owner; expired decisions need a fresh proposal."); group("definition-shared hire-resolution-required hire-owner-required decision-expired right-required", "R5/R8/R9: digest-bound hire approval by a permitted person", "Use the hire Console path with org.hire. Flagged, credential and root decisions need their owner; expired decisions need a fresh proposal.");
group("root-fire-shared", "R3/R8: root fire must revoke every copy's grants", "Other organizations may retain running copies and grants. Ask the owner to coordinate revocation across those organizations before removing the root agent.");
group("copy-stale", "R3: root policy changed since this bus booted", "Ask the owner to restart the organization bus to load the approved root policy, then launch a fresh copy.");
group("root-policy-unreadable", "R3: root policy evidence must be readable", "Ask the owner to inspect root agent receipt metadata and restart the bus after repairing the read failure.");
group("plugin-disabled", "R3/R6/R10: installed, pinned and enabled plugin capabilities", "Ask the instance owner to verify the installed plugin and the organization owner to enable it. A replacement of an already loaded package needs a host restart; then relaunch the agent."); group("plugin-disabled", "R3/R6/R10: installed, pinned and enabled plugin capabilities", "Ask the instance owner to verify the installed plugin and the organization owner to enable it. A replacement of an already loaded package needs a host restart; then relaunch the agent.");
group("plugin-restart-required", "R3/R10: one imported package pin per host process", "Ask the instance owner to restart the host after replacing an already loaded package, then retry through the supported path."); group("plugin-restart-required", "R3/R10: one imported package pin per host process", "Ask the instance owner to restart the host after replacing an already loaded package, then retry through the supported path.");
group( group(
+55 -1
View File
@@ -42,7 +42,8 @@ function fixture(t) {
const request=extra=>({op:'hire',level:'org',name:'scout',role:{inline:INLINE},route:{harness:'pi',model:'claude-opus-5-5'},reason:'Need help',...extra}); const request=extra=>({op:'hire',level:'org',name:'scout',role:{inline:INLINE},route:{harness:'pi',model:'claude-opus-5-5'},reason:'Need help',...extra});
const raise=extra=>call(caps.chief,'hire.raise',request(extra)); const raise=extra=>call(caps.chief,'hire.raise',request(extra));
t.after(()=>{store.close();rmSync(root,{recursive:true,force:true});}); t.after(()=>{store.close();rmSync(root,{recursive:true,force:true});});
return {root,rolesDir,b,store,file,writer,service,owner,member,caps,call,request,raise,writes:()=>writes}; const reloadChief=authority=>{const doc=loaded();doc.roles.chief.authority=authority;b.reloadBusiness('demo',doc);};
return {root,rolesDir,b,store,file,writer,service,owner,member,caps,call,request,raise,reloadChief,writes:()=>writes};
} }
test('A4 only the chief raises; forged flagged lines refuse without a record',async t=>{ test('A4 only the chief raises; forged flagged lines refuse without a record',async t=>{
const {call,caps,request,store}=fixture(t); const {call,caps,request,store}=fixture(t);
@@ -151,3 +152,56 @@ test('A4 Console lists template hashes without role text, under org.hire',async
assert.ok(list.templates.every(x=>Object.keys(x).sort().join(',')==='name,sha256')); assert.ok(list.templates.every(x=>Object.keys(x).sort().join(',')==='name,sha256'));
assert.throws(()=>call(member(['decide']),'hire.templates.console'),{code:'right-required'}); assert.throws(()=>call(member(['decide']),'hire.templates.console'),{code:'right-required'});
}); });
test('A4 chief proposals require current role.hire for hire/change and role.fire for fire',async t=>{
const f=fixture(t);
f.reloadChief({withinRole:['message.send'],crossRole:[]});
for(const op of ['hire','change','fire'])for(const verb of ['hire.raise','decision.raise']) {
const hire=f.request({op});
await assert.rejects(f.call(f.caps.chief,verb,verb==='decision.raise'?{action:`role.${op}`,hire}:hire),{code:'not-authorized'});
}
assert.equal(f.store.get('SELECT count(*) n FROM decisions').n,0);
f.reloadChief({withinRole:['role.hire'],crossRole:[]});
assert.ok((await f.raise()).id);
assert.ok((await f.raise({op:'change',name:'coder'})).id);
const fire={op:'fire',level:'org',name:'coder',reason:'Finished'};
await assert.rejects(f.call(f.caps.chief,'hire.raise',fire),{code:'not-authorized'});
f.reloadChief({withinRole:['role.fire'],crossRole:[]});
assert.ok((await f.call(f.caps.chief,'hire.raise',fire)).id);
await assert.rejects(f.raise(),{code:'not-authorized'});
});
test('A4 chief authority is checked again after asynchronous proposal preparation',async t=>{
const f=fixture(t),pending=f.raise();
f.reloadChief({withinRole:['message.send'],crossRole:[]});
await assert.rejects(pending,{code:'not-authorized'});
assert.equal(f.store.get('SELECT count(*) n FROM decisions').n,0);
});
test('A4 a member start needing owner approval refuses before saving a decision',async t=>{
const f=fixture(t),cap=f.member(['org.hire']),before=readFileSync(f.file);
for(const inline of [{...INLINE,tools:['read','bash']},{...INLINE,credentials:[{service:'gitea',scopes:['read:repository']}]}])
await assert.rejects(f.call(cap,'hire.start.console',f.request({role:{inline}})),{code:'hire-owner-required'});
assert.equal(f.store.get('SELECT count(*) n FROM decisions').n,0);
assert.deepEqual(readFileSync(f.file),before);
assert.equal((await f.call(cap,'hire.start.console',f.request())).op,'resolved');
});
test('A4 inbox and decision reads expire unopened hires once without changing configuration',async t=>{
const f=fixture(t),d=await f.raise({role:{inline:{...INLINE,tools:['read','bash']}}}),before=readFileSync(f.file);
const human=f.b.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
const original=Date.now;
try {
Date.now=()=>original()+8*86400000;
await assert.rejects(f.call(f.member(['org.hire']),'hire.preview.console',{id:d.id}),{code:'decision-expired'});
assert.deepEqual(f.call(human,'inbox'),[]);
const recent=f.call(human,'decisions',{list:'recent'});
assert.equal(recent[0].closed.by,'expiry');
const another=await f.raise();
Date.now=()=>original()+16*86400000;
assert.deepEqual(f.call(human,'inbox'),[]);
f.call(human,'decisions',{list:'recent'});
assert.equal(f.store.get("SELECT count(*) n FROM decision_events WHERE decision=? AND by='expiry'",another.id).n,1);
} finally {Date.now=original;}
assert.deepEqual(readFileSync(f.file),before);assert.equal(f.writes(),0);
});
+1 -1
View File
@@ -225,7 +225,7 @@ export function typedTools({ resolved, arbiter = false, tracker = false, project
} }
// A4: these propose changes. Only a person with org.hire can approve them. // A4: these propose changes. Only a person with org.hire can approve them.
if (held.has("role.hire")) { if (held.has("role.hire")) {
add("hire", "hire.raise", "role.hire", "Raise a hire or role-change decision for a person to approve. The broker computes flagged grants and credential lines. Nothing is written until approval against the digest. Only the chief raises these decisions.", obj({ add("hire", "hire.raise", "role.hire", "Raise a hire or role-change decision under role.hire for a person to approve. The broker computes flagged grants and credential lines. Nothing is written until approval against the digest. Only the chief raises these decisions.", obj({
op: {type:"string",enum:["hire","change"]}, level:{type:"string",enum:["org","project","root"]}, op: {type:"string",enum:["hire","change"]}, level:{type:"string",enum:["org","project","root"]},
project:str("Project name for project level"), name:str("Instance name"), project:str("Project name for project level"), name:str("Instance name"),
role:{type:"object",description:"Exactly {template, sha256}, {definition, rev}, or {inline: composition}"}, role:{type:"object",description:"Exactly {template, sha256}, {definition, rev}, or {inline: composition}"},
+38 -11
View File
@@ -1,6 +1,6 @@
// A4: private, pinned proposals; public decision records contain metadata and hashes only. // A4: private, pinned proposals; public decision records contain metadata and hashes only.
// This is a trusted host service, never a worker import or a credential binding writer. // This is a trusted host service, never a worker import or a credential binding writer.
import { mkdirSync, writeFileSync, readFileSync, lstatSync, openSync, closeSync, fsyncSync } from 'node:fs'; import { mkdirSync, writeFileSync, readFileSync, readdirSync, lstatSync, openSync, closeSync, fsyncSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { buildRegistry, checkComposition } from './composition.mjs'; import { buildRegistry, checkComposition } from './composition.mjs';
@@ -13,6 +13,7 @@ import { OrganizationError } from './organization.mjs';
import { resolveInstance } from './resolve.mjs'; import { resolveInstance } from './resolve.mjs';
import { checkRoutes } from './launch-settings.mjs'; import { checkRoutes } from './launch-settings.mjs';
import { VERB_RIGHTS } from './org-rights.mjs'; import { VERB_RIGHTS } from './org-rights.mjs';
import { readRegistry, withRegistryWriter } from './org-registry.mjs';
const fail = code => { throw new OrganizationError(code); }; const fail = code => { throw new OrganizationError(code); };
const hash = x => createHash('sha256').update(canonicalJson(x)).digest('hex'); const hash = x => createHash('sha256').update(canonicalJson(x)).digest('hex');
const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/; const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
@@ -36,12 +37,37 @@ const publicOf = p => ({ id:p.id, revision:p.revision, op:p.request.op, level:p.
flagged:p.flagged, credentialLines:p.credentialLines, expiresAt:p.expiresAt, digest:hash(p) }); flagged:p.flagged, credentialLines:p.credentialLines, expiresAt:p.expiresAt, digest:hash(p) });
export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertClean, system = {}, levels = () => null, catalogueFor = async () => null, registryFor = () => buildRegistry() }) { export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertClean, system = {}, levels = () => null, catalogueFor = async () => null, registryFor = () => buildRegistry() }) {
const home = join(dataRoot,'authority-proposals'); const home = join(dataRoot,'authority-proposals');
let receiptCache;
const rootReceipts=()=>{ const rootReceipts=()=>{
try{return readFileSync(join(configDir,'agents-receipts.jsonl'),'utf8').trim().split('\n').filter(Boolean).map(JSON.parse);} try{
const file=join(configDir,'agents-receipts.jsonl'),st=lstatSync(file,{bigint:true});
if(!st.isFile() || st.uid!==BigInt(process.getuid()) || (st.mode&0o022n) || st.nlink!==1n)throw new Error('unsafe receipts');
const stamp=[st.dev,st.ino,st.size,st.mtimeNs,st.ctimeNs].join(':');
if(receiptCache?.stamp===stamp)return receiptCache.rows;
const rows=readFileSync(file,'utf8').trim().split('\n').filter(Boolean).map(JSON.parse);
receiptCache={stamp,rows};return rows;
}
catch(error){if(error.code==='ENOENT')return [];throw error;} catch(error){if(error.code==='ENOENT')return [];throw error;}
}; };
let bootReceipts; let bootReceipts;
try{bootReceipts=rootReceipts();}catch{bootReceipts=null;} try{bootReceipts=rootReceipts();}catch{bootReceipts=null;}
const rootRefusal=name=>{
try {
if(!bootReceipts)return 'root-policy-unreadable';
const latest=rootReceipts().filter(r=>r.agent===name).at(-1),boot=bootReceipts.filter(r=>r.agent===name).at(-1);
if(latest?.verb==='root.agent.remove')return 'instance-fired';
return canonicalJson(latest??null)===canonicalJson(boot??null)?null:'copy-stale';
} catch {return 'root-policy-unreadable';}
};
async function assertRootFireLocal(business,registry=undefined) {
if(registry===undefined)registry=await readRegistry({configDir});
// Another organization can retain a boot snapshot and live external grants even after
// excludeOrgs changes. Without a cross-bus revoke protocol, refuse conservatively.
const others=new Set(Object.keys(registry?.orgs??{}));
for(const file of readdirSync(join(configDir,'businesses')))if(file.endsWith('.json') && ID_PATTERN.test(file.slice(0,-5)))others.add(file.slice(0,-5));
others.delete(business);
if(others.size)throw new OrganizationError('root-fire-shared',`root fire requires coordinated revocation in ${[...others].sort().join(', ')}`);
}
const location = value => { if (!UUID.test(value)) fail('invalid-request'); return join(home,`${value}.json`); }; const location = value => { if (!UUID.test(value)) fail('invalid-request'); return join(home,`${value}.json`); };
const read = value => { try { return readJsonFile(location(value),'private hire proposal',st=>{if(st.uid!==process.getuid() || st.mode&0o077 || st.nlink!==1)fail('decision-stale');}); } catch { fail('decision-stale'); } }; const read = value => { try { return readJsonFile(location(value),'private hire proposal',st=>{if(st.uid!==process.getuid() || st.mode&0o077 || st.nlink!==1)fail('decision-stale');}); } catch { fail('decision-stale'); } };
async function prepare(business, input, { id: decision, expiresAt }) { async function prepare(business, input, { id: decision, expiresAt }) {
@@ -67,6 +93,7 @@ export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertCle
if(chief) {const resolved=resolveInstance({system,business:loaded,instance:doc.launch.by});chief={...chief,...resolved.limits};} if(chief) {const resolved=resolveInstance({system,business:loaded,instance:doc.launch.by});chief={...chief,...resolved.limits};}
if (!chief) fail('no-chief-of-staff'); if (!chief) fail('no-chief-of-staff');
const root = input.level==='root' ? await readRootAgentsFile(configDir) : null; const root = input.level==='root' ? await readRootAgentsFile(configDir) : null;
if(input.level==='root' && input.op==='fire')await assertRootFireLocal(business);
if(root?.document.retired?.includes(input.name))fail('organization-instance-retired'); if(root?.document.retired?.includes(input.name))fail('organization-instance-retired');
const entries = input.level==='root' ? root.document.agents : input.level==='org' ? doc.roles : doc.projects[input.project]?.agents ?? {}; const entries = input.level==='root' ? root.document.agents : input.level==='org' ? doc.roles : doc.projects[input.project]?.agents ?? {};
if (input.level==='project' && (!doc.projects[input.project] || doc.projects[input.project].kind==='library')) fail('project-agents-invalid'); if (input.level==='project' && (!doc.projects[input.project] || doc.projects[input.project].kind==='library')) fail('project-agents-invalid');
@@ -166,8 +193,8 @@ export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertCle
async function check(business, decision, caller) { async function check(business, decision, caller) {
const p=await adjusted(business,decision,caller.adjust), view=publicOf(p); const p=await adjusted(business,decision,caller.adjust), view=publicOf(p);
if (p.business!==business || caller.digest!==view.digest)fail('decision-stale'); if (p.business!==business || caller.digest!==view.digest)fail('decision-stale');
if(Date.parse(p.expiresAt)<=(caller.now??Date.now)())fail('decision-expired');
authorizeHire(view,caller); authorizeHire(view,caller);
if(Date.parse(p.expiresAt)<=Date.now())fail('decision-expired');
if(writer.read(business).revision!==p.revision)fail('decision-stale'); if(writer.read(business).revision!==p.revision)fail('decision-stale');
// Re-run composition against current policy and scan private text again before any effect. // Re-run composition against current policy and scan private text again before any effect.
if(p.entry) { if(p.entry) {
@@ -180,6 +207,7 @@ export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertCle
if(p.request.role.template && readTemplate(rolesDir,p.request.role.template).sha256!==p.request.role.sha256)fail('decision-stale'); if(p.request.role.template && readTemplate(rolesDir,p.request.role.template).sha256!==p.request.role.sha256)fail('decision-stale');
} }
if(p.rootRevision!==null && (await readRootAgentsFile(configDir)).revision!==p.rootRevision)fail('decision-stale'); if(p.rootRevision!==null && (await readRootAgentsFile(configDir)).revision!==p.rootRevision)fail('decision-stale');
if(p.request.level==='root' && p.request.op==='fire')await assertRootFireLocal(business);
return p; return p;
} }
async function apply(business, decision, caller) { async function apply(business, decision, caller) {
@@ -190,16 +218,15 @@ export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertCle
const approvedEntry=p.entry?{...p.entry,rev:(currentDoc.definitions?.[p.definition]?.rev??0)+1}:null; const approvedEntry=p.entry?{...p.entry,rev:(currentDoc.definitions?.[p.definition]?.rev??0)+1}:null;
const excluded=r.level==='root' && r.op==='change' ? currentDoc.agents[r.name]?.excludeOrgs : undefined; const excluded=r.level==='root' && r.op==='change' ? currentDoc.agents[r.name]?.excludeOrgs : undefined;
const entry=r.op==='fire'?null:{definition:`org:${p.definition}`,vars,hireDecision:p.id,...(excluded===undefined?{}:{excludeOrgs:excluded})}; const entry=r.op==='fire'?null:{definition:`org:${p.definition}`,vars,hireDecision:p.id,...(excluded===undefined?{}:{excludeOrgs:excluded})};
if(r.level==='root') return changeRootAgent(configDir,{revision:p.rootRevision,name:r.name,entry}, if(r.level==='root') {
{actor:caller.subject,owner:caller.subject,rolesDir,approvedDefinition:p.entry?{name:p.definition,entry:approvedEntry}:null,decision:p.id,assertClean,beforeReplace:caller.beforeWrite}); const publish=()=>changeRootAgent(configDir,{revision:p.rootRevision,name:r.name,entry},
{actor:caller.subject,owner:caller.subject,rolesDir,approvedDefinition:p.entry?{name:p.definition,entry:approvedEntry}:null,decision:p.id,assertClean,beforeReplace:caller.beforeWrite});
if(r.op==='fire')return withRegistryWriter(configDir,async registry=>{await assertRootFireLocal(business,registry);return publish();});
return publish();
}
if(r.level==='project') return writer.setProjectAgent(business,{revision:p.revision,project:r.project,name:r.name,entry},caller.actor, if(r.level==='project') return writer.setProjectAgent(business,{revision:p.revision,project:r.project,name:r.name,entry},caller.actor,
{approvedDefinition:p.entry?{name:p.definition,entry:approvedEntry}:null,decision:p.id,assertClean,beforeWrite:caller.beforeWrite}); {approvedDefinition:p.entry?{name:p.definition,entry:approvedEntry}:null,decision:p.id,assertClean,beforeWrite:caller.beforeWrite});
return writer.applyHireDecision(business,p,{actor:caller.actor,assertClean}); return writer.applyHireDecision(business,p,{actor:caller.actor,assertClean});
} }
return {prepare,templates:()=>({templates:templateHashes(rolesDir).map(({template,sha256})=>({name:template,sha256}))}),preview:async(business,decision,caller)=>{const p=await adjusted(business,decision,caller.adjust);authorizeHire(publicOf(p),caller);return publicOf(p);},rootActive: name => { return {prepare,templates:()=>({templates:templateHashes(rolesDir).map(({template,sha256})=>({name:template,sha256}))}),preview:async(business,decision,caller)=>{const p=await adjusted(business,decision,caller.adjust);if(Date.parse(p.expiresAt)<=(caller.now??Date.now)())fail('decision-expired');authorizeHire(publicOf(p),caller);return publicOf(p);},rootRefusal,rootActive:name=>rootRefusal(name)===null,check:async(...args)=>{await check(...args);},apply,read: decision => publicOf(read(decision))};
// A3 keeps the boot snapshot. A subsequent change must therefore revoke its earlier
// policy at the next broker call; a fresh boot admits the newly approved snapshot.
try {if(!bootReceipts)return false;const latest=rootReceipts().filter(r=>r.agent===name).at(-1),boot=bootReceipts.filter(r=>r.agent===name).at(-1);return latest?.verb!=='root.agent.remove' && canonicalJson(latest??null)===canonicalJson(boot??null);}
catch{return false;}
},check:async(...args)=>{await check(...args);},apply,read: decision => publicOf(read(decision))};
} }
@@ -136,3 +136,29 @@ test('A4 a root role change preserves the owner-set organization exclusions',asy
const p=await prepare({op:'change',level:'root'});await service.apply('demo',p.id,{...OWNER,digest:p.digest}); const p=await prepare({op:'change',level:'root'});await service.apply('demo',p.id,{...OWNER,digest:p.digest});
assert.deepEqual(JSON.parse(readFileSync(join(root,'agents.json'))).agents.scout.excludeOrgs,['elsewhere']); assert.deepEqual(JSON.parse(readFileSync(join(root,'agents.json'))).agents.scout.excludeOrgs,['elsewhere']);
}); });
test('A4 root fire refuses other organizations before proposal and again before publication',async t=>{
const {root,prepare,service,writer}=setup(t);
writeJson(join(root,'agents.json'),{agentsVersion:1,agents:{scout:{definition:'security-reviewer'}}});
const fire=id=>service.prepare('demo',{op:'fire',level:'root',name:'scout',reason:'Finished'}, {id,expiresAt:'2099-01-01T00:00:00.000Z'});
const pending=await fire('11111111-1111-4111-8111-111111111111');
const before=readFileSync(join(root,'agents.json'));
writeJson(join(root,'businesses/other.json'),businessDoc(root,'other'));
await assert.rejects(service.apply('demo',pending.id,{...OWNER,digest:pending.digest}),{code:'root-fire-shared'});
assert.deepEqual(readFileSync(join(root,'agents.json')),before);
await assert.rejects(fire('22222222-2222-4222-8222-222222222222'),{code:'root-fire-shared'});
assert.ok(writer.read('demo').revision);
});
test('A4 root snapshot refusals distinguish changed, fired and unreadable policy',async t=>{
const {root,service}=setup(t),file=join(root,'agents-receipts.jsonl');
assert.equal(service.rootRefusal('scout'),null);
writeFileSync(file,JSON.stringify({agent:'scout',verb:'root.agent.set'})+'\n',{mode:0o600});
assert.equal(service.rootRefusal('scout'),'copy-stale');
writeFileSync(file,JSON.stringify({agent:'scout',verb:'root.agent.remove'})+'\n');
assert.equal(service.rootRefusal('scout'),'instance-fired');
writeFileSync(file,'invalid json\n');
assert.equal(service.rootRefusal('scout'),'root-policy-unreadable');
const broken=hireDecisions({writer:{},configDir:root,dataRoot:join(root,'data')});
assert.equal(broken.rootRefusal('scout'),'root-policy-unreadable');
});
+2 -1
View File
@@ -50,7 +50,8 @@ product's code, you don't review it and you don't create tasks yourself.
`role.revoke` decision, naming the instance and saying why. Change the `role.revoke` decision, naming the instance and saying why. Change the
launch limits, the model table and the model each instance runs with launch limits, the model table and the model each instance runs with
the `change_organization` tool. Use `hire` to raise a hire or role-change the `change_organization` tool. Use `hire` to raise a hire or role-change
decision and `fire` to raise a fire decision, each with a reason. Nothing decision under `role.hire`, and `fire` to raise a fire decision under
`role.fire`, each with a reason. Nothing
changes until a person with `org.hire` approves the pinned decision. The changes until a person with `org.hire` approves the pinned decision. The
broker computes grants above your own as flagged lines; those and every broker computes grants above your own as flagged lines; those and every
credential line need the owner. Only you raise these decisions; a hired credential line need the owner. Only you raise these decisions; a hired