fix(cli): keep install secrets out of process args

This commit is contained in:
ZacharyZhang-NY committed 2026-07-19 16:07:22 -04:00
1 parent 0e35b845a4
commit c78df90361
6 files changed
+353 -18

No files matched your search

+123
View File
@@ -0,0 +1,123 @@
import fsp from 'node:fs/promises';
import readline from 'node:readline';
import { Writable } from 'node:stream';
const MAX_SECRET_FILE_BYTES = 64 * 1024;
interface SecretSources {
readonly privateKeyFile?: string;
readonly tokenFile?: string;
}
interface SecretTerminal {
readonly interactive: boolean;
readHidden(prompt: string): Promise<string>;
}
export interface InstallSecrets {
readonly privateKey: string;
readonly token?: string;
}
function parseSingleLineSecret(raw: string, label: string, allowEmpty: boolean): string {
const value = raw.endsWith('\r\n')
? raw.slice(0, -2)
: raw.endsWith('\n')
? raw.slice(0, -1)
: raw;
if (!allowEmpty && value.length === 0) {
throw new Error(`${label} is empty`);
}
if (value.includes('\r') || value.includes('\n') || value.includes('\0')) {
throw new Error(`${label} must contain exactly one line`);
}
return value;
}
async function readSecretFile(filePath: string, label: string): Promise<string> {
const stats = await fsp.lstat(filePath);
if (!stats.isFile()) {
throw new Error(`${label} file is not a regular file: ${filePath}`);
}
if (stats.size > MAX_SECRET_FILE_BYTES) {
throw new Error(`${label} file exceeds ${MAX_SECRET_FILE_BYTES} bytes: ${filePath}`);
}
if (process.platform !== 'win32' && (stats.mode & 0o077) !== 0) {
throw new Error(`${label} file must be accessible only by its owner: ${filePath}`);
}
const raw = await fsp.readFile(filePath, 'utf-8');
return parseSingleLineSecret(raw, label, false);
}
async function readHidden(prompt: string): Promise<string> {
if (!process.stdin.isTTY || !process.stderr.isTTY) {
throw new Error('hidden input requires an interactive terminal');
}
let muted = false;
const output = new Writable({
write(chunk, encoding, callback) {
if (!muted) process.stderr.write(chunk, encoding);
callback();
},
});
const terminal = readline.createInterface({
input: process.stdin,
output,
terminal: true,
historySize: 0,
});
process.stderr.write(prompt);
muted = true;
try {
return await new Promise<string>((resolve, reject) => {
terminal.once('SIGINT', () => reject(new Error('secret input cancelled')));
terminal.question('', resolve);
});
} finally {
muted = false;
terminal.close();
process.stderr.write('\n');
}
}
const defaultTerminal: SecretTerminal = {
interactive: Boolean(process.stdin.isTTY && process.stderr.isTTY),
readHidden,
};
export async function resolveInstallSecrets(
sources: SecretSources,
terminal: SecretTerminal = defaultTerminal,
): Promise<InstallSecrets> {
let privateKey: string;
if (sources.privateKeyFile) {
privateKey = await readSecretFile(sources.privateKeyFile, 'private key');
} else if (terminal.interactive) {
privateKey = parseSingleLineSecret(
await terminal.readHidden('Private key: '),
'private key',
false,
);
} else {
throw new Error(
'private key input requires an interactive terminal or --private_key_file <path>',
);
}
let token: string | undefined;
if (sources.tokenFile) {
token = await readSecretFile(sources.tokenFile, 'API token');
} else if (terminal.interactive) {
token = parseSingleLineSecret(
await terminal.readHidden('API token (optional): '),
'API token',
true,
) || undefined;
}
return { privateKey, token };
}
+14 -17
View File
@@ -2,7 +2,9 @@ import { parseArgs } from 'node:util';
import fsp from 'node:fs/promises';
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 { SUPPORTED_AGENTS } from './plugins/registry.js';
import type { AgentPlugin, InstallConfig } from './plugins/base.js';
import { generateHookScript, generateGuardScript } from './plugins/hook-template.js';
@@ -55,7 +57,7 @@ function printUsage(): void {
console.log(`Elydora CLI — Tamper-evident audit for AI coding agents
Usage:
elydora install --agent <name> --org_id <id> --agent_id <id> --private_key <key> --kid <kid> [--token <token>] [--base_url <url>]
elydora install --agent <name> --org_id <id> --agent_id <id> --kid <kid> [--private_key_file <path>] [--token_file <path>] [--base_url <url>]
elydora uninstall --agent <name> [--agent_id <id>]
elydora status
elydora agents
@@ -81,9 +83,9 @@ async function cmdInstall(args: string[]): Promise<void> {
agent: { type: 'string' },
org_id: { type: 'string' },
agent_id: { type: 'string' },
private_key: { type: 'string' },
private_key_file: { type: 'string' },
kid: { type: 'string' },
token: { type: 'string' },
token_file: { type: 'string' },
base_url: { type: 'string' },
},
strict: true,
@@ -101,13 +103,13 @@ async function cmdInstall(args: string[]): Promise<void> {
const agentId = values.agent_id;
if (!agentId) die('--agent_id is required');
const privateKey = values.private_key;
if (!privateKey) die('--private_key is required');
const kid = values.kid;
if (!kid) die('--kid is required');
const token = values.token;
const { privateKey, token } = await resolveInstallSecrets({
privateKeyFile: values.private_key_file,
tokenFile: values.token_file,
});
const baseUrl = values.base_url ?? 'https://api.elydora.com';
// Validate private key by deriving public key
@@ -122,7 +124,8 @@ async function cmdInstall(args: string[]): Promise<void> {
// Create ~/.elydora/{agentId}/ directory
const agentDir = path.join(ELYDORA_DIR, agentId);
await fsp.mkdir(agentDir, { recursive: true });
await ensurePrivateDirectory(ELYDORA_DIR);
await ensurePrivateDirectory(agentDir);
// Write agent config
const agentConfigPath = path.join(agentDir, 'config.json');
@@ -134,18 +137,12 @@ async function cmdInstall(args: string[]): Promise<void> {
...(token ? { token } : {}),
agent_name: agentName,
};
await fsp.writeFile(agentConfigPath, JSON.stringify(agentConfig, null, 2) + '\n', 'utf-8');
if (process.platform !== 'win32') {
await fsp.chmod(agentConfigPath, 0o600);
}
await writePrivateFile(agentConfigPath, JSON.stringify(agentConfig, null, 2) + '\n');
console.log(` Agent config: ${agentConfigPath}`);
// Write private key (chmod 600)
// Write private key with owner-restricted access
const keyPath = path.join(agentDir, 'private.key');
await fsp.writeFile(keyPath, privateKey, { encoding: 'utf-8', mode: 0o600 });
if (process.platform !== 'win32') {
await fsp.chmod(keyPath, 0o600);
}
await writePrivateFile(keyPath, privateKey);
console.log(` Private key: ${keyPath}`);
// Generate and write hook script (PostToolUse — audit logging)
+48
View File
@@ -0,0 +1,48 @@
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<void> {
await fsp.mkdir(directory, { recursive: true, mode: 0o700 });
if (process.platform !== 'win32') {
await fsp.chmod(directory, 0o700);
}
}
export async function writePrivateFile(filePath: string, contents: string): Promise<void> {
const directory = path.dirname(filePath);
await ensurePrivateDirectory(directory);
const temporaryPath = path.join(
directory,
`.${path.basename(filePath)}.${crypto.randomUUID()}.tmp`,
);
let committed = false;
try {
const handle = await fsp.open(temporaryPath, 'wx', 0o600);
try {
await handle.writeFile(contents, 'utf-8');
await handle.sync();
} finally {
await handle.close();
}
await fsp.rename(temporaryPath, filePath);
committed = true;
if (process.platform !== 'win32') {
await fsp.chmod(filePath, 0o600);
}
} finally {
if (!committed) {
try {
await fsp.unlink(temporaryPath);
} catch (error) {
if (!isMissingFile(error)) throw error;
}
}
}
}