fix(cli): confine agent runtime paths
This commit is contained in:
1 parent
c78df90361
commit
856ae5313e
5 files changed
+78
-9
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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 <path>` and `--token_file <path>`. 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 <path>` and `--token_file <path>`. 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
|
||||
|
||||
|
||||
+9
-8
@@ -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<void> {
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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}.`);
|
||||
}
|
||||
|
||||
@@ -8,11 +8,46 @@ function isMissingFile(error: unknown): boolean {
|
||||
|
||||
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.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<void> {
|
||||
const directory = path.dirname(filePath);
|
||||
await ensurePrivateDirectory(directory);
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user