diff --git a/AGENTS.md b/AGENTS.md index 2623e9b..b8547af 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,6 +57,7 @@ This repository publishes the open-source server, console, integration catalog, - Preserve the minimum runtime versions declared by each package. - Keep private keys and API tokens out of process arguments and generated setup commands. Accept them through hidden terminal input or owner-only credential files. - Persist credential-bearing files through owner-only same-directory temporary files and atomic rename. +- Resolve every agent runtime directory as one physical child of `~/.elydora`; reject separators, traversal segments, cross-platform reserved names, symbolic-link directories, and linked identity configs before writes or recursive removal. Validate stored directory identity before changing host CLI configuration, and require an explicit agent ID when discovery is ambiguous. ## Verification diff --git a/sdks/node/README.md b/sdks/node/README.md index b64f1b3..41b2584 100644 --- a/sdks/node/README.md +++ b/sdks/node/README.md @@ -51,7 +51,7 @@ npx elydora install \ --kid agent-456-key-v1 ``` -The CLI reads the private key and optional API token through hidden terminal prompts. For non-interactive installation, store each secret in an owner-only file and pass `--private_key_file ` and `--token_file `. Secret values are rejected as command-line arguments because process listings and shell history can expose them. +The CLI reads the private key and optional API token through hidden terminal prompts. For non-interactive installation, store each secret in an owner-only file and pass `--private_key_file ` and `--token_file `. Secret values are rejected as command-line arguments because process listings and shell history can expose them. Agent IDs map to one physical directory directly under `~/.elydora`; portable filename rules and physical-directory checks apply before writes or recursive removal. Ambiguous uninstall discovery requires an explicit agent ID. Codex performs a one-time trust review for user hooks. Run `/hooks` in Codex after installation and trust the Elydora `PreToolUse` and `PostToolUse` definitions. diff --git a/sdks/node/src/cli.ts b/sdks/node/src/cli.ts index f1d48cf..a0633bf 100644 --- a/sdks/node/src/cli.ts +++ b/sdks/node/src/cli.ts @@ -4,7 +4,13 @@ import path from 'node:path'; import os from 'node:os'; import { resolveInstallSecrets } from './cli-secrets.js'; import { derivePublicKey } from './crypto.js'; -import { ensurePrivateDirectory, writePrivateFile } from './secure-files.js'; +import { + ensurePrivateDirectory, + requirePhysicalDirectory, + requirePhysicalFile, + resolvePrivateChildDirectory, +} 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'; @@ -53,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 { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +async function readInstalledAgent(agentId: string): Promise { + const agentDirectory = resolvePrivateChildDirectory(ELYDORA_DIR, agentId); + if (!(await requirePhysicalDirectory(agentDirectory))) return undefined; + + const configPath = path.join(agentDirectory, 'config.json'); + if (!(await requirePhysicalFile(configPath))) return undefined; + let raw: string; + try { + raw = await fsp.readFile(configPath, 'utf-8'); + } catch (error) { + 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 { + 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 @@ -102,6 +167,7 @@ async function cmdInstall(args: string[]): Promise { const agentId = values.agent_id; if (!agentId) die('--agent_id is required'); + const agentDir = resolvePrivateChildDirectory(ELYDORA_DIR, agentId); const kid = values.kid; if (!kid) die('--kid is required'); @@ -123,7 +189,6 @@ async function cmdInstall(args: string[]): Promise { console.log(`Verifying private key... Public key: ${publicKey.slice(0, 12)}...`); // Create ~/.elydora/{agentId}/ directory - const agentDir = path.join(ELYDORA_DIR, agentId); await ensurePrivateDirectory(ELYDORA_DIR); await ensurePrivateDirectory(agentDir); @@ -194,31 +259,36 @@ async function cmdUninstall(args: string[]): Promise { } let agentId = values.agent_id; + 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)!; @@ -228,11 +298,8 @@ async function cmdUninstall(args: string[]): Promise { await plugin.uninstall(agentId); // Remove entire agent directory - const agentDir = path.join(ELYDORA_DIR, agentId); - try { - await fsp.rm(agentDir, { recursive: true, force: true }); - } catch { - // Already removed + if (agentDirectoryExists) { + await fsp.rm(agentDir, { recursive: true }); } console.log(`Elydora audit hook uninstalled for ${registryEntry.name}.`); @@ -244,25 +311,7 @@ async function cmdStatus(): Promise { 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(); diff --git a/sdks/node/src/runtime-paths.ts b/sdks/node/src/runtime-paths.ts new file mode 100644 index 0000000..279cada --- /dev/null +++ b/sdks/node/src/runtime-paths.ts @@ -0,0 +1,77 @@ +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 { + 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 { + 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 async function requirePhysicalFile(filePath: string): Promise { + let metadata; + try { + metadata = await fsp.lstat(filePath); + } catch (error) { + if (isMissingPath(error)) return false; + throw new Error(`Could not inspect agent runtime file: ${filePath}`, { cause: error }); + } + if (!metadata.isFile() || metadata.isSymbolicLink()) { + throw new Error(`Agent runtime config is not a physical file: ${filePath}`); + } + 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; +} diff --git a/sdks/node/src/secure-files.ts b/sdks/node/src/secure-files.ts index 9dc19c6..3662ceb 100644 --- a/sdks/node/src/secure-files.ts +++ b/sdks/node/src/secure-files.ts @@ -1,17 +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 { - await fsp.mkdir(directory, { recursive: true, mode: 0o700 }); - if (process.platform !== 'win32') { - await fsp.chmod(directory, 0o700); - } -} +import { ensurePrivateDirectory, isMissingPath } from './runtime-paths.js'; export async function writePrivateFile(filePath: string, contents: string): Promise { const directory = path.dirname(filePath); @@ -41,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; } } } diff --git a/sdks/node/test/cli-security.test.mjs b/sdks/node/test/cli-security.test.mjs index 897672e..fdb7e81 100644 --- a/sdks/node/test/cli-security.test.mjs +++ b/sdks/node/test/cli-security.test.mjs @@ -1,11 +1,12 @@ import assert from 'node:assert/strict'; -import { chmod, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { chmod, mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { spawn } from 'node:child_process'; import test from 'node:test'; import { resolveInstallSecrets } from '../dist/cli-secrets.js'; +import { requirePhysicalDirectory } from '../dist/runtime-paths.js'; const PRIVATE_KEY = Buffer.alloc(32, 7).toString('base64url'); const API_TOKEN = 'ely_test_token'; @@ -102,6 +103,151 @@ test('CLI rejects legacy secret arguments before writing files', async () => { } }); +test('CLI rejects agent IDs that escape the credential directory', async () => { + const invalidAgentIds = [ + '../escape', + '..\\escape', + 'C:escape', + 'agent.', + 'agent ', + ' agent', + 'CON', + 'COM¹.log', + '.', + '..', + ]; + for (const agentId of invalidAgentIds) { + const homeDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-node-cli-')); + try { + const result = await runCli([ + 'install', + '--agent', 'opencode', + '--org_id', 'org-1', + '--agent_id', agentId, + '--kid', 'key-1', + ], homeDir); + + assert.equal(result.code, 1); + assert.match(result.stderr, /(Invalid agent ID for local storage|Agent ID escapes)/); + await assert.rejects(stat(path.join(homeDir, '.elydora')), { code: 'ENOENT' }); + } finally { + await rm(homeDir, { recursive: true, force: true }); + } + } +}); + +test('uninstall validates the stored runtime identity before plugin changes', async () => { + const homeDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-node-cli-')); + const runtimeDir = path.join(homeDir, '.elydora', 'stored-directory'); + try { + await mkdir(runtimeDir, { recursive: true }); + await writeFile( + path.join(runtimeDir, 'config.json'), + JSON.stringify({ agent_name: 'opencode', agent_id: 'different-agent' }), + 'utf-8', + ); + const result = await runCli(['uninstall', '--agent', 'opencode'], homeDir); + + assert.equal(result.code, 1); + assert.match(result.stderr, /crosses its runtime directory/); + assert.equal((await stat(runtimeDir)).isDirectory(), true); + } finally { + await rm(homeDir, { recursive: true, force: true }); + } +}); + +test('uninstall requires an explicit ID for ambiguous agent runtimes', async () => { + const homeDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-node-cli-')); + try { + for (const agentId of ['agent-1', 'agent-2']) { + const runtimeDir = path.join(homeDir, '.elydora', agentId); + await mkdir(runtimeDir, { recursive: true }); + await writeFile( + path.join(runtimeDir, 'config.json'), + JSON.stringify({ agent_name: 'opencode', agent_id: agentId }), + 'utf-8', + ); + } + const result = await runCli(['uninstall', '--agent', 'opencode'], homeDir); + + assert.equal(result.code, 1); + assert.match(result.stderr, /Multiple installed agents/); + assert.equal((await stat(path.join(homeDir, '.elydora', 'agent-1'))).isDirectory(), true); + assert.equal((await stat(path.join(homeDir, '.elydora', 'agent-2'))).isDirectory(), true); + } finally { + await rm(homeDir, { recursive: true, force: true }); + } +}); + +test('runtime directory checks reject symbolic-link children', async (t) => { + const homeDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-node-cli-')); + const targetDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-node-target-')); + const runtimeRoot = path.join(homeDir, '.elydora'); + const linkPath = path.join(runtimeRoot, 'agent-1'); + try { + await mkdir(runtimeRoot, { recursive: true }); + try { + await symlink(targetDir, linkPath, 'dir'); + } catch (error) { + t.skip(`Directory symbolic links are unavailable: ${error.message}`); + return; + } + + await assert.rejects( + requirePhysicalDirectory(linkPath), + /not a physical directory/, + ); + } finally { + await rm(homeDir, { recursive: true, force: true }); + await rm(targetDir, { recursive: true, force: true }); + } +}); + +test('status surfaces malformed runtime config', async () => { + const homeDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-node-cli-')); + const runtimeDir = path.join(homeDir, '.elydora', 'agent-1'); + try { + await mkdir(runtimeDir, { recursive: true }); + await writeFile(path.join(runtimeDir, 'config.json'), '{invalid-json', 'utf-8'); + + const result = await runCli(['status'], homeDir); + + assert.equal(result.code, 1); + assert.match(result.stderr, /Could not parse agent config/); + } finally { + await rm(homeDir, { recursive: true, force: true }); + } +}); + +test('status rejects symbolic-link runtime configs', async (t) => { + const homeDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-node-cli-')); + const targetDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-node-target-')); + const runtimeDir = path.join(homeDir, '.elydora', 'agent-1'); + const targetConfig = path.join(targetDir, 'config.json'); + try { + await mkdir(runtimeDir, { recursive: true }); + await writeFile( + targetConfig, + JSON.stringify({ agent_name: 'opencode', agent_id: 'agent-1' }), + 'utf-8', + ); + try { + await symlink(targetConfig, path.join(runtimeDir, 'config.json'), 'file'); + } catch (error) { + t.skip(`File symbolic links are unavailable: ${error.message}`); + return; + } + + const result = await runCli(['status'], homeDir); + + assert.equal(result.code, 1); + assert.match(result.stderr, /runtime config is not a physical file/); + } finally { + await rm(homeDir, { recursive: true, force: true }); + await rm(targetDir, { recursive: true, force: true }); + } +}); + test('CLI installs from owner-only files and protects persisted credentials', async () => { const homeDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-node-cli-')); const privateKeyFile = path.join(homeDir, 'private-key');