diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index 944686ebe..3e776d47e 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -1558,7 +1558,10 @@ export class Broker { #copyRefused(b, instance) { 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(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.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}`); @@ -6316,13 +6319,24 @@ export class Broker { } async #raiseHire(s, input, immediate) { 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'); } - else { this.#agent(s); if(s.role!==b.launch?.by)fail('role-not-chief'); } + else chiefAllowed(); if(!this.#hires)fail('not-configured'); 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}); // 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}`; this.#store.transaction(()=>{ const raiser=immediate?'human':s.role; @@ -6334,23 +6348,21 @@ export class Broker { return {...this.#decisionView(this.#decision(s,did)),hire:p}; } 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']); identifier(a.id); const d=this.#decision(s,a.id), raise=this.#raised(s.business,d.id); let p=raise?.hire; 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(a.adjust!==undefined) { if(!this.#hires?.preview)fail('not-configured'); 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(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(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}); } @@ -6375,6 +6387,20 @@ export class Broker { 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) { const instance=p.level==='project'?`${p.name}.${p.project}`:p.name; const b=this.#business(s.business); @@ -7817,6 +7843,7 @@ export class Broker { const allowed = verb === 'trail' ? ['subject'] : verb === 'tasks' ? TASK_ARGS : []; keys(a, allowed, verb === 'trail' ? ['subject'] : []); if (!s.human && !s.reader) this.#agent(s); + if (verb === 'inbox')this.#expireHires(s); if (verb === 'inbox') return this.#store .all( @@ -7862,6 +7889,7 @@ export class Broker { case 'decisions': { if (!s.human && !s.reader) fail('human-required'); keys(a, ['list'], ['list']); + this.#expireHires(s); if (a.list === 'roles') return this.#store .all( diff --git a/packages/bus/src/refusals.mjs b/packages/bus/src/refusals.mjs index fb552d6e6..07c0b45b7 100644 --- a/packages/bus/src/refusals.mjs +++ b/packages/bus/src/refusals.mjs @@ -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("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-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( diff --git a/packages/bus/tests/hire-decisions.test.mjs b/packages/bus/tests/hire-decisions.test.mjs index b7bc577a6..255a1aac2 100644 --- a/packages/bus/tests/hire-decisions.test.mjs +++ b/packages/bus/tests/hire-decisions.test.mjs @@ -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 raise=extra=>call(caps.chief,'hire.raise',request(extra)); 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=>{ 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.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); +}); diff --git a/packages/harness/src/tools.mjs b/packages/harness/src/tools.mjs index 49843f131..95224d9ce 100644 --- a/packages/harness/src/tools.mjs +++ b/packages/harness/src/tools.mjs @@ -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. 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"]}, project:str("Project name for project level"), name:str("Instance name"), role:{type:"object",description:"Exactly {template, sha256}, {definition, rev}, or {inline: composition}"}, diff --git a/packages/organization/src/hire-decisions.mjs b/packages/organization/src/hire-decisions.mjs index 941d406b6..58ba8934c 100644 --- a/packages/organization/src/hire-decisions.mjs +++ b/packages/organization/src/hire-decisions.mjs @@ -1,6 +1,6 @@ // 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. -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 { createHash } from 'node:crypto'; import { buildRegistry, checkComposition } from './composition.mjs'; @@ -13,6 +13,7 @@ import { OrganizationError } from './organization.mjs'; import { resolveInstance } from './resolve.mjs'; import { checkRoutes } from './launch-settings.mjs'; import { VERB_RIGHTS } from './org-rights.mjs'; +import { readRegistry, withRegistryWriter } from './org-registry.mjs'; const fail = code => { throw new OrganizationError(code); }; 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}$/; @@ -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) }); export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertClean, system = {}, levels = () => null, catalogueFor = async () => null, registryFor = () => buildRegistry() }) { const home = join(dataRoot,'authority-proposals'); + let receiptCache; 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;} }; let bootReceipts; 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 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 }) { @@ -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) fail('no-chief-of-staff'); 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'); 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'); @@ -166,8 +193,8 @@ export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertCle async function check(business, decision, caller) { const p=await adjusted(business,decision,caller.adjust), view=publicOf(p); 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); - if(Date.parse(p.expiresAt)<=Date.now())fail('decision-expired'); if(writer.read(business).revision!==p.revision)fail('decision-stale'); // Re-run composition against current policy and scan private text again before any effect. 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.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; } 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 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})}; - if(r.level==='root') return 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.level==='root') { + 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, {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 {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 => { - // 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))}; + 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))}; } diff --git a/packages/organization/tests/hire-decisions.test.mjs b/packages/organization/tests/hire-decisions.test.mjs index f0c68e72a..517867a11 100644 --- a/packages/organization/tests/hire-decisions.test.mjs +++ b/packages/organization/tests/hire-decisions.test.mjs @@ -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}); 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'); +}); diff --git a/roles/chief-of-staff.md b/roles/chief-of-staff.md index 2bfa18060..4dd8d4462 100644 --- a/roles/chief-of-staff.md +++ b/roles/chief-of-staff.md @@ -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 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 - 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 broker computes grants above your own as flagged lines; those and every credential line need the owner. Only you raise these decisions; a hired