Compare commits

...
3 Commits
47 changed files with 793 additions and 89 deletions

No files matched your search

+7
View File
@@ -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). |
+11 -1
View File
@@ -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');
@@ -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');
+240
View File
@@ -0,0 +1,240 @@
// 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(
"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. 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",
"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-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",
"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."
);
// 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;
+10 -9
View File
@@ -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) => {
+3 -2
View File
@@ -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 => {
+2 -1
View File
@@ -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);
});
+4 -3
View File
@@ -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 => {
@@ -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';",
+147
View File
@@ -0,0 +1,147 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync, readFileSync, readdirSync, 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';
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-'));
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);
// 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', () => {
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, ...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);
}
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'));
});
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/);
});
+3 -2
View File
@@ -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.
+5 -1
View File
@@ -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) => {
+2
View File
@@ -0,0 +1,2 @@
// Compatibility path for the organization package's caller-rails projection.
export * from "../../organization/src/rails.mjs";
@@ -1 +1 @@
{"business":"acme","contract":"<ROOT>/roles/coder.md","credentials":{"gitea":{"file":"<ROOT>/secrets/coder-gitea.token","rotateBy":"2099-01-01","service":"gitea"},"vikunja":{"expires":"2099-01-01","file":"<ROOT>/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":"<ROOT>/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":"<ROOT>/roles/coder.md","credentials":{"gitea":{"file":"<ROOT>/secrets/coder-gitea.token","rotateBy":"2099-01-01","service":"gitea"},"vikunja":{"expires":"2099-01-01","file":"<ROOT>/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":"<ROOT>/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}
@@ -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",
+1 -1
View File
@@ -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}
+17 -1
View File
@@ -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"
}
@@ -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);
}
+1 -1
View File
@@ -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"`);
+2 -2
View File
@@ -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"}');
+4 -4
View File
@@ -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");
});
@@ -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("<ROOT>").replace(/[a-f0-9]{64}/g, "<DIGEST>");
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"));
+3 -1
View File
@@ -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";
}
+2
View File
@@ -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) } : {}) });
+10 -2
View File
@@ -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"));
}
+1 -1
View File
@@ -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/);
@@ -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']);
@@ -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']);
});
+1 -1
View File
@@ -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"]);
});
+62
View File
@@ -0,0 +1,62 @@
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' }] },
]);
});
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/);
});
@@ -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'));
});
+16 -3
View File
@@ -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",
]);
+8 -8
View File
@@ -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"]);
@@ -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'));
});
+54
View File
@@ -0,0 +1,54 @@
// 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 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 = 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 });
}
}
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 === '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.');
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');
}
+2
View File
@@ -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,
+1 -1
View File
@@ -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");
@@ -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/);
}
});
+2
View File
@@ -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",
+1 -1
View File
@@ -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) => {
@@ -45,6 +45,7 @@
"policy.change",
"prd.approve",
"decision.resolve.technical",
"decision.resolve.delivery",
"library.write"
],
"vars": {
@@ -97,6 +98,7 @@
"policy.change": "gated",
"prd.approve": "gated",
"decision.resolve.technical": "gated",
"decision.resolve.delivery": "within",
"library.write": "gated"
},
"refused": null
@@ -144,6 +146,7 @@
"policy.change": "gated",
"prd.approve": "gated",
"decision.resolve.technical": "within",
"decision.resolve.delivery": "gated",
"library.write": "gated"
},
"refused": null
@@ -192,6 +195,7 @@
"policy.change": "gated",
"prd.approve": "gated",
"decision.resolve.technical": "gated",
"decision.resolve.delivery": "gated",
"library.write": "gated"
},
"refused": null
@@ -237,6 +241,7 @@
"policy.change": "gated",
"prd.approve": "gated",
"decision.resolve.technical": "gated",
"decision.resolve.delivery": "gated",
"library.write": "gated"
},
"refused": null
@@ -281,7 +286,8 @@
"account.create": "gated",
"policy.change": "gated",
"prd.approve": "gated",
"decision.resolve.technical": "gated",
"decision.resolve.technical": "within",
"decision.resolve.delivery": "within",
"library.write": "gated"
},
"refused": null
+5 -4
View File
@@ -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.
+5 -4
View File
@@ -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.
+5 -5
View File
@@ -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
+4 -4
View File
@@ -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
+5 -4
View File
@@ -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.
+5 -5
View File
@@ -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.