From 7035ce61b32eaa5ac2498a491f4621617b9e0e64 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Tue, 10 Nov 2026 18:51:06 -0600 Subject: [PATCH 1/2] feat(harness): expose caller rails and refusal guidance (#1873) --- packages/bus/README.md | 7 + packages/bus/src/broker.mjs | 12 +- packages/bus/src/organization-context.mjs | 1 + packages/bus/src/refusals.mjs | 163 ++++++++++++++++++ packages/bus/src/server.mjs | 19 +- packages/bus/tests/citation-guard.test.mjs | 5 +- packages/bus/tests/library-write.test.mjs | 3 +- packages/bus/tests/project-files.test.mjs | 7 +- packages/bus/tests/project-runtime.test.mjs | 2 + packages/bus/tests/rails.test.mjs | 118 +++++++++++++ packages/bus/tests/tester.test.mjs | 5 +- packages/bus/tests/transport.test.mjs | 6 +- packages/business/src/rails.mjs | 2 + .../fixtures/rename-a/installed-preimage.json | 2 +- .../fixtures/rename-a/installed-resolve.json | 85 +++++++++ .../tests/fixtures/rename-a/preimage.json | 2 +- .../tests/fixtures/rename-a/resolved.json | 18 +- .../tests/rename-v1-contract.test.mjs | 2 +- packages/cli/tests/case2-install.test.mjs | 2 +- packages/cli/tests/launcher.test.mjs | 4 +- packages/cli/tests/lifecycle.test.mjs | 8 +- packages/cli/tests/rename-b-commands.test.mjs | 2 +- packages/harness/src/bundle.mjs | 4 +- packages/harness/src/runner.mjs | 2 + packages/harness/src/tools.mjs | 12 +- packages/harness/tests/bundle.test.mjs | 2 +- packages/harness/tests/library-tools.test.mjs | 6 +- .../tests/library-write-tools.test.mjs | 8 +- packages/harness/tests/mcp-server.test.mjs | 2 +- packages/harness/tests/rails.test.mjs | 54 ++++++ packages/harness/tests/request-tools.test.mjs | 11 +- packages/harness/tests/runner.test.mjs | 19 +- packages/harness/tests/tools.test.mjs | 16 +- .../tests/write-record-object.test.mjs | 12 +- packages/organization/src/rails.mjs | 53 ++++++ packages/organization/src/resolve.mjs | 2 + packages/organization/tests/resolve.test.mjs | 2 +- .../tests/spec-review-contracts.test.mjs | 20 +++ packages/seat/src/sandbox.mjs | 2 + packages/seat/tests/sandbox.test.mjs | 2 +- roles/coder.md | 9 +- roles/cto.md | 9 +- roles/merge-gate.md | 10 +- roles/pm.md | 8 +- roles/reviewer.md | 9 +- roles/security-reviewer.md | 10 +- 46 files changed, 671 insertions(+), 88 deletions(-) create mode 100644 packages/bus/src/refusals.mjs create mode 100644 packages/bus/tests/rails.test.mjs create mode 100644 packages/business/src/rails.mjs create mode 100644 packages/harness/tests/rails.test.mjs create mode 100644 packages/organization/src/rails.mjs diff --git a/packages/bus/README.md b/packages/bus/README.md index 866666d6b..9e0ab54d5 100644 --- a/packages/bus/README.md +++ b/packages/bus/README.md @@ -189,8 +189,15 @@ those messages stranded: no later holder receives or reads them, and `agents` lists them under `stranded` (Agent state, below). `message.read` is an explicit acknowledgement. `trail(messageId)` lets an operator inspect content and delivery evidence after an uncertain result. +Broker socket refusals retain `ok:false`, `error` and any scoped `reason`, and add +`rail` and `nextStep` from `src/refusals.mjs`. Guidance is static and never +quotes caller input or file bytes. The runner and typed tools carry the guidance +to the agent. `src/rails.mjs` in the organization package supplies the shared +prompt/live projection; `decisionAutonomy` is the extension point for AU. + | Verb | Arguments and result | |---|---| +| `my_rails` | No args. Read only the current caller's held actions and routes, resolve rights with fixed autonomy levels, network, credential service/scope names and messaging targets. Recomputed from live policy on every call; never returns credential references or values, writes evidence, or raises a decision. Exposed to every managed role as `my_rails`. | | `role.claim` | No args; claims capability's role/run. One current holder per business/instance; a revoked run cannot reclaim that role, including after restart. | | `role.release` | No args; only its current holder releases. | | `role.revoke` | `{role,decision}`; needs an approved decision for this exact target and holder incarnation, routed as REQ-DEC-2 says: the human's for an uncited gated raise, the arbiter's for one citing the current spec. A role whose file lists `role.end` (the chief of staff, row 123, REQ-COS-1) sends `{role,reason}` instead: no decision, a reason of 1 to 500 characters, logged in the `action.allowed` body. Sending both is `invalid-request`; the caller's own run is `role-end-refused`; any other role sending `reason` gets `decision-required`. An instance whose definition is in `END_PROTECTED` (the assistant) is ended or revoked only by the chief of staff or with a decision the human resolved through the CLI (`via` `cli`); anyone else's reason, or a decision an arbiter resolved, is `role-end-refused` (Jason's ruling Q169, row 139). A target whose definition is `THREAD_DEFINITION` (the assistant) has no role claim: the end revokes the current claim of each of its threads, refuses `not-held` when none is held, and returns `{revoked:true,threads:N}` (row 155, #1649). | diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index 1dc2767af..3423236b1 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -1,3 +1,5 @@ +import { ownRails } from '../../organization/src/rails.mjs'; +import { refusalGuidance } from './refusals.mjs'; import { PROXY_PAGE_VERBS, PROXY_PAGE_CODES } from './proxy-page-vocabulary.mjs'; import { randomUUID, randomBytes, createHash } from 'node:crypto'; import { validManagementKey, proxyTiming } from '../../model-proxy/src/validation.mjs'; @@ -245,10 +247,11 @@ const canonical = (x) => Number.isFinite(Date.parse(x)) && new Date(x).toISOString() === x; export class BusError extends Error { - // Only explicitly scoped refusals carry a reason; the envelope passes it on. + // Guidance is static policy text; never quote arguments or file bytes here. constructor(code, reason) { super(code); this.code = code; + Object.assign(this, refusalGuidance(code)); if (reason !== undefined) this.reason = reason; } } @@ -6938,6 +6941,13 @@ export class Broker { // Preserve the common request gates and refuse additions until the vocabulary names them. if (verb.startsWith('tasks.') && !PERSON_TASK_VERBS.includes(verb)) fail('unknown-verb'); switch (verb) { + case 'my_rails': { + keys(a, []); + this.#agent(s); + const entry = b.roles[s.role], definition = b.definitions?.[entry.definition]; + return ownRails({ instance: s.role, authority: entry.authority, network: entry.network ?? definition?.network, + credentials: definition?.credentials ?? [], messaging: definition?.messaging, arbiters: b.arbiters, launcher: b.launch?.by }); + } case 'role.claim': { keys(a, []); if (s.human) fail('agent-required'); diff --git a/packages/bus/src/organization-context.mjs b/packages/bus/src/organization-context.mjs index 6efb1ac2b..f36d02ac7 100644 --- a/packages/bus/src/organization-context.mjs +++ b/packages/bus/src/organization-context.mjs @@ -16,6 +16,7 @@ export function busOrganization(business, resolved) { !r.limits?.authority ) throw new BusError('invalid-resolution'); + out.roles[role].network = r.limits.network; out.roles[role].authority = structuredClone(r.limits.authority); // REQ-HARN-2: "web" in the resolved tool limit is the grant for the broker's web verbs. out.roles[role].web = (r.limits.tools ?? []).includes('web'); diff --git a/packages/bus/src/refusals.mjs b/packages/bus/src/refusals.mjs new file mode 100644 index 000000000..c93290b4d --- /dev/null +++ b/packages/bus/src/refusals.mjs @@ -0,0 +1,163 @@ +// Static refusal table: each listed code maps to one rail and next step. These strings +// never interpolate caller arguments, credential references, policy values or file bytes. +const entries = []; +const group = (codes, rail, nextStep) => { + const guidance = Object.freeze({ rail, nextStep }); + for (const code of codes.split(' ')) { + if (entries.some(([known]) => known === code)) throw new Error('duplicate refusal guidance'); + entries.push([code, guidance]); + } +}; +group( + "access-refused access-revoked share-exists share-not-covered share-not-found tester-dev-only tester-off tester-refused tester-required tester-target-not-running thread-not-allowed thread-not-owned", + "R7: caller access and scoped session boundary", + "Use only the caller's own permitted scope. Ask the owner to inspect access or sharing through the supported controls; never borrow another identity." +); +group( + "adapter-failed check-threw duplicate-run malformed-answer no-verdict not-a-merge not-approved not-collected not-human not-merge-option not-resolved not-thread-launch peercred-required reply-limits-unreadable socket-exists store-unreadable target-branch workspace-rename-failed", + "R3: fail closed on an unsatisfied operation precondition", + "Inspect the supported status and report the refusal code to the responsible owner; do not bypass it or retry an uncertain side effect." +); +group( + "addressee-ambiguous invalid-approval-choice invalid-arbiter invalid-authority invalid-business invalid-choice invalid-class invalid-domain invalid-envelope invalid-harness invalid-messaging invalid-options invalid-plugins invalid-recommendation invalid-request invalid-snapshot invalid-spec-citation invalid-state invalid-task-ref invalid-timestamp name-is-instance name-is-role name-taken project-name-is-repo thread-instance-ambiguous unknown-action unknown-addressee unknown-business unknown-commit unknown-model unknown-plugin unknown-repo unknown-request unknown-requirement unknown-role unknown-run unknown-verb", + "R3: validated vocabulary and request shape", + "Check the tool schema and permitted identifiers, correct the request, then retry; do not guess around a refusal." +); +group( + "agent-required", + "Role/session: agent identity required", + "Use this tool only from an authorized managed agent session." +); +group( + "agent-running fire-in-progress instance-fired instance-firing instance-not-listed instance-retired instance-running instance-starting launch-record-mismatch launch-revoked launcher-released not-drained not-drained-run not-exited not-held not-holder not-recipient not-retired revoke-refused revoked role-already-held room-not-open room-thread-name-taken run-ended run-not-ended run-revoked session-clear session-clear-cancelled session-clear-complete session-clearing session-exists session-name-too-long session-not-found session-op-conflict session-retired session-revision-stale thread-already-held thread-exists thread-instance-mismatch thread-instance-missing thread-not-found thread-required thread-required-reply-to-thread-message thread-required-use-thread-send thread-revoked", + "Role/session: current holder and lifecycle fence", + "Read current session/status and ask the launcher or owner to finish the supported lifecycle transition. A stale or ended run must not reclaim another holder's identity." +); +group( + "apikey-invalid callback-url-invalid harness-off harness-refused key-quota-no-model-key login-state-unknown model-catalogue-unavailable model-harness-incompatible model-key-collision model-provider-unavailable models-ambiguous models-invalid models-unconfirmed models-write-refused priority-invalid priority-unconfirmed priority-write-refused provider-not-allowed proxy-config-invalid proxy-down proxy-first-output-invalid proxy-init-failed proxy-key-invalid proxy-key-issue-failed proxy-key-refused proxy-key-revoking proxy-open proxy-revoke-revision-conflict proxy-rotate-grace-invalid proxy-uncertain thinking-unsupported", + "R1/R3: owner-managed model and provider configuration", + "Ask the owner to inspect the provider configuration through its supported controls; never put keys or callback contents in messages." +); +group( + "block-recipient block-reply-required block-sender message-recipient-refused message-sender-refused tester-reply-required tester-thread-human user-reply-required user-thread-human", + "Role messaging: permitted sender, recipient and reply context", + "Read my_rails and use only a permitted target and the required reply context. Ask the owner if the permitted route is unavailable." +); +group( + "command-not-granted credential-file credential-revoke-failed credential-unknown credential-leak gitea-reach-unknown gitea-reach-wider gitea-user-admin gitea-user-disabled gitea-user-foreign gitea-user-gone gitea-user-orphan grant-admin-missing grant-admin-nothing-left grant-admin-rotation-due grant-busy grant-chief-only grant-lost grant-revoke-failed grant-revoke-running grant-token-unknown grants-revoking remote-not-granted remote-host-mismatch remote-not-fork scope-changed", + "R1/R8: scoped and revocable access", + "Ask the owner to inspect the scoped access and binding state through the supported access controls; do not supply credentials in a call or widen your own grants." +); +group( + "confirm-required outcome-unknown outcome-unknown-revoked revision-conflict revision-conflict-revoked", + "R2/R3: current revision and verified effect", + "Read the latest status, revision and receipts before retrying. Obtain the required confirmation through the supported human path." +); +group( + "decision-closed decision-consumed decision-mismatch decision-not-approved decision-not-eligible decision-not-found decision-not-held decision-stale supersede-refused", + "Role decisions: current, matching, approved and single-use decision", + "Read the current decision, its route and its resolution and consumption receipts. Obtain the operation's matching approval from the permitted resolver before acting; a consumed approval cannot be reused." +); +group( + "decision-not-allowed", + "Role authority: no free-text decision channel", + "Use the role's permitted message path to report the need to a person; do not use another decision channel." +); +group( + "decision-required", + "Role authority and R4: approval before acting", + "Read my_rails. Raise a decision for the action and its permitted arbiter; hard waits go to a person. Use blocking false when there is no task." +); +group( + "decision-route-refused", + "Role messaging: decision close route", + "Ask the owner to inspect the raiser and resolver messaging policy; do not route around the refused close path." +); +group( + "definition-not-hireable instance-kept launch-not-configured no-chief-of-staff organization-busy organization-definition-refused organization-instance-referenced organization-instance-retired role-end-refused staff-request-refused", + "Role configuration and protected instance controls", + "Read the permitted configuration/status and ask the owner or configured chief to repair it through the supported path. Do not edit policy to bypass the refusal." +); +group( + "field-writer task-already-archived task-archived task-conflict task-create-conflict task-create-uncertain task-not-archived task-not-found task-position-full task-project-refused task-required task-scope-required task-subject-required", + "Role task ownership, allowed fields and current task state", + "Read the current task and assigned role. Use the permitted task operation with current evidence; ask the PM for scope or assignment changes through the decision path." +); +group( + "fork-foreign gitea-admin-unavailable gitea-failed gitea-not-configured gitea-not-declared host-busy host-changed host-creative-role host-enrolled host-failed host-in-use host-key-mismatch host-line-left host-not-configured host-not-declared host-not-enrolled host-not-granted host-not-ready host-remove-failed host-unknown publish-busy publish-unknown", + "Role access: declared service, host or remote and verified outcome", + "Check the supported access/status read and ask the owner to repair the declared service or grant. If an effect is uncertain, verify its receipt before retrying." +); +group( + "human-required", + "R4/R8: person-only operation", + "A person must perform or approve this operation through its supported human path; an agent must not retry as a person." +); +group( + "land-unknown no-push not-a-reviewer ref-moved self-review stale-commit verdict-exists verdict-not-approve verdict-not-found verdict-stale work-not-yours work-ref", + "Role review and landing: independent verdict on the current work", + "Read the latest work and review receipts. Obtain the required independent review or matching merge decision for the current commit before retrying." +); +group( + "library-fetch-no-grant library-not-configured library-notice-store-failed library-write-no-grant library-write-refused web-failed web-not-configured web-not-granted", + "Role tools: granted and configured web/library access", + "Read my_rails and the permitted service status. Use only granted tools; ask the owner to repair unavailable service configuration." +); +group( + "message-not-found no-subject not-assistant operation-not-found operation-not-open", + "R7/role scope: visible object and allowed operation", + "Use the caller's permitted reads to locate current context, then retry only the supported operation. Ask the owner if it remains unavailable." +); +group( + "not-authorized", + "Role authority: held action or approved decision", + "Read my_rails and use the applicable decision path; ask the owner to inspect grants if no supported path exists." +); +group( + "not-configured project-already-archived project-archived project-exists project-not-archived project-not-configured project-not-found project-record-invalid project-remote-configured project-remote-invalid project-remote-library project-remote-protected project-remote-unknown project-spec-human-only system-type-not-enabled tracker-not-store usage-not-configured", + "R3/role scope: configured project and service state", + "Read the supported project/service status and ask its owner to repair missing configuration or state before retrying." +); +group( + "not-resolver", + "Role resolve right and decision route", + "Only the routed resolver holding the matching decision.resolve right may resolve. Read my_rails and notify that resolver." +); +group( + "prd-arbiter-unavailable prd-authority-refused prd-changes-requested prd-decision-closed prd-not-approved prd-self-approval request-access-refused request-closed request-evidence-required request-expired request-failed request-not-configured request-not-found request-not-taken request-origin-refused request-project-refused request-revision-conflict request-selection-refused request-tracker-refused request-verification-refused requirement-snapshot-refused requirement-snapshot-required requirement-snapshot-revoked spec-unconfirmed stale-spec", + "Role workflow: current admitted request and approved requirements", + "Read the current request, requirements and decision state. Ask the responsible PM or routed arbiter to supply the missing current evidence before continuing." +); +group( + "processing-audit-conflict processing-control-unsafe processing-uninitialized processing-generation-changed processing-halted processing-state-invalid storage-refused storage-unavailable", + "R2/R3: available audited storage and processing state", + "Stop the refused operation and report the processing or storage state to the owner. Resume only after the supported status shows the condition is repaired." +); +group( + "read-only", + "Role/session: read-only capability", + "Use the permitted reads. Ask the owner or an authorized writer to perform the change." +); +group( + "reserved-event", + "R2: trusted append-only evidence", + "Use the typed operation that records its own evidence; never emit a reserved event directly." +); +group( + "role-not-chief", + "R9: only the configured chief manages hires", + "Send the staffing need to the configured chief through an allowed message path." +); +group( + "unauthenticated", + "R7: authenticated caller identity", + "Ask the launcher to restore the caller's own authenticated session; never borrow another capability." +); +group( + "request-timeout request-too-large incomplete-request invalid-response response-too-large unknown-tool tool-failed capacity-full", + "R3/role tools: bounded protocol and available capacity", + "Check the tool schema and narrow oversized requests. For a timeout, inspect status and receipts before retrying; never assume an uncertain operation did not happen." +); +export const REFUSALS = Object.freeze(Object.fromEntries(entries)); +const UNKNOWN = Object.freeze({ rail: "R3: fail closed", nextStep: "Report the refusal code to the responsible owner and inspect supported status; do not bypass the refusal." }); +export const refusalGuidance = code => Object.hasOwn(REFUSALS, code) ? REFUSALS[code] : UNKNOWN; diff --git a/packages/bus/src/server.mjs b/packages/bus/src/server.mjs index 7706862c9..6572cf922 100644 --- a/packages/bus/src/server.mjs +++ b/packages/bus/src/server.mjs @@ -9,11 +9,12 @@ const LIMIT = 65536; const PROJECT_SAVE_AT_START = new RegExp(`"verb":"${PROJECT_SAVE_VERB.replaceAll('.', '\\.')}"`); const LIBRARY_SAVE_AT_START = /"verb":"library\.file\.save"/; const SAVE_VERB_AT_START = { test: text => PROJECT_SAVE_AT_START.test(text) || LIBRARY_SAVE_AT_START.test(text) }; -// The refusal envelope. C2-06 (sage-3599): `reason` is added only when the error carries one, which -// only explicitly scoped refusals do, so every other envelope is byte-identical to before. -const refusal = (e, other) => e instanceof BusError - ? { ok: false, error: e.code, ...(typeof e.reason === 'string' ? { reason: e.reason } : {}) } - : { ok: false, error: other }; +// Preserve every code and scoped reason, adding only static rail/next-step guidance. +const refusal = (e, other) => { + const error = e instanceof BusError ? e : new BusError(other); + return { ok: false, error: error.code, rail: error.rail, nextStep: error.nextStep, + ...(typeof error.reason === 'string' ? { reason: error.reason } : {}) }; +}; // One request per connection. There is deliberately no reconnect/retry or SQL verb. // `tasks` ({handle, timeout}) is the trusted S3 adapter; only the eight task verbs reach it. // `web` is the trusted web service (web.mjs), or a function from a business id to one (or null); only @@ -48,16 +49,16 @@ export async function serve({ broker, path, authenticateHuman = null, authentica socket.end(JSON.stringify(result) + '\n'); }; socket.setTimeout(timeout, () => { - finish({ ok: false, error: 'request-timeout' }); + finish(refusal(new BusError('request-timeout'))); socket.destroySoon(); }); socket.on('data', async (chunk) => { if (done) return; - if (processing) return finish({ ok: false, error: 'invalid-envelope' }); + if (processing) return finish(refusal(new BusError('invalid-envelope'))); const total = input.length + chunk.length; if (total > LIMIT && !(total <= PROJECT_SAVE_REQUEST_MAX && SAVE_VERB_AT_START.test(Buffer.concat([input, chunk]).subarray(0, 1024).toString('latin1')))) { - finish({ ok: false, error: 'request-too-large' }); + finish(refusal(new BusError('request-too-large'))); return; } input = Buffer.concat([input, chunk]); @@ -187,7 +188,7 @@ export async function serve({ broker, path, authenticateHuman = null, authentica } }); socket.on('end', () => { - if (!done && !processing) finish({ ok: false, error: 'incomplete-request' }); + if (!done && !processing) finish(refusal(new BusError('incomplete-request'))); }); }); await new Promise((resolve, reject) => { diff --git a/packages/bus/tests/citation-guard.test.mjs b/packages/bus/tests/citation-guard.test.mjs index 7d7bbba7a..a7d5b9a2f 100644 --- a/packages/bus/tests/citation-guard.test.mjs +++ b/packages/bus/tests/citation-guard.test.mjs @@ -4,6 +4,7 @@ import { connect } from 'node:net'; import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { Client } from '../src/client.mjs'; +import { refusalGuidance } from '../src/refusals.mjs'; import { serve } from '../src/server.mjs'; import { FIXED_TEXTS, GuardError } from '../../library/src/guard.mjs'; import { git } from '../../library/tests/helpers.mjs'; @@ -138,7 +139,7 @@ test('C2-06 page store is session-local, caps 20 fetches and 200 KiB, drops at d await assert.rejects(f.call(replacement, 'message.send', { to: 'human', body: citedBody([{ url: 'https://fixture.invalid/21', quote: 'xxxx' }]) }), { code: 'citation-unfetched' }); }); -test('C2-06 transport passes safe capped guard reason; ordinary refusal envelope is byte-identical', async t => { +test('C2-06 transport passes safe capped guard reason; ordinary refusal retains its code and adds static rail guidance', async t => { const f = citationWorld(t, { guard: async () => { throw new GuardError('citation-invalid', 'guard reason '.repeat(50)); } }); f.call(f.agent, 'role.claim'); const path = join(f.root, 'socket'); const server = await serve({ broker: f.broker, path }); t.after(() => server.close()); @@ -148,7 +149,7 @@ test('C2-06 transport passes safe capped guard reason; ordinary refusal envelope const socket = connect(path); let text = ''; socket.on('error', reject); socket.on('data', b => { text += b; }); socket.on('end', () => resolve(text)); socket.on('connect', () => socket.write(JSON.stringify({ cap: f.agent, verb: 'unknown-fixture' }) + '\n')); }); - assert.equal(raw, '{"ok":false,"error":"unknown-verb"}\n'); + assert.deepEqual(JSON.parse(raw), { ok: false, error: 'unknown-verb', ...refusalGuidance('unknown-verb') }); }); test('C2-06 non-library businesses retain role.claim shape and unguarded free sends', async t => { diff --git a/packages/bus/tests/library-write.test.mjs b/packages/bus/tests/library-write.test.mjs index 24a337a72..d191e5bfc 100644 --- a/packages/bus/tests/library-write.test.mjs +++ b/packages/bus/tests/library-write.test.mjs @@ -1,4 +1,5 @@ // Row 288: broker authority, exact notice and strict Console revert API. Fixture grant rows only. +import { refusalGuidance } from '../src/refusals.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs'; @@ -180,7 +181,7 @@ test('C2-02 socket: a save name nested in proof cannot enlarge an agent envelope socket.on('error', reject); socket.on('connect', () => socket.write(request)); socket.on('data', bytes => { result += bytes; if (result.includes('\n')) { socket.destroy(); resolve(JSON.parse(result)); } }); }); - assert.deepEqual(response, { ok: false, error: 'request-too-large' }); + assert.deepEqual(response, { ok: false, error: 'request-too-large', ...refusalGuidance('request-too-large') }); assert.equal(f.seen.length, 0); }); diff --git a/packages/bus/tests/project-files.test.mjs b/packages/bus/tests/project-files.test.mjs index 4493f0e77..5babb3d9f 100644 --- a/packages/bus/tests/project-files.test.mjs +++ b/packages/bus/tests/project-files.test.mjs @@ -5,6 +5,7 @@ import { join } from 'node:path'; import { fixture, git } from './spec-fixtures.mjs'; import { serve } from '../src/server.mjs'; import { Client } from '../src/client.mjs'; +import { refusalGuidance } from '../src/refusals.mjs'; import { PROJECT_FILE_VERBS } from '../src/broker.mjs'; // Row 148 (#1642): the human-only verbs behind the Console's spec and docs editor. const refusals = f => f.store.all("SELECT body FROM events WHERE kind='action.refused'").map(r => JSON.parse(r.body).code); @@ -109,11 +110,11 @@ test('a request that names the save verb first and another verb last is refused const f = await socketFixture(t); const pad = 'x'.repeat(100000); const sneaky = `{"human":{"fixture":true},"verb":"project.file.save","verb":"agents","args":{"pad":"${pad}"}}\n`; - assert.deepEqual(await raw(f.path, sneaky), { ok: false, error: 'request-too-large' }); + assert.deepEqual(await raw(f.path, sneaky), { ok: false, error: 'request-too-large', ...refusalGuidance('request-too-large') }); const unmarked = await raw(f.path, JSON.stringify({ human: { fixture: true }, verb: 'agents', args: { pad } }) + '\n'); - assert.deepEqual(unmarked, { ok: false, error: 'request-too-large' }); + assert.deepEqual(unmarked, { ok: false, error: 'request-too-large', ...refusalGuidance('request-too-large') }); const over = await raw(f.path, JSON.stringify({ human: { fixture: true }, verb: 'project.file.save', args: { pad: 'x'.repeat(1024 * 1024 + 80000) } }) + '\n'); - assert.deepEqual(over, { ok: false, error: 'request-too-large' }); + assert.deepEqual(over, { ok: false, error: 'request-too-large', ...refusalGuidance('request-too-large') }); }); test('two saves racing for one repository make one commit', async t => { diff --git a/packages/bus/tests/project-runtime.test.mjs b/packages/bus/tests/project-runtime.test.mjs index 9d3e756f6..fb105275e 100644 --- a/packages/bus/tests/project-runtime.test.mjs +++ b/packages/bus/tests/project-runtime.test.mjs @@ -13,6 +13,8 @@ test('Broker keeps the sandbox client import closure: only the listed imports, n const source = readFileSync(new URL('../src/broker.mjs', import.meta.url), 'utf8'); assert.deepEqual([...source.matchAll(/^import .*;$/gm)].map(m => m[0]), [ // Rows330/331: pure verb/refusal vocabulary, never the host management implementation. + "import { ownRails } from '../../organization/src/rails.mjs';", + "import { refusalGuidance } from './refusals.mjs';", "import { PROXY_PAGE_VERBS, PROXY_PAGE_CODES } from './proxy-page-vocabulary.mjs';", // Row 210 (#1707): createHash gives host.run events the argv, stdin and output sha256 values. "import { randomUUID, randomBytes, createHash } from 'node:crypto';", diff --git a/packages/bus/tests/rails.test.mjs b/packages/bus/tests/rails.test.mjs new file mode 100644 index 000000000..b28333048 --- /dev/null +++ b/packages/bus/tests/rails.test.mjs @@ -0,0 +1,118 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { connect } from 'node:net'; +import { PROXY_PAGE_CODES } from '../src/proxy-page-vocabulary.mjs'; +import { Broker, BusError } from '../src/broker.mjs'; +import { REFUSALS, refusalGuidance } from '../src/refusals.mjs'; +import { Store } from '../src/store.mjs'; +import { busOrganization } from '../src/organization-context.mjs'; +import { serve } from '../src/server.mjs'; +import { Client } from '../src/client.mjs'; +import { resolveInstance, validateBusinessDocument } from '../../organization/src/index.mjs'; +import { businessDoc, REPO_ROLES, systemFor } from '../../organization/tests/helpers.mjs'; +import { railsText } from '../../organization/src/rails.mjs'; +const SECRET = 'sk-test-NOT-A-SECRET-1234'; +function fixture(t) { + const root = mkdtempSync(join(tmpdir(), 'rails-')); + const business = validateBusinessDocument(businessDoc(root), join(root, 'acme.json'), { rolesDir: REPO_ROLES }); + const resolve = b => Object.fromEntries(Object.keys(b.roles).map(instance => [instance, resolveInstance({ system: systemFor(root), business: b, instance })])); + writeFileSync(business.roles.coder.credentials.gitea.file, SECRET); + const resolved = resolve(business), store = new Store(root); + const broker = new Broker({ store, businesses: { acme: busOrganization(business, resolved) } }); + const cap = broker.bindLaunch({ business: 'acme', role: 'coder', run: 'coder-run', harness: 'pi' }); + broker.request(cap, { verb: 'role.claim' }); + t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); }); + return { root, business, resolved, resolve, broker, store, cap }; +} + +test('my_rails equals the resolved prompt content, reads live narrowing, and writes/raises nothing', t => { + const f = fixture(t), call = args => f.broker.request(f.cap, { verb: 'my_rails', args }); + const before = f.store.get('SELECT count(*) n FROM events').n; + assert.deepEqual(call({}), f.resolved.coder.rails); + const result = call({}); result.actions.length = 0; + assert.notEqual(call({}).actions.length, 0, 'returned rails do not mutate policy'); + const business = structuredClone(f.business); + business.roles.coder.vars['limits.authority'] = ['message.send']; + const narrowed = f.resolve(business); + f.broker.reloadBusiness('acme', busOrganization(business, narrowed)); + assert.deepEqual(call({}), narrowed.coder.rails); + assert.equal(f.store.get('SELECT count(*) n FROM events').n, before); + assert.equal(f.store.get('SELECT count(*) n FROM decisions').n, 0); +}); + +test('my_rails cannot select another identity or expose credential refs and file/call values', t => { + const f = fixture(t); + for (const args of [{ agent: 'cto' }, { role: 'cto' }, { instance: 'cto' }, { business: 'other' }, { token: SECRET }]) { + assert.throws(() => f.broker.request(f.cap, { verb: 'my_rails', args }), { code: 'invalid-request' }); + } + const rails = f.broker.request(f.cap, { verb: 'my_rails' }); + const text = JSON.stringify(rails) + railsText(rails); + for (const value of [SECRET, 'placeholder-not-a-token', f.resolved.coder.credentials.gitea.file, f.resolved.cto.credentials.gitea.file]) assert.ok(!text.includes(value), value); + const human = f.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true }); + assert.throws(() => f.broker.request(human, { verb: 'my_rails' }), { code: 'agent-required' }); +}); + +function raw(path, request) { + return new Promise((resolve, reject) => { + const socket = connect(path); let out = ''; + socket.on('error', reject); + socket.on('connect', () => socket.end(JSON.stringify(request) + '\n')); + socket.on('data', b => out += b); + socket.on('end', () => resolve(JSON.parse(out))); + }); +} +test('cross-role refusal envelope names its rail and next step without changing code or outcome', async t => { + const f = fixture(t), path = join(f.root, 'bus.sock'); + const server = await serve({ broker: f.broker, path }); t.after(() => server.close()); + const request = { cap: f.cap, verb: 'decision.resolve', args: { id: 'missing', choice: SECRET } }; + const refused = await raw(path, request); + assert.equal(refused.ok, false); assert.equal(refused.error, 'decision-not-found'); + assert.deepEqual({ rail: refused.rail, nextStep: refused.nextStep }, REFUSALS['decision-not-found']); + assert.ok(!JSON.stringify(refused).includes(SECRET)); + await assert.rejects(new Client({ path, cap: f.cap }).call('decision.resolve', request.args), e => { + assert.equal(e.code, 'decision-not-found'); assert.equal(e.nextStep, refused.nextStep); return true; + }); + const decision = f.broker.request(f.cap, { verb: 'decision.raise', args: { action: 'task.scope.change', domain: 'delivery', + question: 'Scope?', options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'no', blocking: false } }); + const cross = await raw(path, { cap: f.cap, verb: 'decision.resolve', args: { id: decision.id, choice: 'yes' } }); + assert.equal(cross.error, 'not-resolver'); assert.match(cross.rail, /resolve right/); assert.match(cross.nextStep, /routed resolver/); + assert.equal(f.store.get('SELECT count(*) n FROM decision_events WHERE decision=?', decision.id).n, 0); +}); + +test('every literal fail branch in broker has static guidance; guidance never contains payload values', () => { + const source = readFileSync(new URL('../src/broker.mjs', import.meta.url), 'utf8'); + // Balance each call (including ternaries), so a following statement cannot masquerade + // as a fail code. Every literal branch, with either quote style, must be in the table. + for (const match of source.matchAll(/\bfail\s*\(/g)) { + let depth = 1, i = match.index + match[0].length; + while (depth && i < source.length) { + const char = source[i++]; + if (char === "'" || char === '"') { + const start = i - 1; + let value = ''; + while (i < source.length && source[i] !== char) { + if (source[i] === "\\") { i += 2; continue; } + value += source[i++]; + } + i++; + if (/^[a-z][a-z0-9-]*$/.test(value) && (value.includes('-') || !source.slice(match.index + match[0].length, start).trim())) assert.ok(Object.hasOwn(REFUSALS, value), value); + } else if (char === '(') depth++; + else if (char === ')') depth--; + } + assert.equal(depth, 0, 'balanced fail call'); + } + // Finite delegated fail(code) sources must be covered as well as literal branches. + for (const code of [...PROXY_PAGE_CODES, 'remote-host-mismatch', 'remote-not-fork', + 'processing-control-unsafe', 'processing-uninitialized', 'processing-generation-changed']) { + assert.ok(Object.hasOwn(REFUSALS, code), code); + } + for (const [code, guidance] of Object.entries(REFUSALS)) { + assert.ok(guidance.rail.length > 0 && guidance.nextStep.length > 0, code); + assert.deepEqual({ rail: new BusError(code).rail, nextStep: new BusError(code).nextStep }, guidance); + assert.ok(!JSON.stringify(guidance).includes(SECRET)); + } + assert.deepEqual(refusalGuidance(SECRET), refusalGuidance('unknown-external-code')); +}); diff --git a/packages/bus/tests/tester.test.mjs b/packages/bus/tests/tester.test.mjs index f927794bf..4ae367e89 100644 --- a/packages/bus/tests/tester.test.mjs +++ b/packages/bus/tests/tester.test.mjs @@ -10,6 +10,7 @@ import { Store } from '../src/store.mjs'; import { Broker, TASK_VERBS } from '../src/broker.mjs'; import { serve } from '../src/server.mjs'; import { Client } from '../src/client.mjs'; +import { refusalGuidance } from '../src/refusals.mjs'; import { startBroker } from '../src/runtime.mjs'; import { readProcess, verifyHuman } from '../src/human.mjs'; import { verifyTester, testerNonce } from '../src/tester.mjs'; @@ -387,9 +388,9 @@ test('an envelope carries exactly one of cap, human and tester; no tester authen const cap = b.bindReader({ business: 'demo' }); const role = b.bindLaunch({ business: 'demo', role: 'pm', run: 'pm-run', harness: 'pi' }); for (const r of [{ tester: p, cap }, { tester: p, cap: role }, { tester: p, human: p }, { tester: p, human: p, cap }]) - assert.deepEqual(await raw(path, { ...r, verb: 'agents' }), { ok: false, error: 'invalid-envelope' }, Object.keys(r).join('+')); + assert.deepEqual(await raw(path, { ...r, verb: 'agents' }), { ok: false, error: 'invalid-envelope', ...refusalGuidance('invalid-envelope') }, Object.keys(r).join('+')); assert.equal(calls, 0); - assert.deepEqual(await raw(bare, { tester: p, verb: 'agents' }), { ok: false, error: 'tester-required' }); + assert.deepEqual(await raw(bare, { tester: p, verb: 'agents' }), { ok: false, error: 'tester-required', ...refusalGuidance('tester-required') }); assert.deepEqual(await raw(path, { tester: p, verb: 'agents' }), { ok: true, result: [] }); assert.equal(calls, 1); // The tester's capability lasts one request, as the human's does. diff --git a/packages/bus/tests/transport.test.mjs b/packages/bus/tests/transport.test.mjs index 8436de391..2b8168228 100644 --- a/packages/bus/tests/transport.test.mjs +++ b/packages/bus/tests/transport.test.mjs @@ -8,6 +8,7 @@ import { Store } from '../src/store.mjs'; import { Broker } from '../src/broker.mjs'; import { serve } from '../src/server.mjs'; import { Client } from '../src/client.mjs'; +import { refusalGuidance } from '../src/refusals.mjs'; import { views } from '../src/views.mjs'; const businesses = { demo: { @@ -72,7 +73,10 @@ test('malformed, oversized and identity-forging envelopes refuse without echoing const out = await raw(input); assert.equal(JSON.parse(out).ok, false); assert.ok(!out.includes('not-json')); - assert.ok(out.length < 150); + const refusal = JSON.parse(out); + assert.ok(['invalid-envelope', 'request-too-large'].includes(refusal.error)); + assert.deepEqual(refusal, { ok: false, error: refusal.error, ...refusalGuidance(refusal.error) }); + assert.ok(out.length < 1024); // A bounded static description, independent of input size. } }); test('client preserves UTF-8 when a response divides a multibyte character', async (t) => { diff --git a/packages/business/src/rails.mjs b/packages/business/src/rails.mjs new file mode 100644 index 000000000..0f655dcb1 --- /dev/null +++ b/packages/business/src/rails.mjs @@ -0,0 +1,2 @@ +// Compatibility path for the organization package's caller-rails projection. +export * from "../../organization/src/rails.mjs"; diff --git a/packages/business/tests/fixtures/rename-a/installed-preimage.json b/packages/business/tests/fixtures/rename-a/installed-preimage.json index efe5e9ec8..bc5f2dd61 100644 --- a/packages/business/tests/fixtures/rename-a/installed-preimage.json +++ b/packages/business/tests/fixtures/rename-a/installed-preimage.json @@ -1 +1 @@ -{"business":"acme","contract":"/roles/coder.md","credentials":{"gitea":{"file":"/secrets/coder-gitea.token","rotateBy":"2099-01-01","service":"gitea"},"vikunja":{"expires":"2099-01-01","file":"/secrets/coder-vikunja.token","service":"vikunja"}},"definition":"coder","holder":null,"instance":"coder","launch":null,"limits":{"authority":{"crossRole":["task.scope.change"],"withinRole":["task.update.assigned","git.push.working","review.request","message.send","record.write"]},"network":"none","tools":["read","write","edit","bash","grep","find","ls"]},"project":"stack","provenance":{"bus.tester":"default","dataRoot":"system","environment":"system","execution.adapter":"system","execution.backend":"system","execution.model":"system","execution.provider":"system","gitea.baseUrl":"business:acme","harness":"business:acme:roles.coder","limits.network":["business:acme:roles.coder"],"tracker.baseUrl":"business:acme","tracker.descendants":"default","tracker.kind":"default","tracker.pollSeconds":"business:acme","tracker.project":"project:stack","tracker.reconcileMinutes":"default"},"skills":["ms-unslop","ms-sdlc","ms-file-read","ms-file-write"],"tracker":{"bot":"bot-acme-coder","botId":5},"vars":{"bus.tester":false,"dataRoot":"/data","environment":"development","execution.adapter":"mock","execution.backend":"docker","execution.model":"glm-5.3-flash","execution.provider":"zai","gitea.baseUrl":"https://git.example","harness":"pi","limits.network":"none","tracker.baseUrl":"http://127.0.0.1:3456","tracker.descendants":false,"tracker.kind":"vikunja","tracker.pollSeconds":60,"tracker.project":3,"tracker.reconcileMinutes":60},"webSource":null} +{"business":"acme","contract":"/roles/coder.md","credentials":{"gitea":{"file":"/secrets/coder-gitea.token","rotateBy":"2099-01-01","service":"gitea"},"vikunja":{"expires":"2099-01-01","file":"/secrets/coder-vikunja.token","service":"vikunja"}},"definition":"coder","holder":null,"instance":"coder","launch":null,"limits":{"authority":{"crossRole":["task.scope.change"],"withinRole":["task.update.assigned","git.push.working","review.request","message.send","record.write"]},"network":"none","tools":["read","write","edit","bash","grep","find","ls"]},"project":"stack","provenance":{"bus.tester":"default","dataRoot":"system","environment":"system","execution.adapter":"system","execution.backend":"system","execution.model":"system","execution.provider":"system","gitea.baseUrl":"business:acme","harness":"business:acme:roles.coder","limits.network":["business:acme:roles.coder"],"tracker.baseUrl":"business:acme","tracker.descendants":"default","tracker.kind":"default","tracker.pollSeconds":"business:acme","tracker.project":"project:stack","tracker.reconcileMinutes":"default"},"rails":{"actions":[{"action":"task.update.assigned","routes":[{"to":"alone"}]},{"action":"git.push.working","routes":[{"to":"alone"}]},{"action":"review.request","routes":[{"to":"alone"}]},{"action":"message.send","routes":[{"to":"alone"}]},{"action":"record.write","routes":[{"to":"alone"}]},{"action":"task.scope.change","routes":[{"domain":"delivery","to":"pm"},{"domain":"technical","to":"cto"}]}],"credentials":[{"scopes":["write:issue","write:repository","read:user"],"service":"gitea"},{"scopes":{"projects":["views_buckets_tasks"],"tasks":["read_one","update"],"tasks_comments":["create"]},"service":"vikunja"}],"messaging":null,"network":"none","resolve":[]},"skills":["ms-unslop","ms-sdlc","ms-file-read","ms-file-write"],"tracker":{"bot":"bot-acme-coder","botId":5},"vars":{"bus.tester":false,"dataRoot":"/data","environment":"development","execution.adapter":"mock","execution.backend":"docker","execution.model":"glm-5.3-flash","execution.provider":"zai","gitea.baseUrl":"https://git.example","harness":"pi","limits.network":"none","tracker.baseUrl":"http://127.0.0.1:3456","tracker.descendants":false,"tracker.kind":"vikunja","tracker.pollSeconds":60,"tracker.project":3,"tracker.reconcileMinutes":60},"webSource":null} diff --git a/packages/business/tests/fixtures/rename-a/installed-resolve.json b/packages/business/tests/fixtures/rename-a/installed-resolve.json index da5f3f64b..82d62a30a 100644 --- a/packages/business/tests/fixtures/rename-a/installed-resolve.json +++ b/packages/business/tests/fixtures/rename-a/installed-resolve.json @@ -67,6 +67,91 @@ ] } }, + "rails": { + "actions": [ + { + "action": "task.update.assigned", + "routes": [ + { + "to": "alone" + } + ] + }, + { + "action": "git.push.working", + "routes": [ + { + "to": "alone" + } + ] + }, + { + "action": "review.request", + "routes": [ + { + "to": "alone" + } + ] + }, + { + "action": "message.send", + "routes": [ + { + "to": "alone" + } + ] + }, + { + "action": "record.write", + "routes": [ + { + "to": "alone" + } + ] + }, + { + "action": "task.scope.change", + "routes": [ + { + "domain": "delivery", + "to": "pm" + }, + { + "domain": "technical", + "to": "cto" + } + ] + } + ], + "resolve": [], + "network": "none", + "credentials": [ + { + "service": "gitea", + "scopes": [ + "write:issue", + "write:repository", + "read:user" + ] + }, + { + "service": "vikunja", + "scopes": { + "tasks": [ + "read_one", + "update" + ], + "tasks_comments": [ + "create" + ], + "projects": [ + "views_buckets_tasks" + ] + } + } + ], + "messaging": null + }, "credentials": { "gitea": { "service": "gitea", diff --git a/packages/business/tests/fixtures/rename-a/preimage.json b/packages/business/tests/fixtures/rename-a/preimage.json index 398a5de72..3a6c27315 100644 --- a/packages/business/tests/fixtures/rename-a/preimage.json +++ b/packages/business/tests/fixtures/rename-a/preimage.json @@ -1 +1 @@ -{"business":"acme","contract":"/synthetic/roles/coder.md","credentials":{},"definition":"coder","holder":"example","instance":"coder","launch":null,"limits":{"authority":{"crossRole":[],"withinRole":["message.send"]},"network":"none","tools":["read","write"]},"project":"stack","provenance":{"bus.tester":"default","dataRoot":"system","environment":"system","execution.adapter":"system","execution.backend":"system","execution.model":"system","execution.provider":"system","limits.network":["business:acme:roles.coder"],"model":"business:acme:roles.coder","tracker.baseUrl":"business:acme","tracker.descendants":"default","tracker.kind":"default","tracker.pollSeconds":"project:stack:roles.coder","tracker.project":"project:stack","tracker.reconcileMinutes":"default"},"skills":[],"tracker":null,"vars":{"bus.tester":false,"dataRoot":"/synthetic/data","environment":"development","execution.adapter":"mock","execution.backend":"docker","execution.model":"system-model","execution.provider":"zai","limits.network":"none","model":"agent-model","tracker.baseUrl":"http://127.0.0.1:3456","tracker.descendants":false,"tracker.kind":"vikunja","tracker.pollSeconds":15,"tracker.project":3,"tracker.reconcileMinutes":60},"webSource":null} +{"business":"acme","contract":"/synthetic/roles/coder.md","credentials":{},"definition":"coder","holder":"example","instance":"coder","launch":null,"limits":{"authority":{"crossRole":[],"withinRole":["message.send"]},"network":"none","tools":["read","write"]},"project":"stack","provenance":{"bus.tester":"default","dataRoot":"system","environment":"system","execution.adapter":"system","execution.backend":"system","execution.model":"system","execution.provider":"system","limits.network":["business:acme:roles.coder"],"model":"business:acme:roles.coder","tracker.baseUrl":"business:acme","tracker.descendants":"default","tracker.kind":"default","tracker.pollSeconds":"project:stack:roles.coder","tracker.project":"project:stack","tracker.reconcileMinutes":"default"},"rails":{"actions":[{"action":"message.send","routes":[{"to":"alone"}]}],"credentials":[],"messaging":null,"network":"none","resolve":[]},"skills":[],"tracker":null,"vars":{"bus.tester":false,"dataRoot":"/synthetic/data","environment":"development","execution.adapter":"mock","execution.backend":"docker","execution.model":"system-model","execution.provider":"zai","limits.network":"none","model":"agent-model","tracker.baseUrl":"http://127.0.0.1:3456","tracker.descendants":false,"tracker.kind":"vikunja","tracker.pollSeconds":15,"tracker.project":3,"tracker.reconcileMinutes":60},"webSource":null} diff --git a/packages/business/tests/fixtures/rename-a/resolved.json b/packages/business/tests/fixtures/rename-a/resolved.json index fb41f0d94..3a1cc27d8 100644 --- a/packages/business/tests/fixtures/rename-a/resolved.json +++ b/packages/business/tests/fixtures/rename-a/resolved.json @@ -54,10 +54,26 @@ "crossRole": [] } }, + "rails": { + "actions": [ + { + "action": "message.send", + "routes": [ + { + "to": "alone" + } + ] + } + ], + "resolve": [], + "network": "none", + "credentials": [], + "messaging": null + }, "credentials": {}, "tracker": null, "launch": null, "webSource": null, "skills": [], - "digest": "975144f5fcd6989c3393175b1713908c632c04f378b5f28a95cb2f4e552fbefa" + "digest": "5a8df768f2359fd8c0131eb5e8ce8d427f68d6d465ba20d42b413a280a922ef0" } diff --git a/packages/business/tests/rename-v1-contract.test.mjs b/packages/business/tests/rename-v1-contract.test.mjs index fa9953af5..9c69878af 100644 --- a/packages/business/tests/rename-v1-contract.test.mjs +++ b/packages/business/tests/rename-v1-contract.test.mjs @@ -117,7 +117,7 @@ test('installed wrapper pins v1 validate and resolve output, preimage and digest 'installed-resolve.json': normalized(resolved), 'installed-preimage.json': normalized(canonicalJson(preimage)) + '\n', }; - assert.equal(sha256(snapshots['installed-preimage.json'].slice(0, -1)), '5fc9d6258f4725b0e260d99e5cf81058a05d21b8ff298bb53653efbbc8013da9'); + assert.equal(sha256(snapshots['installed-preimage.json'].slice(0, -1)), '0a12b35f55d033b91bd89745859028d252be52b05ca4083964b778a27ec20fd6'); for (const [name, actual] of Object.entries(snapshots)) { assert.equal(actual, fixture(name), name); } diff --git a/packages/cli/tests/case2-install.test.mjs b/packages/cli/tests/case2-install.test.mjs index 5dbcd2264..63db8cb84 100644 --- a/packages/cli/tests/case2-install.test.mjs +++ b/packages/cli/tests/case2-install.test.mjs @@ -71,7 +71,7 @@ function world(t, { profile = "library" } = {}) { script(join(bin, "stack-copy"), `echo "stack-copy $*" >>'${log}' [ "$1" = copy ] && [ "$3" = --copies ] || exit 4 mkdir -p "$4/$2/scripts" && : >"$4/$2/scripts/mosaic" && echo "copied: $4/$2" -for file in scripts/case2-persona-import.mjs packages/harness/src/bundle.mjs packages/harness/src/tools.mjs packages/business/src/vocabulary.mjs packages/business/src/selector.mjs packages/organization/src/vocabulary.mjs packages/organization/src/selector.mjs packages/plugins/model-provider/cliproxy/index.mjs; do +for file in scripts/case2-persona-import.mjs packages/harness/src/bundle.mjs packages/harness/src/tools.mjs packages/organization/src/rails.mjs packages/bus/src/refusals.mjs packages/business/src/vocabulary.mjs packages/business/src/selector.mjs packages/organization/src/vocabulary.mjs packages/organization/src/selector.mjs packages/plugins/model-provider/cliproxy/index.mjs; do mkdir -p "$4/$2/$(dirname "$file")" && cp '${REPO}'"$file" "$4/$2/$file" || exit 1 done mkdir -p "$4/$2/node_modules" && cp -R '${REPO}node_modules/yaml' "$4/$2/node_modules/yaml"`); diff --git a/packages/cli/tests/launcher.test.mjs b/packages/cli/tests/launcher.test.mjs index 1cef07688..5ec0b06e9 100644 --- a/packages/cli/tests/launcher.test.mjs +++ b/packages/cli/tests/launcher.test.mjs @@ -369,8 +369,8 @@ test("a launched pm's request_staff reaches the chief of staff; no other session const bad = await call("pm", `TOOL request_staff ${JSON.stringify({ task_ref: "vikunja:1/2", roles: [{ role: "coder", count: 0 }] })}`); assert.match(bad.body, /invalid-request/); // The chief of staff has no request_staff, and a pm has no launch. - assert.equal((await call("chief", 'TOOL request_staff {"task_ref":"vikunja:1/2","roles":[{"role":"coder","count":1}]}')).body, "refused: unknown-tool"); - assert.equal((await call("pm", 'TOOL launch {"instance":"coder"}')).body, "refused: unknown-tool"); + assert.equal((await call("chief", 'TOOL request_staff {"task_ref":"vikunja:1/2","roles":[{"role":"coder","count":1}]}')).body.split("\n")[0], "refused: unknown-tool"); + assert.equal((await call("pm", 'TOOL launch {"instance":"coder"}')).body.split("\n")[0], "refused: unknown-tool"); // Nothing was launched by asking: the chief of staff's limits are the same, and sonnet has one place left. assert.deepEqual(readSessions(f.dataRoot).map((s) => s.instance).sort(), ["chief", "pm"]); const launched = await call("chief", 'TOOL launch {"instance":"coder"}'); diff --git a/packages/cli/tests/lifecycle.test.mjs b/packages/cli/tests/lifecycle.test.mjs index d317e4f9b..7e8cdfed5 100644 --- a/packages/cli/tests/lifecycle.test.mjs +++ b/packages/cli/tests/lifecycle.test.mjs @@ -867,7 +867,7 @@ test("row 194 D3 item 5: the chief's session hires with its tool and launches it assert.equal(listed.launched, true); // Hire vars set only model and limits.* (N3). harness and thinking are agent-layer route vars the // human sets, so until then the launch refuses no-route. - assert.equal(await ask("launch", { instance: "sec-2" }), "refused: no-route"); + assert.equal((await ask("launch", { instance: "sec-2" })).split("\n")[0], "refused: no-route"); const route = { harness: "pi", model: "claude-sonnet-5-5", thinking: "high" }; s.call("instances.set", { revision: s.call("instances").revision, instance: "sec-2", op: "change", vars: route }); const launched = JSON.parse(await ask("launch", { instance: "sec-2" })); @@ -875,13 +875,13 @@ test("row 194 D3 item 5: the chief's session hires with its tool and launches it assert.deepEqual(s.running("sec-2").map((x) => x.run), [launched.run]); assert.equal(readSessions(p.f.dataRoot).find((x) => x.run === launched.run).launchedBy, "chief"); // The schema's pattern guides the model. The host refuses any name its file doesn't list. - assert.equal(await ask("launch", { instance: "NOT A NAME" }), "refused: instance-not-listed"); - assert.equal(await ask("launch", { instance: "reviewer" }), "refused: instance-not-listed"); + assert.equal((await ask("launch", { instance: "NOT A NAME" })).split("\n")[0], "refused: instance-not-listed"); + assert.equal((await ask("launch", { instance: "reviewer" })).split("\n")[0], "refused: instance-not-listed"); await s.claimed(launched.run); const fired = JSON.parse(await ask("fire", { name: "sec-2", reason: "the review is done" })); assert.ok(fired.transaction, JSON.stringify(fired)); await until(() => s.running("sec-2").length === 0, 30000); // A fired instance stays in roles, off the listing, so the chief can't launch it again. assert.equal(s.call("instances").instances["sec-2"].launched, false); - assert.equal(await ask("launch", { instance: "sec-2" }), "refused: instance-not-listed"); + assert.equal((await ask("launch", { instance: "sec-2" })).split("\n")[0], "refused: instance-not-listed"); }); diff --git a/packages/cli/tests/rename-b-commands.test.mjs b/packages/cli/tests/rename-b-commands.test.mjs index 7c5f87ef0..7ac643447 100644 --- a/packages/cli/tests/rename-b-commands.test.mjs +++ b/packages/cli/tests/rename-b-commands.test.mjs @@ -147,7 +147,7 @@ test("rename r2 B2: org config prints the stage A golden v1 bytes, preimage and } const normalized = (text) => text.split(root).join("").replace(/[a-f0-9]{64}/g, ""); const preimageText = `${normalized(canonicalJson(preimage))}\n`; - assert.equal(sha256(preimageText.slice(0, -1)), "5fc9d6258f4725b0e260d99e5cf81058a05d21b8ff298bb53653efbbc8013da9"); + assert.equal(sha256(preimageText.slice(0, -1)), "0a12b35f55d033b91bd89745859028d252be52b05ca4083964b778a27ec20fd6"); assert.equal(normalized(validate), golden("installed-validate.json")); assert.equal(normalized(resolved), golden("installed-resolve.json")); assert.equal(preimageText, golden("installed-preimage.json")); diff --git a/packages/harness/src/bundle.mjs b/packages/harness/src/bundle.mjs index f3ad798b3..319dd5ac4 100644 --- a/packages/harness/src/bundle.mjs +++ b/packages/harness/src/bundle.mjs @@ -14,6 +14,7 @@ import { createHash } from "node:crypto"; import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; +import { railsText, ownRails } from "../../organization/src/rails.mjs"; import { typedTools, untypedActions } from "./tools.mjs"; import { route as defaultProxyRoute } from "../../plugins/model-provider/cliproxy/index.mjs"; import { CREATIVE_DEFINITIONS } from "../../organization/src/vocabulary.mjs"; @@ -281,7 +282,7 @@ function prompt({ contract, resolved, session, tools, untyped, builtins, persona ...checkoutLines(session.checkouts ?? []), // Row 89 (#1583): the names the harness gives its built-ins, as the manifest lists them. `- Built-in tools: ${builtins.join(", ") || "none"}.`, - `- Bus tools: ${tools.map((t) => t.name).join(", ")}. The broker decides every call; a refusal names its reason.`, + `- Bus tools: ${tools.map((t) => t.name).join(", ")}. The broker decides every call; a refusal carries its code, the rail it enforces and the next step (plus a reason when available).`, "- Each message to you arrives as one turn. Your final answer goes back to the sender as the reply, so end each turn with the answer itself.", "- You never hold a token. You act on the tracker, the forge and other roles only through the bus tools.", ]; @@ -306,6 +307,7 @@ function prompt({ contract, resolved, session, tools, untyped, builtins, persona if (named.has("request_staff")) lines.push("- Staff: you don't launch sessions. For intake or a task, request_staff takes exactly one of request or task_ref and names each role with how many. The chief of staff answers by message with the instances it launched, or with the refusal and its reason."); if (untyped.length) lines.push(`- Actions you hold that have no bus tool yet: ${untyped.join(", ")}. Say so instead of working around it.`); + lines.push("", railsText(resolved.rails ?? ownRails({ instance: resolved.instance, authority: resolved.limits.authority, network: resolved.limits.network })), ""); return lines.join("\n") + "\n"; } diff --git a/packages/harness/src/runner.mjs b/packages/harness/src/runner.mjs index acbd7db2c..8a0aaba67 100644 --- a/packages/harness/src/runner.mjs +++ b/packages/harness/src/runner.mjs @@ -33,6 +33,7 @@ // capability or the claim stopped working (ended, revoked), 23 the broker // stayed unreachable, 2 a bad run directory. +import { refusalGuidance } from "../../bus/src/refusals.mjs"; import { spawn } from "node:child_process"; import { setTimeout as delay } from "node:timers/promises"; import { chmodSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, closeSync } from "node:fs"; @@ -366,6 +367,7 @@ export function toolServer({ path, tools, client, local }) { answer({ ok: true, result }); } catch (e) { answer({ ok: false, error: /^[a-z-]{1,64}$/.test(e.code ?? "") ? e.code : "tool-failed", + ...refusalGuidance(e.code), ...(typeof e.reason === "string" && request.tool === "advance_request" && tool.verb === "request.advance" && e.code === "request-evidence-required" ? { reason: e.reason.slice(0, 300) } : tool.verb === "record.write" && e.code === "project-record-invalid" && typeof e.reason === "string" ? { reason: e.reason.slice(0, 300) } : {}) }); diff --git a/packages/harness/src/tools.mjs b/packages/harness/src/tools.mjs index 84ee01a77..53454ee35 100644 --- a/packages/harness/src/tools.mjs +++ b/packages/harness/src/tools.mjs @@ -10,6 +10,7 @@ // socket (MOSAIC_TOOL_SOCKET) with `{tool, args}`; the runner checks the // name against this list and forwards the call with its own capability. +import { refusalGuidance } from "../../bus/src/refusals.mjs"; import { connect } from "node:net"; import { ACTIONS, ID_PATTERN, THREAD_DEFINITION, CREATIVE_DEFINITIONS } from "../../organization/src/vocabulary.mjs"; @@ -372,10 +373,11 @@ export function typedTools({ resolved, arbiter = false, tracker = false, project requirement_snapshot: str("Exact approved snapshot ID; omit to read the active snapshot"), })); } + add("my_rails", "my_rails", null, "Read only your current actions, routes, resolve levels, network, credential service/scope names and messaging limits. No values or other agents' rails.", obj({})); // mos-1308 / M3: no generic spec, evidence, tracker, decision or write tools. The // C2-08 web grant still supplies broker web reads to companion and research only. if (CREATIVE_DEFINITIONS.includes(resolved.definition)) - return tools.filter(tool => tool.name === "send_message" || tool.name.startsWith("library_") || + return tools.filter(tool => tool.name === "my_rails" || tool.name === "send_message" || tool.name.startsWith("library_") || tool.name === "web_search" || tool.name === "web_fetch"); return tools; } @@ -429,7 +431,13 @@ export function callTool(path, tool, args = {}, { timeout = 60_000 } = {}) { if (!input.includes("\n")) return; try { const r = JSON.parse(input); - r.ok ? end(null, r.result) : end(new Error(tool === "intake_question" && r.error === "outcome-unknown" ? "outcome-unknown" : tool === "launch" && r.error === "instance-running" ? "refused: instance-running: target instance is already running and will receive its waiting messages when it claims; you must not launch it again or relay the notice" : `refused: ${r.error}${typeof r.reason === "string" && tool === "advance_request" && r.error === "request-evidence-required" ? `: ${r.reason.slice(0, 300)}` : tool === "write_record" && r.error === "project-record-invalid" && typeof r.reason === "string" ? `: ${r.reason.slice(0, 300)}` : ""}`)); + if (r.ok) return end(null, r.result); + const guidance = refusalGuidance(r.error); + const error = new Error(tool === "intake_question" && r.error === "outcome-unknown" ? "outcome-unknown" : tool === "launch" && r.error === "instance-running" ? "refused: instance-running: target instance is already running and will receive its waiting messages when it claims; you must not launch it again or relay the notice" : `refused: ${r.error}${typeof r.reason === "string" && tool === "advance_request" && r.error === "request-evidence-required" ? `: ${r.reason.slice(0, 300)}` : tool === "write_record" && r.error === "project-record-invalid" && typeof r.reason === "string" ? `: ${r.reason.slice(0, 300)}` : ""}`); + error.code = r.error; + Object.assign(error, guidance); + error.message += `\nRail: ${guidance.rail}\nNext step: ${guidance.nextStep}`; + end(error); } catch { end(unknown("tool reply isn't JSON")); } diff --git a/packages/harness/tests/bundle.test.mjs b/packages/harness/tests/bundle.test.mjs index 43a9831c5..8844e4600 100644 --- a/packages/harness/tests/bundle.test.mjs +++ b/packages/harness/tests/bundle.test.mjs @@ -200,7 +200,7 @@ test("a chief-of-staff bundle lists end_run, change_organization and set_name as const r = resolvedFor(t, "chief", (d) => (d.roles.chief.vars = { harness, model: "claude-opus-5-5" })); const b = build(t, r); const prefix = harness === "pi" ? "" : "mcp__mosaic__"; - assert.deepEqual(b.manifest.typed, ["send_message", "launch", "end_run", "hire", "fire", "organization_revision", "change_organization", "set_name", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"].map((n) => prefix + n), harness); + assert.deepEqual(b.manifest.typed, ["send_message", "launch", "end_run", "hire", "fire", "organization_revision", "change_organization", "set_name", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", "my_rails"].map((n) => prefix + n), harness); assert.deepEqual(b.manifest.untyped, ["decision.resolve.technical", "decision.resolve.delivery"], harness); assert.match(readFileSync(b.files.prompt, "utf8"), /Bus tools: send_message, launch, end_run, hire, fire, organization_revision, change_organization, set_name, read_spec, write_record, raise_decision, /); assert.match(readFileSync(b.files.prompt, "utf8"), /Actions you hold that have no bus tool yet/); diff --git a/packages/harness/tests/library-tools.test.mjs b/packages/harness/tests/library-tools.test.mjs index 5d80cd26d..683c98d6d 100644 --- a/packages/harness/tests/library-tools.test.mjs +++ b/packages/harness/tests/library-tools.test.mjs @@ -14,9 +14,9 @@ const role = (t, definition, harness = 'pi') => resolvedFor(t, 'bean', d => { d.roles.bean = { definition, credentials: { gitea: { ...d.roles.coder.credentials.gitea } }, vars: { harness, model: 'claude-sonnet-5-5', thinking: 'high' } }; }); const expected = definition => ['send_message', ...(['companion', 'research'].includes(definition) - ? ['web_search', 'web_fetch'] : []), ...LOOKUPS, 'library_doc_write', 'library_doc_delete']; + ? ['web_search', 'web_fetch'] : []), ...LOOKUPS, 'library_doc_write', 'library_doc_delete', 'my_rails']; -test('M3: per-role snapshots contain eight names, or ten with the C2-08 web grant', t => { +test('M3/TI: per-role snapshots contain nine names, or eleven with the C2-08 web grant', t => { for (const definition of CREATIVE) { const tools = typedTools({ resolved: role(t, definition), library: true, tracker: true, arbiter: true }); assert.deepEqual(tools.map(t => t.name), expected(definition), definition); @@ -72,7 +72,7 @@ test('bundle flag reaches both harness tool manifests and creative prompt withou test('C2-05c scope schemas expand only find/search/get, keeping manuscript and tool names unchanged', t => { const tools = typedTools({ resolved: role(t, 'ideas'), library: true }); - assert.deepEqual(tools.map(t => t.name), ['send_message', ...LOOKUPS, 'library_doc_write', 'library_doc_delete']); + assert.deepEqual(tools.map(t => t.name), ['send_message', ...LOOKUPS, 'library_doc_write', 'library_doc_delete', 'my_rails']); for (const name of ['library_find', 'library_search', 'library_get']) { const tool = tools.find(t => t.name === name); assert.deepEqual(tool.parameters.properties.scope.enum, ['canon', 'playground', 'supporting']); diff --git a/packages/harness/tests/library-write-tools.test.mjs b/packages/harness/tests/library-write-tools.test.mjs index 8abd6a7e4..b1207cb70 100644 --- a/packages/harness/tests/library-write-tools.test.mjs +++ b/packages/harness/tests/library-write-tools.test.mjs @@ -11,12 +11,12 @@ const role = (t, definition) => resolvedFor(t, 'bean', doc => { doc.roles.bean = { definition, credentials: { gitea: { ...doc.roles.coder.credentials.gitea } }, vars: { harness: 'pi', model: 'claude-sonnet-5-5', thinking: 'high' } }; }); -test('C2-02 M3: each creative role has eight tools, or ten with its web grant', t => { +test('C2-02 M3/TI: each creative role has nine tools, or eleven with its web grant', t => { for (const definition of CREATIVE) { const resolved = role(t, definition), tools = typedTools({ resolved, library: true, tracker: true, arbiter: true }); - const expected = ['send_message', ...(['companion', 'research'].includes(definition) ? ['web_search', 'web_fetch'] : []), ...LOOKUPS, ...WRITES]; + const expected = ['send_message', ...(['companion', 'research'].includes(definition) ? ['web_search', 'web_fetch'] : []), ...LOOKUPS, ...WRITES, 'my_rails']; assert.deepEqual(tools.map(tool => tool.name), expected, definition); - assert.equal(tools.length, ['companion', 'research'].includes(definition) ? 10 : 8); + assert.equal(tools.length, ['companion', 'research'].includes(definition) ? 11 : 9); assert.equal(untypedActions(resolved, tools).includes('library.write'), false); } }); @@ -56,5 +56,5 @@ test('C2-02 tools: no library, noncreative or no within-role write grant exposes const narrowed = structuredClone(creative); narrowed.limits.authority.withinRole = narrowed.limits.authority.withinRole.filter(action => action !== 'library.write'); narrowed.limits.authority.crossRole.push('library.write'); - assert.deepEqual(typedTools({ resolved: narrowed, library: true }).map(tool => tool.name), ['send_message', ...LOOKUPS]); + assert.deepEqual(typedTools({ resolved: narrowed, library: true }).map(tool => tool.name), ['send_message', ...LOOKUPS, 'my_rails']); }); diff --git a/packages/harness/tests/mcp-server.test.mjs b/packages/harness/tests/mcp-server.test.mjs index c86f6ec88..79e6ad05d 100644 --- a/packages/harness/tests/mcp-server.test.mjs +++ b/packages/harness/tests/mcp-server.test.mjs @@ -68,7 +68,7 @@ test("tools/call goes through the tool socket; a refusal is an isError result", assert.deepEqual(JSON.parse(ok.content[0].text), { tool: "list_agents", args: { scope: "all" } }); const refused = (await server.send("tools/call", { name: "launch", arguments: { instance: "coder" } })).result; assert.equal(refused.isError, true); - assert.equal(refused.content[0].text, "refused: capacity-full"); + assert.match(refused.content[0].text, /^refused: capacity-full\nRail: .+\nNext step: .+/); assert.deepEqual(sock.calls.map((c) => c.tool), ["list_agents", "launch"]); }); diff --git a/packages/harness/tests/rails.test.mjs b/packages/harness/tests/rails.test.mjs new file mode 100644 index 000000000..bac6f586c --- /dev/null +++ b/packages/harness/tests/rails.test.mjs @@ -0,0 +1,54 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { buildBundle } from '../src/bundle.mjs'; +import { typedTools } from '../src/tools.mjs'; +import { resolvedFor, scratch, HARNESS, REPO_SKILLS } from './helpers.mjs'; +import { ownRails, railsText, decisionAutonomy } from '../../organization/src/rails.mjs'; + +const SECRET = 'sk-test-NOT-A-SECRET-1234'; +test('coder prompt lists every held action with its route and only service/scope names', t => { + const resolved = resolvedFor(t, 'coder'); + const root = scratch(t), workspace = join(root, 'ws'); mkdirSync(workspace); + const bundle = buildBundle({ dir: join(root, 'bundle'), resolved, + contract: '# Contract', harness: HARNESS.pi, skillsDir: REPO_SKILLS, + session: { run: 'r1', workspace, toolSocket: join(root, 'tool.sock'), turnMarker: join(root, 'turn.done') } }); + const prompt = readFileSync(bundle.files.prompt, 'utf8'); + assert.ok(prompt.includes(railsText(resolved.rails))); + for (const action of resolved.limits.authority.withinRole) assert.ok(prompt.includes(`- ${action}: alone.`)); + assert.match(prompt, /task.scope.change: delivery \(default\): arbiter instance "pm"; technical: arbiter instance "cto"/); + assert.match(prompt, /Network: none/); + assert.match(prompt, /Credential service gitea; scopes:.*write:repository/); + assert.ok(!railsText(resolved.rails).includes(resolved.credentials.gitea.file)); + assert.ok(!railsText(resolved.rails).includes(SECRET)); + assert.match(prompt, /a refusal carries its code, the rail it enforces and the next step/); +}); + +test('all roles including creative roles receive a parameterless caller-only my_rails tool', t => { + for (const instance of ['coder', 'chief', 'reviewer']) { + const tool = typedTools({ resolved: resolvedFor(t, instance) }).find(t => t.name === 'my_rails'); + assert.equal(tool.verb, 'my_rails'); + assert.deepEqual(tool.parameters, { type: 'object', properties: {}, required: [], additionalProperties: false }); + } + const creative = typedTools({ resolved: { ...resolvedFor(t, 'coder'), definition: 'companion' } }); + assert.ok(creative.some(t => t.name === 'my_rails')); +}); + +test('resolve rights name their classes and fixed levels through the AU extension point', t => { + const { rails } = resolvedFor(t, 'cto'); + assert.deepEqual(rails.resolve, [{ right: 'decision.resolve.technical', classes: [ + { name: 'cross-role', level: decisionAutonomy('cross-role') }, { name: 'merge', level: decisionAutonomy('merge') }, + ] }]); + assert.match(railsText(rails), /merge: out of the loop/); +}); + +test('fixed domains, arbiter self-raises and hard waits describe existing routes only', () => { + const rails = ownRails({ instance: 'cto', authority: { withinRole: ['deploy'], + crossRole: ['git.merge.protected', 'policy.change'] }, arbiters: { technical: 'cto', delivery: 'pm' } }); + assert.deepEqual(rails.actions, [ + { action: 'deploy', routes: [{ to: 'human' }] }, + { action: 'git.merge.protected', routes: [{ domain: 'technical', to: 'human' }] }, + { action: 'policy.change', routes: [{ domain: 'delivery', to: 'pm' }] }, + ]); +}); diff --git a/packages/harness/tests/request-tools.test.mjs b/packages/harness/tests/request-tools.test.mjs index cf69b9263..86cf97c60 100644 --- a/packages/harness/tests/request-tools.test.mjs +++ b/packages/harness/tests/request-tools.test.mjs @@ -392,7 +392,12 @@ test('advance_request exposes the named task evidence refusal to the PM tool', a const { Client } = await import('../../bus/src/client.mjs'); const server = await runner.toolServer({ path, tools: [{ name: 'advance_request', verb: 'request.advance' }, { name: 'other_tool', verb: 'request.advance' }], local: {}, client: new Client({ path: busPath, cap: 'fixture' }) }); t.after(() => new Promise(resolve => server.close(resolve))); - await assert.rejects(callTool(path, 'advance_request', {}), { message: `refused: request-evidence-required: ${reason.slice(0, 300)}` }); - await assert.rejects(callTool(path, 'advance_request', { other: true }), { message: 'refused: project-record-invalid' }); - await assert.rejects(callTool(path, 'other_tool', {}), { message: 'refused: request-evidence-required' }); + const refusal = message => error => { + assert.equal(error.message.split('\n')[0], message); + assert.match(error.message, /\nRail: .+\nNext step: .+/); + return true; + }; + await assert.rejects(callTool(path, 'advance_request', {}), refusal(`refused: request-evidence-required: ${reason.slice(0, 300)}`)); + await assert.rejects(callTool(path, 'advance_request', { other: true }), refusal('refused: project-record-invalid')); + await assert.rejects(callTool(path, 'other_tool', {}), refusal('refused: request-evidence-required')); }); diff --git a/packages/harness/tests/runner.test.mjs b/packages/harness/tests/runner.test.mjs index 690194fa1..466f97676 100644 --- a/packages/harness/tests/runner.test.mjs +++ b/packages/harness/tests/runner.test.mjs @@ -477,10 +477,23 @@ test("typed tools carry the runner's capability; launch goes to the host's launc ctx.call(ctx.pm, "message.send", { to: "coder", body: 'TOOL resolve_decision {"id":"x"}' }); ctx.call(ctx.pm, "message.send", { to: "coder", body: 'TOOL send_message {"to":"nobody","body":"x"}' }); await until(() => replies(ctx)() && ctx.inbox.length >= 4); - assert.deepEqual(ctx.inbox.map((m) => m.body), ["refused: capacity-full", '{"run":"new-run"}', "refused: unknown-tool", "refused: unknown-role"]); + assert.deepEqual(ctx.inbox.map((m) => m.body.split("\n")[0]), ["refused: capacity-full", '{"run":"new-run"}', "refused: unknown-tool", "refused: unknown-role"]); assert.deepEqual(ctx.launches, [{ cap: ctx.cap, instance: "coder" }, { cap: ctx.cap, instance: "reviewer" }]); }); +test("my_rails reaches the live broker with the runner's own capability and refuses an identity selector", async t => { + const ctx = await setup(t, { tools: typedTools({ resolved: resolvedFor(t, "coder") }) }); + ctx.inbox = []; + const r = startRunner(ctx.runDir, ctx.cap); + t.after(() => r.child.kill("SIGKILL")); + ctx.call(ctx.pm, "message.send", { to: "coder", body: 'TOOL my_rails {}' }); + await until(() => replies(ctx)() && ctx.inbox.length >= 1); + assert.deepEqual(JSON.parse(ctx.inbox[0].body), ctx.call(ctx.cap, "my_rails", {})); + ctx.call(ctx.pm, "message.send", { to: "coder", body: 'TOOL my_rails {"role":"cto"}' }); + await until(() => replies(ctx)() && ctx.inbox.length >= 2); + assert.match(ctx.inbox[1].body, /^refused: invalid-request\nRail: .+\nNext step: .+/); +}); + // Row 132 (#1628, REQ-ROLE-2, REQ-COS-1, REQ-LIMIT-1): the chief's two tools reach the broker by their verbs // with the arguments the session gave, and a refusal comes back with the broker's code. // Row 134 (#1669): organization_revision reaches the broker's organization.revision and answers the revision alone. @@ -509,7 +522,7 @@ test("end_run, organization_revision and change_organization reach the broker wi send("change_organization", { ...change, surprise: 1 }); send("change_organization", { ...change, revision: "e".repeat(64) }); await until(() => replies(ctx)() && ctx.inbox.length >= 8); - assert.deepEqual(ctx.inbox.map((m) => m.body), [ + assert.deepEqual(ctx.inbox.map((m) => m.body.split("\n")[0]), [ '{"revoked":true}', "refused: role-end-refused", "refused: not-held", @@ -541,7 +554,7 @@ test("set_name reaches the broker's name.set with the tool's arguments, and refu send({ instance: "coder", name: "rex" }); send({ instance: "coder", name: "pm" }); await until(() => replies(ctx)() && ctx.inbox.length >= 2); - assert.deepEqual(ctx.inbox.map((m) => m.body), [ + assert.deepEqual(ctx.inbox.map((m) => m.body.split("\n")[0]), [ '{"instance":"coder","name":"rex"}', "refused: name-is-instance", ]); diff --git a/packages/harness/tests/tools.test.mjs b/packages/harness/tests/tools.test.mjs index 8b1083272..b6a190192 100644 --- a/packages/harness/tests/tools.test.mjs +++ b/packages/harness/tests/tools.test.mjs @@ -18,7 +18,7 @@ test("the PM gets its task verbs and the reads, and no launch", (t) => { "intake_status", "intake_projects", "intake_open", "intake_question", "intake_propose", "deliver_request", "advance_request", "submit_prd", "task_create", "task_assign", "task_reassign", "task_schedule", "task_priority_change", "task_scope_change", "task_update_assigned", "task_close", - "read_spec", "write_record", "raise_decision", "resolve_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", "list_requirements", + "read_spec", "write_record", "raise_decision", "resolve_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", "list_requirements", "my_rails", ]); // Row 119 (#1613): only the chief of staff launches (REQ-COS-1). assert.equal(pm.launch, null); @@ -47,7 +47,7 @@ test("send_message offers the BLOCK class, beside the classes it already had", ( test("the chief of staff gets launch, send_message, end_run, hire, fire, change_organization and set_name, and no task verbs", (t) => { const chief = resolvedFor(t, "chief"); const tools = typedTools({ resolved: chief, tracker: true }); - assert.deepEqual(names(tools), ["send_message", "launch", "end_run", "hire", "fire", "organization_revision", "change_organization", "set_name", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"]); + assert.deepEqual(names(tools), ["send_message", "launch", "end_run", "hire", "fire", "organization_revision", "change_organization", "set_name", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", "my_rails"]); const launch = tools.find((x) => x.name === "launch"); assert.equal(launch.verb, "launch"); assert.equal(launch.action, "role.launch"); @@ -225,7 +225,7 @@ test("no role's tools send instances or instances.set, and change_organization t test("the assistant gets send_message, the reads and the web tools (it holds the web grant), no launch", (t) => { const assistant = resolvedFor(t, "assistant", (d) => (d.roles.assistant = { definition: "assistant", holder: "mos" })); const tools = typedTools({ resolved: assistant, tracker: true }); - assert.deepEqual(names(tools), ["send_message", "web_search", "web_fetch", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"]); + assert.deepEqual(names(tools), ["send_message", "web_search", "web_fetch", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", "my_rails"]); assert.equal(assistant.launch, null); assert.deepEqual(untypedActions(assistant, tools), []); }); @@ -260,7 +260,7 @@ test("list_requirements reads the requirement ids, for a task.create holder with test("a coder gets no launch, no resolve_decision, and no task tools without a tracker", (t) => { const coder = resolvedFor(t, "coder"); const tools = typedTools({ resolved: coder }); - assert.deepEqual(names(tools), ["send_message", "request_review", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail"]); + assert.deepEqual(names(tools), ["send_message", "request_review", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "my_rails"]); // Held actions without a tool are listed, so the manifest shows them. With // no clone to push from, push_working is one of them. assert.deepEqual(untypedActions(coder, tools), ["task.update.assigned", "git.push.working", "task.scope.change"]); @@ -270,7 +270,7 @@ test("a coder with a tracker gets its own task tools and no task_reassign", (t) const coder = resolvedFor(t, "coder"); assert.deepEqual(names(typedTools({ resolved: coder, tracker: true })), [ "send_message", "task_scope_change", "task_update_assigned", "request_review", - "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", + "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", "my_rails", ]); }); @@ -283,7 +283,7 @@ test("launch only when the business's launch block names the instance as launche test("an action outside the instance's authority has no tool", (t) => { const coder = resolvedFor(t, "coder", (d) => (d.roles.coder.vars["limits.authority"] = ["review.request"])); - assert.deepEqual(names(typedTools({ resolved: coder, tracker: true, projects: ["stack"] })), ["request_review", "read_spec", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"]); + assert.deepEqual(names(typedTools({ resolved: coder, tracker: true, projects: ["stack"] })), ["request_review", "read_spec", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", "my_rails"]); }); // Row 93 (#1587): the review loop's tools, by authority, and the git ones only with a clone. @@ -292,7 +292,7 @@ test("the review loop: push_working and request_review for the coder, fetch_revi const tools = typedTools({ resolved: coder, tracker: true, projects: ["stack", "site"] }); assert.deepEqual(names(tools), [ "send_message", "task_scope_change", "task_update_assigned", "push_working", "request_review", - "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", + "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", "my_rails", ]); assert.deepEqual(untypedActions(coder, tools), []); const push = tools.find((x) => x.name === "push_working"); @@ -309,7 +309,7 @@ test("the review loop: push_working and request_review for the coder, fetch_revi const reviewer = resolvedFor(t, "reviewer"); const rtools = typedTools({ resolved: reviewer, tracker: true, projects: ["stack"] }); - assert.deepEqual(names(rtools), ["send_message", "task_update_assigned", "fetch_review", "review_verdict", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"]); + assert.deepEqual(names(rtools), ["send_message", "task_update_assigned", "fetch_review", "review_verdict", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", "my_rails"]); const fetch = rtools.find((x) => x.name === "fetch_review"); assert.deepEqual([fetch.verb, fetch.action], ["working.fetch", null]); assert.deepEqual(fetch.parameters.required, ["task_ref", "request"]); diff --git a/packages/harness/tests/write-record-object.test.mjs b/packages/harness/tests/write-record-object.test.mjs index e12a6f79d..d973330f9 100644 --- a/packages/harness/tests/write-record-object.test.mjs +++ b/packages/harness/tests/write-record-object.test.mjs @@ -62,14 +62,20 @@ for (const [name, value, detail] of [ }); } -test('agent sees validation detail while other refusal codes remain code-only', async t => { +test('agent sees validation detail and static guidance without unrelated refusal reasons', async t => { const f = await setup(t); await assert.rejects(f.write('requirements/REQ-1.json', { ...requirement, title: '' }), /refused: project-record-invalid: requirement\.title: too short/); - await assert.rejects(f.write('spec/secret-marker.md', '# text'), { message: 'refused: project-spec-human-only' }); + const refusal = message => error => { + assert.equal(error.message.split('\n')[0], message); + assert.match(error.message, /\nRail: .+\nNext step: .+/); + assert.doesNotMatch(error.message, /secret-marker/); + return true; + }; + await assert.rejects(f.write('spec/secret-marker.md', '# text'), refusal('refused: project-spec-human-only')); const path = join(f.root, 'other-tools.sock'); const server = await toolServer({ path, tools: [{ name: 'write_record', verb: 'record.write' }], local: {}, client: { call() { throw Object.assign(new Error('hidden'), { code: 'project-busy', reason: 'secret-marker' }); } } }); t.after(() => server.close()); - await assert.rejects(callTool(path, 'write_record', {}), { message: 'refused: project-busy' }); + await assert.rejects(callTool(path, 'write_record', {}), refusal('refused: project-busy')); }); diff --git a/packages/organization/src/rails.mjs b/packages/organization/src/rails.mjs new file mode 100644 index 000000000..3ef68619a --- /dev/null +++ b/packages/organization/src/rails.mjs @@ -0,0 +1,53 @@ +// Caller-only descriptions, shared by launch and live bus reads. Inputs are validated policy, +// never credential references, vars, call arguments or file contents. +import { GATED_ONLY } from './vocabulary.mjs'; + +const DOMAINS = Object.freeze({ 'git.merge.protected': 'technical', 'policy.change': 'delivery' }); + +// TI extension point for AU: today's fixed levels, without changing any routing. +export function decisionAutonomy(decisionClass) { + return ['cross-role', 'merge', 'policy'].includes(decisionClass) ? 'out of the loop' : 'in the loop'; +} + +export function ownRails({ instance, authority, network, credentials = [], messaging = null, arbiters = {}, launcher = null }) { + const actions = []; + for (const [kind, held] of Object.entries(authority ?? {})) { + for (const action of held) { + const hard = GATED_ONLY.includes(action) || (action === 'role.launch' && launcher !== instance); + const domains = DOMAINS[action] ? [DOMAINS[action]] : ['delivery', 'technical']; + const routes = hard ? [{ to: 'human' }] : kind === 'withinRole' ? [{ to: 'alone' }] + : domains.map(domain => ({ domain, to: arbiters[domain] === instance ? 'human' : arbiters[domain] ?? 'unconfigured' })); + actions.push({ action, routes }); + } + } + const resolve = [...(authority?.withinRole ?? []), ...(authority?.crossRole ?? [])] + .filter(action => action.startsWith('decision.resolve.')).map(right => { + const domain = right.slice('decision.resolve.'.length); + const classes = domain === 'technical' ? ['cross-role', 'merge'] : domain === 'delivery' ? ['cross-role', 'policy'] : []; + return { right, classes: classes.map(name => ({ name, level: decisionAutonomy(name) })) }; + }); + return { + actions, resolve, network: network ?? 'unknown', + credentials: credentials.map(({ service, scopes }) => ({ service, ...(scopes === undefined ? {} : { scopes: structuredClone(scopes) }) })), + messaging: messaging ? { sendTo: [...messaging.sendTo], receiveFrom: [...messaging.receiveFrom] } : null, + }; +} + +export function railsText(rails) { + const lines = ['## Your rails', '', 'These are your resolved rails. Call my_rails for the current rails after a narrowing.']; + for (const { action, routes } of rails.actions) { + const route = routes.map(({ domain, to }) => `${domain ? `${domain}${domain === 'delivery' && routes.length > 1 ? ' (default)' : ''}: ` : ''}${to === 'human' ? 'a person' : to === 'alone' ? 'alone' : to === 'unconfigured' ? 'unconfigured; report missing arbiter' : `arbiter instance ${JSON.stringify(to)}`}`).join('; '); + lines.push(`- ${action}: ${route}.`); + } + if (!rails.actions.length) lines.push('- Held actions: none.'); + for (const { right, classes } of rails.resolve) lines.push(`- ${right} levels: ${classes.map(c => `${c.name}: ${c.level}`).join('; ') || 'unconfigured'}.`); + lines.push('- A resolve right must be held withinRole, and the decision must route to you. PRD approval also follows the request mode and author checks.'); + lines.push('- Unheld actions are gated. Hard waits always go to a person; raise with blocking false when there is no task. A current spec citation can route other gated work to its arbiter.'); + lines.push('- Revoking a protected instance is a hard wait; ending it follows the chief/person protection checks. Messaging also checks the recipient\'s policy.'); + lines.push(`- Network: ${rails.network}.`); + for (const credential of rails.credentials) lines.push(`- Credential service ${credential.service}; scopes: ${credential.scopes === undefined ? 'none declared' : JSON.stringify(credential.scopes)}.`); + if (!rails.credentials.length) lines.push('- Credential services: none.'); + lines.push(`- Messaging sendTo: ${rails.messaging ? rails.messaging.sendTo.join(', ') || 'none' : 'unrestricted by your definition'}.`); + lines.push(`- Messaging receiveFrom: ${rails.messaging ? rails.messaging.receiveFrom.join(', ') || 'none' : 'unrestricted by your definition'}.`); + return lines.join('\n'); +} diff --git a/packages/organization/src/resolve.mjs b/packages/organization/src/resolve.mjs index 09c04ae43..b698a4fcf 100644 --- a/packages/organization/src/resolve.mjs +++ b/packages/organization/src/resolve.mjs @@ -3,6 +3,7 @@ // the record a launcher or the broker uses. Plain values: the most specific // layer wins. Limits: every layer narrows, nothing widens. +import { ownRails } from "./rails.mjs"; import { refuse } from "./errors.mjs"; import { ACTIONS, NETWORKS, isHostKind } from "./vocabulary.mjs"; import { checkVars, mergeVars } from "./vars.mjs"; @@ -89,6 +90,7 @@ export function resolveInstance({ system, business, project = null, instance }) limits: { tools, network, authority: { withinRole, crossRole } }, // Row 210 (#1707, design D5): a host key is used only by the bus child, never by the session, // so it stays out of the session's credentials and the harness's needs. + rails: ownRails({ instance, authority: { withinRole, crossRole }, network, credentials: definition.credentials, messaging: definition.messaging, arbiters: business.arbiters, launcher: business.launch?.by }), credentials: Object.fromEntries(Object.entries(entry.credentials).filter(([kind]) => !isHostKind(kind))), tracker: entry.tracker, launch, diff --git a/packages/organization/tests/resolve.test.mjs b/packages/organization/tests/resolve.test.mjs index 57a0a2e68..2945db5d1 100644 --- a/packages/organization/tests/resolve.test.mjs +++ b/packages/organization/tests/resolve.test.mjs @@ -164,7 +164,7 @@ test("the record carries what the broker and launcher need", () => { const s = setup(); const pm = resolve(s, "pm"); assert.deepEqual(Object.keys(pm).sort(), [ - "business", "contract", "credentials", "definition", "digest", "holder", "instance", "launch", "limits", "project", "provenance", "skills", "tracker", "vars", "webSource", + "business", "contract", "credentials", "definition", "digest", "holder", "instance", "launch", "limits", "project", "provenance", "rails", "skills", "tracker", "vars", "webSource", ]); assert.equal(pm.contract, join(REPO_ROLES, "pm.md")); assert.equal(pm.holder, "sage"); diff --git a/packages/organization/tests/spec-review-contracts.test.mjs b/packages/organization/tests/spec-review-contracts.test.mjs index aaa66220c..217b421d4 100644 --- a/packages/organization/tests/spec-review-contracts.test.mjs +++ b/packages/organization/tests/spec-review-contracts.test.mjs @@ -4,6 +4,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { REPO_ROLES } from "./helpers.mjs"; @@ -41,3 +42,22 @@ test("the assistant summarizes a spec review in its own words and doesn't repeat // The user's own rulings still go to the chief of staff verbatim; only a review result is summarized. assert.match(text, /Relay each ruling the user gives verbatim to the chief\s+of\s+staff/); }); + +// TI: exact authorized contract wording, independently pinned for review. +test("TI contracts point at the generated and live caller rails", () => { + const pins = { + "coder": "9a31e19cbea5953fd9598def446e0e5e351869498f593f46468cf03fc0e2759b", + "cto": "c4c53b00eea1a6c73c90f2b5a9e54960848972a517c4e6ee47198e91dc8ddf45", + "merge-gate": "8d39144cab994781cadf7ac0dbfc41a1dd8a3c59ef66d916c79e9d63324572ad", + "pm": "0f7cbae8085709ab2907483543681ff7dc002ac2f9fea14d0b840213e9cf6d56", + "reviewer": "5269da2de779f3e399838a1c8357201862a4bd25c03de71ca8ebb31978dea6b7", + "security-reviewer": "eb664413c47d405ffb593cf15d3278217f09c74ddef0a179dcf22d9bf041378a", + }; + for (const [name, hash] of Object.entries(pins)) { + const text = contract(name); + assert.equal(createHash('sha256').update(text).digest('hex'), hash, name); + assert.match(text, /"Your\s+rails"/); + assert.match(text, /`my_rails`/); + assert.doesNotMatch(text, /launch record you\s+received/); + } +}); diff --git a/packages/seat/src/sandbox.mjs b/packages/seat/src/sandbox.mjs index 2dd9c5ee1..c2e864dc2 100644 --- a/packages/seat/src/sandbox.mjs +++ b/packages/seat/src/sandbox.mjs @@ -60,6 +60,8 @@ export const RUNNER_FILES = Object.freeze([ "packages/harness/src/pi-extension.mjs", "packages/bus/src/client.mjs", "packages/bus/src/broker.mjs", + "packages/bus/src/refusals.mjs", + "packages/organization/src/rails.mjs", "packages/bus/src/proxy-page-vocabulary.mjs", // Row 331 r5: parsing only; common.mjs and PIN remain outside the sandbox. "packages/model-proxy/src/validation.mjs", diff --git a/packages/seat/tests/sandbox.test.mjs b/packages/seat/tests/sandbox.test.mjs index 9b50c8e7f..7637d85ca 100644 --- a/packages/seat/tests/sandbox.test.mjs +++ b/packages/seat/tests/sandbox.test.mjs @@ -351,7 +351,7 @@ test("a confined session's shell reaches its own workspace and run directory and assert.equal(readFileSync(join(x.ws, "probe"), "utf8"), "ws\n"); // The harness reaches its tool socket: the refusal is the tool server's, // not a failed connect. - assert.equal(await x.ask("TOOL no_such_tool {}"), "refused: unknown-tool"); + assert.equal((await x.ask("TOOL no_such_tool {}")).split("\n")[0], "refused: unknown-tool"); }); test("a bind that exposes a stand-in, or a missing workspace bind, fails the matching check", { skip, timeout: 60000 }, async (t) => { diff --git a/roles/coder.md b/roles/coder.md index 4f0c7e8e4..8825a1170 100644 --- a/roles/coder.md +++ b/roles/coder.md @@ -38,10 +38,11 @@ Within your grants, act on what you see without waiting to be asked, and raise a decision or send a message for anything outside them. Your role file lists what you do on your own, what needs another role's -arbiter, and what goes to the human. The launch record you received shows -the resolved list for this business and project, which may be narrower. -Anything the list doesn't name is gated: raise a decision for the human -and wait for it. Never route around a refusal. +arbiter, and what goes to the human. The "Your rails" prompt section shows +the resolved list for this business and project, which may be narrower. Call +`my_rails` for your current rails. Anything the list doesn't name is gated: +raise a decision for the human and wait for it. Never route around a +refusal. Handing a task to someone else, or changing its scope, is cross-role. Raise it as a decision for the delivery arbiter. diff --git a/roles/cto.md b/roles/cto.md index debd60867..ba6c2e745 100644 --- a/roles/cto.md +++ b/roles/cto.md @@ -43,10 +43,11 @@ Within your grants, act on what you see without waiting to be asked, and raise a decision or send a message for anything outside them. Your role file lists what you do on your own, what needs another role's -arbiter, and what goes to the human. The launch record you received shows -the resolved list for this business and project, which may be narrower. -Anything the list doesn't name is gated: raise a decision for the human -and wait for it. Never route around a refusal. +arbiter, and what goes to the human. The "Your rails" prompt section shows +the resolved list for this business and project, which may be narrower. Call +`my_rails` for your current rails. Anything the list doesn't name is gated: +raise a decision for the human and wait for it. Never route around a +refusal. Changing a task's scope is cross-role. Raise it as a decision for the delivery arbiter. diff --git a/roles/merge-gate.md b/roles/merge-gate.md index f8e714c33..2a21b16aa 100644 --- a/roles/merge-gate.md +++ b/roles/merge-gate.md @@ -50,11 +50,11 @@ Within your grants, act on what you see without waiting to be asked, and raise a decision or send a message for anything outside them. Your role file grants one action, `git.merge.protected`, as cross-role: -every landing needs a decision resolved by the technical arbiter. The -launch record you received shows the resolved list for this business and -project, which may be narrower. You can't push, review, send messages or -change a task. For anything else, raise a decision and wait for it. Never -route around a refusal. +every landing needs a decision resolved by the technical arbiter. The "Your +rails" prompt section shows the resolved list for this business and project, +which may be narrower. Call `my_rails` for your current rails. You can't +push, review, send messages or change a task. For anything else, raise a +decision and wait for it. Never route around a refusal. ## Protocol diff --git a/roles/pm.md b/roles/pm.md index 4574a6b00..a56b4fc2f 100644 --- a/roles/pm.md +++ b/roles/pm.md @@ -108,10 +108,10 @@ A `role.revoke` raise whose target is the assistant is a hard wait too: it goes to the human, cited or not. Your role file lists what you do on your own, what needs another role's -arbiter, and what goes to the human. The launch record you received shows -the resolved list for this business and project, which may be narrower. -Anything the list doesn't name is gated: raise a decision and wait for -it. Never route around a refusal. +arbiter, and what goes to the human. The "Your rails" prompt section shows +the resolved list for this business and project, which may be narrower. Call +`my_rails` for your current rails. Anything the list doesn't name is gated: +raise a decision and wait for it. Never route around a refusal. A blocking decision holds a task: it needs `task_ref`, and the task waits for the answer. A decision with no task is non-blocking, and an diff --git a/roles/reviewer.md b/roles/reviewer.md index 2bf229b79..ec1f2264f 100644 --- a/roles/reviewer.md +++ b/roles/reviewer.md @@ -35,10 +35,11 @@ Within your grants, act on what you see without waiting to be asked, and raise a decision or send a message for anything outside them. Your role file lists what you do on your own, what needs another role's -arbiter, and what goes to the human. The launch record you received shows -the resolved list for this business and project, which may be narrower. -Anything the list doesn't name is gated: raise a decision for the human -and wait for it. Never route around a refusal. +arbiter, and what goes to the human. The "Your rails" prompt section shows +the resolved list for this business and project, which may be narrower. Call +`my_rails` for your current rails. Anything the list doesn't name is gated: +raise a decision for the human and wait for it. Never route around a +refusal. Your verdict gates the work in the queue and on the bus. An approval in Gitea is advisory. diff --git a/roles/security-reviewer.md b/roles/security-reviewer.md index b54e3e34e..98fb4c466 100644 --- a/roles/security-reviewer.md +++ b/roles/security-reviewer.md @@ -24,11 +24,11 @@ code you review, and you have no shell and no write tools. Within your grants, act on what you see without waiting to be asked, and raise a decision or send a message for anything outside them. -Your role file grants the verdict and nothing else. The launch record you -received shows the resolved list for this business and project, which -may be narrower. You can't push, merge, send messages or change a task. -For anything else, raise a decision and wait for it. Never route around a -refusal. +Your role file grants the verdict and nothing else. The "Your rails" prompt +section shows the resolved list for this business and project, which may be +narrower. Call `my_rails` for your current rails. You can't push, merge, +send messages or change a task. For anything else, raise a decision and wait +for it. Never route around a refusal. Your verdict gates the work in the queue and on the bus. An approval in Gitea is advisory. -- 2.54.0 From ddf4f895cd0e41fb9d6110c7ea2cbd4ddc860029 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Tue, 10 Nov 2026 19:42:36 -0600 Subject: [PATCH 2/2] fix(rails): close review guidance and coverage gaps (#1873) --- packages/bus/src/refusals.mjs | 83 ++++++++++++++++++++++++++- packages/bus/tests/rails.test.mjs | 33 ++++++++++- packages/harness/tests/rails.test.mjs | 8 +++ packages/organization/src/rails.mjs | 5 +- 4 files changed, 122 insertions(+), 7 deletions(-) diff --git a/packages/bus/src/refusals.mjs b/packages/bus/src/refusals.mjs index c93290b4d..0844f1c5f 100644 --- a/packages/bus/src/refusals.mjs +++ b/packages/bus/src/refusals.mjs @@ -49,9 +49,14 @@ group( "Ask the owner to inspect the scoped access and binding state through the supported access controls; do not supply credentials in a call or widen your own grants." ); group( - "confirm-required outcome-unknown outcome-unknown-revoked revision-conflict revision-conflict-revoked", + "outcome-unknown outcome-unknown-revoked revision-conflict revision-conflict-revoked", "R2/R3: current revision and verified effect", - "Read the latest status, revision and receipts before retrying. Obtain the required confirmation through the supported human path." + "Read the latest status, revision and receipts before retrying. Verify whether an uncertain effect already happened, and use the current revision for any new attempt." +); +group( + "confirm-required", + "R2/R3: explicit confirmation of the current operation", + "Read the current status and obtain the required confirmation through the supported human path." ); group( "decision-closed decision-consumed decision-mismatch decision-not-approved decision-not-eligible decision-not-found decision-not-held decision-stale supersede-refused", @@ -79,10 +84,15 @@ group( "Read the permitted configuration/status and ask the owner or configured chief to repair it through the supported path. Do not edit policy to bypass the refusal." ); group( - "field-writer task-already-archived task-archived task-conflict task-create-conflict task-create-uncertain task-not-archived task-not-found task-position-full task-project-refused task-required task-scope-required task-subject-required", + "field-writer task-already-archived task-archived task-conflict task-create-conflict task-create-uncertain task-not-archived task-not-found task-position-full task-project-refused task-scope-required task-subject-required", "Role task ownership, allowed fields and current task state", "Read the current task and assigned role. Use the permitted task operation with current evidence; ask the PM for scope or assignment changes through the decision path." ); +group( + "task-required", + "Role decisions: a blocking decision belongs to a task", + "Supply the task_ref for this work, or raise with blocking false when there is no task." +); group( "fork-foreign gitea-admin-unavailable gitea-failed gitea-not-configured gitea-not-declared host-busy host-changed host-creative-role host-enrolled host-failed host-in-use host-key-mismatch host-line-left host-not-configured host-not-declared host-not-enrolled host-not-granted host-not-ready host-remove-failed host-unknown publish-busy publish-unknown", "Role access: declared service, host or remote and verified outcome", @@ -158,6 +168,73 @@ group( "R3/role tools: bounded protocol and available capacity", "Check the tool schema and narrow oversized requests. For a timeout, inspect status and receipts before retrying; never assume an uncertain operation did not happen." ); +// Finite error vocabularies forwarded by the project's, library's and request's +// typed adapters are part of the same table, not an unhelpful unknown-code path. +group( + "project-busy library-sync-busy", + "Role storage: exclusive write or sync already in progress", + "Wait for the current operation to finish, read its status, then retry once the lock is clear." +); +group( + "stale-base library-accept-stale", + "R2/R3: current file or served revision", + "Read the latest file or library status and its current revision. Reconcile the intended change before retrying with that base; never overwrite a newer change blindly." +); +group( + "citation-cross-universe citation-invalid citation-missing citation-unavailable citation-unfetched citations-over-limit playground-cited-as-canon reply-invalid", + "Role replies: validated citations, provenance and scope", + "Use permitted library, spec or web reads and supply a valid reply with current citations in the allowed universe and scope. Narrow oversized citation lists; never invent provenance." +); +group( + "invalid-request-admission invalid-request-effect invalid-request-event invalid-request-plan invalid-request-projects artifact-path-invalid artifact-type-refused", + "R3: typed request plan, delivery and evidence schema", + "Check the current request's tool schema and correct the plan, artifact path or evidence. Delivery requires the supported self-contained HTML artifact and matching verification evidence." +); +group( + "prd-admission-revoked prd-author-invalid prd-candidate-invalid prd-candidate-required prd-candidate-size prd-candidate-stale prd-requirement-invalid", + "Role PRD workflow: current candidate, authors and admission", + "Read the current request, candidate, requirements, mode and approval. Ask the PM to correct or submit the current candidate through the supported workflow; never approve your own authorship." +); +group( + "request-question-open request-question-closed request-question-mismatch request-phase-refused request-attachment-mismatch request-delivery-required request-document-refused", + "Role request workflow: current phase, question and selected attachment", + "Read intake_status and follow its current question, wait or delivery step. A pending question needs its answer before another question; use only the operation and attachment valid for the current phase." +); +group( + "request-effect-conflict request-effect-unprepared request-mode-revision-conflict request-operation-conflict request-plan-binding-refused request-plan-path-collision request-plan-path-refused request-project-collision", + "R2/R3: current request revision and bound operation", + "Read the current request status, plan and operation receipts. Recover an existing operation before retrying; use a new operation ID only for a new intended operation, and never bypass a path or project collision." +); +group( + "request-notification-unavailable request-services-unavailable library-credential-missing library-human-missing library-mapping-invalid library-problem-code library-remote-invalid library-state-invalid library-diverged library-accept-invalid", + "R1/R3: owner-configured service, mapping and accepted library state", + "Read the supported service or library status and ask the owner to repair the missing binding, mapping or accepted state. Check existing operation receipts before retrying an uncertain effect." +); +group( + "library-commit library-git library-push-failed project-commit-uncertain project-init-cleanup", + "R2/R3: verified repository effect and recovery", + "Read current repository status and receipts to determine what already happened. Report an uncertain commit, push or cleanup to the owner before any new write; do not retry an uncertain effect blindly." +); +group( + "project-author project-dirty project-git project-id project-invalid project-no-commit project-not-declared project-not-empty project-path project-read-commit project-read-content project-read-missing project-read-path project-record-path project-records-conflict project-records-missing project-remote project-repository tester-project-area", + "Role project scope: declared repository, clean state and allowed paths", + "Read the permitted project status and file metadata. Correct the declared project or allowed path when possible; ask the owner to repair repository state or identity. Preserve existing changes and never bypass repository or path checks." +); +group( + "project-record-unchanged", + "R2: no new record effect for unchanged content", + "Read the current record and its existing commit receipt. If it already matches the intended content, use that evidence instead of retrying the same write." +); +group( + "library-write-story-path library-revert-not-agent", + "Role library writes: supporting paths and permitted agent-owned commits", + "Use only the supporting paths and agent-owned commit reversals permitted by the library tool. Preserve story and canon files; ask the owner when the requested edit is outside that scope." +); +group( + "query-argument-invalid query-chapter-ambiguous query-item-too-large query-universe-unknown", + "Role library reads: known universe, unambiguous selection and bounded result", + "Read the permitted library index, choose a known universe and an unambiguous book or chapter, and narrow the query or requested range before retrying." +); export const REFUSALS = Object.freeze(Object.fromEntries(entries)); const UNKNOWN = Object.freeze({ rail: "R3: fail closed", nextStep: "Report the refusal code to the responsible owner and inspect supported status; do not bypass the refusal." }); export const refusalGuidance = code => Object.hasOwn(REFUSALS, code) ? REFUSALS[code] : UNKNOWN; diff --git a/packages/bus/tests/rails.test.mjs b/packages/bus/tests/rails.test.mjs index b28333048..f416c66be 100644 --- a/packages/bus/tests/rails.test.mjs +++ b/packages/bus/tests/rails.test.mjs @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { mkdtempSync, rmSync, readFileSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, rmSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { connect } from 'node:net'; @@ -14,6 +14,7 @@ import { Client } from '../src/client.mjs'; import { resolveInstance, validateBusinessDocument } from '../../organization/src/index.mjs'; import { businessDoc, REPO_ROLES, systemFor } from '../../organization/tests/helpers.mjs'; import { railsText } from '../../organization/src/rails.mjs'; +import { GUARD_CODES } from '../../library/src/reply-protocol.mjs'; const SECRET = 'sk-test-NOT-A-SECRET-1234'; function fixture(t) { const root = mkdtempSync(join(tmpdir(), 'rails-')); @@ -80,6 +81,16 @@ test('cross-role refusal envelope names its rail and next step without changing const cross = await raw(path, { cap: f.cap, verb: 'decision.resolve', args: { id: decision.id, choice: 'yes' } }); assert.equal(cross.error, 'not-resolver'); assert.match(cross.rail, /resolve right/); assert.match(cross.nextStep, /routed resolver/); assert.equal(f.store.get('SELECT count(*) n FROM decision_events WHERE decision=?', decision.id).n, 0); + // Exercise an actual cross-role action without its required decision, not just resolve. + const live = busOrganization(f.business, f.resolved); + live.roles.coder.authority.crossRole.push('role.end'); + f.broker.reloadBusiness('acme', live); + const decisions = f.store.get('SELECT count(*) n FROM decisions').n; + const required = await raw(path, { cap: f.cap, verb: 'role.revoke', args: { role: 'reviewer', reason: SECRET } }); + assert.equal(required.ok, false); assert.equal(required.error, 'decision-required'); + assert.deepEqual({ rail: required.rail, nextStep: required.nextStep }, REFUSALS['decision-required']); + assert.ok(!JSON.stringify(required).includes(SECRET)); + assert.equal(f.store.get('SELECT count(*) n FROM decisions').n, decisions); }); test('every literal fail branch in broker has static guidance; guidance never contains payload values', () => { @@ -105,7 +116,7 @@ test('every literal fail branch in broker has static guidance; guidance never co assert.equal(depth, 0, 'balanced fail call'); } // Finite delegated fail(code) sources must be covered as well as literal branches. - for (const code of [...PROXY_PAGE_CODES, 'remote-host-mismatch', 'remote-not-fork', + for (const code of [...PROXY_PAGE_CODES, ...GUARD_CODES, 'project-busy', 'remote-host-mismatch', 'remote-not-fork', 'processing-control-unsafe', 'processing-uninitialized', 'processing-generation-changed']) { assert.ok(Object.hasOwn(REFUSALS, code), code); } @@ -116,3 +127,21 @@ test('every literal fail branch in broker has static guidance; guidance never co } assert.deepEqual(refusalGuidance(SECRET), refusalGuidance('unknown-external-code')); }); + +test('finite forwarded project, library and request errors have static next steps', () => { + const modules = [ + ...['index', 'git', 'paths'].map(name => new URL(`../../project/src/${name}.mjs`, import.meta.url)), + ...readdirSync(new URL('../../library/src/', import.meta.url)).filter(f => f.endsWith('.mjs')) + .map(name => new URL(`../../library/src/${name}`, import.meta.url)), + ...['request-store', 'request-lifecycle'].map(name => new URL(`../src/${name}.mjs`, import.meta.url)), + ]; + for (const file of modules) { + const source = readFileSync(file, 'utf8'); + for (const match of source.matchAll(/\b(?:new\s+(?:ProjectError|LibraryError|RequestError|GuardError)|raise|refuse|fail)\s*\(\s*['"]([a-z][a-z0-9-]*)['"]/g)) { + assert.ok(Object.hasOwn(REFUSALS, match[1]), `${file.pathname}: ${match[1]}`); + } + } + assert.doesNotMatch(REFUSALS['outcome-unknown'].nextStep, /confirmation|human/); + assert.doesNotMatch(REFUSALS['revision-conflict'].nextStep, /confirmation|human/); + assert.match(REFUSALS['task-required'].nextStep, /task_ref.*blocking false/); +}); diff --git a/packages/harness/tests/rails.test.mjs b/packages/harness/tests/rails.test.mjs index bac6f586c..67d521048 100644 --- a/packages/harness/tests/rails.test.mjs +++ b/packages/harness/tests/rails.test.mjs @@ -52,3 +52,11 @@ test('fixed domains, arbiter self-raises and hard waits describe existing routes { action: 'policy.change', routes: [{ domain: 'delivery', to: 'pm' }] }, ]); }); + +test('hire and fire are unavailable to a chief that is not the configured launcher', t => { + const { rails } = resolvedFor(t, 'chief', doc => { delete doc.launch; }); + for (const action of ['role.hire', 'role.fire']) { + assert.deepEqual(rails.actions.find(a => a.action === action).routes, [{ to: 'unavailable' }]); + } + assert.match(railsText(rails), /role.hire: unavailable here; only the configured chief can hire or fire/); +}); diff --git a/packages/organization/src/rails.mjs b/packages/organization/src/rails.mjs index 3ef68619a..db7cfd409 100644 --- a/packages/organization/src/rails.mjs +++ b/packages/organization/src/rails.mjs @@ -13,9 +13,10 @@ export function ownRails({ instance, authority, network, credentials = [], messa const actions = []; for (const [kind, held] of Object.entries(authority ?? {})) { for (const action of held) { + const chiefOnly = ['role.hire', 'role.fire'].includes(action) && launcher !== instance; const hard = GATED_ONLY.includes(action) || (action === 'role.launch' && launcher !== instance); const domains = DOMAINS[action] ? [DOMAINS[action]] : ['delivery', 'technical']; - const routes = hard ? [{ to: 'human' }] : kind === 'withinRole' ? [{ to: 'alone' }] + const routes = chiefOnly ? [{ to: 'unavailable' }] : hard ? [{ to: 'human' }] : kind === 'withinRole' ? [{ to: 'alone' }] : domains.map(domain => ({ domain, to: arbiters[domain] === instance ? 'human' : arbiters[domain] ?? 'unconfigured' })); actions.push({ action, routes }); } @@ -36,7 +37,7 @@ export function ownRails({ instance, authority, network, credentials = [], messa export function railsText(rails) { const lines = ['## Your rails', '', 'These are your resolved rails. Call my_rails for the current rails after a narrowing.']; for (const { action, routes } of rails.actions) { - const route = routes.map(({ domain, to }) => `${domain ? `${domain}${domain === 'delivery' && routes.length > 1 ? ' (default)' : ''}: ` : ''}${to === 'human' ? 'a person' : to === 'alone' ? 'alone' : to === 'unconfigured' ? 'unconfigured; report missing arbiter' : `arbiter instance ${JSON.stringify(to)}`}`).join('; '); + const route = routes.map(({ domain, to }) => `${domain ? `${domain}${domain === 'delivery' && routes.length > 1 ? ' (default)' : ''}: ` : ''}${to === 'human' ? 'a person' : to === 'alone' ? 'alone' : to === 'unavailable' ? 'unavailable here; only the configured chief can hire or fire' : to === 'unconfigured' ? 'unconfigured; report missing arbiter' : `arbiter instance ${JSON.stringify(to)}`}`).join('; '); lines.push(`- ${action}: ${route}.`); } if (!rails.actions.length) lines.push('- Held actions: none.'); -- 2.54.0