From 5dbd7e72d31e1f41ce88bc2bdf8cdb56e8c1d33b Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Wed, 11 Nov 2026 13:31:57 -0600 Subject: [PATCH] fix(org): copy binding review round, CB1 (#1873) Security review P2s: - A binding proposal keeps only its structured request fields. The free-text reason is never written to the private proposal or covered by its digest. - A root copy's receipts are checked at prepare, at check, and again under the organization lock before the write and before publication. A copy removed, replaced or unreadable since boot refuses with instance-fired, copy-stale or root-policy-unreadable. Build review P2: - A root copy's admitted projects come from the project unions, recomputed from the current document and the boot's root agents. A project that excludes the copy, or whose union refuses, is never in reach; a request naming it refuses with credential-scope-exceeded. Build review P3s: tests for proposal-time shadowing, the loader's copyBindings validation, a pending record that covers every service, a stricter path check on the refusal table, and refusal codes on every bare rejects in the binding tests. --- packages/bus/tests/copy-bindings.test.mjs | 14 +-- packages/organization/src/copy-bindings.mjs | 7 +- packages/organization/src/hire-decisions.mjs | 31 +++++- .../organization/tests/copy-bindings.test.mjs | 101 +++++++++++++++++- 4 files changed, 136 insertions(+), 17 deletions(-) diff --git a/packages/bus/tests/copy-bindings.test.mjs b/packages/bus/tests/copy-bindings.test.mjs index db251b1a6..2a44096ac 100644 --- a/packages/bus/tests/copy-bindings.test.mjs +++ b/packages/bus/tests/copy-bindings.test.mjs @@ -76,8 +76,8 @@ test('CB1 bus: no autonomy level routes a binding away from the owner', async t const d = await f.raise(); assert.equal(d.route_to, 'human', authority); // The authority resolver cannot take it, nor can the chief through the agent verb. - await assert.rejects(f.call(resolver, 'decision.resolve', { id: d.id, choice: 'yes' }), authority); - await assert.rejects(f.call(f.caps.chief, 'decision.resolve', { id: d.id, choice: 'yes' }), authority); + await assert.rejects(f.call(resolver, 'decision.resolve', { id: d.id, choice: 'yes' }), { code: 'hire-resolution-required' }, authority); + await assert.rejects(f.call(f.caps.chief, 'decision.resolve', { id: d.id, choice: 'yes' }), { code: 'hire-resolution-required' }, authority); assert.equal(f.writes(), 0, authority); assert.deepEqual(f.bindings(), {}, authority); // A project-level unbind is not root; it lists the lines it removes and still goes to the owner. @@ -86,7 +86,7 @@ test('CB1 bus: no autonomy level routes a binding away from the owner', async t const u = await f.raise({ level: 'project', project: 'stack', name: 'checker', binding: { services: null } }); assert.equal(u.hire.level, 'project'); assert.equal(u.route_to, 'human'); - await assert.rejects(f.call(resolver, 'decision.resolve', { id: u.id, choice: 'yes' })); + await assert.rejects(f.call(resolver, 'decision.resolve', { id: u.id, choice: 'yes' }), { code: 'hire-resolution-required' }); assert.equal(f.bindings()['checker.stack'].state, 'pending'); } } @@ -99,9 +99,9 @@ test('CB1 bus: members with org.hire or decide, the tester and a reader cannot r await assert.rejects(f.call(f.member(['org.hire']), 'hire.resolve.console', { id: d.id, choice: 'yes', digest }), { code: 'human-required' }); await assert.rejects(f.call(f.member(['org.hire', 'decide'], { instanceOwner: true }), 'hire.resolve.console', { id: d.id, choice: 'yes', digest }), { code: 'human-required' }); await assert.rejects(f.call(f.member(['decide']), 'hire.resolve.console', { id: d.id, choice: 'yes', digest }), { code: 'right-required' }); - await assert.rejects(f.call(f.b.bindTester({ business: 'demo' }), 'hire.resolve.console', { id: d.id, choice: 'yes', digest })); - await assert.rejects(f.call(f.b.bindReader({ business: 'demo' }), 'hire.resolve.console', { id: d.id, choice: 'yes', digest })); - await assert.rejects(f.call(f.owner, 'decision.resolve', { id: d.id, choice: 'yes' })); + await assert.rejects(f.call(f.b.bindTester({ business: 'demo' }), 'hire.resolve.console', { id: d.id, choice: 'yes', digest }), { code: 'human-required' }); + await assert.rejects(f.call(f.b.bindReader({ business: 'demo' }), 'hire.resolve.console', { id: d.id, choice: 'yes', digest }), { code: 'human-required' }); + await assert.rejects(f.call(f.owner, 'decision.resolve', { id: d.id, choice: 'yes' }), { code: 'human-required' }); assert.equal(f.writes(), 0); assert.deepEqual(f.bindings(), {}); await assert.rejects(f.call(f.owner, 'hire.resolve.console', { id: d.id, choice: 'yes', digest: '0'.repeat(64) }), { code: 'decision-stale' }); @@ -130,6 +130,6 @@ test('CB1 bus: every new refusal has a table entry with a next step', () => { for (const code of ['organization-bindings-protected', 'credential-binding-changed', 'credential-role-changed', 'credential-definition-unpinned', 'credential-scope-exceeded', 'credential-unbound', 'copy-unavailable', 'copy-refused']) { assert.ok(Object.hasOwn(REFUSALS, code), code); assert.ok(REFUSALS[code].nextStep?.length > 20, code); - assert.ok(!REFUSALS[code].nextStep.includes('/home'), code); + assert.ok(!/(^|[\s("'`])(\/|~\/)/.test(REFUSALS[code].nextStep), code); } }); diff --git a/packages/organization/src/copy-bindings.mjs b/packages/organization/src/copy-bindings.mjs index 3c149d02e..3a3752745 100644 --- a/packages/organization/src/copy-bindings.mjs +++ b/packages/organization/src/copy-bindings.mjs @@ -252,7 +252,9 @@ export function validateCopyBindings(value, { trackers = [] } = {}) { // record copyBindings[instance] from the organization document, or undefined // pin definitionPin of the copy's current definition (null: cannot pin) // role the copy's current resolved role ({credentials: [...]}) -// projects the organization's currently admitted project ids +// projects the project ids that admit this copy now: for a root copy, the projects +// whose union holds it (an excluded or refusing project is left out), not +// the organization's whole project list // Returns { ok: true, copyId, generation, services: { V: { scopes, projects } } } when every // service the role needs is bound, or { ok: false, code, service } with the first refusal. Only a // `bound` record resolves; pending, partial, tombstoned, foreign and stale records never do. @@ -293,7 +295,8 @@ export function resolveCopyBinding({ orgId, incarnation, snapshot, instance, rec // The binding payload a chief proposes: { service: { scopes, projects } } for a bind, or null for // an unbind. Each service must be one the copy's role declares, its scopes within the role and its -// projects admitted for the copy. Never trims a broader request. Returns the normalized services. +// projects admitted for the copy (`projects`: for a root copy, the projects whose union holds it). +// Never trims a broader request. Returns the normalized services. export function checkBindingRequest(services, { copy, role, projects }) { if (services === null) return null; if (!isPlainObject(services) || Object.keys(services).length === 0) refuse("binding services field is invalid"); diff --git a/packages/organization/src/hire-decisions.mjs b/packages/organization/src/hire-decisions.mjs index 88dee9e27..e2d3d9bd7 100644 --- a/packages/organization/src/hire-decisions.mjs +++ b/packages/organization/src/hire-decisions.mjs @@ -17,6 +17,7 @@ import { ensureOrgIncarnation, orgIncarnation, readRegistry, withRegistryWriter import { checkDefinitions } from './definitions.mjs'; import { loadRole } from './role.mjs'; import { copyInstanceName } from './agent-copies.mjs'; +import { resolveAgentLevels } from './agent-levels.mjs'; import { bindingFail, checkBindingRequest, definitionPin, newCopyId, samePin } from './copy-bindings.mjs'; const fail = code => { throw new OrganizationError(code); }; const hash = x => createHash('sha256').update(canonicalJson(x)).digest('hex'); @@ -111,6 +112,8 @@ export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertCle const copy = instance !== null && snapshot?.org === business && Object.hasOwn(snapshot.copies ?? {}, instance) ? snapshot.copies[instance] : null; if (!copy || copy.name !== request.name || copy.project !== project || copy.level !== request.level) fail('copy-unavailable'); if (copy.state === 'refused') fail(copy.claims ? 'copy-refused' : 'copy-unavailable'); + // A root copy is only as fresh as its root receipts: removed, replaced or unreadable since boot refuses. + if (copy.level === 'root') { const stale = rootRefusal(copy.name); if (stale) fail(stale); } // A named instance added since boot shadows the copy (design section 6). if (Object.hasOwn(doc.roles ?? {}, instance)) fail('copy-unavailable'); const definitions = { ...(snapshot.definitions ?? {}), ...(doc.definitions ?? {}) }; @@ -118,7 +121,18 @@ export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertCle if (!pin) fail('credential-definition-unpinned'); const role = copy.definition.startsWith('org:') ? checkDefinitions(definitions, 'copy definitions')[copy.definition] : loadRole(rolesDir, copy.definition); if (!role) fail('credential-definition-unpinned'); - return { instance, copy, pin, role, definitions, source: { level: copy.level, name: copy.name, project } }; + // The projects that admit this copy now. A root copy reaches only the projects whose union, + // recomputed from this document and the boot's root agents, still holds it: an exclude entry or a + // refusing union bars it, and a request naming that project refuses (credential-scope-exceeded). + const code = Object.keys(doc.projects ?? {}).filter(p => doc.projects[p]?.kind !== 'library'); + let projects = code; + if (copy.level === 'root') { + let levels; + try { levels = resolveAgentLevels({ orgId: business, doc: { ...doc, definitions }, root: { agents: snapshot.rootAgents ?? {} }, rolesDir }); } + catch (error) { if (error?.name === 'BusinessError') fail('copy-unavailable'); throw error; } + projects = code.filter(p => levels.unions[p]?.ok && levels.unions[p].agents.some(a => a.level === 'root' && a.name === copy.name)); + } + return { instance, copy, pin, role, definitions, projects, source: { level: copy.level, name: copy.name, project } }; }; const bindingLines = services => credentialLines(Object.entries(services ?? {}).map(([service, v]) => ({ service, scopes: v.scopes }))); async function prepareBinding(business, input, { id: decision, expiresAt }) { @@ -128,7 +142,7 @@ export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertCle const { document: doc, revision } = writer.read(business); const facts = bindingFacts(business, input, doc); let after; - try { after = checkBindingRequest(input.binding.services, { copy: facts.copy, role: facts.role, projects: Object.keys(doc.projects ?? {}).filter(p => doc.projects[p]?.kind !== 'library') }); } + try { after = checkBindingRequest(input.binding.services, { copy: facts.copy, role: facts.role, projects: facts.projects }); } catch (error) { if (error?.name === 'CopyBindingError') fail(error.code); fail('invalid-request'); } const current = doc.copyBindings?.[facts.instance] ?? null; if (current && canonicalJson(current.source) !== canonicalJson(facts.source)) fail('credential-binding-changed'); @@ -141,7 +155,10 @@ export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertCle instance: facts.instance, source: facts.source, copyId: current?.copyId ?? newCopyId(), fresh: !current, orgIncarnation: incarnation, definition: facts.pin, before: current && current.state !== 'unbound' ? structuredClone(current.services) : null, after, generation: current?.generation ?? 0, }; - const p = { id: decision, business, kind: 'binding', prior: null, revision, rootRevision: null, request: structuredClone(input), entry: null, definition: null, + // Only the structured fields are kept. The free-text reason is never written to the proposal or + // covered by its digest: a chief may paste a path or a token fragment there by mistake. + const request = { op: input.op, level: input.level, name: input.name, ...(input.level === 'project' ? { project: input.project } : {}), binding: { services: structuredClone(input.binding.services) } }; + const p = { id: decision, business, kind: 'binding', prior: null, revision, rootRevision: null, request, entry: null, definition: null, binding, flagged: [], credentialLines: bindingLines(after ?? binding.before), expiresAt }; mkdirSync(home, { recursive: true, mode: 0o700 }); const st = lstatSync(home); if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || st.mode & 0o077) fail('organization-refused'); @@ -163,7 +180,7 @@ export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertCle if ((current?.generation ?? 0) !== b.generation || (current ? current.copyId !== b.copyId : !b.fresh)) fail('credential-binding-changed'); if (b.after !== null) { let after; - try { after = checkBindingRequest(p.request.binding.services, { copy: facts.copy, role: facts.role, projects: Object.keys(doc.projects ?? {}).filter(x => doc.projects[x]?.kind !== 'library') }); } + try { after = checkBindingRequest(p.request.binding.services, { copy: facts.copy, role: facts.role, projects: facts.projects }); } catch (error) { if (error?.name === 'CopyBindingError') fail(error.code); fail('invalid-request'); } if (canonicalJson(after) !== canonicalJson(b.after)) fail('decision-stale'); } @@ -321,7 +338,11 @@ export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertCle const p=await check(business,decision,caller); if(p.kind==='binding') { const facts=await checkBinding(business,p); - return writer.applyCopyBindingDecision(business,{...p,digest:publicOf(p).digest},{actor:caller.actor,owner:caller.owner===true,definitions:facts.definitions,beforeWrite:caller.beforeWrite,beforePublish:caller.beforePublish}); + // A root copy's receipts are read again under the organization lock, before the write and + // before publication, so a removal or replacement after checkBinding still refuses. + const fresh=()=>{if(facts.copy.level==='root'){const stale=rootRefusal(facts.copy.name);if(stale)fail(stale);}}; + return writer.applyCopyBindingDecision(business,{...p,digest:publicOf(p).digest},{actor:caller.actor,owner:caller.owner===true,definitions:facts.definitions, + beforeWrite:()=>{fresh();caller.beforeWrite?.();},beforePublish:()=>{fresh();caller.beforePublish?.();}}); } const r=p.request; const vars=r.route ? {harness:r.route.harness,model:r.route.model,...(r.route.thinking?{thinking:r.route.thinking}:{})} : {}; diff --git a/packages/organization/tests/copy-bindings.test.mjs b/packages/organization/tests/copy-bindings.test.mjs index 93345e816..43e20c549 100644 --- a/packages/organization/tests/copy-bindings.test.mjs +++ b/packages/organization/tests/copy-bindings.test.mjs @@ -158,12 +158,13 @@ test('CB0 copyBindings schema: ids and scope names only, every refusal positiona // ---- CB1: decision subtype, guarded writer ---- -function setup(t, { incarnation = true } = {}) { +function setup(t, { incarnation = true, edit = d => d } = {}) { const root = mkdtempSync(join(tmpdir(), 'cb1-')); t.after(() => rmSync(root, { recursive: true, force: true })); const roles = rolesCopy(root); const doc = { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] } }; doc.projects.stack.agents = { checker: { definition: 'reviewer' } }; + edit(doc); const file = writeJson(join(root, 'businesses/demo.json'), doc); const dataRoot = join(root, 'data'); mkdirSync(dataRoot, { mode: 0o700 }); const ctx = { configDir: root, dataRoot, instance: 'test' }; @@ -301,7 +302,7 @@ test('CB1 stale revision, digest or generation, expiry, replay and a wrong incar const s = await f.prepare(f.bind(), ID3); await f.service.apply('demo', s.id, { ...OWNER, digest: s.digest }); const once = f.bytes(); - await assert.rejects(f.service.apply('demo', s.id, { ...OWNER, digest: s.digest })); + await assert.rejects(f.service.apply('demo', s.id, { ...OWNER, digest: s.digest }), { code: 'decision-stale' }); const full = { ...JSON.parse(readFileSync(join(f.dataRoot, 'authority-proposals', `${s.id}.json`), 'utf8')), digest: s.digest }; // Generation: the writer refuses a proposal at an old generation even with a fresh revision. assert.throws(() => f.writer.applyCopyBindingDecision('demo', { ...full, revision: f.writer.read('demo').revision }, { actor: 'jason', owner: true }), { code: 'credential-binding-changed' }); @@ -317,7 +318,7 @@ test('CB1 stale revision, digest or generation, expiry, replay and a wrong incar assert.throws(() => f.writer.applyCopyBindingDecision('demo', { ...uf, binding: { ...uf.binding, orgIncarnation: 'f'.repeat(32), definition: { ...uf.binding.definition, sha256: '0'.repeat(64) } } }, { actor: 'jason', owner: true }), { code: 'credential-role-changed' }); // A binding is never reversed through the AU inverse, and never adjusted. await assert.rejects(f.service.reverse('demo', s.id, OWNER), { code: 'decision-stale' }); - await assert.rejects(f.service.check('demo', u.id, { ...OWNER, digest: u.digest, adjust: { role: {} } })); + await assert.rejects(f.service.check('demo', u.id, { ...OWNER, digest: u.digest, adjust: { role: {} } }), { code: 'invalid-request' }); }); test('CB1 a legacy organization gets its incarnation at the first proposal, under the registry lock', async t => { @@ -347,6 +348,10 @@ test('CB1 a pending or partial binding never launches the copy', async t => { assert.equal(resolveCopyBinding({ ...args, record: rec }).code, 'credential-unbound'); // Even forced to bound, gitea alone is partial for a role that also needs vikunja. assert.deepEqual(resolveCopyBinding({ ...args, record: { ...rec, state: 'bound' } }), { ok: false, code: 'credential-unbound', service: 'vikunja' }); + // A pending record that covers every service the role needs is still not a binding; bound, it is. + const whole = { ...rec, services: { ...rec.services, vikunja: { slot: 'self', scopes: { tasks: ['read_one'] }, resources: { projects: ['stack'] } } } }; + assert.equal(resolveCopyBinding({ ...args, record: { ...whole, state: 'bound' } }).ok, true); + assert.deepEqual(resolveCopyBinding({ ...args, record: whole }), { ok: false, code: 'credential-unbound', service: 'gitea' }); }); test('CB1 no proposal, record, receipt or refusal names a token or a path', async t => { @@ -370,6 +375,65 @@ test('CB1 no proposal, record, receipt or refusal names a token or a path', asyn void sha; }); +test('CB1 fix: the free-text reason is never written to a binding proposal', async t => { + const f = setup(t); + const leak = '/home/someone/.config/mosaic-dev/secrets/gitea tok-fragment-9f8e'; + const p = await f.prepare(f.bind(GITEA, { reason: `Bind; see ${leak}` })); + const stored = readFileSync(join(f.dataRoot, 'authority-proposals', `${p.id}.json`), 'utf8'); + assert.ok(!stored.includes(leak) && !stored.includes('tok-fragment') && !stored.includes('"reason"'), 'no reason text on disk'); + assert.deepEqual(Object.keys(JSON.parse(stored).request).sort(), ['binding', 'level', 'name', 'op']); + assert.ok(!JSON.stringify(p).includes('tok-fragment')); + // The proposal still applies on its digest. + await f.service.apply('demo', p.id, { ...OWNER, digest: p.digest }); + assert.equal(f.writer.read('demo').document.copyBindings.rev.state, 'pending'); + assert.ok(!readFileSync(join(f.root, 'businesses/.history/demo/changes.jsonl'), 'utf8').includes('tok-fragment')); +}); + +test('CB1 fix: a root copy removed or replaced since boot refuses at prepare, at apply and before publication', async t => { + const receipts = root => join(root, 'agents-receipts.jsonl'); + const add = (root, row) => writeFileSync(receipts(root), JSON.stringify(row) + '\n', { flag: 'a', mode: 0o600 }); + // Removed since boot: prepare refuses instance-fired; replaced: copy-stale. + for (const [verb, code] of [['root.agent.remove', 'instance-fired'], ['root.agent.set', 'copy-stale']]) { + const f = setup(t); await f.ready; + add(f.root, { agent: 'rev', verb, at: FUTURE }); + await assert.rejects(f.prepare(), { code }, verb); + let saved = []; try { saved = readdirSync(join(f.dataRoot, 'authority-proposals')); } catch (e) { if (e.code !== 'ENOENT') throw e; } + assert.deepEqual(saved, [], verb); + } + // Prepared while fresh, then the root copy is removed: apply refuses and writes nothing. + { + const f = setup(t); const p = await f.prepare(); const before = f.bytes(); + add(f.root, { agent: 'rev', verb: 'root.agent.remove', at: FUTURE }); + await assert.rejects(f.service.apply('demo', p.id, { ...OWNER, digest: p.digest }), { code: 'instance-fired' }); + assert.deepEqual(f.bytes(), before); + } + // The change lands after checkBinding, under the lock: the publication fence still refuses. + { + const f = setup(t); const p = await f.prepare(); const before = f.bytes(); + let wrote = false; + const late = () => { if (!wrote) { wrote = true; add(f.root, { agent: 'rev', verb: 'root.agent.set', at: FUTURE }); } }; + await assert.rejects(f.service.apply('demo', p.id, { ...OWNER, digest: p.digest, beforeWrite: late }), { code: 'copy-stale' }); + assert.equal(wrote, true); + assert.deepEqual(f.bytes(), before); + } + // A receipt for another root agent leaves this copy fresh; an unreadable receipts file refuses. + { + const f = setup(t); await f.ready; + add(f.root, { agent: 'other', verb: 'root.agent.remove', at: FUTURE }); + const p = await f.prepare(); + writeFileSync(receipts(f.root), 'not json\n', { mode: 0o600 }); + await assert.rejects(f.service.apply('demo', p.id, { ...OWNER, digest: p.digest }), { code: 'root-policy-unreadable' }); + } + // A project copy does not read root receipts. + { + const f = setup(t); await f.ready; + add(f.root, { agent: 'checker', verb: 'root.agent.remove', at: FUTURE }); + const p = await f.prepare({ op: 'change', level: 'project', project: 'stack', name: 'checker', reason: 'Bind', binding: { services: GITEA } }); + await f.service.apply('demo', p.id, { ...OWNER, digest: p.digest }); + assert.equal(f.writer.read('demo').document.copyBindings['checker.stack'].state, 'pending'); + } +}); + test('CB1 the org service refuses copyBindings on create and on a whole-document update', async t => { const root = mkdtempSync(join(tmpdir(), 'cb1-svc-')); t.after(() => rmSync(root, { recursive: true, force: true })); @@ -383,3 +447,34 @@ test('CB1 the org service refuses copyBindings on create and on a whole-document assert.equal((await showOrg(c, 'alpha')).doc.copyBindings, undefined); await updateOrg(c, 'alpha', current.doc, { ifRevision: current.revision }); }); + +test('CB1 fix: a root copy binds only projects whose union still admits it', async t => { + const f = setup(t, { edit: doc => { doc.projects.web = { root: doc.projects.stack.root }; doc.projects.stack.agents.rev = { exclude: true }; } }); + // stack excludes rev: a request naming it refuses whole, never trimmed to web. + await assert.rejects(f.prepare(f.bind({ gitea: { scopes: ['read:repository'], projects: ['stack', 'web'] } })), { code: 'credential-scope-exceeded' }); + await assert.rejects(f.prepare(f.bind({ gitea: { scopes: ['read:repository'], projects: ['stack'] } }, {}), ID2), { code: 'credential-scope-exceeded' }); + // web still admits it. + const p = await f.prepare(f.bind({ gitea: { scopes: ['read:repository'], projects: ['web'] } }), ID3); + assert.deepEqual(p.binding.after.gitea.resources.projects, ['web']); +}); + +test('CB1 fix: a named instance added after boot shadows the copy at proposal time', async t => { + const f = setup(t); await f.ready; + const r = f.writer.read('demo'), edited = structuredClone(r.document); + const like = Object.values(edited.roles).find(e => e.definition === 'reviewer'); + edited.roles.rev = { ...structuredClone(like), tracker: { bot: 'bot-demo-rev', botId: 977 } }; + f.writer.write('demo', edited, { revision: r.revision, actor: 'jason' }); + await assert.rejects(f.prepare(), { code: 'copy-unavailable' }); +}); + +test('CB1 fix: the loader refuses a copyBindings record holding a token, naming neither the value nor a path', t => { + const root = mkdtempSync(join(tmpdir(), 'cb1-load-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const roles = rolesCopy(root); + const doc = { ...businessDoc(root, 'demo'), copyBindings: { rev: { ...record(), state: 'pending', token: SECRET } } }; + const file = join(root, 'businesses/demo.json'); + assert.throws(() => validateBusinessDocument(doc, file, { rolesDir: roles }), e => e.name === 'BusinessError' && !e.message.includes(SECRET) && !e.message.includes(root)); + // The same document without the token loads. + const { token: _, ...clean } = doc.copyBindings.rev; + validateBusinessDocument({ ...doc, copyBindings: { rev: clean } }, file, { rolesDir: roles }); +});