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) {
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(
+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("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(
+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 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);
});
+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.
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}"},
+38 -11
View File
@@ -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))};
}
@@ -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');
});
+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
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