From 3bebe3863667e607959b70efb021a85201b4f133 Mon Sep 17 00:00:00 2001 From: "Marty (Clawdbot)" Date: Wed, 25 Mar 2026 16:46:57 -0500 Subject: [PATCH 1/2] fix: security audit fixes (docker bind, config safety, keychain, validation) --- .gitignore | 2 + README.md | 2 +- package-lock.json | 6 +-- package.json | 4 +- src/api.ts | 9 +++- src/commands/agents.ts | 33 ++++++++----- src/commands/init.ts | 6 ++- src/commands/login.ts | 9 ++-- src/commands/status.ts | 82 +++++++++++++++----------------- src/commands/tasks.ts | 35 ++++++++++++-- src/commands/whoami.ts | 21 +++++++- src/config.ts | 7 ++- src/secret-store.ts | 17 +++++-- src/templates/docker-compose.yml | 4 +- 14 files changed, 154 insertions(+), 83 deletions(-) diff --git a/.gitignore b/.gitignore index 8e3dbb7..4e5ff68 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ node_modules dist *.js.map +/docker-compose.yml +*.tgz diff --git a/README.md b/README.md index 5dfb29c..2355d76 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ delega tasks create "content" --due "2026-03-15" delega tasks show # Show task details delega tasks complete # Mark task as completed delega tasks delete # Delete a task -delega tasks delegate # Delegate to another agent +delega tasks delegate --content "description" delega tasks delegate --content "subtask description" ``` diff --git a/package-lock.json b/package-lock.json index c2eb133..da1c2d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "delega-cli", + "name": "@delega-dev/cli", "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "delega-cli", + "name": "@delega-dev/cli", "version": "1.1.0", "license": "MIT", "dependencies": { @@ -20,7 +20,7 @@ "typescript": "^5.5.0" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@types/node": { diff --git a/package.json b/package.json index 25fded0..ca3096a 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ }, "scripts": { "build": "tsc && node scripts/copy-templates.mjs", - "dev": "ts-node --esm src/index.ts" + "dev": "npx tsx src/index.ts" }, "dependencies": { "chalk": "^5.3.0", @@ -39,7 +39,7 @@ "cli" ], "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" }, "publishConfig": { "access": "public", diff --git a/src/api.ts b/src/api.ts index 9b09ade..3ba4553 100644 --- a/src/api.ts +++ b/src/api.ts @@ -61,7 +61,14 @@ export async function apiRequest( path: string, body?: unknown, ): Promise> { - const apiKey = getApiKey(); + let apiKey: string | undefined; + try { + apiKey = getApiKey(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`Configuration error: ${msg}`); + process.exit(1); + } if (!apiKey) { console.error("Not authenticated. Run: delega login"); process.exit(1); diff --git a/src/commands/agents.ts b/src/commands/agents.ts index 7362dae..9b58621 100644 --- a/src/commands/agents.ts +++ b/src/commands/agents.ts @@ -1,6 +1,6 @@ import { Command } from "commander"; import chalk from "chalk"; -import { apiCall } from "../api.js"; +import { apiCall, apiRequest } from "../api.js"; import { printTable, formatDate, label, confirm } from "../ui.js"; interface Agent { @@ -94,21 +94,32 @@ Examples: $ delega agents rotate abc123 --yes Skip confirmation (for scripts/agents) $ delega agents rotate abc123 --json Get new API key as JSON $ delega agents rotate abc123 --dry-run Preview without rotating -`) + `) .action(async (id: string, opts) => { if (opts.dryRun) { - let agent: Agent | undefined; - try { - agent = await apiCall("GET", `/agents/${id}`); - } catch { - // Graceful degradation: if GET fails, just show the ID - } + const result = await apiRequest("GET", `/agents/${id}`); + const agent = result.ok ? (result.data as Agent) : undefined; if (opts.json) { - console.log(JSON.stringify({ dry_run: true, agent_id: id, agent_name: agent?.name ?? null, action: "rotate-key" }, null, 2)); + console.log( + JSON.stringify( + { + dry_run: true, + agent_id: id, + agent_name: agent ? (agent.display_name || agent.name) : null, + action: "rotate-key", + }, + null, + 2, + ), + ); return; } - const display = agent?.name ? `${agent.name} (${id})` : id; - console.log(`Would rotate API key for agent ${display}. Old key would stop working immediately.`); + if (agent) { + console.log(`Would rotate API key for agent "${agent.display_name || agent.name}" (${id}).`); + } else { + console.log(`Would rotate API key for agent ${id}.`); + } + console.log("No changes made."); return; } if (!opts.yes) { diff --git a/src/commands/init.ts b/src/commands/init.ts index c30cfda..642f503 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -566,6 +566,11 @@ async function runSelfHostedSetup(): Promise { console.log(chalk.dim("Starting Delega with Docker...")); startDockerCompose(node_path.dirname(composePath)); + console.log( + chalk.dim( + "Delega is bound to localhost only. To expose on your network, edit docker-compose.yml.", + ), + ); console.log(); console.log(chalk.dim("Waiting for the local API health check...")); @@ -735,7 +740,6 @@ export const initCommand = new Command("init") .addHelpText("after", ` Examples: $ delega init Interactive setup wizard - $ delega init --api-url Use a custom API URL This command walks you through: 1. Choosing hosted (api.delega.dev) or self-hosted (Docker) deployment diff --git a/src/commands/login.ts b/src/commands/login.ts index b429cc4..13506ae 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -1,6 +1,6 @@ import { Command } from "commander"; import node_readline from "node:readline"; -import { saveConfig, loadConfig, normalizeApiUrl, persistApiKey } from "../config.js"; +import { getApiUrl, loadConfig, persistApiKey, saveConfig } from "../config.js"; import { formatNetworkError } from "../api.js"; import { printBanner } from "../ui.js"; @@ -64,12 +64,11 @@ Examples: } // Validate by calling the API - const config = loadConfig(); + let config: ReturnType; let apiUrl: string; try { - apiUrl = normalizeApiUrl( - config.api_url || process.env.DELEGA_API_URL || "https://api.delega.dev", - ); + config = loadConfig(); + apiUrl = getApiUrl(); } catch (err) { const msg = err instanceof Error ? err.message : String(err); console.error(`Configuration error: ${msg}`); diff --git a/src/commands/status.ts b/src/commands/status.ts index 94e5aa8..95cde04 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -38,21 +38,19 @@ Examples: $ delega status --json Output as JSON (for scripting) `) .action(async (opts) => { - // 1. Check for API key (non-fatal — we can still show health info) - const apiKey = getApiKey(); - - // 2. Resolve API URL + let apiKey: string | undefined; let apiUrl: string; try { + // 1. Check for API key (non-fatal — we can still show health info) + apiKey = getApiKey(); + + // 2. Resolve API URL apiUrl = getApiUrl(); } catch (err) { console.error(`Configuration error: ${err instanceof Error ? err.message : err}`); process.exit(1); } - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 15_000); - // 3. Hit /health (unauthenticated — just checks connectivity) let healthy = false; let serverVersion: string | undefined; @@ -60,47 +58,43 @@ Examples: let stats: Stats | undefined; try { + const res = await fetch(apiUrl + "/health", { + signal: AbortSignal.timeout(15_000), + }); + if (res.ok) { + healthy = true; + try { + const data = await res.json() as HealthResponse; + serverVersion = data.version; + } catch { /* health may return empty 200 */ } + } + } catch { + healthy = false; + } + + // 4. Hit /agent/me (authenticated) — direct fetch so network errors don't exit + if (healthy && apiKey) { + const authHeaders = { "X-Agent-Key": apiKey, "Content-Type": "application/json" }; try { - const res = await fetch(apiUrl + "/health", { - signal: controller.signal, + const meRes = await fetch(apiUrl + "/agent/me", { + headers: authHeaders, + signal: AbortSignal.timeout(15_000), }); - if (res.ok) { - healthy = true; - try { - const data = await res.json() as HealthResponse; - serverVersion = data.version; - } catch { /* health may return empty 200 */ } + if (meRes.ok) { + me = await meRes.json() as MeResponse; } - } catch { - healthy = false; - } + } catch { /* graceful degradation — show partial output */ } - // 4. Hit /agent/me (authenticated) — direct fetch so network errors don't exit - if (healthy && apiKey) { - const authHeaders = { "X-Agent-Key": apiKey, "Content-Type": "application/json" }; - try { - const meRes = await fetch(apiUrl + "/agent/me", { - headers: authHeaders, - signal: controller.signal, - }); - if (meRes.ok) { - me = await meRes.json() as MeResponse; - } - } catch { /* graceful degradation — show partial output */ } - - // 5. Hit /stats (authenticated) — direct fetch for same reason - try { - const statsRes = await fetch(apiUrl + "/stats", { - headers: authHeaders, - signal: controller.signal, - }); - if (statsRes.ok) { - stats = await statsRes.json() as Stats; - } - } catch { /* graceful degradation */ } - } - } finally { - clearTimeout(timeout); + // 5. Hit /stats (authenticated) — direct fetch for same reason + try { + const statsRes = await fetch(apiUrl + "/stats", { + headers: authHeaders, + signal: AbortSignal.timeout(15_000), + }); + if (statsRes.ok) { + stats = await statsRes.json() as Stats; + } + } catch { /* graceful degradation */ } } // 6. Output diff --git a/src/commands/tasks.ts b/src/commands/tasks.ts index fd33efc..d5ad137 100644 --- a/src/commands/tasks.ts +++ b/src/commands/tasks.ts @@ -15,7 +15,7 @@ interface Task { content: string; status: string; priority: number; - labels?: string[]; + labels?: string[] | string; due_date?: string; created_at?: string; updated_at?: string; @@ -33,10 +33,26 @@ interface Comment { created_at?: string; } +function parsePriority(value: string): number { + const n = parseInt(value, 10); + if (isNaN(n) || n < 1 || n > 4) { + throw new Error("Priority must be 1, 2, 3, or 4."); + } + return n; +} + +function parsePositiveInt(value: string): number { + const n = parseInt(value, 10); + if (isNaN(n) || n < 1) { + throw new Error("Must be a positive integer."); + } + return n; +} + const tasksList = new Command("list") .description("List tasks") .option("--completed", "Include completed tasks") - .option("--limit ", "Limit results", parseInt) + .option("--limit ", "Limit results", parsePositiveInt) .option("--json", "Output raw JSON") .addHelpText("after", ` Examples: @@ -82,7 +98,7 @@ Examples: const tasksCreate = new Command("create") .description("Create a new task") .argument("", "Task content") - .option("--priority ", "Priority 1-4 (default: 1)", (v: string) => parseInt(v, 10), 1) + .option("--priority ", "Priority 1-4 (default: 1)", parsePriority, 1) .option("--labels ", "Comma-separated labels") .option("--due ", "Due date (YYYY-MM-DD)") .option("--json", "Output raw JSON") @@ -137,9 +153,18 @@ Examples: label("Content", task.content); label("Status", statusBadge(task.status)); label("Priority", priorityBadge(task.priority)); - const labels = typeof task.labels === "string" ? JSON.parse(task.labels) : task.labels; - if (labels && labels.length > 0) { + let labels = task.labels; + if (typeof labels === "string") { + try { + labels = JSON.parse(labels) as string[] | string; + } catch { + // Keep malformed labels as the raw string instead of crashing. + } + } + if (Array.isArray(labels) && labels.length > 0) { label("Labels", labels.join(", ")); + } else if (typeof labels === "string" && labels) { + label("Labels", labels); } if (task.due_date) label("Due", formatDate(task.due_date)); if (task.assigned_to_agent_id) label("Delegated To", task.assigned_to_agent_id); diff --git a/src/commands/whoami.ts b/src/commands/whoami.ts index d392ef1..8408291 100644 --- a/src/commands/whoami.ts +++ b/src/commands/whoami.ts @@ -25,14 +25,20 @@ interface MeResponse { export const whoamiCommand = new Command("whoami") .description("Show current authenticated agent") + .option("--json", "Output raw JSON") .addHelpText("after", ` Examples: $ delega whoami Show current agent identity + $ delega whoami --json Output as JSON (for scripting) `) - .action(async () => { + .action(async (opts) => { const me = await apiRequest("GET", "/agent/me"); if (me.ok) { const payload = me.data as MeResponse; + if (opts.json) { + console.log(JSON.stringify(payload, null, 2)); + return; + } const agent = payload.agent; if (!agent) { console.error("Current server did not return agent details."); @@ -61,6 +67,19 @@ Examples: } await apiCall("GET", "/tasks?completed=true"); + if (opts.json) { + console.log( + JSON.stringify( + { + authenticated: true, + server: "Current API does not expose /agent/me", + }, + null, + 2, + ), + ); + return; + } console.log(); label("Authenticated", "yes"); label("Server", "Current API does not expose /agent/me"); diff --git a/src/config.ts b/src/config.ts index 5c5d3b1..6e9d249 100644 --- a/src/config.ts +++ b/src/config.ts @@ -35,11 +35,14 @@ export function loadConfig(): DelegaConfig { if (!node_fs.existsSync(configPath)) { return {}; } + + const raw = node_fs.readFileSync(configPath, "utf-8"); try { - const raw = node_fs.readFileSync(configPath, "utf-8"); return JSON.parse(raw) as DelegaConfig; } catch { - return {}; + throw new Error( + "Configuration file ~/.delega/config.json is corrupted. Fix or delete it, then retry.", + ); } } diff --git a/src/secret-store.ts b/src/secret-store.ts index d0319cf..9ef9268 100644 --- a/src/secret-store.ts +++ b/src/secret-store.ts @@ -41,11 +41,18 @@ function readMacosKeychain(): string | undefined { } function writeMacosKeychain(apiKey: string): void { - node_child_process.execFileSync( - "security", - ["add-generic-password", "-U", "-a", ACCOUNT_NAME, "-s", SERVICE_NAME, "-w", apiKey], - { stdio: "ignore" }, - ); + const tmpPath = node_path.join(node_os.tmpdir(), `delega-key-${process.pid}`); + try { + node_fs.writeFileSync(tmpPath, apiKey, { encoding: "utf-8", mode: 0o600 }); + node_child_process.execSync( + `security add-generic-password -U -a "${ACCOUNT_NAME}" -s "${SERVICE_NAME}" -w "$(cat '${tmpPath}')"`, + { stdio: "ignore" }, + ); + } finally { + try { + node_fs.unlinkSync(tmpPath); + } catch {} + } } function readLinuxSecretTool(): string | undefined { diff --git a/src/templates/docker-compose.yml b/src/templates/docker-compose.yml index 7e477dd..83723cf 100644 --- a/src/templates/docker-compose.yml +++ b/src/templates/docker-compose.yml @@ -3,8 +3,8 @@ services: image: ghcr.io/delega-dev/delega:__DELEGA_VERSION__ restart: unless-stopped ports: - # Change to 127.0.0.1:__DELEGA_PORT__:18890 to restrict to this machine only - - "__DELEGA_PORT__:18890" + # Change 127.0.0.1 to 0.0.0.0 only if you need network exposure + - "127.0.0.1:__DELEGA_PORT__:18890" volumes: - delega-data:/app/.delega From ef21e4744512ba7250cbce41d8d7a9d2901ce14f Mon Sep 17 00:00:00 2001 From: "Marty (Clawdbot)" Date: Wed, 25 Mar 2026 17:02:21 -0500 Subject: [PATCH 2/2] fix: pipe keychain secret via stdin, remove duplicate README example --- README.md | 1 - src/secret-store.ts | 19 +++++++------------ 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 2355d76..c322b08 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,6 @@ delega tasks create "content" --due "2026-03-15" delega tasks show # Show task details delega tasks complete # Mark task as completed delega tasks delete # Delete a task -delega tasks delegate --content "description" delega tasks delegate --content "subtask description" ``` diff --git a/src/secret-store.ts b/src/secret-store.ts index 9ef9268..26a3df9 100644 --- a/src/secret-store.ts +++ b/src/secret-store.ts @@ -41,18 +41,13 @@ function readMacosKeychain(): string | undefined { } function writeMacosKeychain(apiKey: string): void { - const tmpPath = node_path.join(node_os.tmpdir(), `delega-key-${process.pid}`); - try { - node_fs.writeFileSync(tmpPath, apiKey, { encoding: "utf-8", mode: 0o600 }); - node_child_process.execSync( - `security add-generic-password -U -a "${ACCOUNT_NAME}" -s "${SERVICE_NAME}" -w "$(cat '${tmpPath}')"`, - { stdio: "ignore" }, - ); - } finally { - try { - node_fs.unlinkSync(tmpPath); - } catch {} - } + // Pass -w without a value so `security` reads the password interactively from stdin. + // We pipe the key via stdin to avoid exposing it in process argv (visible in `ps`). + node_child_process.execFileSync( + "security", + ["add-generic-password", "-U", "-a", ACCOUNT_NAME, "-s", SERVICE_NAME, "-w"], + { input: apiKey, encoding: "utf-8", stdio: ["pipe", "ignore", "ignore"] }, + ); } function readLinuxSecretTool(): string | undefined {