diff --git a/AGENTS.md b/AGENTS.md index 85aed7e..b4b2129 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,6 +38,7 @@ This repository owns the published `@elydora/sdk` package, its `elydora` CLI, lo - Avoid compatibility shims without a named public or user configuration contract. - 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, and symbolic-link directories before writes. ## Verification diff --git a/README.md b/README.md index 4e60b4e..4c41fc7 100644 --- a/README.md +++ b/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`; path separators and traversal segments are rejected. ### Commands diff --git a/src/cli.ts b/src/cli.ts index f1d48cf..3a71910 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,7 +4,11 @@ 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, + resolvePrivateChildDirectory, + 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'; @@ -102,6 +106,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 +128,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,6 +198,7 @@ async function cmdUninstall(args: string[]): Promise { } let agentId = values.agent_id; + if (agentId) resolvePrivateChildDirectory(ELYDORA_DIR, agentId); // If --agent_id not provided, scan ~/.elydora/*/config.json for matching agent_name if (!agentId) { @@ -228,12 +233,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 - } + const agentDir = resolvePrivateChildDirectory(ELYDORA_DIR, agentId); + await fsp.rm(agentDir, { recursive: true, force: true }); console.log(`Elydora audit hook uninstalled for ${registryEntry.name}.`); } diff --git a/src/secure-files.ts b/src/secure-files.ts index 9dc19c6..08235e7 100644 --- a/src/secure-files.ts +++ b/src/secure-files.ts @@ -8,11 +8,46 @@ function isMissingFile(error: unknown): boolean { 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 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.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; +} + export async function writePrivateFile(filePath: string, contents: string): Promise { const directory = path.dirname(filePath); await ensurePrivateDirectory(directory); diff --git a/test/cli-security.test.mjs b/test/cli-security.test.mjs index 897672e..eda1f7a 100644 --- a/test/cli-security.test.mjs +++ b/test/cli-security.test.mjs @@ -102,6 +102,38 @@ 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 ', + '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('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');