From d2794c8fbb8059d241a24c189a6ce7aecc00ad48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 19 Jul 2026 16:34:01 -0400 Subject: [PATCH] fix(cli): reject linked runtime configs --- AGENTS.md | 2 +- src/cli.ts | 4 ++-- src/runtime-paths.ts | 14 ++++++++++++++ test/cli-security.test.mjs | 29 +++++++++++++++++++++++++++++ 4 files changed, 46 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0bf235f..ace956c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,7 +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, cross-platform reserved names, and symbolic-link directories before writes or recursive removal. Validate stored directory identity before changing host CLI configuration, and require an explicit agent ID when discovery is ambiguous. +- 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/src/cli.ts b/src/cli.ts index 5fbad54..a0633bf 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -6,8 +6,8 @@ import { resolveInstallSecrets } from './cli-secrets.js'; import { derivePublicKey } from './crypto.js'; import { ensurePrivateDirectory, - isMissingPath, requirePhysicalDirectory, + requirePhysicalFile, resolvePrivateChildDirectory, } from './runtime-paths.js'; import { writePrivateFile } from './secure-files.js'; @@ -74,11 +74,11 @@ async function readInstalledAgent(agentId: 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 ( diff --git a/test/cli-security.test.mjs b/test/cli-security.test.mjs index e036f14..fdb7e81 100644 --- a/test/cli-security.test.mjs +++ b/test/cli-security.test.mjs @@ -219,6 +219,35 @@ test('status surfaces malformed runtime config', async () => { } }); +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');