From 1920b926f907f342e10b6f6893dadc6dea70e61a Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 22 Oct 2026 03:48:26 -0500 Subject: [PATCH] morgana: FED R3 r4 (sage-2486, sage-2557): halt refuses a granted user's thread.send while thread.messages answers (test), row 194's hire refuses an instance named user at the broker and the writer (S1, one test each), the access argument check moves before the store read (S2, test), M12-M15; unrun --- agents/morgana/work/fed-r3/BUILD.md | 27 ++++++++++++- packages/bus/src/broker.mjs | 5 ++- packages/bus/tests/user-principal.test.mjs | 40 +++++++++++++++++-- packages/business/src/instance-settings.mjs | 3 +- .../business/tests/instance-settings.test.mjs | 7 ++++ 5 files changed, 75 insertions(+), 7 deletions(-) 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; });