fix(cli): reject linked runtime configs
This commit is contained in:
1 parent
188b70655d
commit
d2794c8fbb
4 files changed
+46
-3
No files matched your search
@@ -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.
|
- 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.
|
- 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.
|
- 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
|
## Verification
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -6,8 +6,8 @@ import { resolveInstallSecrets } from './cli-secrets.js';
|
|||||||
import { derivePublicKey } from './crypto.js';
|
import { derivePublicKey } from './crypto.js';
|
||||||
import {
|
import {
|
||||||
ensurePrivateDirectory,
|
ensurePrivateDirectory,
|
||||||
isMissingPath,
|
|
||||||
requirePhysicalDirectory,
|
requirePhysicalDirectory,
|
||||||
|
requirePhysicalFile,
|
||||||
resolvePrivateChildDirectory,
|
resolvePrivateChildDirectory,
|
||||||
} from './runtime-paths.js';
|
} from './runtime-paths.js';
|
||||||
import { writePrivateFile } from './secure-files.js';
|
import { writePrivateFile } from './secure-files.js';
|
||||||
@@ -74,11 +74,11 @@ async function readInstalledAgent(agentId: string): Promise<InstalledAgent | und
|
|||||||
if (!(await requirePhysicalDirectory(agentDirectory))) return undefined;
|
if (!(await requirePhysicalDirectory(agentDirectory))) return undefined;
|
||||||
|
|
||||||
const configPath = path.join(agentDirectory, 'config.json');
|
const configPath = path.join(agentDirectory, 'config.json');
|
||||||
|
if (!(await requirePhysicalFile(configPath))) return undefined;
|
||||||
let raw: string;
|
let raw: string;
|
||||||
try {
|
try {
|
||||||
raw = await fsp.readFile(configPath, 'utf-8');
|
raw = await fsp.readFile(configPath, 'utf-8');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isMissingPath(error)) return undefined;
|
|
||||||
throw new Error(`Could not read agent config: ${configPath}`, { cause: error });
|
throw new Error(`Could not read agent config: ${configPath}`, { cause: error });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,20 @@ export async function requirePhysicalDirectory(directory: string): Promise<boole
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function requirePhysicalFile(filePath: string): Promise<boolean> {
|
||||||
|
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 {
|
export function resolvePrivateChildDirectory(root: string, childName: string): string {
|
||||||
const isWindowsDeviceName = /^(?:con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])(?:\.|$)/i.test(childName);
|
const isWindowsDeviceName = /^(?:con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])(?:\.|$)/i.test(childName);
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -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 () => {
|
test('CLI installs from owner-only files and protects persisted credentials', async () => {
|
||||||
const homeDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-node-cli-'));
|
const homeDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-node-cli-'));
|
||||||
const privateKeyFile = path.join(homeDir, 'private-key');
|
const privateKeyFile = path.join(homeDir, 'private-key');
|
||||||
|
|||||||
Reference in new issue
Block a user