fix(cli): validate runtimes before uninstall

This commit is contained in:
ZacharyZhang-NY committed 2026-07-19 16:32:42 -04:00
1 parent 924bed6c21
commit 188b70655d
6 files changed
+243 -94

No files matched your search

+91 -43
View File
@@ -6,9 +6,11 @@ import { resolveInstallSecrets } from './cli-secrets.js';
import { derivePublicKey } from './crypto.js';
import {
ensurePrivateDirectory,
isMissingPath,
requirePhysicalDirectory,
resolvePrivateChildDirectory,
writePrivateFile,
} from './secure-files.js';
} from './runtime-paths.js';
import { writePrivateFile } from './secure-files.js';
import { SUPPORTED_AGENTS } from './plugins/registry.js';
import type { AgentPlugin, InstallConfig } from './plugins/base.js';
import { generateHookScript, generateGuardScript } from './plugins/hook-template.js';
@@ -57,6 +59,65 @@ function die(message: string): never {
process.exit(1);
}
interface InstalledAgent {
agentId: string;
agentName: string;
configPath: string;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
async function readInstalledAgent(agentId: string): Promise<InstalledAgent | undefined> {
const agentDirectory = resolvePrivateChildDirectory(ELYDORA_DIR, agentId);
if (!(await requirePhysicalDirectory(agentDirectory))) return undefined;
const configPath = path.join(agentDirectory, 'config.json');
let raw: string;
try {
raw = await fsp.readFile(configPath, 'utf-8');
} catch (error) {
if (isMissingPath(error)) return undefined;
throw new Error(`Could not read agent config: ${configPath}`, { cause: error });
}
let config: unknown;
try {
config = JSON.parse(raw);
} catch (error) {
throw new Error(`Could not parse agent config: ${configPath}`, { cause: error });
}
if (
!isRecord(config)
|| typeof config.agent_id !== 'string'
|| typeof config.agent_name !== 'string'
) {
throw new Error(`Agent config has an invalid runtime identity: ${configPath}`);
}
if (config.agent_id !== agentId) {
throw new Error(`Agent config crosses its runtime directory: ${configPath}`);
}
resolvePrivateChildDirectory(ELYDORA_DIR, config.agent_id);
return { agentId, agentName: config.agent_name, configPath };
}
async function discoverInstalledAgents(): Promise<InstalledAgent[]> {
if (!(await requirePhysicalDirectory(ELYDORA_DIR))) return [];
const entries = await fsp.readdir(ELYDORA_DIR, { withFileTypes: true });
const installedAgents: InstalledAgent[] = [];
for (const entry of entries) {
if (entry.isSymbolicLink()) {
throw new Error(`Agent runtime path is not a physical directory: ${path.join(ELYDORA_DIR, entry.name)}`);
}
if (!entry.isDirectory()) continue;
const installedAgent = await readInstalledAgent(entry.name);
if (installedAgent) installedAgents.push(installedAgent);
}
return installedAgents;
}
function printUsage(): void {
console.log(`Elydora CLI — Tamper-evident audit for AI coding agents
@@ -198,32 +259,36 @@ async function cmdUninstall(args: string[]): Promise<void> {
}
let agentId = values.agent_id;
if (agentId) resolvePrivateChildDirectory(ELYDORA_DIR, agentId);
let agentDir: string;
let agentDirectoryExists: boolean;
// If --agent_id not provided, scan ~/.elydora/*/config.json for matching agent_name
if (!agentId) {
try {
const entries = await fsp.readdir(ELYDORA_DIR, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const cfgPath = path.join(ELYDORA_DIR, entry.name, 'config.json');
try {
const raw = await fsp.readFile(cfgPath, 'utf-8');
const cfg = JSON.parse(raw);
if (cfg.agent_name === agentName) {
agentId = entry.name;
break;
}
} catch {
// Skip unreadable configs
}
if (agentId) {
agentDir = resolvePrivateChildDirectory(ELYDORA_DIR, agentId);
const runtimeRootExists = await requirePhysicalDirectory(ELYDORA_DIR);
agentDirectoryExists = runtimeRootExists
? await requirePhysicalDirectory(agentDir)
: false;
if (agentDirectoryExists) {
const installedAgent = await readInstalledAgent(agentId);
if (installedAgent && installedAgent.agentName !== agentName) {
throw new Error(
`Agent runtime ${agentId} belongs to ${installedAgent.agentName}, not ${agentName}`,
);
}
} catch {
// ELYDORA_DIR may not exist
}
if (!agentId) {
} else {
const matches = (await discoverInstalledAgents())
.filter((installedAgent) => installedAgent.agentName === agentName);
if (matches.length === 0) {
die(`No installed agent found for "${agentName}". Use --agent_id to specify explicitly.`);
}
if (matches.length > 1) {
die(`Multiple installed agents found for "${agentName}". Use --agent_id to specify explicitly.`);
}
agentId = matches[0].agentId;
agentDir = resolvePrivateChildDirectory(ELYDORA_DIR, agentId);
agentDirectoryExists = true;
}
const plugin = PLUGINS.get(agentName)!;
@@ -233,8 +298,9 @@ async function cmdUninstall(args: string[]): Promise<void> {
await plugin.uninstall(agentId);
// Remove entire agent directory
const agentDir = resolvePrivateChildDirectory(ELYDORA_DIR, agentId);
await fsp.rm(agentDir, { recursive: true, force: true });
if (agentDirectoryExists) {
await fsp.rm(agentDir, { recursive: true });
}
console.log(`Elydora audit hook uninstalled for ${registryEntry.name}.`);
}
@@ -245,25 +311,7 @@ async function cmdStatus(): Promise<void> {
let anyInstalled = false;
// Scan ~/.elydora/*/config.json to discover installed agents
const installedAgents: Array<{ agentId: string; agentName: string; configPath: string }> = [];
try {
const entries = await fsp.readdir(ELYDORA_DIR, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const cfgPath = path.join(ELYDORA_DIR, entry.name, 'config.json');
try {
const raw = await fsp.readFile(cfgPath, 'utf-8');
const cfg = JSON.parse(raw);
if (cfg.agent_name && cfg.agent_id) {
installedAgents.push({ agentId: entry.name, agentName: cfg.agent_name, configPath: cfgPath });
}
} catch {
// Skip unreadable configs
}
}
} catch {
// ELYDORA_DIR may not exist
}
const installedAgents = await discoverInstalledAgents();
for (const [name, plugin] of PLUGINS) {
const st = await plugin.status();
+63
View File
@@ -0,0 +1,63 @@
import fsp from 'node:fs/promises';
import path from 'node:path';
export function isMissingPath(error: unknown): boolean {
return error instanceof Error && 'code' in error && error.code === 'ENOENT';
}
export async function ensurePrivateDirectory(directory: string): Promise<void> {
await fsp.mkdir(directory, { recursive: true, mode: 0o700 });
const metadata = await fsp.lstat(directory);
if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
throw new Error(`Private directory path is not a physical directory: ${directory}`);
}
if (process.platform !== 'win32') {
await fsp.chmod(directory, 0o700);
}
}
export async function requirePhysicalDirectory(directory: string): Promise<boolean> {
let metadata;
try {
metadata = await fsp.lstat(directory);
} catch (error) {
if (isMissingPath(error)) return false;
throw new Error(`Could not inspect agent runtime directory: ${directory}`, { cause: error });
}
if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
throw new Error(`Agent runtime path is not a physical directory: ${directory}`);
}
return true;
}
export function resolvePrivateChildDirectory(root: string, childName: string): string {
const isWindowsDeviceName = /^(?:con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])(?:\.|$)/i.test(childName);
if (
childName.length === 0
|| /[<>:"|?*\u0000-\u001f]/.test(childName)
|| childName.includes('/')
|| childName.includes('\\')
|| childName === '.'
|| childName === '..'
|| childName.startsWith(' ')
|| childName.endsWith('.')
|| childName.endsWith(' ')
|| isWindowsDeviceName
) {
throw new Error(`Invalid agent ID for local storage: ${JSON.stringify(childName)}`);
}
const resolvedRoot = path.resolve(root);
const candidate = path.resolve(resolvedRoot, childName);
const relative = path.relative(resolvedRoot, candidate);
if (
relative.length === 0
|| path.isAbsolute(relative)
|| relative === '..'
|| relative.startsWith(`..${path.sep}`)
|| relative.includes(path.sep)
) {
throw new Error(`Agent ID escapes the local storage directory: ${JSON.stringify(childName)}`);
}
return candidate;
}
+2 -48
View File
@@ -1,53 +1,7 @@
import crypto from 'node:crypto';
import fsp from 'node:fs/promises';
import path from 'node:path';
function isMissingFile(error: unknown): boolean {
return error instanceof Error && 'code' in error && error.code === 'ENOENT';
}
export async function ensurePrivateDirectory(directory: string): Promise<void> {
await fsp.mkdir(directory, { recursive: true, mode: 0o700 });
const metadata = await fsp.lstat(directory);
if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
throw new Error(`Private directory path is not a physical directory: ${directory}`);
}
if (process.platform !== 'win32') {
await fsp.chmod(directory, 0o700);
}
}
export function resolvePrivateChildDirectory(root: string, childName: string): string {
const isWindowsDeviceName = /^(?:con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])(?:\.|$)/i.test(childName);
if (
childName.length === 0
|| /[<>:"|?*\u0000-\u001f]/.test(childName)
|| childName.includes('/')
|| childName.includes('\\')
|| childName === '.'
|| childName === '..'
|| childName.startsWith(' ')
|| childName.endsWith('.')
|| childName.endsWith(' ')
|| isWindowsDeviceName
) {
throw new Error(`Invalid agent ID for local storage: ${JSON.stringify(childName)}`);
}
const resolvedRoot = path.resolve(root);
const candidate = path.resolve(resolvedRoot, childName);
const relative = path.relative(resolvedRoot, candidate);
if (
relative.length === 0
|| path.isAbsolute(relative)
|| relative === '..'
|| relative.startsWith(`..${path.sep}`)
|| relative.includes(path.sep)
) {
throw new Error(`Agent ID escapes the local storage directory: ${JSON.stringify(childName)}`);
}
return candidate;
}
import { ensurePrivateDirectory, isMissingPath } from './runtime-paths.js';
export async function writePrivateFile(filePath: string, contents: string): Promise<void> {
const directory = path.dirname(filePath);
@@ -77,7 +31,7 @@ export async function writePrivateFile(filePath: string, contents: string): Prom
try {
await fsp.unlink(temporaryPath);
} catch (error) {
if (!isMissingFile(error)) throw error;
if (!isMissingPath(error)) throw error;
}
}
}