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.
193 lines
12 KiB
JavaScript
193 lines
12 KiB
JavaScript
// `mosaic run evidence ORG --thread REF [--verify] [--json]`: the run records a
|
|
// thread's launches left behind, read-only (r4 §7). A bus launch writes no
|
|
// result.json or snapshot, so the evidence is what it does write once:
|
|
// result -> <run>/session.json (wx 0600) plus the launch and end records in
|
|
// the organization's launch log
|
|
// snapshot -> <run>/bundle/manifest.json (wx 0600), every `files` digest recomputed
|
|
// stderr -> <run>/turns/NNNN.stderr (wx 0600), hashed
|
|
// A launch has no id of its own; the launch log line (file, line, sha256) names it.
|
|
// Exit: 0 found (and verified with --verify), 1 a check failed, 2 nothing found or
|
|
// invalid, 3 refused, 4 usage.
|
|
import { createHash } from 'node:crypto';
|
|
import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, readdirSync } from 'node:fs';
|
|
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
import { configDir } from '../../organization/src/organization-config.mjs';
|
|
import { resolveOrg, OrgError } from '../../organization/src/org-registry.mjs';
|
|
import { launchLogFile, runsDir } from '../../seat/src/session.mjs';
|
|
import * as cliConfig from './config.mjs';
|
|
import { DEV_OWNER, devOwnerInstance } from './dev-local-owner.mjs';
|
|
import { CliError } from './errors.mjs';
|
|
import { parseArgs, resolveThread } from './thread.mjs';
|
|
|
|
export const RUN_USAGE = 'mosaic run evidence ORG --thread REF [--verify] [--json] [--tester]';
|
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
const RUN_ID = /^r[0-9a-f]{12}$/;
|
|
const STDERR = /^[0-9]{4}\.stderr$/;
|
|
const sha256 = buf => createHash('sha256').update(buf).digest('hex');
|
|
const usage = why => new CliError(`${why}\nusage: ${RUN_USAGE}`, 4);
|
|
|
|
// A regular file of this user, opened without following a link. Returns its
|
|
// bytes, or throws {problem}.
|
|
function readOwned(path) {
|
|
let fd;
|
|
try { fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW); }
|
|
catch (e) { throw Object.assign(new Error(e.code === 'ENOENT' ? 'missing' : `cannot open (${e.code})`), { problem: true }); }
|
|
try {
|
|
const st = fstatSync(fd);
|
|
if (!st.isFile()) throw Object.assign(new Error('not a regular file'), { problem: true });
|
|
if (st.uid !== process.getuid()) throw Object.assign(new Error('not owned by this user'), { problem: true });
|
|
return { bytes: readFileSync(fd), mode: st.mode & 0o777 };
|
|
} finally { closeSync(fd); }
|
|
}
|
|
|
|
// One evidence file: its path, sha256 and any problem. `expect` is the recorded digest.
|
|
function fileEvidence(path, { expect = null, privateMode = true } = {}) {
|
|
const out = { path, sha256: null, ok: false };
|
|
try {
|
|
const { bytes, mode } = readOwned(path);
|
|
out.sha256 = sha256(bytes);
|
|
if (privateMode && mode !== 0o600) out.problem = `mode ${mode.toString(8)}, expected 600`;
|
|
else if (expect !== null && expect !== out.sha256) out.problem = 'digest differs from the manifest';
|
|
else out.ok = true;
|
|
return { ...out, bytes };
|
|
} catch (e) {
|
|
if (!e.problem) throw e;
|
|
return { ...out, problem: e.message };
|
|
}
|
|
}
|
|
|
|
const strip = ({ bytes, ...rest }) => rest;
|
|
const inside = (root, path) => { const r = relative(root, path); return r !== '' && !r.startsWith('..') && !r.startsWith(sep) && r !== '..'; };
|
|
|
|
// The launch log's records for one thread, with their line numbers and digests.
|
|
export function threadRecords(file, thread) {
|
|
const log = fileEvidence(file);
|
|
if (!log.ok && log.problem === 'missing') return { file, missing: true, launches: [], ends: [], malformed: 0 };
|
|
if (!log.ok) throw new CliError(`run-evidence-refused: launch log: ${log.problem}`, 3);
|
|
const launches = [], ends = []; let malformed = 0;
|
|
log.bytes.toString('utf8').split('\n').forEach((line, i) => {
|
|
if (!line) return;
|
|
let rec;
|
|
try { rec = JSON.parse(line); } catch { malformed++; return; }
|
|
if (rec?.thread !== thread) return;
|
|
const at = { file, line: i + 1, sha256: sha256(line), record: rec };
|
|
if (rec.event === 'launch') launches.push(at);
|
|
else if (rec.event === 'end') ends.push(at);
|
|
});
|
|
return { file, missing: false, launches, ends, malformed };
|
|
}
|
|
|
|
// Everything one run left, checked against its own records. `verify` recomputes digests.
|
|
export function runEvidence({ runs, thread, launch, ends, verify }) {
|
|
const run = launch.record.run;
|
|
const problems = [];
|
|
const out = { run, launch: { file: launch.file, line: launch.line, sha256: launch.sha256, at: launch.record.at ?? null, session: launch.record.session ?? null, model: launch.record.model ?? null, harness: launch.record.harness ?? null },
|
|
end: ends.filter(e => e.record.run === run).map(e => ({ file: e.file, line: e.line, sha256: e.sha256, at: e.record.at ?? null, reason: e.record.reason ?? null, exitCode: e.record.exitCode ?? null })) };
|
|
if (typeof run !== 'string' || !RUN_ID.test(run)) return { ...out, ok: false, problems: [`launch record names no valid run id (${JSON.stringify(run)})`] };
|
|
const runDir = join(runs, run);
|
|
out.runDir = runDir;
|
|
let st;
|
|
try { st = lstatSync(runDir); } catch (e) { if (e.code !== 'ENOENT') throw e; }
|
|
if (!st) return { ...out, ok: false, problems: ['run directory missing'] };
|
|
if (st.isSymbolicLink() || !st.isDirectory() || st.uid !== process.getuid()) return { ...out, ok: false, problems: ['run directory is not a directory of this user'] };
|
|
|
|
const session = fileEvidence(join(runDir, 'session.json'));
|
|
if (session.ok) {
|
|
let doc = null;
|
|
try { doc = JSON.parse(session.bytes.toString('utf8')); } catch { session.ok = false; session.problem = 'not JSON'; }
|
|
if (doc && (doc.thread !== thread || doc.run !== run)) { session.ok = false; session.problem = `names thread ${doc.thread} run ${doc.run}`; }
|
|
}
|
|
out.result = strip(session);
|
|
if (!session.ok) problems.push(`session.json: ${session.problem}`);
|
|
|
|
const bundleDir = join(runDir, 'bundle');
|
|
const manifestPath = join(bundleDir, 'manifest.json');
|
|
const manifest = fileEvidence(manifestPath);
|
|
let doc = null;
|
|
if (manifest.ok) {
|
|
try { doc = JSON.parse(manifest.bytes.toString('utf8')); } catch { manifest.ok = false; manifest.problem = 'not JSON'; }
|
|
if (doc && doc.run !== run) { manifest.ok = false; manifest.problem = `names run ${doc.run}`; }
|
|
else if (launch.record.manifest && resolve(launch.record.manifest) !== manifestPath) { manifest.ok = false; manifest.problem = `launch record names ${launch.record.manifest}`; }
|
|
}
|
|
const files = [];
|
|
if (doc && verify) {
|
|
const recorded = doc.files && typeof doc.files === 'object' ? Object.entries(doc.files) : [];
|
|
if (recorded.length === 0 && manifest.ok) { manifest.ok = false; manifest.problem = 'manifest records no file digests'; }
|
|
for (const [name, digest] of recorded) {
|
|
const path = resolve(bundleDir, name);
|
|
if (!inside(runDir, path)) { files.push({ name, path, ok: false, problem: 'outside the run directory' }); continue; }
|
|
files.push({ name, ...strip(fileEvidence(path, { expect: digest, privateMode: false })) });
|
|
}
|
|
}
|
|
out.snapshot = { ...strip(manifest), ...(verify ? { files } : {}) };
|
|
if (!manifest.ok) problems.push(`bundle/manifest.json: ${manifest.problem}`);
|
|
for (const f of files) if (!f.ok) problems.push(`bundle file ${f.name}: ${f.problem}`);
|
|
|
|
const turns = join(runDir, 'turns');
|
|
let names = [];
|
|
try { names = readdirSync(turns).filter(n => STDERR.test(n)).sort(); } catch (e) { if (e.code !== 'ENOENT' && e.code !== 'ENOTDIR') throw e; }
|
|
out.stderr = names.map(n => strip(fileEvidence(join(turns, n))));
|
|
if (out.stderr.length === 0) problems.push('no turns/*.stderr');
|
|
for (const s of out.stderr) if (!s.ok) problems.push(`${relative(runDir, s.path)}: ${s.problem}`);
|
|
if (!verify) for (const s of [out.result, out.snapshot, ...out.stderr]) delete s.sha256;
|
|
return { ...out, ok: problems.length === 0, problems };
|
|
}
|
|
|
|
// The thread id for REF: a full id as given, else the bus's own resolution, else a
|
|
// unique thread name of the caller's.
|
|
async function threadId(ref, call) {
|
|
if (UUID.test(ref)) return { id: ref.toLowerCase(), label: ref };
|
|
if (!call) throw new CliError(`thread ${JSON.stringify(ref)} needs the bus to resolve; pass the thread id`, 2);
|
|
try { return await resolveThread(call, ref); }
|
|
catch (e) {
|
|
if (!(e instanceof CliError) || e.exitCode !== 2) throw e;
|
|
const hits = (await call('thread.list')).filter(t => t.name === ref);
|
|
if (hits.length === 1) return { id: hits[0].id, label: ref };
|
|
if (hits.length > 1) throw new CliError(`${hits.length} threads are named ${JSON.stringify(ref)}; use <project>/<name> or the id`, 2);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
export async function runCommand(rest, io, deps = {}) {
|
|
const [sub, ...args] = rest;
|
|
if (sub !== 'evidence') throw usage(sub ? `unknown run command ${sub}` : 'run needs a command');
|
|
const parsed = parseArgs(args, `usage: ${RUN_USAGE}`, { flags: ['--verify', '--json', '--tester'], values: ['--thread'] });
|
|
if (parsed.positional.length !== 1) throw usage('run evidence takes one organization');
|
|
if (!parsed.values['--thread']) throw usage('run evidence needs --thread');
|
|
const id = parsed.positional[0];
|
|
let instance;
|
|
try { instance = devOwnerInstance(io.env); }
|
|
catch (e) { throw Object.assign(new CliError(`run-evidence-instance-refused: ${e.message}`, 3), { code: 'run-evidence-instance-refused' }); }
|
|
const system = deps.system ?? cliConfig.loadSystem({ env: io.env, instance });
|
|
const ctx = { configDir: configDir(io.env), dataRoot: system.dataRoot, instance, principal: DEV_OWNER };
|
|
let org;
|
|
try { org = await resolveOrg(ctx, id); }
|
|
catch (e) { if (e instanceof OrgError) throw Object.assign(new CliError(`${e.code}: ${e.message}`, e.code === 'org-unknown' ? 2 : 3), { code: e.code }); throw e; }
|
|
// Until the per-organization paths land (PLAN C3, orgPaths in config.mjs), the
|
|
// launcher writes runs and the launch log under the instance data root.
|
|
const base = typeof cliConfig.orgPaths === 'function' ? org.root : system.dataRoot;
|
|
const callFor = deps.callFor ? () => deps.callFor({ positional: [], flags: parsed.flags, values: { '--business': id } }) : null;
|
|
const thread = await threadId(parsed.values['--thread'], UUID.test(parsed.values['--thread']) ? null : callFor?.());
|
|
const verify = parsed.flags.has('--verify');
|
|
const log = threadRecords(launchLogFile(base, id), thread.id);
|
|
const runs = log.launches.map(launch => runEvidence({ runs: runsDir(base, id), thread: thread.id, launch, ends: log.ends, verify }));
|
|
const verified = verify ? runs.length > 0 && runs.every(r => r.ok) : null;
|
|
const report = { org: { id: org.id, root: org.root, state: org.state }, instance, base, launchLog: { file: log.file, missing: log.missing, malformed: log.malformed }, thread, runs, verified };
|
|
if (parsed.flags.has('--json')) io.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
else {
|
|
const lines = [`org ${org.id} (${org.state}) at ${org.root}`, `thread ${thread.label}${thread.label !== thread.id ? ` (${thread.id})` : ''}`, `launch log ${log.file}${log.missing ? ' (missing)' : ''}`];
|
|
for (const r of runs) {
|
|
lines.push(`run ${r.run} launch ${r.launch.file}:${r.launch.line} sha256 ${r.launch.sha256} ${r.end.length ? `ended ${r.end.at(-1).reason ?? ''}` : 'no end record'}`);
|
|
if (r.result) lines.push(` result ${r.result.path}${r.result.sha256 ? ` ${r.result.sha256}` : ''}${r.result.ok ? '' : ` FAIL ${r.result.problem}`}`);
|
|
if (r.snapshot) lines.push(` snapshot ${r.snapshot.path}${r.snapshot.sha256 ? ` ${r.snapshot.sha256}` : ''}${verify ? ` (${r.snapshot.files.filter(f => f.ok).length}/${r.snapshot.files.length} files match)` : ''}`);
|
|
for (const s of r.stderr ?? []) lines.push(` stderr ${s.path}${s.sha256 ? ` ${s.sha256}` : ''}${s.ok ? '' : ` FAIL ${s.problem}`}`);
|
|
for (const p of r.problems) lines.push(` problem: ${p}`);
|
|
}
|
|
lines.push(verify ? `verified: ${verified ? 'yes' : 'no'}` : 'not verified (pass --verify)');
|
|
io.stdout.write(`${lines.join('\n')}\n`);
|
|
}
|
|
if (runs.length === 0) throw new CliError(`no launch for thread ${thread.label} in the launch log`, 2);
|
|
if (verify && !verified) throw new CliError('run evidence failed verification', 1);
|
|
return report;
|
|
}
|