diff --git a/agents/morgana/work/fed-r3/BUILD.md b/agents/morgana/work/fed-r3/BUILD.md index 63afd30d7..513d4bd56 100644 --- a/agents/morgana/work/fed-r3/BUILD.md +++ b/agents/morgana/work/fed-r3/BUILD.md @@ -147,13 +147,38 @@ mutants the design and brief name: - N2: server.mjs answers a `user` envelope with no `authenticateUser` as `human-required`, the existing default for a non-tester kind. The runtime always passes `authenticateUser`, so only a bare test server sees it. +## r4 (sage-2486, sage-2557): on row 194's landing + +Row 194 landed as fd6dd327c. r4 rebases R3 onto it and adds four changes. Sage ruled on each. + +- Halt (sage-2486): processing halt is the kill switch, so it refuses a granted user's writes + (thread.create, thread.open, thread.send) with `processing-halted`. The reads on #processingGate's list, + thread.messages among them, still answer. The kill-switch plan (:14-16) needs this, and Dewey agreed. Test: + "R3 r4: while halted, a granted user's thread.send refuses processing-halted and thread.messages still + answers". setup() takes a `processing` stand-in for it, as lifecycle-verbs T15 does. +- S1 (sage-2557): row 194's hire takes an instance name. Its two checks refused only `human` and `tester`, so + a chief could hire `user` and get past N1's load-check refusal. Both now refuse `user` with the code they + already give (`invalid-request`): broker #hireFireArgs, and the writer's hire() in instance-settings.mjs. One + test each: user-principal "R3 r4: the chief's hire refuses an instance named user ..." (with `helper` as a + control that passes the name check), and a hire loop in instance-settings' "row 165 refuses each + validation failure ..." that checks the file's bytes stay unchanged. +- S2 (sage-2557, sage-2501): the `access` TypeError check moves up beside harnessOn, testerOn and roleFile, + before the store is read or initializeControl runs. Test: "R3 r4: a non-function access throws before + the store is read" (null store, T8b style). +- Merge: the three 194 conflicts in broker.mjs are unions. R3's `user` role refusal moves into 194's + checkBusiness(). instance-settings takes 194's add/change/delete op list with R3's `user` refusal. + +Mutants M12-M15 kill each of the changes above: M12 the broker hire, M13 the writer hire, M14 the access +check moved below the store read, M15 thread.send added to the halt-exempt list. The kit has 19 mutants plus +the M3x two-walls run. All 19 anchors match exactly once on the r4 tree. + ## Gate The kit is /mnt/storage/scratch/morgana-r3 (`slot-r3.sh`, `mutate.py`, `busy.sh`), run as `bash busy.sh && CAND= bash slot-r3.sh` on Sage's CLEAR. It runs these files whole: user-principal, principal, human, tester, tester-threads, tester-writes, threads, names, messaging, messages, decisions, decision-route, broker, transport, peer-transport, process, message-filter, harness runner and business -instance-settings. Then the bus, harness and business suites, then the fifteen mutants, each on its named +instance-settings. Then the bus, harness and business suites, then the nineteen mutants (r4), each on its named test and restored after each, then the M3x two-walls run. It uses umask 022 and TMPDIR from `mktemp -d /mnt/storage/scratch/tmp/gr3XXXX`, refuses unless HEAD is the candidate and the tree is clean, and stops after two unexpected failures (mos-0823). diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index aee370166..72cf0f90c 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -407,6 +407,7 @@ export class Broker { if (harnessOn !== null && typeof harnessOn !== 'function') throw new TypeError('harnessOn must be a function'); if (testerOn !== null && typeof testerOn !== 'function') throw new TypeError('testerOn must be a function'); if (roleFile !== null && typeof roleFile !== 'function') throw new TypeError('roleFile must be a function'); + if (access !== null && typeof access !== 'function') throw new TypeError('access must be a function'); this.#businesses = structuredClone(businesses); this.#store = store; // A Broker is a trusted host startup, including in-process hosts. Never repair an existing state as off. @@ -425,7 +426,6 @@ export class Broker { this.#hostKeys = hostKeys; this.#giteaGrant = giteaGrant; this.#giteaKeys = giteaKeys; - if (access !== null && typeof access !== 'function') throw new TypeError('access must be a function'); this.#access = access; this.#project = project; this.#library = library; @@ -3842,7 +3842,8 @@ export class Broker { // The shape of hire and fire arguments. #hireFireArgs(action, args) { identifier(args.name); - if (args.name === 'human' || args.name === 'tester') fail('invalid-request'); + // FED R3 r4 (sage-2557): `user` is the granted user's principal, as checkBusiness refuses it. + if (args.name === 'human' || args.name === 'tester' || args.name === 'user') fail('invalid-request'); string(args.reason, 500); if (action === 'role.fire') return; identifier(args.definition); diff --git a/packages/bus/tests/user-principal.test.mjs b/packages/bus/tests/user-principal.test.mjs index c001d2a39..2012b70dc 100644 --- a/packages/bus/tests/user-principal.test.mjs +++ b/packages/bus/tests/user-principal.test.mjs @@ -21,7 +21,8 @@ const grant = (over = {}) => ({ id: 'g-1', user: 'alice', business: 'demo', projects: ['*'], instances: ['assistant'], level: 'collaborator', ...over, }); -function setup(t, { grants = [grant()] } = {}) { +// `processing` stands in for the halt switch, as lifecycle-verbs.test.mjs's T15 does. +function setup(t, { grants = [grant()], processing = null } = {}) { const root = mkdtempSync(join(tmpdir(), 'bus-user-principal-')); const store = new Store(root); t.after(() => { @@ -42,6 +43,7 @@ function setup(t, { grants = [grant()] } = {}) { definitions: { 'chief-of-staff': {}, assistant: { messaging: shipped('assistant').messaging } }, }, }, + ...(processing ? { processing } : {}), access: () => { access.reads++; if (access.fail) throw new Error('access file refused'); @@ -71,8 +73,8 @@ function setup(t, { grants = [grant()] } = {}) { } // Two projects, alice granted only `p1`, and alice's thread A in it. -function granted(t) { - const ctx = setup(t); +function granted(t, options) { + const ctx = setup(t, options); const p1 = ctx.call('human', 'project.create', { name: 'shared' }).id; const p2 = ctx.call('human', 'project.create', { name: 'private' }).id; ctx.access.doc.grants = [grant({ projects: [p1] })]; @@ -343,3 +345,35 @@ test('R3: name.set refuses the name user', (t) => { const { code } = setup(t); assert.equal(code('human', 'name.set', { instance: 'assistant', name: 'user' }), 'name-is-instance'); }); + +// FED R3 r4 (sage-2486): halt is the kill switch, so it refuses a granted user's write as it refuses +// the human's. The read still answers, and nothing is stored. +test('R3 r4: while halted, a granted user\'s thread.send refuses processing-halted and thread.messages still answers', (t) => { + const generation = '0b6e7f6e-4c1d-4a8e-9a51-6d2f3c4b5a69'; + const sw = { state: { version: 1, halted: false, generation } }; + const { call, code, store, A } = granted(t, { processing: { read: () => sw.state } }); + const sent = call('alice', 'thread.send', { thread: A, body: 'before' }); + sw.state = { version: 1, halted: true, generation }; + const stored = store.get('SELECT count(*) n FROM messages').n; + assert.equal(code('alice', 'thread.send', { thread: A, body: 'during' }), 'processing-halted'); + assert.equal(store.get('SELECT count(*) n FROM messages').n, stored); + assert.deepEqual(call('alice', 'thread.messages', { thread: A }).map((m) => m.id), [sent.id]); +}); + +// FED R3 r4 (sage-2557): row 194's hire takes an instance name, and `user` is refused as human and +// tester are, so a hire can't write a role the load check refuses. +test('R3 r4: the chief\'s hire refuses an instance named user with the code it gives human and tester', (t) => { + const { b, code, caps } = setup(t); + caps.chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi', address: 'chief' }); + b.request(caps.chief, { verb: 'role.claim' }); + const hire = (name) => code('chief', 'instance.hire', { name, definition: 'coder', reason: 'more hands' }); + for (const name of ['human', 'tester', 'user']) assert.equal(hire(name), 'invalid-request', name); + // A name that isn't reserved passes the name check and refuses later, for want of role files. + assert.notEqual(hire('helper'), 'invalid-request'); +}); + +// FED R3 r4 (sage-2557, sage-2501): `access` is checked with the other function arguments, before +// the broker touches the store, so a null store never gets read. +test('R3 r4: a non-function access throws before the store is read', () => { + assert.throws(() => new Broker({ store: null, businesses: { demo: {} }, access: 'access.json' }), /access must be a function/); +}); diff --git a/packages/business/src/instance-settings.mjs b/packages/business/src/instance-settings.mjs index 5440dafae..46586de92 100644 --- a/packages/business/src/instance-settings.mjs +++ b/packages/business/src/instance-settings.mjs @@ -298,7 +298,8 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r const { document: old, revision } = writer.read(business); if (!old.launch) throw new OrganizationError("organization-launch-not-configured"); const { name, definition } = args; - if (!id(name) || !id(definition) || name === "human" || name === "tester") throw new OrganizationError("invalid-request"); + // FED R3 r4 (sage-2557): and user, the granted user's principal, which the broker's load check refuses. + if (!id(name) || !id(definition) || name === "human" || name === "tester" || name === "user") throw new OrganizationError("invalid-request"); if (Object.hasOwn(args, "vars") && (!isPlainObject(args.vars) || !Object.keys(args.vars).every(HIRE_VAR))) { throw new OrganizationError("invalid-request", "hire vars may set only model and limits keys"); } diff --git a/packages/business/tests/instance-settings.test.mjs b/packages/business/tests/instance-settings.test.mjs index 2577e1c23..2a6c1d214 100644 --- a/packages/business/tests/instance-settings.test.mjs +++ b/packages/business/tests/instance-settings.test.mjs @@ -142,6 +142,13 @@ test("row 165 refuses each validation failure and leaves the file's bytes alone" refuses(f, { instance: "tester", op: "add", definition: "merge-gate" }, "invalid-request"); // FED R3 (dewey-0777): and user, the granted user's principal. refuses(f, { instance: "user", op: "add", definition: "merge-gate" }, "invalid-request"); + // FED R3 r4 (sage-2557): row 194's hire refuses the same three names, and leaves the file alone. + for (const name of ["human", "tester", "user"]) { + const bytes = readFileSync(f.file, "utf8"); + assert.throws(() => f.settings.hire("acme", { name, definition: "security-reviewer" }, { actor: "chief", context: {} }), + e => e.code === "invalid-request", name); + assert.equal(readFileSync(f.file, "utf8"), bytes, name); + } const one = fixture(doc => { doc.launch.instances = ["coder"]; }); refuses(one, { instance: "coder", op: "change", launch: false }, "BusinessError"); const none = fixture(doc => { delete doc.launch; });