106 lines
3.6 KiB
JavaScript
106 lines
3.6 KiB
JavaScript
import { readFileSync, lstatSync } from 'node:fs';
|
|
import { basename, resolve } from 'node:path';
|
|
import { BusError } from './broker.mjs';
|
|
const MARKERS = [
|
|
'MOSAIC_BUS_CAP',
|
|
'MOSAIC_RUN_ID',
|
|
'MOSAIC_AGENT_RUN',
|
|
'CLAUDECODE',
|
|
'CLAUDE_CODE_ENTRYPOINT',
|
|
'CODEX_THREAD_ID',
|
|
'PI_AGENT_DIR',
|
|
];
|
|
// Row 252 (#1753, Webby N5): the Console wrapper's nonce and the subject its spawn bound. The
|
|
// verifier reads each from the wrapper's initial environment, never from its proof alone.
|
|
export const CONSOLE_ENV = Object.freeze(['MOSAIC_BUS_CONSOLE_NONCE', 'MOSAIC_BUS_CONSOLE_BUSINESS', 'MOSAIC_BUS_CONSOLE_PROVIDER', 'MOSAIC_BUS_CONSOLE_ISS', 'MOSAIC_BUS_CONSOLE_SUB']);
|
|
const NONCE_KEYS = ['MOSAIC_BUS_CLI_NONCE', 'MOSAIC_BUS_USER_NONCE', ...CONSOLE_ENV];
|
|
const refuse = () => {
|
|
throw new BusError('human-required');
|
|
};
|
|
export function readProcess(pid, { readFile = readFileSync, stat = lstatSync } = {}) {
|
|
if (!Number.isSafeInteger(pid) || pid < 1) refuse();
|
|
try {
|
|
const dir = `/proc/${pid}`,
|
|
raw = readFile(dir + '/stat', 'utf8'),
|
|
fields = raw.slice(raw.lastIndexOf(')') + 2).split(' ');
|
|
let env = {};
|
|
let environment;
|
|
try {
|
|
environment = readFile(dir + '/environ', 'utf8');
|
|
} catch (e) {
|
|
if (e.code !== 'EACCES') throw e;
|
|
env = null;
|
|
}
|
|
if (environment !== undefined)
|
|
for (const entry of environment.split('\0')) {
|
|
const i = entry.indexOf('=');
|
|
const key = entry.slice(0, i);
|
|
if (NONCE_KEYS.includes(key) || MARKERS.includes(key)) env[key] = entry.slice(i + 1);
|
|
}
|
|
return {
|
|
pid,
|
|
ppid: Number(fields[1]),
|
|
startTime: fields[19],
|
|
uid: stat(dir).uid,
|
|
argv: readFile(dir + '/cmdline', 'utf8')
|
|
.split('\0')
|
|
.filter(Boolean),
|
|
env,
|
|
};
|
|
} catch {
|
|
refuse();
|
|
}
|
|
}
|
|
// Cooperative same-UID process checks, not peer credentials or a hostile-process wall.
|
|
// A nonce in the CLI's launch environment binds the submitted PID to a live CLI.
|
|
export function verifyHuman(proof, { cliPath, launches = [], readProcess: read = readProcess, nonceKey = 'MOSAIC_BUS_CLI_NONCE' }) {
|
|
if (
|
|
!proof ||
|
|
typeof proof !== 'object' ||
|
|
Object.keys(proof).some((k) => !['pid', 'startTime', 'nonce', 'business'].includes(k)) ||
|
|
!Number.isSafeInteger(proof.pid) ||
|
|
proof.pid < 2 ||
|
|
typeof proof.business !== 'string' ||
|
|
typeof proof.nonce !== 'string' ||
|
|
!/^[a-f0-9]{64}$/.test(proof.nonce)
|
|
)
|
|
refuse();
|
|
try {
|
|
const p = read(proof.pid);
|
|
if (
|
|
p.uid !== process.getuid() ||
|
|
p.startTime !== proof.startTime ||
|
|
p.env?.[nonceKey] !== proof.nonce ||
|
|
p.argv[1] !== resolve(cliPath)
|
|
)
|
|
refuse();
|
|
const seen = new Set();
|
|
let current = p;
|
|
for (let depth = 0; depth < 128; depth++) {
|
|
if (seen.has(current.pid)) refuse();
|
|
seen.add(current.pid);
|
|
if (
|
|
(current.env !== null && MARKERS.some((k) => current.env[k])) ||
|
|
launches.some((r) => r.pid === current.pid && r.startTime === current.startTime)
|
|
)
|
|
refuse();
|
|
const command = basename(current.argv[0] ?? '');
|
|
if (
|
|
/^(pi|claude|claude-code|codex)(?:\.js)?$/.test(command) ||
|
|
current.argv.some((v) => /\/(?:pi-coding-agent|codex)\/(?:dist|bin)\//.test(v))
|
|
)
|
|
refuse();
|
|
if (current.ppid === 1) {
|
|
const again = read(proof.pid);
|
|
if (again.startTime !== proof.startTime || again.ppid !== p.ppid) refuse();
|
|
return { business: proof.business };
|
|
}
|
|
if (current.ppid < 2) refuse();
|
|
current = read(current.ppid);
|
|
}
|
|
} catch {
|
|
refuse();
|
|
}
|
|
refuse();
|
|
}
|