diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index 3e776d47e..52c10ad67 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -6292,6 +6292,8 @@ export class Broker { result = this.#credentialVerbs(s.business, verb, a, s.tester ? 'tester' : s.human); } catch (e) { if (e instanceof BusError && e.code.startsWith('credential-')) throw e; + // org-defscan (#1873): the issue's definition check; its reason names where, never the value. + if (e instanceof BusError && ['definition-holds-credential', 'definition-scan-unreadable'].includes(e.code)) throw e; if (e.code === 'revision-conflict' || e.code?.startsWith('organization-')) fail(e.code); if (e.name === 'BusinessError') fail('invalid-request'); throw new BusError('outcome-unknown'); diff --git a/packages/bus/src/credential-verbs.mjs b/packages/bus/src/credential-verbs.mjs index 4640b54c4..21634a0ab 100644 --- a/packages/bus/src/credential-verbs.mjs +++ b/packages/bus/src/credential-verbs.mjs @@ -1,5 +1,6 @@ import { BusError } from './broker.mjs'; import { isHostKind } from '../../organization/src/vocabulary.mjs'; +import { scanOrganizations } from './definition-scan.mjs'; // Row 199 (#1697, design section 6): issue, rotate, revoke and delete of one agent's credentials, for // the broker's human-only verbs. The broker checks the session, the keys and a running claim first. // Each verb leaves one receipt in the organization's changes.jsonl: the actor and a detail naming the @@ -10,7 +11,10 @@ const dateKey = (service) => (isHostKind(service) ? 'rotateBy' : DATE_KEY[servic const deny = (code) => { throw new BusError(code); }; -export function credentialVerbs({ credentials, writer }) { +// `organizations` lists every business this bus serves; issue checks each one's definitions. Without +// it, only the issuing business's. `boot` is the bus's boot copy snapshot's definitions, { org, +// definitions }, checked as well. +export function credentialVerbs({ credentials, writer, organizations = null, boot = null }) { const state = (instance, service) => credentials.status().find((e) => e.instance === instance && e.service === service)?.state; return (id, verb, args, actor) => { @@ -27,8 +31,13 @@ export function credentialVerbs({ credentials, writer }) { const next = structuredClone(document); next.roles[agent].credentials[service] = { store: true, [dateKey(service)]: date }; const detail = { verb: kind, agent, kind: [service], date }; - const status = credentials.issue(instance, service, token, date, () => - writer.write(id, next, { revision, actor, detail }), + // org-defscan (#1873): the value must not already sit in any organization's definitions. The + // check runs inside issue, before the store, the file or memory changes, on this bus loop, so + // no launch admission can run between the check and the value becoming held. + const status = credentials.issue( + instance, service, token, date, + () => writer.write(id, next, { revision, actor, detail }), + (clean) => scanOrganizations({ organizations: organizations?.() ?? [id], own: { id, document }, readRaw: (b) => writer.readRaw(b), boot }, clean), ); // Row 210 (design D9): a host key's public line and fingerprint, for ops-01's authorized_keys. const host = status.fingerprint ? { publicKey: status.publicKey, fingerprint: status.fingerprint } : {}; diff --git a/packages/bus/src/credentials.mjs b/packages/bus/src/credentials.mjs index 1c2a21a60..6cac1e88e 100644 --- a/packages/bus/src/credentials.mjs +++ b/packages/bus/src/credentials.mjs @@ -87,6 +87,19 @@ export function parseHostKey(text) { } return { text: lines.join('\n') + '\n', publicLine, fingerprint, seed, needles }; } +// Credentials.assertClean's test: credential-leak when the value's JSON text holds an entry's token +// or one of its retiring model keys. org-defscan (#1873): issue's check runs it on the value it is +// about to hold. +function assertNotHeld(value, entries) { + let encoded; + try { + encoded = JSON.stringify(value); + } catch { + deny('invalid-request'); + } + for (const { token, model } of entries) + if ([token, ...(model?.retiring ?? []).map(r => r.key)].some(k => encoded?.includes(k))) deny('credential-leak'); +} // D8: refuses credential-leak when the value's JSON text holds any part of the key. export function assertCleanKey(value, key) { let encoded; @@ -458,10 +471,13 @@ export class Credentials { // Row 199 (design section 6): write the token to the agent's store path (tmp, fsync, rename), then // `commit` (the business file's reference becomes store), then memory. The token comes from the // caller's stdin and is never logged. Returns the entry's new status. - issue(instance, service, token, date, commit = () => {}) { + // org-defscan (#1873): `check` runs before anything changes. It gets an assertClean for this value + // alone, the test assertClean makes once the value is held; a throw leaves the store as it was. + issue(instance, service, token, date, commit = () => {}, check = () => {}) { this.#known(instance, service); if (!day(date)) deny('credential-date'); const value = Credentials.#format(service, token); + check((v) => assertNotHeld(v, [value])); this.#invalidateModelProof(instance, service); const file = this.#storePath(instance, service); const agentDir = dirname(file); @@ -600,14 +616,7 @@ export class Credentials { return this.use(session.business + '/' + session.role, service, operation, options); } assertClean(value) { - let encoded; - try { - encoded = JSON.stringify(value); - } catch { - deny('invalid-request'); - } - for (const { token, model } of this.#tokens.values()) - if ([token, ...(model?.retiring ?? []).map(r => r.key)].some(k => encoded?.includes(k))) deny('credential-leak'); + assertNotHeld(value, this.#tokens.values()); } // Row 210 (design D5): whether a host.run for this agent and host is in flight. busy(instance, service) { diff --git a/packages/bus/src/definition-scan.mjs b/packages/bus/src/definition-scan.mjs new file mode 100644 index 000000000..b185b3190 --- /dev/null +++ b/packages/bus/src/definition-scan.mjs @@ -0,0 +1,114 @@ +// #1873 T1 follow-up (org-defscan): the live credential store's held-secret check on an org +// definition's text. A definition is role text written into the organization file; launch hands its +// instructions to an agent as the contract. A file edited by hand, or while the bus was down, never +// went through the writer's check, so bus start and every launch run this one. +// +// The answer names where a held credential sits, never the text there. A key that itself holds a +// credential is named by its parent ("personality key"). Not imported by broker.mjs (the sandbox +// loads that file); runtime.mjs and credential-verbs.mjs are its callers. +import { BusError } from './broker.mjs'; + +export const DEFINITION_HOLDS_CREDENTIAL = 'definition-holds-credential'; +// An organization file credentials.issue could not read to check: the issue refuses. +export const DEFINITION_SCAN_UNREADABLE = 'definition-scan-unreadable'; +// A definition name the scan quotes: the organization loader's id pattern. Anything else is "*". +const NAME = /^[a-z0-9][a-z0-9-]{0,63}$/; +// A path segment the scan quotes; anything else becomes "*" (the hit is still reported). +const SEGMENT = /^[A-Za-z0-9_-]{1,64}$/; +const MAX_DEPTH = 32; + +const isClean = (assertClean, value) => { + try { + assertClean(value); + return true; + } catch { + return false; + } +}; + +// The first place in `entry` whose text the store holds, as a path ("instructions", +// "personality.traits[1]", "role key"), or null when the entry is clean. A hit the walk can't place +// (a value JSON.stringify sees and the walk doesn't) is "(entry)": the check fails closed. +export function heldPath(entry, assertClean) { + if (isClean(assertClean, entry)) return null; + const join = (path, key) => { + const segment = SEGMENT.test(key) && isClean(assertClean, key) ? key : '*'; + return path ? `${path}.${segment}` : segment; + }; + const walk = (value, path, depth) => { + if (typeof value === 'string') return isClean(assertClean, value) ? null : path || '(entry)'; + if (value === null || typeof value !== 'object' || depth > MAX_DEPTH) return null; + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + const hit = walk(value[i], `${path}[${i}]`, depth + 1); + if (hit) return hit; + } + return null; + } + for (const [key, child] of Object.entries(value)) { + if (!isClean(assertClean, key)) return path ? `${path} key` : 'key'; + const hit = walk(child, join(path, key), depth + 1); + if (hit) return hit; + } + return null; + }; + return walk(entry, '', 0) ?? '(entry)'; +} + +// The refusal for one definition. `name` is the definition's name without "org:". At issue time +// (`organization` given) it names the organization too, and the value is the one being issued. +export function definitionRefusal(name, path, { organization, boot = false } = {}) { + const quoted = typeof name === 'string' && NAME.test(name) ? name : '*'; + const where = `${boot ? 'boot copy ' : ''}${name === null ? 'definitions' : `definitions.${quoted} ${path}`}`; + if (organization === undefined) return new BusError(DEFINITION_HOLDS_CREDENTIAL, `${where} holds a credential this bus holds`); + const org = typeof organization === 'string' && NAME.test(organization) ? organization : '*'; + return new BusError(DEFINITION_HOLDS_CREDENTIAL, `organization ${org} ${where} holds the credential being issued`); +} + +// The first entry of a `definitions` map that holds a credential, as { name, path }, or null. +// `prefix` is stripped from each name ("org:" for a business's resolved definitions). A map that is +// not an object is { name: null } when its text holds one. +export function heldDefinition(definitions, assertClean, { prefix = '' } = {}) { + if (definitions === undefined) return null; // an organization with no definitions key + if (definitions === null || typeof definitions !== 'object' || Array.isArray(definitions)) + return isClean(assertClean, definitions) ? null : { name: null, path: null }; + for (const [key, entry] of Object.entries(definitions)) { + if (prefix && !key.startsWith(prefix)) continue; + if (!isClean(assertClean, key)) return { name: '*', path: 'key' }; + const path = heldPath(entry, assertClean); + if (path !== null) return { name: key.slice(prefix.length), path }; + } + return null; +} + +// Every entry of a `definitions` map; throws the refusal for the first that holds a credential. +export function scanDefinitions(definitions, assertClean, options = {}) { + const hit = heldDefinition(definitions, assertClean, options); + if (hit) throw definitionRefusal(hit.name, hit.path, options); +} + +// org-defscan (#1873, Lead ruling on the re-check P2): credentials.issue and rotate refuse a value +// that already sits in any organization's definitions, so no definition text becomes a held value +// after its launch checks passed. `organizations` is every business this bus serves; `own` is the +// issuing one's document, already read. Any other file is read raw, as bus start reads it, so a +// definition no instance names is covered; a file that can't be read refuses. `boot` is the bus's boot +// copy snapshot ({ org, definitions }, #1873 A3/A4): the root agents file's definitions merged with the +// organization's at boot. Every reload re-applies it, so its text is live for the bus's lifetime. +export function scanOrganizations({ organizations, own, readRaw, boot = null }, assertClean) { + for (const organization of organizations) { + let definitions; + if (organization === own.id) definitions = own.document.definitions; + else { + let raw; + try { + raw = readRaw(organization); + } catch { + raw = { readable: false }; + } + if (raw?.readable !== true) throw new BusError(DEFINITION_SCAN_UNREADABLE); + definitions = raw.document.definitions; + } + scanDefinitions(definitions, assertClean, { organization }); + } + if (boot?.definitions !== undefined) scanDefinitions(boot.definitions, assertClean, { organization: boot.org, boot: true }); +} diff --git a/packages/bus/src/process.mjs b/packages/bus/src/process.mjs index 1f181d337..a7e6949ac 100644 --- a/packages/bus/src/process.mjs +++ b/packages/bus/src/process.mjs @@ -2,6 +2,7 @@ // never command arguments, stdout or service-token-bearing environment variables. import { startBroker } from './runtime.mjs'; import { BusError } from './broker.mjs'; +import { DEFINITION_HOLDS_CREDENTIAL } from './definition-scan.mjs'; const LAUNCH_OPS = new Set([ 'identity', 'authorizeLaunch', @@ -9,6 +10,7 @@ const LAUNCH_OPS = new Set([ 'endLaunch', 'recoverLaunch', 'credentialStatus', + 'definitionScan', 'modelPreflight', 'modelAdmission', 'modelStall', @@ -90,6 +92,8 @@ function launchOp(m) { if (m.op === 'request.chief') return runtime.requestChief(m.business); if (m.op === 'request.admission') return runtime.admission({ business: m.business, project: m.project, projects: m.projects, instance: m.instance, revision: m.revision, thread: m.thread, session: m.session }); if (m.op === 'credentialStatus') return runtime.credentialStatus(m.business, m.role); + // #1873 T1 follow-up (org-defscan): the launcher's held-secret check of an org: definition. + if (m.op === 'definitionScan') return runtime.definitionScan({ business: m.business, definition: m.definition, entry: m.entry }); if (m.op === 'modelAdmission') return runtime.modelAdmission(m); if (m.op === 'modelPreflight') return runtime.modelPreflight(m); if (m.op === 'modelStall') return runtime.modelStall(m); @@ -163,7 +167,9 @@ if (!process.send) { } process.send({ ok: true, path: runtime.path, launches: runtime.launches, readers: runtime.readers }); } catch (e) { - process.send?.(tag(message, { ok: false, error: e instanceof BusError ? e.code : 'startup-refused' }), () => + // org-defscan: a held credential in a definition says which definition and key, never the text. + const reason = e instanceof BusError && e.code === DEFINITION_HOLDS_CREDENTIAL && typeof e.reason === 'string' ? { reason: e.reason } : {}; + process.send?.(tag(message, { ok: false, error: e instanceof BusError ? e.code : 'startup-refused', ...reason }), () => close(2), ); } diff --git a/packages/bus/src/refusals.mjs b/packages/bus/src/refusals.mjs index 07c0b45b7..4115ccb3e 100644 --- a/packages/bus/src/refusals.mjs +++ b/packages/bus/src/refusals.mjs @@ -90,6 +90,16 @@ group( "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( + "definition-holds-credential", + "R1: no held credential in an organization definition's role text", + "Ask the organization owner to remove the credential from the named definition and key, then restart the bus, relaunch the agent or issue the credential again. For a hire, deny it and have the chief raise it again without that text. Do not copy the text anywhere to inspect it." +); +group( + "definition-scan-unreadable", + "R1/R3: a credential is issued only after every organization's definitions are checked", + "Ask the instance owner to repair the organization file the bus cannot read, then issue the credential again. Nothing was stored." +); 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", diff --git a/packages/bus/src/runtime.mjs b/packages/bus/src/runtime.mjs index d607f68f1..c558903b1 100644 --- a/packages/bus/src/runtime.mjs +++ b/packages/bus/src/runtime.mjs @@ -38,6 +38,8 @@ import { ModelKeys, modelOrganization, stopModelRuns, MODEL_STOP_TIMEOUT_MS } fr import { instanceSettings } from '../../organization/src/instance-settings.mjs'; import { organizationWriter, OrganizationError, setReplyLimits } from '../../organization/src/organization.mjs'; import { definitionVerbs, DEFINITION_BUS_VERBS } from '../../organization/src/definition-verbs.mjs'; +import { ORG_PREFIX, orgDefinitionName } from '../../organization/src/definitions.mjs'; +import { DEFINITION_HOLDS_CREDENTIAL, heldPath, scanDefinitions } from './definition-scan.mjs'; import { ROLE_RIGHTS } from '../../organization/src/org-rights.mjs'; import { getProjectRemote } from '../../organization/src/project-remotes.mjs'; import { credentialVerbs } from './credential-verbs.mjs'; @@ -387,6 +389,18 @@ export async function startBroker({ mkdirSync(storeRoot, { recursive: true, mode: 0o700 }); credentials = new Credentials({ references, repoRoots: protectedRoots, dataRoot, storeRoot, held, modelAgents: proxy ? Object.entries(businesses).flatMap(([id, b]) => Object.keys(b.roles).map(role => id + '/' + role)) : [] }); + // #1873 T1 follow-up (org-defscan): no organization definition's text holds a credential this store + // holds. The boot copy's org: definitions first, then every entry of the file itself, named or not. + // A hit refuses the start; the refusal names the definition and key, never the text. + const clean = value => credentials.assertClean(value); + for (const [id, b] of Object.entries(businesses)) { + scanDefinitions(b.definitions, clean, { prefix: ORG_PREFIX }); + if (!organization) continue; + const raw = organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot: instanceRoot ?? dataRoot }).readRaw(id); + if (raw.readable) scanDefinitions(raw.document.definitions, clean); + } + // The boot copy snapshot's definitions (the root agents file's, A4): every reload re-applies them. + if (copies?.definitions !== undefined) scanDefinitions(copies.definitions, clean, { boot: true }); if (proxy) { const inventory = modelRunInventory(dataRoot, modelRuns, credentials); modelKeys = new ModelKeys({ credentials, client: modelProxyClient, configDir: org ? instanceConfigDir : organization.dir, org: org?.id ?? null, lock: proxyLock, @@ -655,9 +669,19 @@ export async function startBroker({ if (instance !== undefined && (typeof instance !== 'string' || typeof launched !== 'string')) return no('project-unknown'); if (instance !== undefined) { let now; - try { now = writer?.read(id).revision; } catch { return no('organization-unreadable'); } - if (now === undefined) return no('organization-unreadable'); - if (now !== launched) return no('organization-changed'); + try { now = writer?.read(id); } catch { return no('organization-unreadable'); } + if (now?.revision === undefined) return no('organization-unreadable'); + if (now.revision !== launched) return no('organization-changed'); + // org-defscan (#1873): the launch boundary. A credential issued since the launcher's scan + // runs on this same loop, so the org: definition the instance names is checked again here, + // in the reply the launcher spawns after with no further wait. + const name = orgDefinitionName(now.document.roles?.[instance]?.definition); + if (name !== null) { + const clean = value => credentials.assertClean(value); + let held; + try { clean(name); held = heldPath(now.document.definitions?.[name], clean); } catch { held = 'name'; } + if (held !== null) return no(DEFINITION_HOLDS_CREDENTIAL); + } } for (const p of projects) { const result = admission({ business: id, project: p }); @@ -868,7 +892,7 @@ export async function startBroker({ // Row 194 (D2.3): the role files as they stand, for the hire checks. hires: organization ? (()=>{ const service=hireDecisions({writer:organizationWriter({dir:organization.dir,rolesDir:organization.rolesDir,dataRoot:instanceRoot??dataRoot}),rolesDir:organization.rolesDir,configDir:organization.dir,dataRoot, - system:organization.system,levels,catalogueFor:async(id,doc)=>doc.plugins?.['model-provider']?.length?catalogueFor(id,{fresh:true}):null,assertClean:value=>credentials.assertClean(value),registryFor:doc=>organizationPlugins(instanceRoot??dataRoot,doc.plugins).registry}); + system:organization.system,levels,catalogueFor:async(id,doc)=>doc.plugins?.['model-provider']?.length?catalogueFor(id,{fresh:true}):null,assertClean:value=>credentials.assertClean(value),scanDefinition:(name,entry)=>scanDefinitions({[name]:entry},value=>credentials.assertClean(value)),registryFor:doc=>organizationPlugins(instanceRoot??dataRoot,doc.plugins).registry}); return {...service,apply:async(id,decision,caller)=>{ const proposal=service.read(decision); const publish=async()=>{ @@ -900,7 +924,8 @@ export async function startBroker({ return settings; } : null, credentialVerbs: organization - ? credentialVerbs({ credentials, writer: organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot:instanceRoot ?? dataRoot }) }) + ? credentialVerbs({ credentials, writer: organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot:instanceRoot ?? dataRoot }), + organizations: () => Object.keys(businesses), boot: copies?.definitions ? { org: copies.org, definitions: copies.definitions } : null }) : null, // Row 65: a new launch binds only on a harness its business switches on, read from the file // as it stands now; an unreadable file switches none on. @@ -1148,6 +1173,20 @@ export async function startBroker({ .filter((c) => c.instance === business + '/' + role) .map(({ service, date, state, keyId, issuedAt }) => ({ service, date, state, ...(service === 'model' ? { keyId, issuedAt } : {}) })); } + // #1873 T1 follow-up (org-defscan): the launcher's check of the org: definition it is about to + // launch, as it read it from the organization file. Answers where a held credential sits, or null; + // never the text. A malformed ask refuses, so the launch refuses. + function definitionScan({ business, definition, entry } = {}) { + if (typeof business !== 'string' || !Object.hasOwn(businesses, business) || typeof definition !== 'string' + || !definition.startsWith(ORG_PREFIX) || definition.length > 128 || entry === null || typeof entry !== 'object' || Array.isArray(entry)) + throw new BusError('invalid-request'); + let size; + try { size = JSON.stringify(entry).length; } catch { throw new BusError('invalid-request'); } + if (size > 4 * 1024 * 1024) throw new BusError('invalid-request'); + const clean = value => credentials.assertClean(value); + try { clean(definition); } catch { return { held: 'name' }; } + return { held: heldPath(entry, clean) }; + } // C2-03a: each library entry with a sync user fetches once at start, then every 60 s. A pass // still running skips the tick. Only codes are logged, never git's output. const timed = Object.values(businesses).flatMap(business => Object.entries(business.projects ?? {}) @@ -1321,6 +1360,7 @@ export async function startBroker({ sessionClearStart: (r) => broker.sessionClearStart(r), refuse: (cap, code, subject) => broker.refuse(cap, code, subject), credentialStatus, + definitionScan, launches: bound, readers: readCaps, async close() { diff --git a/packages/bus/tests/definition-scan.test.mjs b/packages/bus/tests/definition-scan.test.mjs new file mode 100644 index 000000000..fcff8c9e6 --- /dev/null +++ b/packages/bus/tests/definition-scan.test.mjs @@ -0,0 +1,336 @@ +// #1873 T1 follow-up (org-defscan): bus start runs the live credential store's held-secret check on +// every organization definition, and the launch check asks the bus the same question. A hit names +// the definition and the key, never the text there or the credential's name. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { BusError } from '../src/broker.mjs'; +import { REFUSALS } from '../src/refusals.mjs'; +import { DEFINITION_HOLDS_CREDENTIAL, DEFINITION_SCAN_UNREADABLE, heldPath, scanDefinitions, scanOrganizations } from '../src/definition-scan.mjs'; +import { startBroker } from '../src/runtime.mjs'; +import { organizationWriter } from '../../organization/src/organization.mjs'; +import { bootConfig, loadSystem } from '../../cli/src/config.mjs'; +import { fixture, tmp } from '../../cli/tests/helpers.mjs'; +import { entryFromTemplate, readTemplate } from '../../organization/src/definitions.mjs'; +import { REPO_ROLES, writeJson } from '../../organization/tests/helpers.mjs'; + +// The fixture bus holds SENTINEL as coder's gitea token. +const SENTINEL = 'sk-test-NOT-A-SECRET-1234'; +const UNHELD = 'sk-test-UNHELD-LOOKS-LIKE-A-TOKEN-0000'; + +function files(dir) { + return readdirSync(dir, { withFileTypes: true }).flatMap((e) => e.isDirectory() ? files(join(dir, e.name)) : e.isFile() ? [join(dir, e.name)] : []); +} + +// An organization whose coder names org:builder, a copy of the coder template; `edit` changes the +// builder entry before the file is written. `held` is the coder's gitea token at start. +function org(t, edit = (entry) => entry, { held = SENTINEL } = {}) { + const root = tmp(t, 'defscan-'); + const builder = edit(entryFromTemplate(readTemplate(REPO_ROLES, 'coder'))); + const f = fixture(root, 'acme', (doc) => ({ ...doc, definitions: { builder }, roles: { ...doc.roles, coder: { ...doc.roles.coder, definition: 'org:builder' } } })); + writeFileSync(f.doc.roles.coder.credentials.gitea.file, held); + const file = join(root, 'config/businesses/acme.json'); + const boot = () => bootConfig({ system: loadSystem({ env: f.env }), businessId: 'acme', env: f.env }); + const start = (b = boot()) => startBroker({ dataRoot: f.dataRoot, businesses: b.businesses, organization: b.organization }); + return { root, f, file, boot, start }; +} + +function refusedWithout(e, reason) { + assert.ok(e instanceof BusError, String(e)); + assert.equal(e.code, DEFINITION_HOLDS_CREDENTIAL); + assert.equal(e.reason, reason); + for (const text of [e.message, e.reason, String(e.stack), JSON.stringify(e)]) { + assert.ok(!text.includes(SENTINEL), 'the refusal never carries the value'); + assert.ok(!/gitea/.test(text), 'the refusal never names the credential'); + } + return true; +} + +// The start's outcome: the refusal, or "started" after closing a bus that should not have started. +const outcome = (start) => start.then(async (bus) => { await bus.close(); return 'started'; }, (e) => e); + +function nothingWritten(root) { + for (const path of files(root)) { + if (path.endsWith('.token') || path.endsWith('acme.json')) continue; + assert.ok(!readFileSync(path).includes(SENTINEL), path); + } +} + +test('org-defscan: the refusal code is in the TI refusal table with a next step', () => { + const entry = REFUSALS[DEFINITION_HOLDS_CREDENTIAL]; + assert.ok(entry, 'definition-holds-credential is a known refusal'); + assert.match(entry.rail, /^R\d+:/); + assert.match(entry.nextStep, /definition/); + assert.match(entry.nextStep, /issue the credential again/); + assert.match(entry.nextStep, /For a hire, deny it/); + // The issue-time check's fail-closed code for an organization file it cannot read. + const unreadable = REFUSALS[DEFINITION_SCAN_UNREADABLE]; + assert.ok(unreadable, 'definition-scan-unreadable is a known refusal'); + assert.match(unreadable.rail, /^R\d+/); + assert.match(unreadable.nextStep, /organization file/); + assert.equal(new BusError(DEFINITION_SCAN_UNREADABLE).nextStep, unreadable.nextStep); +}); + +test('org-defscan: a held credential in a definition\'s instructions refuses bus start, naming definition and key only', async (t) => { + const o = org(t, (entry) => ({ ...entry, instructions: `${entry.instructions}\nThe forge token is ${SENTINEL}.\n` })); + refusedWithout(await outcome(o.start()), 'definitions.builder instructions holds a credential this bus holds'); + nothingWritten(o.root); +}); + +test('org-defscan: a held credential in a personality field refuses bus start the same way', async (t) => { + const o = org(t, (entry) => ({ ...entry, personality: { summary: `Careful. Key ${SENTINEL}.`, traits: ['plain'] } })); + refusedWithout(await outcome(o.start()), 'definitions.builder personality.summary holds a credential this bus holds'); + const p = org(t, (entry) => ({ ...entry, personality: { summary: 'Careful.', traits: ['plain', `x${SENTINEL}`] } })); + refusedWithout(await outcome(p.start()), 'definitions.builder personality.traits[1] holds a credential this bus holds'); + nothingWritten(o.root); + nothingWritten(p.root); +}); + +test('org-defscan: a definition in the file but not in the boot copy is checked too', async (t) => { + const o = org(t); + const boot = o.boot(); + // Written after the load and before the bus starts: no instance names it, so only the raw read sees it. + const doc = JSON.parse(readFileSync(o.file, 'utf8')); + doc.definitions.spare = { ...doc.definitions.builder, instructions: `Spare: ${SENTINEL}\n` }; + writeJson(o.file, doc); + refusedWithout(await outcome(o.start(boot)), 'definitions.spare instructions holds a credential this bus holds'); +}); + +test('org-defscan: a clean file starts, and token-looking text the store does not hold starts', async (t) => { + for (const edit of [(entry) => entry, (entry) => ({ ...entry, instructions: `${entry.instructions}\nExample: ${UNHELD}\n`, personality: { summary: `Says ${UNHELD}.` } })]) { + const o = org(t, edit); + const bus = await o.start(); + try { + // The launch check's question: a clean entry answers null, a held one names the key only. + const entry = o.boot().businesses.acme.definitions['org:builder']; + assert.deepEqual(bus.definitionScan({ business: 'acme', definition: 'org:builder', entry }), { held: null }); + const hit = bus.definitionScan({ business: 'acme', definition: 'org:builder', entry: { ...entry, instructions: `x ${SENTINEL}` } }); + assert.deepEqual(hit, { held: 'instructions' }); + // A malformed question refuses rather than answering clean. + for (const bad of [{}, { business: 'acme', definition: 'builder', entry }, { business: 'other', definition: 'org:builder', entry }, + { business: 'acme', definition: 'org:builder', entry: null }, { business: 'acme', definition: 'org:builder', entry: [entry] }]) + assert.throws(() => bus.definitionScan(bad), (e) => e.code === 'invalid-request'); + } finally { + await bus.close(); + } + } +}); + +// The coder's gitea credential as the human verb issues or rotates it. +function verbs(o, bus) { + const human = bus.broker.bindHuman({ business: 'acme', human: o.f.doc.human, via: 'cli', outsideAgent: true }); + return { + issue: (token) => bus.broker.request(human, { verb: 'credentials.issue', args: { agent: 'coder', service: 'gitea', token, date: '2099-01-01' } }), + revoke: () => bus.broker.request(human, { verb: 'credentials.revoke', args: { agent: 'coder', service: 'gitea', reason: 'test' } }), + }; +} +// Whether the bus holds `value` now, asked the way the launch check asks. +const holds = (bus, value) => bus.definitionScan({ business: 'acme', definition: 'org:builder', entry: { instructions: `x ${value}` } }).held !== null; +const state = (bus) => bus.credentials.status().find((e) => e.instance === 'acme/coder' && e.service === 'gitea')?.state; + +function issueRefused(e, value, reason) { + assert.ok(e instanceof BusError, String(e)); + assert.equal(e.code, DEFINITION_HOLDS_CREDENTIAL); + assert.equal(e.reason, reason); + for (const text of [e.message, e.reason, String(e.stack), JSON.stringify(e)]) { + assert.ok(!text.includes(value), 'the refusal never carries the value'); + assert.ok(!/gitea/.test(text), 'the refusal never names the credential'); + } + return true; +} + +// No file under the root holds `value` except the organization file it was written into and that +// file's own history copies. +function onlyInOrganization(root, value) { + for (const path of files(root)) + if (!path.endsWith('acme.json') && !path.includes('/businesses/.history/acme/')) assert.ok(!readFileSync(path).includes(value), path); +} + +test('org-defscan: a credential issued between the final launch admission and spawn is refused at issue; the store stays as it was', async (t) => { + // The definition carries text that is not held at start. After the launch's final admission answers + // ready, the human rotates the coder's token to that text with the same date. On the bus before this + // fix the rotate succeeded and the agent spawned with a held value in its contract. + const FIRST = 'fake_first_token_0123456789'; + const o = org(t, (entry) => ({ ...entry, instructions: `${entry.instructions}\nExample ${SENTINEL}\n` }), { held: 'fake_boot_token_5555555555' }); + const boot = o.boot(); + const bus = await o.start(boot); + t.after(() => bus.close()); + const v = verbs(o, bus); + // A clean issue still works. + assert.equal(v.issue(FIRST).state, 'valid'); + const writer = organizationWriter({ dir: boot.organization.dir, rolesDir: boot.organization.rolesDir, dataRoot: o.f.dataRoot }); + const revision = writer.read('acme').revision; + const changes = files(o.root).filter((p) => p.endsWith('changes.jsonl')).map((p) => [p, readFileSync(p, 'utf8')]); + const entry = o.boot().businesses.acme.definitions['org:builder']; + assert.deepEqual(bus.definitionScan({ business: 'acme', definition: 'org:builder', entry }), { held: null }); + const final = () => bus.admission({ business: 'acme', projects: [], instance: 'coder', revision }); + assert.equal(final().ready, true); + // Between the admission's answer and the spawn: the rotate. + assert.throws(() => v.issue(SENTINEL), (e) => issueRefused(e, SENTINEL, 'organization acme definitions.builder instructions holds the credential being issued')); + // Nothing changed: the old token is held, the new one is not, the file and its receipts are as they were. + assert.ok(holds(bus, FIRST) && !holds(bus, SENTINEL)); + assert.equal(state(bus), 'valid'); + assert.equal(writer.read('acme').revision, revision); + for (const [p, text] of changes) assert.equal(readFileSync(p, 'utf8'), text, p); + onlyInOrganization(o.root, SENTINEL); + assert.equal(final().ready, true, 'the contract the agent gets holds no held value'); +}); + +test('org-defscan: issue over a revoked entry refuses the same way for a personality field, and a clean issue then works', async (t) => { + const o = org(t, (entry) => ({ ...entry, personality: { summary: 'Careful.', traits: ['plain', `uses ${SENTINEL}`] } }), { held: 'fake_boot_token_5555555555' }); + const bus = await o.start(); + t.after(() => bus.close()); + const v = verbs(o, bus); + v.revoke(); + assert.equal(state(bus), 'revoked'); + assert.throws(() => v.issue(SENTINEL), (e) => issueRefused(e, SENTINEL, 'organization acme definitions.builder personality.traits[1] holds the credential being issued')); + assert.equal(state(bus), 'revoked'); + assert.ok(!holds(bus, SENTINEL)); + onlyInOrganization(o.root, SENTINEL); + const issued = v.issue('fake_clean_token_4444444444'); + assert.deepEqual([issued.verb, issued.state], ['issue', 'valid']); +}); + +test('org-defscan: a value held without the issue verb still refuses the launch at its final admission, even when the file revision is unchanged', async (t) => { + // Defence behind the issue check: the store comes to hold text already in the definition by a path + // other than the human verb, which leaves the organization file's bytes, so its revision, as they were. + const ROTATED = 'fake_rotated_token_9876543210'; + const o = org(t, (entry) => ({ ...entry, instructions: `${entry.instructions}\nExample ${ROTATED}\n` })); + const boot = o.boot(); + const bus = await o.start(boot); + t.after(() => bus.close()); + const writer = organizationWriter({ dir: boot.organization.dir, rolesDir: boot.organization.rolesDir, dataRoot: o.f.dataRoot }); + const revision = writer.read('acme').revision; + const final = () => bus.admission({ business: 'acme', projects: [], instance: 'coder', revision }); + assert.equal(final().ready, true); + bus.credentials.issue('acme/coder', 'gitea', ROTATED, '2099-01-01'); + assert.equal(writer.read('acme').revision, revision, 'the store change left the file as it was'); + assert.deepEqual(final(), { ready: false, revision: null, code: DEFINITION_HOLDS_CREDENTIAL }); + // An instance on a shipped template is not asked about a definition. + assert.equal(bus.admission({ business: 'acme', projects: [], instance: 'reviewer', revision }).ready, true); +}); + +test('org-defscan: issue checks the other organizations the bus serves, and refuses when one\'s file cannot be read', async (t) => { + // The bus serves acme and beta. beta's file has a definition holding text acme's coder then tries to + // be issued; later beta's file stops being JSON. Neither issue changes acme's store or file. + const o = org(t, (entry) => entry, { held: 'fake_boot_token_5555555555' }); + const boot = o.boot(); + const betaFile = join(o.root, 'config/businesses/beta.json'); + const acme = JSON.parse(readFileSync(o.file, 'utf8')); + const spare = { ...acme.definitions.builder, personality: { summary: 'Spare.', voice: `quotes ${SENTINEL}` } }; + writeJson(betaFile, { ...acme, id: 'beta', definitions: { spare } }); + const bus = await o.start({ ...boot, businesses: { ...boot.businesses, beta: { ...boot.businesses.acme, id: 'beta' } } }); + t.after(() => bus.close()); + const v = verbs(o, bus); + const writer = organizationWriter({ dir: boot.organization.dir, rolesDir: boot.organization.rolesDir, dataRoot: o.f.dataRoot }); + const revision = writer.read('acme').revision; + assert.throws(() => v.issue(SENTINEL), (e) => issueRefused(e, SENTINEL, 'organization beta definitions.spare personality.voice holds the credential being issued')); + writeFileSync(betaFile, '{ not json'); + const CLEAN = 'fake_clean_token_4444444444'; + assert.throws(() => v.issue(CLEAN), (e) => e instanceof BusError && e.code === DEFINITION_SCAN_UNREADABLE && !JSON.stringify(e).includes(CLEAN) && !String(e.stack).includes(CLEAN)); + for (const value of [SENTINEL, CLEAN]) assert.ok(!holds(bus, value)); + assert.equal(state(bus), 'valid'); + assert.equal(writer.read('acme').revision, revision); + onlyInOrganization(o.root, CLEAN); + // With beta's file readable again, the clean issue goes through. + writeJson(betaFile, { ...acme, id: 'beta', definitions: {} }); + assert.equal(v.issue(CLEAN).state, 'valid'); + assert.ok(holds(bus, CLEAN)); +}); + +test('org-defscan: the issue check covers every organization the bus serves, and an unreadable file refuses', () => { + const held = 'HELD-VALUE-77'; + const assertClean = (value) => { if ((JSON.stringify(value) ?? '').includes(held)) throw new BusError('credential-leak'); }; + const own = { id: 'acme', document: { definitions: { builder: { instructions: 'fine' } } } }; + const files = { beta: { readable: true, document: { definitions: { spare: { instructions: 'fine', personality: { voice: `say ${held}` } } } } }, + gamma: { readable: true, document: {} }, broken: { readable: false } }; + const reads = []; + const readRaw = (id) => { reads.push(id); if (id === 'throws') throw new Error('EACCES'); return files[id]; }; + assert.doesNotThrow(() => scanOrganizations({ organizations: ['acme', 'gamma'], own, readRaw }, assertClean)); + assert.deepEqual(reads, ['gamma'], 'the issuing organization is the document already read'); + assert.throws(() => scanOrganizations({ organizations: ['acme', 'gamma', 'beta'], own, readRaw }, assertClean), + (e) => e.code === DEFINITION_HOLDS_CREDENTIAL && e.reason === 'organization beta definitions.spare personality.voice holds the credential being issued' && !JSON.stringify(e).includes(held)); + assert.throws(() => scanOrganizations({ organizations: ['acme', { ...own, id: 'x' }.id], own: { id: 'acme', document: { definitions: { [held]: {} } } }, readRaw: () => ({ readable: true, document: {} }) }, assertClean), + (e) => e.reason === 'organization acme definitions.* key holds the credential being issued' && !e.reason.includes(held)); + for (const bad of ['broken', 'throws', 'missing']) + assert.throws(() => scanOrganizations({ organizations: ['acme', bad], own, readRaw }, assertClean), (e) => e.code === DEFINITION_SCAN_UNREADABLE && e.reason === undefined); +}); + +test('org-defscan: heldPath names the first place, a key by its parent, and never the text', () => { + const held = 'HELD-VALUE-77'; + const assertClean = (value) => { if ((JSON.stringify(value) ?? '').includes(held)) throw new BusError('credential-leak'); }; + assert.equal(heldPath({ instructions: 'fine', personality: { traits: ['a', 'b'] } }, assertClean), null); + assert.equal(heldPath({ instructions: 'fine', personality: { traits: ['a', `b${held}`] } }, assertClean), 'personality.traits[1]'); + assert.equal(heldPath({ role: { tools: [held] } }, assertClean), 'role.tools[0]'); + assert.equal(heldPath({ personality: { [held]: 'x' } }, assertClean), 'personality key'); + assert.equal(heldPath({ [`${held}`]: 'x' }, assertClean), 'key'); + assert.throws(() => scanDefinitions({ 'org:a': { instructions: 'ok' }, 'org:b': { instructions: held } }, assertClean, { prefix: 'org:' }), + (e) => e.code === DEFINITION_HOLDS_CREDENTIAL && e.reason === 'definitions.b instructions holds a credential this bus holds'); + // A name outside the loader's pattern is not quoted. + assert.throws(() => scanDefinitions({ 'Bad Name': { instructions: held } }, assertClean), (e) => e.reason === 'definitions.* instructions holds a credential this bus holds'); + assert.throws(() => scanDefinitions({ [held]: { instructions: 'ok' } }, assertClean), (e) => e.reason === 'definitions.* key holds a credential this bus holds' && !e.reason.includes(held)); + assert.doesNotThrow(() => scanDefinitions(undefined, assertClean)); +}); + +test('org-defscan: an approved hire whose definition holds a credential issued after the raise refuses before the decision is consumed', async (t) => { + // The chief raises a hire whose instructions carry text the store does not hold yet. The proposal is + // private, so the issue-time check cannot see it, and the store then comes to hold that text. The + // approval runs the same definition check as start, launch and issue: the refusal names the code, the + // decision stays open, and the organization file is as it was. + const LATER = 'fake_later_token_0123456789'; + const o = org(t, (entry) => entry, { held: 'fake_boot_token_5555555555' }); + const boot = o.boot(); + const bus = await o.start(boot); + t.after(() => bus.close()); + const chief = bus.broker.bindLaunch({ business: 'acme', role: 'chief', run: 'chief-run', harness: 'pi' }); + bus.broker.request(chief, { verb: 'role.claim' }); + const inline = { tools: ['read'], network: 'none', authority: { withinRole: [], crossRole: [] }, credentials: [], instructions: `Read and report.\nExample ${LATER}\n` }; + const raised = await bus.broker.request(chief, { verb: 'hire.raise', args: { op: 'hire', level: 'org', name: 'scout', role: { inline }, route: { harness: 'pi', model: 'glm-5.3-flash' }, reason: 'Need help' } }); + // Issued through the store, so the organization file and its revision stay as they were and the + // approval reaches the definition check rather than the revision check. + bus.credentials.issue('acme/coder', 'gitea', LATER, '2099-01-01'); + assert.ok(holds(bus, LATER)); + const writer = organizationWriter({ dir: boot.organization.dir, rolesDir: boot.organization.rolesDir, dataRoot: o.f.dataRoot }); + const before = readFileSync(o.file); + const revision = writer.read('acme').revision; + const owner = bus.broker.bindConsole({ business: 'acme', human: o.f.doc.human, subject: { provider: 'test', iss: 'https://issuer.test', sub: 'owner' } }); + const refused = (e) => { + assert.equal(e.code, DEFINITION_HOLDS_CREDENTIAL); + for (const text of [e.message, String(e.reason), String(e.stack), JSON.stringify(e)]) assert.ok(!text.includes(LATER), 'the refusal never carries the value'); + return true; + }; + await assert.rejects(bus.broker.request(owner, { verb: 'hire.resolve.console', args: { id: raised.id, choice: 'yes', digest: raised.hire.digest } }), refused); + // Nothing was written. + assert.deepEqual(readFileSync(o.file), before); + assert.equal(writer.read('acme').revision, revision); + assert.ok(!Object.hasOwn(writer.read('acme').document.roles, 'scout')); + // The decision was not consumed: a second approval meets the same check, not decision-closed. + await assert.rejects(bus.broker.request(owner, { verb: 'hire.resolve.console', args: { id: raised.id, choice: 'yes', digest: raised.hire.digest } }), refused); + assert.deepEqual(readFileSync(o.file), before); + // A hire with clean text, raised and approved on the same bus, is written as before. + const clean = await bus.broker.request(chief, { verb: 'hire.raise', args: { op: 'hire', level: 'org', name: 'helper', role: { inline: { ...inline, instructions: 'Read and report.\n' } }, route: { harness: 'pi', model: 'glm-5.3-flash' }, reason: 'Need help' } }); + assert.equal((await bus.broker.request(owner, { verb: 'hire.resolve.console', args: { id: clean.id, choice: 'yes', digest: clean.hire.digest } })).choice, 'yes'); + assert.ok(Object.hasOwn(writer.read('acme').document.roles, 'helper')); + onlyInOrganization(join(o.root, 'config/businesses'), LATER); +}); + +test('org-defscan: the boot copy snapshot\'s definitions (the root agents file\'s) are checked at start and at issue', async (t) => { + // A4: a root definition reaches an organization through the boot copy snapshot, which every reload + // re-applies. Its text is in no organization file, so the start and issue checks read the snapshot. + const LATER = 'fake_later_token_0123456789'; + const snapshot = (instructions) => ({ org: 'acme', root: 'ok', rootAgents: {}, copies: {}, + definitions: { rooted: { ...entryFromTemplate(readTemplate(REPO_ROLES, 'coder')), instructions } } }); + const startWith = (o, copies, b = o.boot()) => startBroker({ dataRoot: o.f.dataRoot, businesses: b.businesses, organization: b.organization, copies }); + // Held at start: the start refuses, naming the snapshot's definition and key. + const held = org(t); + refusedWithout(await outcome(startWith(held, snapshot(`Root copy ${SENTINEL}\n`))), 'boot copy definitions.rooted instructions holds a credential this bus holds'); + // Not held at start: the bus starts, then issuing that text refuses and the store is as it was. + const o = org(t, (entry) => entry, { held: 'fake_boot_token_5555555555' }); + const bus = await startWith(o, snapshot(`Root copy ${LATER}\n`)); + t.after(() => bus.close()); + assert.throws(() => verbs(o, bus).issue(LATER), (e) => issueRefused(e, LATER, 'organization acme boot copy definitions.rooted instructions holds the credential being issued')); + assert.ok(!holds(bus, LATER)); + assert.equal(state(bus), 'valid'); + assert.equal(verbs(o, bus).issue('fake_clean_token_4444444444').state, 'valid'); +}); diff --git a/packages/cli/src/boot-status.mjs b/packages/cli/src/boot-status.mjs index 67cdf40d6..97931a0e5 100644 --- a/packages/cli/src/boot-status.mjs +++ b/packages/cli/src/boot-status.mjs @@ -37,9 +37,10 @@ function write(dataRoot, status, event) { appendFileSync(bootEventsFile(dataRoot), `${JSON.stringify(event)}\n`, { mode: 0o600 }); } -// A start refused: keeps the time of the last good load from the status it replaces. -export function recordBootRefused(dataRoot, { business, file, reason, at = new Date().toISOString() }) { - const prior = readBootStatus(dataRoot); +// A start refused: keeps the time of the last good load from the status it replaces. `prior` is the +// status from before this start, for a refusal that comes after the start recorded its load (org-defscan: +// the broker's held-secret check on the definitions). +export function recordBootRefused(dataRoot, { business, file, reason, at = new Date().toISOString(), prior = readBootStatus(dataRoot) }) { const lastGood = prior?.ok ? prior.at : prior?.lastGood ?? null; const text = String(reason).slice(0, REASON_MAX); write(dataRoot, { ok: false, at, business, file, reason: text, lastGood }, { event: "bus.boot.refused", at, business, file, reason: text }); diff --git a/packages/cli/src/cli.mjs b/packages/cli/src/cli.mjs index 0d314951c..314ff713f 100644 --- a/packages/cli/src/cli.mjs +++ b/packages/cli/src/cli.mjs @@ -651,9 +651,14 @@ async function bus(rest, io, deps) { throw e; } if (instance) boot.consoleOwners = [...(boot.consoleOwners ?? []), DEV_OWNER]; + const priorBoot = readBootStatus(org.root); try { recordBootOk(org.root, { business: businessId, file }); } catch { /* status is advisory */ } const binding = readNotifyConfig(org.root, businessId); - const host = await startHost({ boot, business: businessId, notifier: binding ? { binding } : null, refuseStale: true, options: { devLocalOwner: Boolean(instance), pm: parsed.flags.has("--pm"), noLaunch: !launches } }); + const host = await startHost({ boot, business: businessId, notifier: binding ? { binding } : null, refuseStale: true, options: { devLocalOwner: Boolean(instance), pm: parsed.flags.has("--pm"), noLaunch: !launches } }).catch((e) => { + // org-defscan: the broker refused a definition that holds a credential it holds; the load was not good after all. + if (e.code === "definition-holds-credential") try { recordBootRefused(org.root, { business: businessId, file, reason: e.message, prior: priorBoot }); } catch { /* the refusal still throws */ } + throw e; + }); try { // A dev fixture serves only the organizations that use it; their launches carry no provider credential. const orgDoc = instance ? JSON.parse(readFileSync(file, "utf8")) : undefined; diff --git a/packages/cli/src/host.mjs b/packages/cli/src/host.mjs index 62bc7d3be..8286713af 100644 --- a/packages/cli/src/host.mjs +++ b/packages/cli/src/host.mjs @@ -228,7 +228,10 @@ export async function startHost({ boot, business, notifier = null, options = nul if (ready?.ok !== true) { await ended(broker, 5000); release(); - throw new CliError(`broker refused to start: ${typeof ready?.error === "string" ? ready.error : "startup-refused"}`, 3); + const code = typeof ready?.error === "string" ? ready.error : "startup-refused"; + // org-defscan: the broker's reason names the definition and key that hold a credential, never the text. + const why = code === "definition-holds-credential" && typeof ready?.reason === "string" ? `: ${ready.reason.slice(0, 300)}` : ""; + throw Object.assign(new CliError(`broker refused to start: ${code}${why}`, 3), code === "definition-holds-credential" ? { code } : {}); } // Every step from here to host.json shares one failure path: it stops the diff --git a/packages/cli/src/launcher.mjs b/packages/cli/src/launcher.mjs index 7b82d65ee..49299f2c5 100644 --- a/packages/cli/src/launcher.mjs +++ b/packages/cli/src/launcher.mjs @@ -380,9 +380,11 @@ export function createLauncher({ // workspace and the thread's store, so no session of the instance starts while one lives. if (threaded && [...registry.sessions, ...leftover, ...survivors.filter(liveRun)].some((s) => s.business === b.id && s.instance === instance && !s.session)) throw new Refusal("legacy-run-live", `a run of ${instance} with no session is still live`); - let resolved; + let resolved, definitionEntry; try { resolved = resolveInstance({ system: vars, business: b, instance, enforcePlugins:true, pluginRoot:system.instanceRoot ?? dataRoot }); + // org-defscan (#1873): the entry the contract came from, taken before any await lets another check replace b. + definitionEntry = String(resolved.definition).startsWith("org:") ? b.definitions?.[resolved.definition] : undefined; } catch (e) { throw new Refusal(e.code === "plugin-disabled" ? "plugin-disabled" : "unsupported-harness", e.code === "plugin-disabled" ? e.plugin : e.message); } @@ -413,6 +415,12 @@ export function createLauncher({ const primary = routeOf("primary"); const fallback = hasFallback(resolved) ? routeOf("fallback") : null; if (!resolved.contract && typeof resolved.instructions !== "string") throw new Refusal("no-role-contract", `role ${resolved.definition} has no contract file`); + // org-defscan (#1873): an org: definition, as this check just read it, passes the bus's held-secret check before a contract exists. + if (String(resolved.definition).startsWith("org:")) { + const { held } = (await host.op({ op: "definitionScan", business: b.id, definition: resolved.definition, entry: definitionEntry })) ?? {}; + if (typeof held === "string") throw new Refusal("definition-holds-credential", `${instance}'s definition ${resolved.definition} holds a credential in ${held}`); + if (held !== null) throw new Refusal("organization-refused", `the bus did not check ${instance}'s definition ${resolved.definition}`); + } // Row 64 (#1557): every skill on the role's list is enabled and readable before a run exists. try { readSkills(resolved.skills, skillsDir); diff --git a/packages/cli/tests/definition-scan.test.mjs b/packages/cli/tests/definition-scan.test.mjs new file mode 100644 index 000000000..238a0e05c --- /dev/null +++ b/packages/cli/tests/definition-scan.test.mjs @@ -0,0 +1,129 @@ +// #1873 T1 follow-up (org-defscan): a definition that holds a credential the bus holds refuses at +// launch (the launch check asks the bus before a contract exists) and at `mosaic bus start`, and the +// value reaches no refusal, record or log. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { createLauncher } from '../src/launcher.mjs'; +import { bootConfig, loadSystem } from '../src/config.mjs'; +import { readBootStatus, bootEventsFile } from '../src/boot-status.mjs'; +import { hostFile } from '../src/host.mjs'; +import { startBroker } from '../../bus/src/runtime.mjs'; +import { initializeControl } from '../../processing-control/src/state.mjs'; +import { launchLogFile } from '../../seat/src/session.mjs'; +import { entryFromTemplate, readTemplate } from '../../organization/src/definitions.mjs'; +import { reserveOrg, activateOrg } from '../../organization/src/org-registry.mjs'; +import { REPO_ROLES, writeJson } from '../../organization/tests/helpers.mjs'; +import { fixture, notifyConfig, tmp, withoutHostCredentials } from './helpers.mjs'; + +// The fixture bus holds SENTINEL as coder's gitea token. +const SENTINEL = 'sk-test-NOT-A-SECRET-1234'; +const CLI = fileURLToPath(new URL('../src/cli.mjs', import.meta.url)); + +function files(dir) { + if (!existsSync(dir)) return []; + return readdirSync(dir, { withFileTypes: true }).flatMap((e) => e.isDirectory() ? files(join(dir, e.name)) : e.isFile() ? [join(dir, e.name)] : []); +} +const withBuilder = (builder) => (doc) => ({ ...doc, definitions: { builder }, roles: { ...doc.roles, coder: { ...doc.roles.coder, definition: 'org:builder' } } }); + +test('org-defscan: a file edited after start to hold a held credential refuses the next launch of an instance naming it; a template launch still works', async (t) => { + const root = tmp(t); + const f = fixture(root, 'acme', withBuilder(entryFromTemplate(readTemplate(REPO_ROLES, 'coder')))); + const file = join(root, 'config/businesses/acme.json'); + writeFileSync(f.doc.roles.coder.credentials.gitea.file, SENTINEL); + withoutHostCredentials(f.env, { piKey: true }); + initializeControl(f.dataRoot); + const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: 'acme', env: f.env }); + const bus = await startBroker({ dataRoot: f.dataRoot, businesses: boot.businesses, organization: boot.organization }); + t.after(() => bus.close()); + // The real host's op path for the scan, over a structured clone as IPC carries it; the fixture + // stops at authorization, so "reached authorizeLaunch" is "would have launched". + const ops = []; + const host = { business: 'acme', beforeClose: () => {}, op: async (x) => { + ops.push(x.op); + if (x.op === 'identity') return { business: 'acme', role: 'pm' }; + if (x.op === 'mayLaunch') return { allow: true }; + if (x.op === 'authorizeLaunch') throw Object.assign(Error('fixture authorization stop'), { code: 'fixture-policy' }); + if (x.op === 'definitionScan') return structuredClone(bus.definitionScan(structuredClone({ business: x.business, definition: x.definition, entry: x.entry }))); + return {}; + } }; + const launcher = createLauncher({ host, system: JSON.parse(readFileSync(f.config, 'utf8')), env: f.env, bwrap: '/usr/bin/true', log: () => {} }); + t.after(() => launcher.close?.()); + + // Clean: the scan passes and the launch reaches authorization. + assert.equal((await launcher.fromSession('fixture-cap', 'coder')).error, 'fixture-policy'); + assert.ok(ops.includes('definitionScan')); + + // Edited by hand while the bus runs. + const doc = JSON.parse(readFileSync(file, 'utf8')); + doc.definitions.builder.instructions += `\nThe forge token is ${SENTINEL}.\n`; + writeJson(file, doc); + ops.length = 0; + assert.deepEqual(await launcher.fromSession('fixture-cap', 'coder'), { ok: false, error: 'definition-holds-credential' }); + assert.ok(!ops.includes('authorizeLaunch'), 'nothing launches'); + const event = JSON.parse(readFileSync(launchLogFile(f.dataRoot, 'acme'), 'utf8').trim().split('\n').at(-1)); + assert.equal(event.code, 'definition-holds-credential'); + assert.equal(event.instance, 'coder'); + assert.match(event.why, /coder's definition org:builder holds a credential in instructions/); + + // A personality field is checked the same way. + doc.definitions.builder.instructions = doc.definitions.builder.instructions.replace(SENTINEL, 'REDACTED'); + doc.definitions.builder.personality = { summary: 'Plain.', voice: `Signs off with ${SENTINEL}` }; + writeJson(file, doc); + assert.deepEqual(await launcher.fromSession('fixture-cap', 'coder'), { ok: false, error: 'definition-holds-credential' }); + assert.match(JSON.parse(readFileSync(launchLogFile(f.dataRoot, 'acme'), 'utf8').trim().split('\n').at(-1)).why, /in personality\.voice$/); + + // An instance on a shipped template launches as before, and asks no scan. + ops.length = 0; + assert.equal((await launcher.fromSession('fixture-cap', 'reviewer')).error, 'fixture-policy'); + assert.ok(!ops.includes('definitionScan')); + + // The value reached no record the bus or the launcher wrote. + for (const path of files(f.dataRoot)) assert.ok(!readFileSync(path).includes(SENTINEL), path); +}); + +// A bootstrapped instance with an active organization whose file names org:builder. +async function orgFixture(t, builder) { + const root = tmp(t, 'o-'); + const f = fixture(root, 'acme', withBuilder(builder)); + const configDir = join(root, '.config', 'mosaic-boot'); + const env = { ...f.env, HOME: root, MOSAIC_CONFIG: join(configDir, 'config.json') }; + const bootstrap = spawnSync(join(process.cwd(), 'scripts/bootstrap.sh'), ['--instance', 'boot'], { env, encoding: 'utf8' }); + assert.equal(bootstrap.status, 0, bootstrap.stderr); + mkdirSync(join(configDir, 'businesses'), { recursive: true, mode: 0o700 }); + const file = join(configDir, 'businesses', 'acme.json'); + writeFileSync(file, readFileSync(join(root, 'config/businesses/acme.json')), { mode: 0o600 }); + writeFileSync(f.doc.roles.coder.credentials.gitea.file, SENTINEL); + const dataRoot = join(root, '.mosaic-boot'); + const ctx = { configDir, dataRoot, instance: 'boot' }; + const org = await reserveOrg(ctx, 'acme'); + await activateOrg(ctx, 'acme'); + // A notifier config, so a start that passes the load goes on to start the broker. + notifyConfig(org.root, 'acme', null); + return { env, file, dataRoot, orgRoot: org.root }; +} + +test('org-defscan: mosaic bus start refuses a held credential in a definition, records why, and the value is nowhere', async (t) => { + const builder = entryFromTemplate(readTemplate(REPO_ROLES, 'coder')); + const o = await orgFixture(t, { ...builder, instructions: `${builder.instructions}\nThe forge token is ${SENTINEL}.\n` }); + const run = (...args) => spawnSync(process.execPath, [CLI, ...args], { env: o.env, encoding: 'utf8', timeout: 60000 }); + const r = run('bus', 'start', 'acme', '--detach', '--no-launch'); + // Should a start get through (it must not), stop it before the fixture's directory goes. + if (r.status === 0 || existsSync(hostFile(o.orgRoot))) run('bus', 'stop', 'acme'); + assert.equal(r.status, 3, r.stderr); + assert.ok(!existsSync(join(o.orgRoot, 'bus', 'broker.sock')), 'no socket'); + const s = readBootStatus(o.orgRoot); + assert.equal(s.ok, false); + assert.equal(s.business, 'acme'); + assert.match(s.reason, /definition-holds-credential: definitions\.builder instructions holds a credential this bus holds/); + assert.equal(s.lastGood, null, 'the load that the broker then refused is not a good load'); + const events = readFileSync(bootEventsFile(o.orgRoot), 'utf8').trim().split('\n').map(JSON.parse); + assert.equal(events.at(-1).event, 'bus.boot.refused'); + const status = run('bus', 'status', 'acme', '--json'); + assert.equal(JSON.parse(status.stdout).boot.ok, false); + for (const text of [r.stdout, r.stderr, status.stdout, status.stderr]) assert.ok(!text.includes(SENTINEL)); + for (const path of files(o.dataRoot)) assert.ok(!readFileSync(path).includes(SENTINEL), path); +}); diff --git a/packages/cli/tests/lifecycle.test.mjs b/packages/cli/tests/lifecycle.test.mjs index e074ba346..7548107d6 100644 --- a/packages/cli/tests/lifecycle.test.mjs +++ b/packages/cli/tests/lifecycle.test.mjs @@ -91,6 +91,8 @@ async function start(t, { f, adapter }, options = {}) { refuse: (m) => runtime.refuse(m.cap, m.code, m.subject), endLaunch: (m) => runtime.endLaunch(m.record), credentialStatus: (m) => runtime.credentialStatus(m.business, m.role), + // org-defscan (#1873): an org: definition's launch asks the bus's held-secret check, as process.mjs answers it. + definitionScan: (m) => runtime.definitionScan({ business: m.business, definition: m.definition, entry: m.entry }), threadsState: (m) => runtime.threadsState(m.business), threadIdle: (m) => runtime.threadIdle(m.record), mayLaunch: (m) => runtime.mayLaunch(m), diff --git a/packages/cli/tests/org-definitions.test.mjs b/packages/cli/tests/org-definitions.test.mjs index 1f2f6371b..d29c96739 100644 --- a/packages/cli/tests/org-definitions.test.mjs +++ b/packages/cli/tests/org-definitions.test.mjs @@ -21,6 +21,7 @@ const host = { business: 'acme', beforeClose: () => {}, op: async (x) => { if (x.op === 'identity') return { business: 'acme', role: 'pm' }; if (x.op === 'mayLaunch') return { allow: true }; if (x.op === 'authorizeLaunch') throw stop(); + if (x.op === 'definitionScan') return { held: null }; return {}; } }; diff --git a/packages/organization/src/hire-decisions.mjs b/packages/organization/src/hire-decisions.mjs index 58ba8934c..138618b8d 100644 --- a/packages/organization/src/hire-decisions.mjs +++ b/packages/organization/src/hire-decisions.mjs @@ -35,7 +35,15 @@ const publicOf = p => ({ id:p.id, revision:p.revision, op:p.request.op, level:p. ...(p.entry ? {role:structuredClone(p.entry.role)} : {}), // reason and all free text stay in the private proposal as well. flagged:p.flagged, credentialLines:p.credentialLines, expiresAt:p.expiresAt, digest:hash(p) }); -export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertClean, system = {}, levels = () => null, catalogueFor = async () => null, registryFor = () => buildRegistry() }) { +// org-defscan (#1873): `scanDefinition(name, entry)` is the bus's definition check on the approved +// entry; it throws (definition-holds-credential, naming the definition and key) when the live store +// holds text in it. Without one, the entry still goes through assertClean whole. +export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertClean, scanDefinition, system = {}, levels = () => null, catalogueFor = async () => null, registryFor = () => buildRegistry() }) { + const scanEntry = (name, entry) => { + if (typeof scanDefinition === 'function') return scanDefinition(name, structuredClone(entry)); + if (typeof assertClean !== 'function') fail('definition-secret-check-required'); + assertClean(entry); + }; const home = join(dataRoot,'authority-proposals'); let receiptCache; const rootReceipts=()=>{ @@ -151,6 +159,9 @@ export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertCle async function adjusted(business, decision, adjust) { const p=read(decision); if(p.business!==business)fail('decision-stale'); + // org-defscan (#1873): the stored entry is the definition text an approval writes. A credential + // issued after the proposal refuses it here, before preview, check or apply goes further. + if(p.entry)scanEntry(p.definition,p.entry); if(adjust===undefined)return p; keys(adjust,['role','credentials','route']); if(!p.entry)fail('invalid-request'); @@ -216,6 +227,8 @@ export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertCle const vars=r.route ? {harness:r.route.harness,model:r.route.model,...(r.route.thinking?{thinking:r.route.thinking}:{})} : {}; const currentDoc=r.level==='root'?(await readRootAgentsFile(configDir)).document:writer.read(business).document; const approvedEntry=p.entry?{...p.entry,rev:(currentDoc.definitions?.[p.definition]?.rev??0)+1}:null; + // org-defscan (#1873): check() ran before the awaits above; the entry the writers get is checked again. + if(approvedEntry)scanEntry(p.definition,approvedEntry); const excluded=r.level==='root' && r.op==='change' ? currentDoc.agents[r.name]?.excludeOrgs : undefined; const entry=r.op==='fire'?null:{definition:`org:${p.definition}`,vars,hireDecision:p.id,...(excluded===undefined?{}:{excludeOrgs:excluded})}; if(r.level==='root') {