Skip to content

Commit ad05747

Browse files
fix: security audit findings - critical + high + medium + low (#13)
* fix: security audit fixes (docker bind, config safety, keychain, validation) * fix: pipe keychain secret via stdin, remove duplicate README example --------- Co-authored-by: Marty (Clawdbot) <marty@mcmillan.io>
1 parent fbfdc5c commit ad05747

14 files changed

Lines changed: 145 additions & 80 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
node_modules
22
dist
33
*.js.map
4+
/docker-compose.yml
5+
*.tgz

README.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,6 @@ delega tasks create "content" --due "2026-03-15"
7676
delega tasks show <id> # Show task details
7777
delega tasks complete <id> # Mark task as completed
7878
delega tasks delete <id> # Delete a task
79-
delega tasks delegate <task-id> <agent-id> # Delegate to another agent
8079
delega tasks delegate <task-id> <agent-id> --content "subtask description"
8180
```
8281

package-lock.json

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
},
99
"scripts": {
1010
"build": "tsc && node scripts/copy-templates.mjs",
11-
"dev": "ts-node --esm src/index.ts"
11+
"dev": "npx tsx src/index.ts"
1212
},
1313
"dependencies": {
1414
"chalk": "^5.3.0",
@@ -39,7 +39,7 @@
3939
"cli"
4040
],
4141
"engines": {
42-
"node": ">=18.0.0"
42+
"node": ">=20.0.0"
4343
},
4444
"publishConfig": {
4545
"access": "public",

src/api.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,14 @@ export async function apiRequest<T = unknown>(
6161
path: string,
6262
body?: unknown,
6363
): Promise<ApiResponse<T>> {
64-
const apiKey = getApiKey();
64+
let apiKey: string | undefined;
65+
try {
66+
apiKey = getApiKey();
67+
} catch (err) {
68+
const msg = err instanceof Error ? err.message : String(err);
69+
console.error(`Configuration error: ${msg}`);
70+
process.exit(1);
71+
}
6572
if (!apiKey) {
6673
console.error("Not authenticated. Run: delega login");
6774
process.exit(1);

src/commands/agents.ts

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Command } from "commander";
22
import chalk from "chalk";
3-
import { apiCall } from "../api.js";
3+
import { apiCall, apiRequest } from "../api.js";
44
import { printTable, formatDate, label, confirm } from "../ui.js";
55

66
interface Agent {
@@ -94,21 +94,32 @@ Examples:
9494
$ delega agents rotate abc123 --yes Skip confirmation (for scripts/agents)
9595
$ delega agents rotate abc123 --json Get new API key as JSON
9696
$ delega agents rotate abc123 --dry-run Preview without rotating
97-
`)
97+
`)
9898
.action(async (id: string, opts) => {
9999
if (opts.dryRun) {
100-
let agent: Agent | undefined;
101-
try {
102-
agent = await apiCall<Agent>("GET", `/agents/${id}`);
103-
} catch {
104-
// Graceful degradation: if GET fails, just show the ID
105-
}
100+
const result = await apiRequest<Agent>("GET", `/agents/${id}`);
101+
const agent = result.ok ? (result.data as Agent) : undefined;
106102
if (opts.json) {
107-
console.log(JSON.stringify({ dry_run: true, agent_id: id, agent_name: agent?.name ?? null, action: "rotate-key" }, null, 2));
103+
console.log(
104+
JSON.stringify(
105+
{
106+
dry_run: true,
107+
agent_id: id,
108+
agent_name: agent ? (agent.display_name || agent.name) : null,
109+
action: "rotate-key",
110+
},
111+
null,
112+
2,
113+
),
114+
);
108115
return;
109116
}
110-
const display = agent?.name ? `${agent.name} (${id})` : id;
111-
console.log(`Would rotate API key for agent ${display}. Old key would stop working immediately.`);
117+
if (agent) {
118+
console.log(`Would rotate API key for agent "${agent.display_name || agent.name}" (${id}).`);
119+
} else {
120+
console.log(`Would rotate API key for agent ${id}.`);
121+
}
122+
console.log("No changes made.");
112123
return;
113124
}
114125
if (!opts.yes) {

src/commands/init.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -566,6 +566,11 @@ async function runSelfHostedSetup(): Promise<SetupResult> {
566566

567567
console.log(chalk.dim("Starting Delega with Docker..."));
568568
startDockerCompose(node_path.dirname(composePath));
569+
console.log(
570+
chalk.dim(
571+
"Delega is bound to localhost only. To expose on your network, edit docker-compose.yml.",
572+
),
573+
);
569574

570575
console.log();
571576
console.log(chalk.dim("Waiting for the local API health check..."));
@@ -735,7 +740,6 @@ export const initCommand = new Command("init")
735740
.addHelpText("after", `
736741
Examples:
737742
$ delega init Interactive setup wizard
738-
$ delega init --api-url <url> Use a custom API URL
739743
740744
This command walks you through:
741745
1. Choosing hosted (api.delega.dev) or self-hosted (Docker) deployment

src/commands/login.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Command } from "commander";
22
import node_readline from "node:readline";
3-
import { saveConfig, loadConfig, normalizeApiUrl, persistApiKey } from "../config.js";
3+
import { getApiUrl, loadConfig, persistApiKey, saveConfig } from "../config.js";
44
import { formatNetworkError } from "../api.js";
55
import { printBanner } from "../ui.js";
66

@@ -64,12 +64,11 @@ Examples:
6464
}
6565

6666
// Validate by calling the API
67-
const config = loadConfig();
67+
let config: ReturnType<typeof loadConfig>;
6868
let apiUrl: string;
6969
try {
70-
apiUrl = normalizeApiUrl(
71-
config.api_url || process.env.DELEGA_API_URL || "https://api.delega.dev",
72-
);
70+
config = loadConfig();
71+
apiUrl = getApiUrl();
7372
} catch (err) {
7473
const msg = err instanceof Error ? err.message : String(err);
7574
console.error(`Configuration error: ${msg}`);

src/commands/status.ts

Lines changed: 38 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -38,69 +38,63 @@ Examples:
3838
$ delega status --json Output as JSON (for scripting)
3939
`)
4040
.action(async (opts) => {
41-
// 1. Check for API key (non-fatal — we can still show health info)
42-
const apiKey = getApiKey();
43-
44-
// 2. Resolve API URL
41+
let apiKey: string | undefined;
4542
let apiUrl: string;
4643
try {
44+
// 1. Check for API key (non-fatal — we can still show health info)
45+
apiKey = getApiKey();
46+
47+
// 2. Resolve API URL
4748
apiUrl = getApiUrl();
4849
} catch (err) {
4950
console.error(`Configuration error: ${err instanceof Error ? err.message : err}`);
5051
process.exit(1);
5152
}
5253

53-
const controller = new AbortController();
54-
const timeout = setTimeout(() => controller.abort(), 15_000);
55-
5654
// 3. Hit /health (unauthenticated — just checks connectivity)
5755
let healthy = false;
5856
let serverVersion: string | undefined;
5957
let me: MeResponse | undefined;
6058
let stats: Stats | undefined;
6159

6260
try {
61+
const res = await fetch(apiUrl + "/health", {
62+
signal: AbortSignal.timeout(15_000),
63+
});
64+
if (res.ok) {
65+
healthy = true;
66+
try {
67+
const data = await res.json() as HealthResponse;
68+
serverVersion = data.version;
69+
} catch { /* health may return empty 200 */ }
70+
}
71+
} catch {
72+
healthy = false;
73+
}
74+
75+
// 4. Hit /agent/me (authenticated) — direct fetch so network errors don't exit
76+
if (healthy && apiKey) {
77+
const authHeaders = { "X-Agent-Key": apiKey, "Content-Type": "application/json" };
6378
try {
64-
const res = await fetch(apiUrl + "/health", {
65-
signal: controller.signal,
79+
const meRes = await fetch(apiUrl + "/agent/me", {
80+
headers: authHeaders,
81+
signal: AbortSignal.timeout(15_000),
6682
});
67-
if (res.ok) {
68-
healthy = true;
69-
try {
70-
const data = await res.json() as HealthResponse;
71-
serverVersion = data.version;
72-
} catch { /* health may return empty 200 */ }
83+
if (meRes.ok) {
84+
me = await meRes.json() as MeResponse;
7385
}
74-
} catch {
75-
healthy = false;
76-
}
86+
} catch { /* graceful degradation — show partial output */ }
7787

78-
// 4. Hit /agent/me (authenticated) — direct fetch so network errors don't exit
79-
if (healthy && apiKey) {
80-
const authHeaders = { "X-Agent-Key": apiKey, "Content-Type": "application/json" };
81-
try {
82-
const meRes = await fetch(apiUrl + "/agent/me", {
83-
headers: authHeaders,
84-
signal: controller.signal,
85-
});
86-
if (meRes.ok) {
87-
me = await meRes.json() as MeResponse;
88-
}
89-
} catch { /* graceful degradation — show partial output */ }
90-
91-
// 5. Hit /stats (authenticated) — direct fetch for same reason
92-
try {
93-
const statsRes = await fetch(apiUrl + "/stats", {
94-
headers: authHeaders,
95-
signal: controller.signal,
96-
});
97-
if (statsRes.ok) {
98-
stats = await statsRes.json() as Stats;
99-
}
100-
} catch { /* graceful degradation */ }
101-
}
102-
} finally {
103-
clearTimeout(timeout);
88+
// 5. Hit /stats (authenticated) — direct fetch for same reason
89+
try {
90+
const statsRes = await fetch(apiUrl + "/stats", {
91+
headers: authHeaders,
92+
signal: AbortSignal.timeout(15_000),
93+
});
94+
if (statsRes.ok) {
95+
stats = await statsRes.json() as Stats;
96+
}
97+
} catch { /* graceful degradation */ }
10498
}
10599

106100
// 6. Output

src/commands/tasks.ts

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ interface Task {
1515
content: string;
1616
status: string;
1717
priority: number;
18-
labels?: string[];
18+
labels?: string[] | string;
1919
due_date?: string;
2020
created_at?: string;
2121
updated_at?: string;
@@ -33,10 +33,26 @@ interface Comment {
3333
created_at?: string;
3434
}
3535

36+
function parsePriority(value: string): number {
37+
const n = parseInt(value, 10);
38+
if (isNaN(n) || n < 1 || n > 4) {
39+
throw new Error("Priority must be 1, 2, 3, or 4.");
40+
}
41+
return n;
42+
}
43+
44+
function parsePositiveInt(value: string): number {
45+
const n = parseInt(value, 10);
46+
if (isNaN(n) || n < 1) {
47+
throw new Error("Must be a positive integer.");
48+
}
49+
return n;
50+
}
51+
3652
const tasksList = new Command("list")
3753
.description("List tasks")
3854
.option("--completed", "Include completed tasks")
39-
.option("--limit <n>", "Limit results", parseInt)
55+
.option("--limit <n>", "Limit results", parsePositiveInt)
4056
.option("--json", "Output raw JSON")
4157
.addHelpText("after", `
4258
Examples:
@@ -82,7 +98,7 @@ Examples:
8298
const tasksCreate = new Command("create")
8399
.description("Create a new task")
84100
.argument("<content>", "Task content")
85-
.option("--priority <n>", "Priority 1-4 (default: 1)", (v: string) => parseInt(v, 10), 1)
101+
.option("--priority <n>", "Priority 1-4 (default: 1)", parsePriority, 1)
86102
.option("--labels <labels>", "Comma-separated labels")
87103
.option("--due <date>", "Due date (YYYY-MM-DD)")
88104
.option("--json", "Output raw JSON")
@@ -137,9 +153,18 @@ Examples:
137153
label("Content", task.content);
138154
label("Status", statusBadge(task.status));
139155
label("Priority", priorityBadge(task.priority));
140-
const labels = typeof task.labels === "string" ? JSON.parse(task.labels) : task.labels;
141-
if (labels && labels.length > 0) {
156+
let labels = task.labels;
157+
if (typeof labels === "string") {
158+
try {
159+
labels = JSON.parse(labels) as string[] | string;
160+
} catch {
161+
// Keep malformed labels as the raw string instead of crashing.
162+
}
163+
}
164+
if (Array.isArray(labels) && labels.length > 0) {
142165
label("Labels", labels.join(", "));
166+
} else if (typeof labels === "string" && labels) {
167+
label("Labels", labels);
143168
}
144169
if (task.due_date) label("Due", formatDate(task.due_date));
145170
if (task.assigned_to_agent_id) label("Delegated To", task.assigned_to_agent_id);

0 commit comments

Comments
 (0)