Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions site/src/content/docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,22 @@ global link.
| `--json` | Machine-readable reports for ingest, refine, review, build, query, check, and eval. `init` retains its text message; MCP uses its stdio protocol rather than a CLI JSON report. |
| `--help` | Usage for the CLI or for a single command |

### Human output and machine data

The `init` root, `ingest` result path, finding strings in the human-readable
`check` and `eval` reports, and `check` configuration errors on stderr use inert
single-line rendering. Terminal controls, C1 controls, bidirectional controls,
and Unicode line separators become visible escapes such as `\u001b`, `\u009b`,
and `\u202e`; line endings become `\n`.
Ordinary text and literal backslashes are unchanged. This is display hardening,
not Markdown sanitization, a filesystem rename, or an admission decision.

This human-output escaping does not alter machine report values. The
`safeJsonStringify` helper used by some JSON outputs escapes the serialized text
without changing decoded values: `JSON.parse` still recovers the original controls,
line endings, quotes, and backslashes. Consumers must render decoded strings
inertly themselves rather than treating machine JSON as sanitized content.

## What each command may write

| Command | Writes | Cannot write |
Expand Down
3 changes: 2 additions & 1 deletion src/cli/commands/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { CliIO } from '../main.js';
import { GENERATED_ARTIFACTS, auditCleanRoom } from '../../eval/clean-room.js';
import type { CleanRoomReport } from '../../eval/clean-room.js';
import { renderCleanRoomMarkdown } from '../../eval/report.js';
import { inertSingleLineText } from '../../presentation/inert.js';

/**
* Directories that never contain authored content. Everything else in the repository is
Expand Down Expand Up @@ -230,7 +231,7 @@ export async function runCheck(root: string, json: boolean, io: CliIO): Promise<
} else {
io.stdout(renderCleanRoomMarkdown(failure) + '\n');
}
io.stderr(`error: ${err.message}\n`);
io.stderr(`error: ${inertSingleLineText(err.message)}\n`);
return 1;
}

Expand Down
3 changes: 2 additions & 1 deletion src/cli/commands/ingest.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { CliIO } from '../main.js';
import { ingestCapture } from '../../bronze/ingest.js';
import { inertSingleLineText } from '../../presentation/inert.js';

export async function runIngest(root: string, file: string | undefined, json: boolean, io: CliIO): Promise<number> {
if (!file) {
Expand All @@ -10,7 +11,7 @@ export async function runIngest(root: string, file: string | undefined, json: bo
if (json) {
io.stdout(JSON.stringify(result, null, 2) + '\n');
} else {
io.stdout(`${result.status}: ${result.source_path}\n`);
io.stdout(`${result.status}: ${inertSingleLineText(result.source_path)}\n`);
}
return 0;
}
3 changes: 2 additions & 1 deletion src/cli/commands/init.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { mkdir, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import type { CliIO } from '../main.js';
import { inertSingleLineText } from '../../presentation/inert.js';

const STARTER_DIRS = [
'bronze',
Expand Down Expand Up @@ -51,6 +52,6 @@ export async function runInit(root: string, io: CliIO): Promise<number> {
}
}

io.stdout(`Initialized Ziggurat vault at ${root}\n`);
io.stdout(`Initialized Ziggurat vault at ${inertSingleLineText(root)}\n`);
return 0;
}
5 changes: 3 additions & 2 deletions src/eval/report.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ConformanceReport } from './conformance.js';
import type { CleanRoomReport } from './clean-room.js';
import { inertSingleLineText } from '../presentation/inert.js';

/** Renders a ConformanceReport as Markdown. */
export function renderConformanceMarkdown(report: ConformanceReport): string {
Expand All @@ -13,7 +14,7 @@ export function renderConformanceMarkdown(report: ConformanceReport): string {

for (const f of report.findings) {
const icon = f.passed ? '✓' : '✗';
lines.push(`${icon} ${f.case_id}: ${f.detail}`);
lines.push(`${icon} ${inertSingleLineText(f.case_id)}: ${inertSingleLineText(f.detail)}`);
}

return lines.join('\n');
Expand All @@ -31,7 +32,7 @@ export function renderCleanRoomMarkdown(report: CleanRoomReport): string {
lines.push('No findings.');
} else {
for (const f of report.findings) {
lines.push(`- ${f.path}:${f.line} [${f.category}] ${f.detail}`);
lines.push(`- ${inertSingleLineText(f.path)}:${f.line} [${inertSingleLineText(f.category)}] ${inertSingleLineText(f.detail)}`);
}
}

Expand Down
238 changes: 238 additions & 0 deletions test/cli-inert-output.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
import assert from 'node:assert/strict';
import { mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import { runCli, type CliIO } from '../src/cli/main.js';
import type { CleanRoomReport } from '../src/eval/clean-room.js';
import type { ConformanceReport } from '../src/eval/conformance.js';
import { renderCleanRoomMarkdown, renderConformanceMarkdown } from '../src/eval/report.js';
import { safeJsonStringify } from '../src/presentation/inert.js';

const LIVE_CONTROLS =
/[\x00-\x09\x0b-\x1f\x7f-\x9f\u061c\u200e\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069]/u;
const HOSTILE = 'before\\literal\\u009b"\u0000\t\u001b[2J\u007f\u0085\u009b31m\u202e\u2066\r\nnext\rlast\nend';
const INERT = 'before\\literal\\u009b"\\u0000\\u0009\\u001b[2J\\u007f\\u0085\\u009b31m\\u202e\\u2066\\nnext\\nlast\\nend';

// C1, bidi, and Unicode line separators are permitted in Windows filenames too.
const HOSTILE_NAME = 'note-\u009b31m\u202e\u2028.md';
const INERT_NAME = 'note-\\u009b31m\\u202e\\u2028.md';

function captureIO(): { io: CliIO; captured: { out: string; err: string } } {
const captured = { out: '', err: '' };
return {
captured,
io: {
stdout: text => { captured.out += text; },
stderr: text => { captured.err += text; },
},
};
}

test('clean-room Markdown renders every finding string as inert single-line text', () => {
const report: CleanRoomReport = {
pass: false,
findings: [{ path: HOSTILE, line: 7, category: HOSTILE, detail: HOSTILE }],
};
const before = structuredClone(report);
const rendered = renderCleanRoomMarkdown(report);

assert.equal(rendered, `# Clean-Room Audit: FAIL\n\n- ${INERT}:7 [${INERT}] ${INERT}`);
assert.doesNotMatch(rendered, LIVE_CONTROLS);
assert.deepEqual(report, before);
});

test('conformance Markdown renders case IDs and details as inert single-line text', () => {
const report: ConformanceReport = {
pass: false,
cases_run: 2,
passed: 1,
failed: 1,
findings: [
{ case_id: HOSTILE, passed: true, detail: HOSTILE },
{ case_id: HOSTILE, passed: false, detail: HOSTILE },
],
};
const before = structuredClone(report);
const rendered = renderConformanceMarkdown(report);

assert.equal(
rendered,
'# Conformance Report: FAIL\n\nCases: 2 | Passed: 1 | Failed: 1\n\n'
+ `\u2713 ${INERT}: ${INERT}\n\u2717 ${INERT}: ${INERT}`,
);
assert.doesNotMatch(rendered, LIVE_CONTROLS);
assert.deepEqual(report, before);
});

test('report renderers preserve ordinary wording, punctuation, and backslashes', () => {
assert.equal(
renderCleanRoomMarkdown({ pass: true, findings: [] }),
'# Clean-Room Audit: PASS\n\nNo findings.',
);
assert.equal(
renderCleanRoomMarkdown({
pass: false,
findings: [{ path: 'notes\\example.md', line: 3, category: 'example', detail: 'Review this file.' }],
}),
'# Clean-Room Audit: FAIL\n\n- notes\\example.md:3 [example] Review this file.',
);
assert.equal(
renderConformanceMarkdown({
pass: true,
cases_run: 1,
passed: 1,
failed: 0,
findings: [{ case_id: 'C001', passed: true, detail: 'Passed correctly.' }],
}),
'# Conformance Report: PASS\n\nCases: 1 | Passed: 1 | Failed: 0\n\n\u2713 C001: Passed correctly.',
);
});

test('safe JSON remains render-safe and byte-exact after parsing, including literal escapes', () => {
const value = {
path: HOSTILE,
body: `${HOSTILE}\n\\n\\r\\u202e\\\\end`,
detail: '\u061c\u200e\u200f\u2028\u2029\u202a\u202b\u202c\u202d\u202e\u2066\u2067\u2068\u2069',
};
for (const space of [undefined, 2]) {
const rendered = safeJsonStringify(value, space);
const decoded = JSON.parse(rendered) as typeof value;
assert.doesNotMatch(rendered, LIVE_CONTROLS);
assert.deepEqual(decoded, JSON.parse(JSON.stringify(value)));
for (const field of ['path', 'body', 'detail'] as const) {
assert.deepEqual(Buffer.from(decoded[field], 'utf8'), Buffer.from(value[field], 'utf8'));
}
}
});

test('init CLI renders real hostile roots inertly without changing the initialized path', async () => {
const base = await mkdtemp(join(tmpdir(), 'ziggurat-inert-init-'));
const root = join(base, 'vault-\u009b31m\u202e\u2028');
try {
for (let attempt = 0; attempt < 2; attempt++) {
const { io, captured } = captureIO();
assert.equal(await runCli(['init', '--root', root], io), 0);
assert.equal(captured.out, `Initialized Ziggurat vault at ${join(base, 'vault-\\u009b31m\\u202e\\u2028')}\n`);
assert.doesNotMatch(captured.out, LIVE_CONTROLS);
assert.equal(captured.err, '');
}
assert.match(await readFile(join(root, 'config', 'ziggurat.yaml'), 'utf8'), /^schema_version: 1\n/u);
} finally {
await rm(base, { recursive: true, force: true });
}
});

test('ingest CLI renders duplicate Bronze paths inertly while machine JSON retains exact filenames', async () => {
const root = await mkdtemp(join(tmpdir(), 'ziggurat-inert-ingest-'));
const body = 'An ordinary captured note.\n';
const inboxPath = join(root, 'inbox', 'note.md');
try {
const initialized = captureIO();
assert.equal(await runCli(['init', '--root', root], initialized.io), 0);
assert.equal(initialized.captured.out, `Initialized Ziggurat vault at ${root}\n`);
assert.equal(initialized.captured.err, '');
await writeFile(inboxPath, body, 'utf8');
const created = captureIO();
assert.equal(await runCli(['ingest', '--root', root, '--file', 'inbox/note.md'], created.io), 0);
const bronzeDir = join(root, 'bronze', 'article');
const files = await readdir(bronzeDir);
assert.equal(files.length, 1);
const filename = files[0];
assert.ok(filename !== undefined);
assert.equal(created.captured.out, `created: bronze/article/${filename}\n`);
assert.equal(created.captured.err, '');

// New ingest paths are slugified; duplicate lookup returns existing Bronze names.
await rename(join(bronzeDir, filename), join(bronzeDir, HOSTILE_NAME));
await writeFile(inboxPath, body, 'utf8');
const human = captureIO();
assert.equal(await runCli(['ingest', '--root', root, '--file', 'inbox/note.md'], human.io), 0);
assert.equal(human.captured.out, `duplicate: bronze/article/${INERT_NAME}\n`);
assert.doesNotMatch(human.captured.out, LIVE_CONTROLS);
assert.equal(human.captured.err, '');

const machine = captureIO();
assert.equal(await runCli(['ingest', '--root', root, '--file', 'inbox/note.md', '--json'], machine.io), 0);
const result = JSON.parse(machine.captured.out) as { status: string; source_path: string };
assert.equal(result.status, 'duplicate');
assert.deepEqual(Buffer.from(result.source_path, 'utf8'), Buffer.from(`bronze/article/${HOSTILE_NAME}`, 'utf8'));
assert.equal(machine.captured.err, '');
assert.equal(await readFile(inboxPath, 'utf8'), body);
} finally {
await rm(root, { recursive: true, force: true });
}
});

test('check CLI renders real finding paths and details inertly while JSON retains their exact values', async () => {
const root = await mkdtemp(join(tmpdir(), 'ziggurat-inert-check-'));
try {
await writeFile(join(root, HOSTILE_NAME), Buffer.from([0xff]));
const human = captureIO();
assert.equal(await runCli(['check', '--root', root, '--audit-clean-room'], human.io), 1);
assert.equal(
human.captured.out,
'# Clean-Room Audit: FAIL\n\n'
+ `- ${INERT_NAME}:0 [unscannable-file] File is not valid UTF-8 text. `
+ `Review it and add an explicit clean-room exclusion if it is safe: ${INERT_NAME}\n`,
);
assert.doesNotMatch(human.captured.out, LIVE_CONTROLS);
assert.equal(human.captured.err, '');

const machine = captureIO();
assert.equal(await runCli(['check', '--root', root, '--audit-clean-room', '--json'], machine.io), 1);
const report = JSON.parse(machine.captured.out) as CleanRoomReport;
assert.deepEqual(report, {
pass: false,
findings: [{
path: HOSTILE_NAME,
category: 'unscannable-file',
line: 0,
detail: 'File is not valid UTF-8 text. Review it and add an explicit clean-room exclusion if it is safe: '
+ HOSTILE_NAME,
}],
});
assert.equal(machine.captured.err, '');
} finally {
await rm(root, { recursive: true, force: true });
}
});

test('check CLI renders unknown config keys inertly on stderr without changing JSON diagnostics', async () => {
const root = await mkdtemp(join(tmpdir(), 'ziggurat-inert-config-'));
const prefix = 'config/clean-room.yaml: unknown configuration keys: ';
const suffix = '. Allowed keys are exclude_paths and project_names.';
const inertDetail = `${prefix}${INERT}${suffix}`;
try {
await mkdir(join(root, 'config'));
await writeFile(join(root, 'config', 'clean-room.yaml'), `${JSON.stringify(HOSTILE)}: true\n`, 'utf8');
for (const json of [false, true]) {
const { io, captured } = captureIO();
const args = ['check', '--root', root, '--audit-clean-room', ...(json ? ['--json'] : [])];
assert.equal(await runCli(args, io), 1);
assert.equal(captured.err, `error: ${inertDetail}\n`);
assert.doesNotMatch(captured.err, LIVE_CONTROLS);
if (json) {
const report = JSON.parse(captured.out) as CleanRoomReport;
assert.deepEqual(report, {
pass: false,
findings: [{
path: 'config/clean-room.yaml',
category: 'invalid-clean-room-config',
line: 0,
detail: `${prefix}${HOSTILE}${suffix}`,
}],
});
} else {
assert.equal(
captured.out,
'# Clean-Room Audit: FAIL\n\n'
+ `- config/clean-room.yaml:0 [invalid-clean-room-config] ${inertDetail}\n`,
);
assert.doesNotMatch(captured.out, LIVE_CONTROLS);
}
}
} finally {
await rm(root, { recursive: true, force: true });
}
});