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)
383 lines
11 KiB
TypeScript
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);
|
|
});
|
|
}
|