Files
stack/packages/organization/tests/hire-decisions.test.mjs

165 lines
12 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { hireDecisions, authorizeHire } from '../src/hire-decisions.mjs';
import { organizationWriter } from '../src/organization.mjs';
import { readTemplate } from '../src/definitions.mjs';
import { loadBusiness } from '../src/organization-config.mjs';
import { businessDoc, rolesCopy, writeJson } from './helpers.mjs';
const INLINE = { tools: ['read'], network: 'none', authority: { withinRole: [], crossRole: [] }, credentials: [], instructions: 'Read carefully. sk-test-NOT-A-SECRET-1234' };
const OWNER = { actor: 'jason', agent: false, rights: ['org.hire'], owner: true, instanceOwner: true, subject: { provider: 'test', iss: 'https://test', sub: 'owner' } };
function setup(t) {
const root = mkdtempSync(join(tmpdir(), 'a4-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const rolesDir = rolesCopy(root), file = writeJson(join(root,'businesses/demo.json'), {...businessDoc(root,'demo'),plugins:{harness:['pi']}});
const writer = organizationWriter({ dir: root, rolesDir });
const dataRoot = join(root, 'data'); mkdirSync(dataRoot, { mode: 0o700 });
const service = hireDecisions({ writer, rolesDir, configDir: root, dataRoot, assertClean: () => {} });
const request = (extra = {}) => ({ op: 'hire', level: 'org', name: 'scout', role: { inline: INLINE }, route: { harness: 'pi', model: 'claude-opus-5-5' }, reason: 'Need a reader', ...extra });
const prepare = (extra, id = '11111111-1111-4111-8111-111111111111') => service.prepare('demo', request(extra), { id, expiresAt: '2099-01-01T00:00:00.000Z' });
return { root, rolesDir, file, writer, service, prepare, request };
}
test('A4 composition flags and credential lines are computed, never supplied; public records omit instructions', async t => {
const { prepare } = setup(t);
for (const field of ['flagged','credentialLines','digest','expiresAt']) await assert.rejects(prepare({ [field]: [] }), { code: 'invalid-request' });
const p = await prepare();
assert.equal(p.flagged.length, 0);
assert.ok(!JSON.stringify(p).includes(INLINE.instructions));
assert.ok(!JSON.stringify(p).includes('sk-test-NOT-A-SECRET-1234'));
const c = await prepare({ role: { inline: { ...INLINE, credentials: [{ service: 'gitea', scopes: ['read:repository'] }] } } }, '22222222-2222-4222-8222-222222222222');
assert.deepEqual(c.credentialLines, [{ service: 'gitea', scope: 'read:repository' }]);
});
test('A4 only org.hire people resolve; flagged, credential and root decisions need their owner', () => {
const member = { ...OWNER, owner: false, instanceOwner: false };
const plain = { level: 'org', flagged: [], credentialLines: [] };
assert.doesNotThrow(() => authorizeHire(plain, member));
for (const p of [{ ...plain, flagged: [{ field: 'tools' }] }, { ...plain, credentialLines: [{ service: 'gitea' }] }, { ...plain, level: 'root' }]) assert.throws(() => authorizeHire(p, member), { code: 'hire-owner-required' });
assert.throws(() => authorizeHire(plain, { ...OWNER, rights: ['decide'] }), { code: 'right-required' });
assert.throws(() => authorizeHire(plain, { ...OWNER, agent: true }), { code: 'human-required' });
});
test('A4 org approval writes the pinned definition, route and a values-free writer receipt', async t => {
const { prepare, service, writer, root, file } = setup(t);
const p = await prepare();
const before = readFileSync(file);
await assert.rejects(service.apply('demo', p.id, { ...OWNER, digest: '0'.repeat(64) }), { code: 'decision-stale' });
assert.deepEqual(readFileSync(file), before);
await service.apply('demo', p.id, { ...OWNER, digest: p.digest });
const doc = writer.read('demo').document;
assert.equal(doc.roles.scout.definition, 'org:hire-11111111-1111-4111-8111-111111111111');
assert.equal(doc.roles.scout.vars.model, 'claude-opus-5-5');
assert.ok(doc.launch.instances.includes('scout'));
const log = readFileSync(join(root,'businesses/.history/demo/changes.jsonl'),'utf8');
assert.ok(log.includes(p.id)); assert.ok(!log.includes(INLINE.instructions)); assert.ok(!log.includes('sk-test-NOT-A-SECRET-1234'));
});
test('A4 approved credential hires stay unbound until all requested bindings exist', async t => {
const { prepare, service, root, rolesDir } = setup(t);
const p = await prepare({ role: { inline: { ...INLINE, credentials: [{ service: 'gitea', scopes: ['read:repository'] }] } } });
await service.apply('demo', p.id, { ...OWNER, digest: p.digest });
const loaded = loadBusiness('demo',{ dir: root, rolesDir });
assert.deepEqual(loaded.roles.scout.unbound, ['gitea']);
});
test('A4 project approval uses the project writer and changes only its agent plus the pinned definition', async t => {
const { prepare, service, writer } = setup(t);
const before = writer.read('demo').document;
const p = await prepare({ level: 'project', project: 'stack' });
await service.apply('demo', p.id, { ...OWNER, digest: p.digest });
const after = writer.read('demo').document;
assert.deepEqual(after.roles, before.roles);
assert.deepEqual(after.launch, before.launch);
assert.equal(after.projects.stack.agents.scout.vars.harness, 'pi');
});
test('A4 root approval uses A2 root writer, records decision id and leaves the organization unchanged', async t => {
const { prepare, service, writer, root } = setup(t);
const before = writer.read('demo').revision;
const p = await prepare({ level: 'root' });
await service.apply('demo', p.id, { ...OWNER, digest: p.digest });
assert.equal(writer.read('demo').revision, before);
const doc = JSON.parse(readFileSync(join(root,'agents.json')));
assert.equal(doc.agents.scout.vars.model, 'claude-opus-5-5');
assert.ok(readFileSync(join(root,'agents-receipts.jsonl'),'utf8').includes(p.id));
});
test('A4 change applies a T1 template update to an in-use definition; direct edits still refuse', async t => {
const { root, rolesDir, file, writer, service, prepare } = setup(t);
const p = await prepare(); await service.apply('demo', p.id, { ...OWNER, digest: p.digest });
const name = writer.read('demo').document.roles.scout.definition.slice(4);
const old = writer.read('demo');
assert.throws(() => writer.setDefinition('demo', name, e => ({ ...e, instructions: 'changed' }), { actor: 'jason', revision: old.revision, rev: 1, assertClean: () => {} }), { code: 'definition-in-use' });
const template = readTemplate(rolesDir,'reviewer');
const change = await service.prepare('demo',{ op: 'change', level: 'org', name: 'scout', role: { template: 'reviewer', sha256: template.sha256 }, route: { harness: 'pi', model: 'claude-opus-5-5' }, reason: 'Apply current template' }, { id: '22222222-2222-4222-8222-222222222222', expiresAt: '2099-01-01T00:00:00.000Z' });
await service.apply('demo',change.id,{ ...OWNER,digest:change.digest });
assert.equal(writer.read('demo').document.definitions[name].rev,2);
assert.equal(writer.read('demo').document.definitions[name].from.sha256,template.sha256);
});
test('A4 reserved names and invalid routes refuse before publication', async t => {
const {prepare,writer}=setup(t),before=writer.read('demo').revision;
for(const name of ['human','tester','user'])await assert.rejects(prepare({name}),{code:'invalid-request'});
await assert.rejects(prepare({route:{harness:'pi',model:'unconfigured'}}),/selects an unknown model/);
assert.equal(writer.read('demo').revision,before);
});
test('A4 human adjustments are digest bound, remove grants and credentials, and never widen', async t => {
const {service,prepare,writer}=setup(t);
const original={...INLINE,tools:['read','bash'],network:'open',authority:{withinRole:['git.merge.protected'],crossRole:[]},credentials:[{service:'gitea',scopes:['read:repository']}]};
const p=await prepare({role:{inline:original}});
const adjust={role:{tools:['read'],network:'none',authority:{withinRole:[],crossRole:[]}},credentials:[],route:{harness:'pi',model:'claude-sonnet-5-5'}};
const view=await service.preview('demo',p.id,{...OWNER,adjust});
assert.notEqual(view.digest,p.digest);assert.deepEqual(view.flagged,[]);assert.deepEqual(view.credentialLines,[]);
await assert.rejects(service.apply('demo',p.id,{...OWNER,adjust,digest:p.digest}),{code:'decision-stale'});
for(const bad of [{role:{tools:['read','write']}},{role:{authority:{withinRole:[],crossRole:['git.merge.protected']}}},{credentials:[{service:'gitea',scopes:['write:repository']}]},{role:{instructions:'different'}}])
await assert.rejects(service.preview('demo',p.id,{...OWNER,adjust:bad}),{code:'invalid-request'});
await service.apply('demo',p.id,{...OWNER,adjust,digest:view.digest});
const doc=writer.read('demo').document,entry=doc.definitions[doc.roles.scout.definition.slice(4)];
assert.deepEqual(entry.role.tools,['read']);assert.deepEqual(entry.role.credentials,[]);assert.equal(entry.role.network,'none');
assert.equal(entry.instructions,INLINE.instructions);assert.equal(doc.roles.scout.vars.model,'claude-sonnet-5-5');
});
test('A4 changed root copies lose broker admission until a fresh boot snapshot',async t=>{
const {service,prepare}=setup(t);
assert.equal(service.rootActive('scout'),true);
const p=await prepare({level:'root'});await service.apply('demo',p.id,{...OWNER,digest:p.digest});
assert.equal(service.rootActive('scout'),false,'an old boot cannot keep an earlier root policy after a decision write');
});
test('A4 a changed route clears the previous thinking override when approval omits it',async t=>{
const {file,writer,prepare,service}=setup(t);
const doc=writer.read('demo').document;doc.roles.scout={definition:'security-reviewer',vars:{harness:'pi',model:'claude-opus-5-5',thinking:'high'}};doc.launch.instances.push('scout');writeJson(file,doc);
const p=await prepare({op:'change'});await service.apply('demo',p.id,{...OWNER,digest:p.digest});
assert.equal(Object.hasOwn(writer.read('demo').document.roles.scout.vars,'thinking'),false);
});
test('A4 a root role change preserves the owner-set organization exclusions',async t=>{
const {root,prepare,service}=setup(t);
writeJson(join(root,'agents.json'),{agentsVersion:1,agents:{scout:{definition:'security-reviewer',vars:{harness:'pi',model:'claude-opus-5-5'},excludeOrgs:['elsewhere']}}});
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');
});