Files
stack/packages/organization/tests/json-redaction.test.mjs
jason.woltje 4c8bf9ca4e fix(organization,cli): refusals name the file by label, never by host path
PR #1916 rule: a refusal names the field or file, never a config value
or a host path. readJsonFile's three refusals and parseJsonFileText now
name the file by its `what` label only. Callers whose label alone is
ambiguous add a name that already passed a strict pattern (business id,
role name, org id, turn id, project key) or a fixed path under the data
root. Exit codes, refusal codes and check order are unchanged.

organization: util, access, project, config-command, library-seats,
org-service deleteOrg receipt, agent-levels regex; T1 files role.mjs and
organization-config.mjs change only the readJsonFile label.
cli: access lock, accounts owner, host state/lock, notifier journal,
notify-config, run-evidence, dev-fixture, git-socket, org-bus, org-console.
discord: turn record label names the turn id.

Sentinel tests (sk-test-NOT-A-SECRET-1234 as a path component) cover each
changed refusal and fail on BASE; old path pins updated.
2026-11-11 01:04:15 -06:00

137 lines
8.1 KiB
JavaScript

import { test } from 'node:test';
import fsPromises from 'node:fs/promises';
import { syncBuiltinESMExports } from 'node:module';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmp, writeJson, REPO_ROLES } from './helpers.mjs';
import { loadBusiness } from '../src/organization-config.mjs';
import { loadBusiness as legacyLoadBusiness } from '../../business/src/business.mjs';
import { loadProject } from '../src/project.mjs';
import { loadRootAgents } from '../src/agent-levels.mjs';
import { readJsonFile } from '../src/util.mjs';
import { organizationWriter } from '../src/organization.mjs';
import { readRegistry, withRegistryWriter } from '../src/org-registry.mjs';
import { createOrg, deleteOrg, notificationShow, showOrg, templateRender } from '../src/org-service.mjs';
const secret = 'sk-test-NOT-A-SECRET-1234';
const malformed = secret;
// PR #1916 redaction (org-redact5): the sentinel is also a path component, so a
// refusal that quoted the host path would carry it.
function fixture(t) {
const top = tmp();
t.after(() => rmSync(top, { recursive: true, force: true }));
const root = join(top, secret);
mkdirSync(root, { mode: 0o700 });
const ctx = { configDir: join(root, 'config'), dataRoot: join(root, 'data'), instance: 'test',
principal: { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' } };
for (const dir of [ctx.configDir, ctx.dataRoot]) mkdirSync(dir, { mode: 0o700 });
return ctx;
}
// The refusal names the file by its label and never by path.
function safe(file, { name = 'SyntaxError', code, exitCode, prefix, label, suffix = '' } = {}) {
return error => {
assert.equal(error.name, name);
assert.equal(error.code, code);
assert.equal(error.exitCode, exitCode);
assert.ok(!error.message.includes(file), error.message);
assert.equal(error.message, `${prefix ?? ''}${label} is not valid JSON${suffix}`);
assert.doesNotMatch(error.message, /sk-test|private|broken|Unexpected|position|line \d/);
assert.ok(!error.stack.includes(secret));
assert.equal(error.cause, undefined);
return true;
};
}
for (const [label, load, relative, labelText] of [
['shared reader', file => readJsonFile(file, 'config file'), 'config.json', 'config file'],
['organization loader', (_file, ctx) => loadBusiness('alpha', { dir: ctx.configDir, rolesDir: REPO_ROLES }), 'businesses/alpha.json', 'business file alpha.json'],
['legacy business loader', (_file, ctx) => legacyLoadBusiness('alpha', { dir: ctx.configDir, rolesDir: REPO_ROLES }), 'businesses/alpha.json', 'business file alpha.json'],
['project loader', (_file, ctx) => loadProject(ctx.configDir), '.mosaic/project.json', 'project file'],
]) test(`${label} names malformed JSON's file without quoting bytes`, t => {
const ctx = fixture(t), file = writeJson(join(ctx.configDir, relative), malformed);
assert.throws(() => load(file, ctx), safe(file, { name: 'BusinessError', exitCode: 2, label: labelText }));
});
test('root agents keep their existing file-only refusal', t => {
const ctx = fixture(t); writeJson(join(ctx.configDir, 'agents.json'), malformed);
assert.throws(() => loadRootAgents(ctx.configDir, { rolesDir: REPO_ROLES }), safe(join(ctx.configDir, 'agents.json'), { name: 'BusinessError', exitCode: 2, label: 'root agent file', suffix: ' (agents.json)' }));
});
for (const operation of ['read', 'write', 'recover', 'findAddition']) test(`organization writer ${operation} does not quote malformed file bytes`, async t => {
const ctx = fixture(t);
await createOrg(ctx, 'alpha', await templateRender('alpha'));
const writer = organizationWriter({ dir: ctx.configDir, rolesDir: REPO_ROLES });
const current = writer.read('alpha');
const receipt = ['recover', 'findAddition'].includes(operation);
const file = receipt ? writer.logFile('alpha') : join(ctx.configDir, 'businesses/alpha.json');
if (receipt) mkdirSync(join(ctx.configDir, 'businesses/.history/alpha'), { recursive: true, mode: 0o700 });
writeJson(file, malformed + '\n');
if (operation === 'write') {
// Exercise the parse after both the revision and registry digest fences.
current.revision = createHash('sha256').update(readFileSync(file)).digest('hex');
const registryFile = join(ctx.configDir, 'org-runtime.json');
const registry = JSON.parse(readFileSync(registryFile)); registry.orgs.alpha.digest = current.revision;
writeJson(registryFile, registry);
}
const invoke = () => operation === 'write'
? writer.write('alpha', current.document, { revision: current.revision, actor: ctx.principal.sub })
: writer[operation]('alpha', {});
assert.throws(invoke, safe(file, { label: receipt ? 'organization receipt file' : 'organization file' }));
});
for (const operation of ['async', 'sync']) test(`${operation} registry read names the file without parser bytes`, async t => {
const ctx = fixture(t), file = writeJson(join(ctx.configDir, 'org-runtime.json'), malformed);
if (operation === 'async') await assert.rejects(readRegistry(ctx), safe(file, { name: 'OrgError', code: 'org-mismatch', prefix: 'invalid registry: ', label: 'metadata file' }));
else assert.throws(() => withRegistryWriter(ctx.configDir, () => assert.fail('must refuse')), safe(file, { label: 'registry file' }));
});
for (const kind of ['owner', 'organization', 'notifier', 'deletion receipt']) test(`service ${kind} refuses malformed JSON without file bytes`, async t => {
const ctx = fixture(t);
const created = await createOrg(ctx, 'alpha', await templateRender('alpha'));
let file;
if (kind === 'owner') file = join(created.root, 'owner.json');
if (kind === 'organization') file = join(ctx.configDir, 'businesses/alpha.json');
if (kind === 'notifier') file = join(created.root, 'notify/alpha/notify.json');
if (kind === 'deletion receipt') {
const registryFile = join(ctx.configDir, 'org-runtime.json');
const registry = JSON.parse(readFileSync(registryFile));
registry.orgs.alpha.state = 'deleting';
registry.orgs.alpha.deletion = { actor: ctx.principal, time: new Date().toISOString(), revision: 1, folder: 'alpha-redaction' };
writeJson(registryFile, registry);
const destination = join(ctx.dataRoot, 'deleted/alpha-redaction');
mkdirSync(destination, { recursive: true, mode: 0o700 });
file = join(destination, 'receipt.json');
}
writeJson(file, malformed);
if (kind === 'organization') {
const registryFile = join(ctx.configDir, 'org-runtime.json');
const registry = JSON.parse(readFileSync(registryFile));
registry.orgs.alpha.digest = createHash('sha256').update(malformed).digest('hex');
writeJson(registryFile, registry);
}
const label = { owner: 'organization owner file', organization: 'organization file', notifier: 'notifier configuration file', 'deletion receipt': 'deletion receipt file' }[kind];
if (kind === 'notifier') await assert.rejects(notificationShow(ctx, 'alpha'), safe(file, { label }));
else if (kind === 'deletion receipt') await assert.rejects(deleteOrg(ctx, 'alpha', { confirm: 'alpha', ifRevision: 1, busStop: async () => assert.fail('must refuse first') }),
safe(file, { label, suffix: '; deletion remains in progress. Inspect the deletion receipt under the data root and rerun mosaic org delete alpha --confirm alpha' }));
else await assert.rejects(showOrg(ctx, 'alpha'), safe(file, { label }));
});
// The packaged template is immutable at runtime; substitute only its read result
// to exercise a corrupt template without editing repository data.
test('template reader refuses malformed JSON without quoting template bytes', async t => {
const original = fsPromises.readFile;
t.mock.method(fsPromises, 'readFile', function(file, ...args) {
if (file instanceof URL && file.pathname.endsWith('/org-template.json')) return Promise.resolve(malformed);
return original.call(this, file, ...args);
});
syncBuiltinESMExports();
try {
await assert.rejects(templateRender('alpha'), safe('/org-template.json', { label: 'organization template file' }));
} finally {
t.mock.restoreAll();
syncBuiltinESMExports();
}
});