clawdie-ai/setup/verify-agent-jails.ts
Mevy Assistant c65c289f08 refactor(multitenant): make tenant and platform identity explicit
Replace ambiguous AGENT_NAME usage across runtime, setup, and helper scripts with explicit TENANT_ID or platform runtime identity where appropriate. Keep AGENT_NAME as a compatibility boundary instead of the primary source for shared runtime naming.

---
Build: pass | Tests: pass — 138 passed (10 files)
2026-04-23 21:41:42 +02:00

383 lines
11 KiB
TypeScript

/**
* setup/verify-agent-jails.ts — Verify agent jail secret scoping.
*
* For each specialist agent jail (db-worker, git-worker, ctrl-worker):
* 1. Confirm jail exists and is running via bastille list
* 2. Confirm /.env.agent exists inside the jail (jail fs root)
* 3. Confirm that domain keys for THIS specialist are present
* 4. Confirm no cross-contamination: no keys belonging to OTHER specialists
*
* Requires: FreeBSD host, root, bastille installed.
*
* Run: sudo npx tsx setup/verify-agent-jails.ts
*/
import fs from 'fs';
import path from 'path';
import { execSync } from 'child_process';
import { PLATFORM_RUNTIME_USER, TENANT_ID } from '../src/config.js';
import { logger } from '../src/logger.js';
import { loadJailRegistry, getAgentJailDef } from '../src/jail-schema.js';
import { commandExists, getPlatform, isRoot } from './platform.js';
import { emitStatus } from './status.js';
import { bastille, jailRoot, resolveJailName } from './bastille-helpers.js';
const LOG = 'logs/setup.log';
// ---------------------------------------------------------------------------
// Secret scoping — single source of truth (must stay in sync with agent-jails.ts)
// ---------------------------------------------------------------------------
const SHARED_LLM_KEYS = [
'ANTHROPIC_API_KEY',
'OPENAI_API_KEY',
'OPENROUTER_API_KEY',
'ZAI_API_KEY',
'GEMINI_API_KEY',
];
const DOMAIN_KEY_ENV_MAP: Record<string, string[]> = {
'db-admin': [
'DB_HOST',
'DB_PORT',
'SKILLS_DB_URL',
'MEMORY_DB_URL',
'OPS_DB_URL',
'SKILLS_DB_USER',
'SKILLS_DB_PASSWORD',
'MEMORY_DB_USER',
'MEMORY_DB_PASSWORD',
'OPS_DB_USER',
'OPS_DB_PASSWORD',
],
'git-admin': [
'REMOTE_GIT_URL',
'GIT_MIRROR_URLS',
'GIT_SSH_KEY_PATH',
'FORGEJO_TOKEN',
],
coordinator: ['CONTROLPLANE_API_KEY'],
};
// All domain keys across all specialists — used to detect cross-contamination
const ALL_DOMAIN_KEYS = new Set(Object.values(DOMAIN_KEY_ENV_MAP).flat());
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Parse KEY=VALUE lines from a .env file into a Set of key names. */
export function parseEnvKeys(content: string): Set<string> {
const keys = new Set<string>();
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const normalized = trimmed.startsWith('export ')
? trimmed.slice('export '.length).trim()
: trimmed;
const eqIdx = normalized.indexOf('=');
if (eqIdx < 1) continue;
keys.add(normalized.slice(0, eqIdx).trim());
}
return keys;
}
/** Check whether a jail is currently running (State == "Up" in bastille list). */
export function jailIsRunning(jailName: string): boolean {
const { output } = bastille('list');
for (const line of output.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('JID')) continue;
// bastille list columns: JID Name Boot Prio State Type IP ...
const cols = trimmed.split(/\s+/u);
if (cols.length >= 5 && cols[1] === jailName) {
return cols[4].toLowerCase() === 'up';
}
}
return false;
}
export interface JailVerifyResult {
specialist: string;
jailName: string;
exists: boolean;
running: boolean;
envFileExists: boolean;
/** Domain keys that should be present but are missing */
missingDomainKeys: string[];
/** Keys belonging to OTHER specialists found in this jail's .env.agent */
leakedKeys: string[];
/** LLM keys found (should be exactly 0 or 1) */
llmKeysFound: string[];
passed: boolean;
notes: string[];
}
// ---------------------------------------------------------------------------
// Core verification
// ---------------------------------------------------------------------------
export function verifyJail(
specialist: string,
jailName: string,
): JailVerifyResult {
const result: JailVerifyResult = {
specialist,
jailName,
exists: false,
running: false,
envFileExists: false,
missingDomainKeys: [],
leakedKeys: [],
llmKeysFound: [],
passed: false,
notes: [],
};
// 1. Existence + running state
const { output: listOutput } = bastille('list');
for (const line of listOutput.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('JID')) continue;
const cols = trimmed.split(/\s+/u);
if (cols.length >= 5 && cols[1] === jailName) {
result.exists = true;
result.running = cols[4].toLowerCase() === 'up';
break;
}
}
if (!result.exists) {
result.notes.push(
`Jail "${jailName}" not found — run: sudo npx tsx setup/agent-jails.ts`,
);
return result;
}
if (!result.running) {
result.notes.push(
`Jail "${jailName}" exists but is not running — run: sudo bastille start ${jailName}`,
);
return result;
}
// 2. .env.agent presence
const envPath = path.join(jailRoot(jailName), '.env.agent');
if (!fs.existsSync(envPath)) {
result.notes.push(`.env.agent not found at ${envPath}`);
return result;
}
result.envFileExists = true;
// 2b. jail-exec staging dir writable by agent user
const jailExecPath = path.join(
jailRoot(jailName),
'var',
'tmp',
'jail-exec',
);
if (!fs.existsSync(jailExecPath)) {
result.notes.push(
`jail-exec dir not found at ${jailExecPath} — run: sudo npx tsx setup/agent-jails.ts`,
);
} else {
try {
fs.accessSync(jailExecPath, fs.constants.W_OK);
} catch {
result.notes.push(
`jail-exec dir not writable by current user: ${jailExecPath}`,
);
}
}
// 2c. NFSv4 ACL check — verify agent user has traverse access
const jailBase = `/usr/local/bastille/jails/${jailName}`;
try {
const aclOutput = execSync(`getfacl ${jailBase}/root`, {
encoding: 'utf-8',
});
if (!aclOutput.includes(`user:${PLATFORM_RUNTIME_USER}:`)) {
result.notes.push(
`NFSv4 ACL missing for ${PLATFORM_RUNTIME_USER} on ${jailBase}/root — run: setfacl -m user:${PLATFORM_RUNTIME_USER}:r-x:allow ${jailBase}/root`,
);
}
} catch {
result.notes.push(
`Could not check ACLs on ${jailBase}/root — getfacl failed`,
);
}
// 2d. Sudoers check — verify agent user can run bastille cmd
const sudoersFile = `/usr/local/etc/sudoers.d/${PLATFORM_RUNTIME_USER}-bastille`;
if (!fs.existsSync(sudoersFile)) {
result.notes.push(
`Sudoers entry missing: ${sudoersFile} — jail exec requires: ${PLATFORM_RUNTIME_USER} ALL=(root) NOPASSWD: /usr/local/bin/bastille cmd *`,
);
}
// 3. Parse keys
const content = fs.readFileSync(envPath, 'utf-8');
const presentKeys = parseEnvKeys(content);
// 4. Check domain keys for this specialist
const ownDomainKeys = DOMAIN_KEY_ENV_MAP[specialist] ?? [];
for (const key of ownDomainKeys) {
if (!presentKeys.has(key)) {
result.missingDomainKeys.push(key);
}
}
// 5. Check for cross-contamination — other specialists' domain keys must not appear
const otherSpecialists = Object.keys(DOMAIN_KEY_ENV_MAP).filter(
(s) => s !== specialist,
);
for (const other of otherSpecialists) {
for (const key of DOMAIN_KEY_ENV_MAP[other]) {
if (presentKeys.has(key)) {
result.leakedKeys.push(key);
}
}
}
// 6. LLM keys — each jail gets at most one shared key
for (const key of SHARED_LLM_KEYS) {
if (presentKeys.has(key)) {
result.llmKeysFound.push(key);
}
}
if (result.llmKeysFound.length > 1) {
result.notes.push(
`Multiple LLM keys found (${result.llmKeysFound.join(', ')}) — expected at most one`,
);
}
// 7. Any unknown keys? (present but not in LLM list and not in any domain map)
for (const key of presentKeys) {
if (!SHARED_LLM_KEYS.includes(key) && !ALL_DOMAIN_KEYS.has(key)) {
result.notes.push(`Unexpected key in .env.agent: ${key}`);
}
}
// 8. Summary
if (result.missingDomainKeys.length > 0) {
result.notes.push(
`Missing domain keys: ${result.missingDomainKeys.join(', ')} — check if these are set in .env on host`,
);
}
if (result.leakedKeys.length > 0) {
result.notes.push(
`LEAKED KEYS from other specialist(s): ${result.leakedKeys.join(', ')} — re-run setup/agent-jails.ts`,
);
}
result.passed =
result.exists &&
result.running &&
result.envFileExists &&
result.leakedKeys.length === 0 &&
result.llmKeysFound.length <= 1;
return result;
}
// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
export async function run(_args: string[]): Promise<void> {
if (getPlatform() !== 'freebsd') {
emitStatus('VERIFY_AGENT_JAILS', {
STATUS: 'failed',
ERROR: 'unsupported_platform',
LOG,
});
process.exit(1);
}
if (!isRoot()) {
emitStatus('VERIFY_AGENT_JAILS', {
STATUS: 'failed',
ERROR: 'requires_root',
LOG,
});
throw new Error('verify_agent_jails_requires_root');
}
if (!commandExists('bastille')) {
emitStatus('VERIFY_AGENT_JAILS', {
STATUS: 'failed',
ERROR: 'missing_bastille',
LOG,
});
throw new Error('missing_bastille');
}
const registry = loadJailRegistry();
const specialists = ['db-admin', 'git-admin', 'coordinator'];
const results: JailVerifyResult[] = [];
for (const specialist of specialists) {
const entry = getAgentJailDef(registry, specialist);
if (!entry) {
logger.warn({ specialist }, 'No agent jail definition found in registry');
continue;
}
const jailName = resolveJailName({
agentName: TENANT_ID,
role: entry.role,
legacyNames: [entry.role],
});
logger.info({ specialist, jailName }, 'Verifying agent jail');
const result = verifyJail(specialist, jailName);
results.push(result);
const status = result.passed
? 'pass'
: result.leakedKeys.length > 0
? 'FAIL_LEAK'
: 'fail';
logger.info(
{
specialist,
jailName,
status,
missing: result.missingDomainKeys,
leaked: result.leakedKeys,
llm: result.llmKeysFound,
notes: result.notes,
},
`Jail verify: ${status}`,
);
}
const allPassed = results.every((r) => r.passed);
const anyLeaks = results.some((r) => r.leakedKeys.length > 0);
emitStatus('VERIFY_AGENT_JAILS', {
STATUS: allPassed ? 'success' : anyLeaks ? 'LEAKED_SECRETS' : 'failed',
PASSED: results.filter((r) => r.passed).length,
FAILED: results.filter((r) => !r.passed).length,
LEAKS: results.flatMap((r) => r.leakedKeys).join(',') || 'none',
LOG,
});
if (anyLeaks) {
process.exit(2);
} else if (!allPassed) {
process.exit(1);
}
}
// Allow running directly
const isMain =
process.argv[1] &&
(process.argv[1].endsWith('verify-agent-jails.ts') ||
process.argv[1].endsWith('verify-agent-jails.js'));
if (isMain) {
run(process.argv.slice(2)).catch((err: unknown) => {
logger.error({ err }, 'verify-agent-jails failed');
process.exit(1);
});
}