diff --git a/AGENTS.md b/AGENTS.md index 17679b1..85aed7e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,8 @@ This repository owns the published `@elydora/sdk` package, its `elydora` CLI, lo - Propagate unexpected errors to the CLI boundary. - Use documented defaults only for genuinely optional configuration. - 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. ## Verification diff --git a/README.md b/README.md index 252c5e0..4e60b4e 100644 --- a/README.md +++ b/README.md @@ -48,10 +48,11 @@ npx elydora install \ --agent claudecode \ --org_id org-123 \ --agent_id agent-456 \ - --private_key \ --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. + ### Commands | Command | Description | diff --git a/src/cli-secrets.ts b/src/cli-secrets.ts new file mode 100644 index 0000000..3b7e101 --- /dev/null +++ b/src/cli-secrets.ts @@ -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; +} + +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 { + 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 { + 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((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 { + 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 ', + ); + } + + 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 }; +} diff --git a/src/cli.ts b/src/cli.ts index c8a52f6..f1d48cf 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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 --org_id --agent_id --private_key --kid [--token ] [--base_url ] + elydora install --agent --org_id --agent_id --kid [--private_key_file ] [--token_file ] [--base_url ] elydora uninstall --agent [--agent_id ] elydora status elydora agents @@ -81,9 +83,9 @@ async function cmdInstall(args: string[]): Promise { 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 { 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 { // 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 { ...(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) diff --git a/src/secure-files.ts b/src/secure-files.ts new file mode 100644 index 0000000..9dc19c6 --- /dev/null +++ b/src/secure-files.ts @@ -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 { + 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 { + 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; + } + } + } +} diff --git a/test/cli-security.test.mjs b/test/cli-security.test.mjs new file mode 100644 index 0000000..897672e --- /dev/null +++ b/test/cli-security.test.mjs @@ -0,0 +1,164 @@ +import assert from 'node:assert/strict'; +import { chmod, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; +import test from 'node:test'; + +import { resolveInstallSecrets } from '../dist/cli-secrets.js'; + +const PRIVATE_KEY = Buffer.alloc(32, 7).toString('base64url'); +const API_TOKEN = 'ely_test_token'; + +async function runCli(args, homeDir) { + return await new Promise((resolve, reject) => { + const child = spawn(process.execPath, [path.resolve('dist/cli.js'), ...args], { + env: { + ...process.env, + HOME: homeDir, + USERPROFILE: homeDir, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + const stdout = []; + const stderr = []; + child.stdout.on('data', (chunk) => stdout.push(chunk)); + child.stderr.on('data', (chunk) => stderr.push(chunk)); + child.once('error', reject); + child.once('close', (code) => resolve({ + code, + stdout: Buffer.concat(stdout).toString('utf-8'), + stderr: Buffer.concat(stderr).toString('utf-8'), + })); + }); +} + +test('install secrets resolve from hidden prompts without process arguments', async () => { + const answers = [PRIVATE_KEY, API_TOKEN]; + const prompts = []; + const secrets = await resolveInstallSecrets({}, { + interactive: true, + async readHidden(prompt) { + prompts.push(prompt); + return answers.shift(); + }, + }); + + assert.deepEqual(secrets, { privateKey: PRIVATE_KEY, token: API_TOKEN }); + assert.deepEqual(prompts, ['Private key: ', 'API token (optional): ']); +}); + +test('non-interactive install requires a credential file for the private key', async () => { + await assert.rejects( + resolveInstallSecrets({}, { + interactive: false, + async readHidden() { + throw new Error('unexpected prompt'); + }, + }), + /--private_key_file /, + ); +}); + +test('credential files contain exactly one secret line', async () => { + const homeDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-node-cli-')); + const privateKeyFile = path.join(homeDir, 'private-key'); + try { + await writeFile(privateKeyFile, `${PRIVATE_KEY}\nsecond-line\n`, { mode: 0o600 }); + if (process.platform !== 'win32') await chmod(privateKeyFile, 0o600); + + await assert.rejects( + resolveInstallSecrets({ privateKeyFile }, { + interactive: false, + async readHidden() { + throw new Error('unexpected prompt'); + }, + }), + /private key must contain exactly one line/, + ); + } finally { + await rm(homeDir, { recursive: true, force: true }); + } +}); + +test('CLI rejects legacy secret arguments before writing files', async () => { + 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', 'agent-1', + '--kid', 'key-1', + '--private_key', PRIVATE_KEY, + '--token', API_TOKEN, + ], homeDir); + + assert.equal(result.code, 1); + assert.match(result.stderr, /Unknown option '--private_key'/); + 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'); + const tokenFile = path.join(homeDir, 'api-token'); + try { + await writeFile(privateKeyFile, `${PRIVATE_KEY}\n`, { mode: 0o600 }); + await writeFile(tokenFile, `${API_TOKEN}\n`, { mode: 0o600 }); + if (process.platform !== 'win32') { + await chmod(privateKeyFile, 0o600); + await chmod(tokenFile, 0o600); + } + + const result = await runCli([ + 'install', + '--agent', 'opencode', + '--org_id', 'org-1', + '--agent_id', 'agent-1', + '--kid', 'key-1', + '--private_key_file', privateKeyFile, + '--token_file', tokenFile, + '--base_url', 'https://api.example.test', + ], homeDir); + + assert.equal(result.code, 0, result.stderr); + assert.doesNotMatch(result.stdout + result.stderr, new RegExp(PRIVATE_KEY)); + assert.doesNotMatch(result.stdout + result.stderr, new RegExp(API_TOKEN)); + + const agentDir = path.join(homeDir, '.elydora', 'agent-1'); + assert.equal(await readFile(path.join(agentDir, 'private.key'), 'utf-8'), PRIVATE_KEY); + const config = JSON.parse(await readFile(path.join(agentDir, 'config.json'), 'utf-8')); + assert.equal(config.token, API_TOKEN); + + const rotatedToken = `${API_TOKEN}_rotated`; + await writeFile(tokenFile, `${rotatedToken}\n`); + const reinstall = await runCli([ + 'install', + '--agent', 'opencode', + '--org_id', 'org-1', + '--agent_id', 'agent-1', + '--kid', 'key-1', + '--private_key_file', privateKeyFile, + '--token_file', tokenFile, + '--base_url', 'https://api.example.test', + ], homeDir); + assert.equal(reinstall.code, 0, reinstall.stderr); + assert.doesNotMatch(reinstall.stdout + reinstall.stderr, new RegExp(rotatedToken)); + const rotatedConfig = JSON.parse( + await readFile(path.join(agentDir, 'config.json'), 'utf-8'), + ); + assert.equal(rotatedConfig.token, rotatedToken); + + if (process.platform !== 'win32') { + assert.equal((await stat(agentDir)).mode & 0o777, 0o700); + assert.equal((await stat(path.join(agentDir, 'private.key'))).mode & 0o777, 0o600); + assert.equal((await stat(path.join(agentDir, 'config.json'))).mode & 0o777, 0o600); + } + } finally { + await rm(homeDir, { recursive: true, force: true }); + } +});