From 40646c73af556cb713bb7ecac83bf4e2676e3e82 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 21 Jan 2026 09:54:48 +0000 Subject: [PATCH] feat: improve exec approvals defaults and wildcard --- CHANGELOG.md | 4 + docs/cli/approvals.md | 8 +- src/cli/exec-approvals-cli.test.ts | 55 +++- src/cli/exec-approvals-cli.ts | 461 ++++++++++++++++++----------- src/infra/exec-approvals.test.ts | 34 +++ src/infra/exec-approvals.ts | 16 +- 6 files changed, 394 insertions(+), 184 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4ef12e56..7fe615ef4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ Docs: https://docs.clawd.bot ## 2026.1.21 +### Changes +- CLI: default exec approvals to the local host, add gateway/node targeting flags, and show target details in allowlist output. +- Exec approvals: support wildcard agent allowlists (`*`) across all agents. + ### Fixes - UI: remove the chat stop button and keep the composer aligned to the bottom edge. diff --git a/docs/cli/approvals.md b/docs/cli/approvals.md index d91eb4bf9..eccc98f77 100644 --- a/docs/cli/approvals.md +++ b/docs/cli/approvals.md @@ -7,8 +7,8 @@ read_when: # `clawdbot approvals` -Manage exec approvals for the **gateway host** or a **node host**. -By default, commands target the gateway. Use `--node` to edit a node’s approvals. +Manage exec approvals for the **local host**, **gateway host**, or a **node host**. +By default, commands target the local approvals file on disk. Use `--gateway` to target the gateway, or `--node` to target a specific node. Related: - Exec approvals: [Exec approvals](/tools/exec-approvals) @@ -19,6 +19,7 @@ Related: ```bash clawdbot approvals get clawdbot approvals get --node +clawdbot approvals get --gateway ``` ## Replace approvals from a file @@ -26,6 +27,7 @@ clawdbot approvals get --node ```bash clawdbot approvals set --file ./exec-approvals.json clawdbot approvals set --node --file ./exec-approvals.json +clawdbot approvals set --gateway --file ./exec-approvals.json ``` ## Allowlist helpers @@ -33,6 +35,7 @@ clawdbot approvals set --node --file ./exec-approvals.json ```bash clawdbot approvals allowlist add "~/Projects/**/bin/rg" clawdbot approvals allowlist add --agent main --node "/usr/bin/uptime" +clawdbot approvals allowlist add --agent "*" "/usr/bin/uname" clawdbot approvals allowlist remove "~/Projects/**/bin/rg" ``` @@ -40,5 +43,6 @@ clawdbot approvals allowlist remove "~/Projects/**/bin/rg" ## Notes - `--node` uses the same resolver as `clawdbot nodes` (id, name, ip, or id prefix). +- `--agent` defaults to `"*"`, which applies to all agents. - The node host must advertise `system.execApprovals.get/set` (macOS app or headless node host). - Approvals files are stored per host at `~/.clawdbot/exec-approvals.json`. diff --git a/src/cli/exec-approvals-cli.test.ts b/src/cli/exec-approvals-cli.test.ts index 2525ee10a..59e1f428f 100644 --- a/src/cli/exec-approvals-cli.test.ts +++ b/src/cli/exec-approvals-cli.test.ts @@ -23,6 +23,14 @@ const defaultRuntime = { }, }; +const localSnapshot = { + path: "/tmp/local-exec-approvals.json", + exists: true, + raw: "{}", + hash: "hash-local", + file: { version: 1, agents: {} }, +}; + vi.mock("./gateway-rpc.js", () => ({ callGatewayFromCli: (method: string, opts: unknown, params?: unknown) => callGatewayFromCli(method, opts, params), @@ -40,8 +48,19 @@ vi.mock("../runtime.js", () => ({ defaultRuntime, })); +vi.mock("../infra/exec-approvals.js", async () => { + const actual = await vi.importActual( + "../infra/exec-approvals.js", + ); + return { + ...actual, + readExecApprovalsSnapshot: () => localSnapshot, + saveExecApprovals: vi.fn(), + }; +}); + describe("exec approvals CLI", () => { - it("loads gateway approvals by default", async () => { + it("loads local approvals by default", async () => { runtimeLogs.length = 0; runtimeErrors.length = 0; callGatewayFromCli.mockClear(); @@ -53,6 +72,22 @@ describe("exec approvals CLI", () => { await program.parseAsync(["approvals", "get"], { from: "user" }); + expect(callGatewayFromCli).not.toHaveBeenCalled(); + expect(runtimeErrors).toHaveLength(0); + }); + + it("loads gateway approvals when --gateway is set", async () => { + runtimeLogs.length = 0; + runtimeErrors.length = 0; + callGatewayFromCli.mockClear(); + + const { registerExecApprovalsCli } = await import("./exec-approvals-cli.js"); + const program = new Command(); + program.exitOverride(); + registerExecApprovalsCli(program); + + await program.parseAsync(["approvals", "get", "--gateway"], { from: "user" }); + expect(callGatewayFromCli).toHaveBeenCalledWith("exec.approvals.get", expect.anything(), {}); expect(runtimeErrors).toHaveLength(0); }); @@ -74,4 +109,22 @@ describe("exec approvals CLI", () => { }); expect(runtimeErrors).toHaveLength(0); }); + + it("defaults allowlist add to wildcard agent", async () => { + runtimeLogs.length = 0; + runtimeErrors.length = 0; + callGatewayFromCli.mockClear(); + + const { registerExecApprovalsCli } = await import("./exec-approvals-cli.js"); + const program = new Command(); + program.exitOverride(); + registerExecApprovalsCli(program); + + await program.parseAsync(["approvals", "allowlist", "add", "/usr/bin/uname"], { from: "user" }); + + const setCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "exec.approvals.set"); + expect(setCall).toBeTruthy(); + const params = setCall?.[2] as { file: { agents?: Record } }; + expect(params.file.agents?.["*"]).toBeTruthy(); + }); }); diff --git a/src/cli/exec-approvals-cli.ts b/src/cli/exec-approvals-cli.ts index 27734ae32..dd706f5ed 100644 --- a/src/cli/exec-approvals-cli.ts +++ b/src/cli/exec-approvals-cli.ts @@ -2,7 +2,12 @@ import fs from "node:fs/promises"; import JSON5 from "json5"; import type { Command } from "commander"; -import type { ExecApprovalsAgent, ExecApprovalsFile } from "../infra/exec-approvals.js"; +import { + readExecApprovalsSnapshot, + saveExecApprovals, + type ExecApprovalsAgent, + type ExecApprovalsFile, +} from "../infra/exec-approvals.js"; import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; import { isRich, theme } from "../terminal/theme.js"; @@ -20,6 +25,7 @@ type ExecApprovalsSnapshot = { type ExecApprovalsCliOpts = NodesRpcOpts & { node?: string; + gateway?: boolean; file?: string; stdin?: boolean; agent?: string; @@ -45,6 +51,7 @@ async function readStdin(): Promise { } async function resolveTargetNodeId(opts: ExecApprovalsCliOpts): Promise { + if (opts.gateway) return null; const raw = opts.node?.trim() ?? ""; if (!raw) return null; return await resolveNodeId(opts as NodesRpcOpts, raw); @@ -60,6 +67,39 @@ async function loadSnapshot( return snapshot; } +function loadSnapshotLocal(): ExecApprovalsSnapshot { + const snapshot = readExecApprovalsSnapshot(); + return { + path: snapshot.path, + exists: snapshot.exists, + hash: snapshot.hash, + file: snapshot.file, + }; +} + +function saveSnapshotLocal(file: ExecApprovalsFile): ExecApprovalsSnapshot { + saveExecApprovals(file); + return loadSnapshotLocal(); +} + +async function loadSnapshotTarget(opts: ExecApprovalsCliOpts): Promise<{ + snapshot: ExecApprovalsSnapshot; + nodeId: string | null; + source: "gateway" | "node" | "local"; +}> { + if (!opts.gateway && !opts.node) { + return { snapshot: loadSnapshotLocal(), nodeId: null, source: "local" }; + } + const nodeId = await resolveTargetNodeId(opts); + const snapshot = await loadSnapshot(opts, nodeId); + return { snapshot, nodeId, source: nodeId ? "node" : "gateway" }; +} + +function formatCliError(err: unknown): string { + const msg = String(err ?? "unknown error"); + return msg.includes("\n") ? msg.split("\n")[0] : msg; +} + async function saveSnapshot( opts: ExecApprovalsCliOpts, nodeId: string | null, @@ -74,7 +114,7 @@ async function saveSnapshot( function resolveAgentKey(value?: string | null): string { const trimmed = value?.trim() ?? ""; - return trimmed ? trimmed : "default"; + return trimmed ? trimmed : "*"; } function normalizeAllowlistEntry(entry: { pattern?: string } | null): string | null { @@ -117,131 +157,167 @@ export function registerExecApprovalsCli(program: Command) { const getCmd = approvals .command("get") .description("Fetch exec approvals snapshot") - .option("--node ", "Target node id/name/IP (defaults to gateway)") + .option("--node ", "Target node id/name/IP") + .option("--gateway", "Force gateway approvals", false) .action(async (opts: ExecApprovalsCliOpts) => { - const nodeId = await resolveTargetNodeId(opts); - const snapshot = await loadSnapshot(opts, nodeId); - if (opts.json) { - defaultRuntime.log(JSON.stringify(snapshot)); - return; - } - - const rich = isRich(); - const heading = (text: string) => (rich ? theme.heading(text) : text); - const muted = (text: string) => (rich ? theme.muted(text) : text); - const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); - - const file = snapshot.file ?? { version: 1 }; - const defaults = file.defaults ?? {}; - const defaultsParts = [ - defaults.security ? `security=${defaults.security}` : null, - defaults.ask ? `ask=${defaults.ask}` : null, - defaults.askFallback ? `askFallback=${defaults.askFallback}` : null, - typeof defaults.autoAllowSkills === "boolean" - ? `autoAllowSkills=${defaults.autoAllowSkills ? "on" : "off"}` - : null, - ].filter(Boolean) as string[]; - const agents = file.agents ?? {}; - const allowlistRows: Array<{ Agent: string; Pattern: string; LastUsed: string }> = []; - const now = Date.now(); - for (const [agentId, agent] of Object.entries(agents)) { - const allowlist = Array.isArray(agent.allowlist) ? agent.allowlist : []; - for (const entry of allowlist) { - const pattern = entry?.pattern?.trim() ?? ""; - if (!pattern) continue; - const lastUsedAt = typeof entry.lastUsedAt === "number" ? entry.lastUsedAt : null; - allowlistRows.push({ - Agent: agentId, - Pattern: pattern, - LastUsed: lastUsedAt - ? `${formatAge(Math.max(0, now - lastUsedAt))} ago` - : muted("unknown"), - }); + try { + const { snapshot, nodeId, source } = await loadSnapshotTarget(opts); + if (opts.json) { + defaultRuntime.log(JSON.stringify(snapshot)); + return; } - } - const summaryRows = [ - { Field: "Path", Value: snapshot.path }, - { Field: "Exists", Value: snapshot.exists ? "yes" : "no" }, - { Field: "Hash", Value: snapshot.hash }, - { Field: "Version", Value: String(file.version ?? 1) }, - { Field: "Socket", Value: file.socket?.path ?? "default" }, - { Field: "Defaults", Value: defaultsParts.length > 0 ? defaultsParts.join(", ") : "none" }, - { Field: "Agents", Value: String(Object.keys(agents).length) }, - { Field: "Allowlist", Value: String(allowlistRows.length) }, - ]; + const rich = isRich(); + const heading = (text: string) => (rich ? theme.heading(text) : text); + const muted = (text: string) => (rich ? theme.muted(text) : text); + if (source === "local") { + defaultRuntime.log(muted("Showing local approvals.")); + defaultRuntime.log(""); + } + const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); - defaultRuntime.log(heading("Approvals")); - defaultRuntime.log( - renderTable({ - width: tableWidth, - columns: [ - { key: "Field", header: "Field", minWidth: 8 }, - { key: "Value", header: "Value", minWidth: 24, flex: true }, - ], - rows: summaryRows, - }).trimEnd(), - ); + const file = snapshot.file ?? { version: 1 }; + const defaults = file.defaults ?? {}; + const defaultsParts = [ + defaults.security ? `security=${defaults.security}` : null, + defaults.ask ? `ask=${defaults.ask}` : null, + defaults.askFallback ? `askFallback=${defaults.askFallback}` : null, + typeof defaults.autoAllowSkills === "boolean" + ? `autoAllowSkills=${defaults.autoAllowSkills ? "on" : "off"}` + : null, + ].filter(Boolean) as string[]; + const agents = file.agents ?? {}; + const targetLabel = source === "local" ? "local" : nodeId ? `node:${nodeId}` : "gateway"; + const allowlistRows: Array<{ + Target: string; + Agent: string; + Pattern: string; + LastUsed: string; + }> = []; + const now = Date.now(); + for (const [agentId, agent] of Object.entries(agents)) { + const allowlist = Array.isArray(agent.allowlist) ? agent.allowlist : []; + for (const entry of allowlist) { + const pattern = entry?.pattern?.trim() ?? ""; + if (!pattern) continue; + const lastUsedAt = typeof entry.lastUsedAt === "number" ? entry.lastUsedAt : null; + allowlistRows.push({ + Target: targetLabel, + Agent: agentId, + Pattern: pattern, + LastUsed: lastUsedAt + ? `${formatAge(Math.max(0, now - lastUsedAt))} ago` + : muted("unknown"), + }); + } + } + + const summaryRows = [ + { Field: "Target", Value: targetLabel }, + { Field: "Path", Value: snapshot.path }, + { Field: "Exists", Value: snapshot.exists ? "yes" : "no" }, + { Field: "Hash", Value: snapshot.hash }, + { Field: "Version", Value: String(file.version ?? 1) }, + { Field: "Socket", Value: file.socket?.path ?? "default" }, + { + Field: "Defaults", + Value: defaultsParts.length > 0 ? defaultsParts.join(", ") : "none", + }, + { Field: "Agents", Value: String(Object.keys(agents).length) }, + { Field: "Allowlist", Value: String(allowlistRows.length) }, + ]; + + defaultRuntime.log(heading("Approvals")); + defaultRuntime.log( + renderTable({ + width: tableWidth, + columns: [ + { key: "Field", header: "Field", minWidth: 8 }, + { key: "Value", header: "Value", minWidth: 24, flex: true }, + ], + rows: summaryRows, + }).trimEnd(), + ); + + if (allowlistRows.length === 0) { + defaultRuntime.log(""); + defaultRuntime.log(muted("No allowlist entries.")); + return; + } - if (allowlistRows.length === 0) { defaultRuntime.log(""); - defaultRuntime.log(muted("No allowlist entries.")); - return; + defaultRuntime.log(heading("Allowlist")); + defaultRuntime.log( + renderTable({ + width: tableWidth, + columns: [ + { key: "Target", header: "Target", minWidth: 10 }, + { key: "Agent", header: "Agent", minWidth: 8 }, + { key: "Pattern", header: "Pattern", minWidth: 20, flex: true }, + { key: "LastUsed", header: "Last Used", minWidth: 10 }, + ], + rows: allowlistRows, + }).trimEnd(), + ); + } catch (err) { + defaultRuntime.error(formatCliError(err)); + defaultRuntime.exit(1); } - - defaultRuntime.log(""); - defaultRuntime.log(heading("Allowlist")); - defaultRuntime.log( - renderTable({ - width: tableWidth, - columns: [ - { key: "Agent", header: "Agent", minWidth: 8 }, - { key: "Pattern", header: "Pattern", minWidth: 20, flex: true }, - { key: "LastUsed", header: "Last Used", minWidth: 10 }, - ], - rows: allowlistRows, - }).trimEnd(), - ); }); nodesCallOpts(getCmd); const setCmd = approvals .command("set") .description("Replace exec approvals with a JSON file") - .option("--node ", "Target node id/name/IP (defaults to gateway)") + .option("--node ", "Target node id/name/IP") + .option("--gateway", "Force gateway approvals", false) .option("--file ", "Path to JSON file to upload") .option("--stdin", "Read JSON from stdin", false) .action(async (opts: ExecApprovalsCliOpts) => { - if (!opts.file && !opts.stdin) { - defaultRuntime.error("Provide --file or --stdin."); - defaultRuntime.exit(1); - return; - } - if (opts.file && opts.stdin) { - defaultRuntime.error("Use either --file or --stdin (not both)."); - defaultRuntime.exit(1); - return; - } - const nodeId = await resolveTargetNodeId(opts); - const snapshot = await loadSnapshot(opts, nodeId); - if (!snapshot.hash) { - defaultRuntime.error("Exec approvals hash missing; reload and retry."); - defaultRuntime.exit(1); - return; - } - const raw = opts.stdin ? await readStdin() : await fs.readFile(String(opts.file), "utf8"); - let file: ExecApprovalsFile; try { - file = JSON5.parse(raw) as ExecApprovalsFile; + if (!opts.file && !opts.stdin) { + defaultRuntime.error("Provide --file or --stdin."); + defaultRuntime.exit(1); + return; + } + if (opts.file && opts.stdin) { + defaultRuntime.error("Use either --file or --stdin (not both)."); + defaultRuntime.exit(1); + return; + } + const { snapshot, nodeId, source } = await loadSnapshotTarget(opts); + if (source === "local") { + defaultRuntime.log(theme.muted("Writing local approvals.")); + } + const targetLabel = source === "local" ? "local" : nodeId ? `node:${nodeId}` : "gateway"; + if (!snapshot.hash) { + defaultRuntime.error("Exec approvals hash missing; reload and retry."); + defaultRuntime.exit(1); + return; + } + const raw = opts.stdin ? await readStdin() : await fs.readFile(String(opts.file), "utf8"); + let file: ExecApprovalsFile; + try { + file = JSON5.parse(raw) as ExecApprovalsFile; + } catch (err) { + defaultRuntime.error(`Failed to parse approvals JSON: ${String(err)}`); + defaultRuntime.exit(1); + return; + } + file.version = 1; + const next = + source === "local" + ? saveSnapshotLocal(file) + : await saveSnapshot(opts, nodeId, file, snapshot.hash); + const payload = opts.json ? JSON.stringify(next) : JSON.stringify(next, null, 2); + if (!opts.json) { + defaultRuntime.log(theme.muted(`Target: ${targetLabel}`)); + } + defaultRuntime.log(payload); } catch (err) { - defaultRuntime.error(`Failed to parse approvals JSON: ${String(err)}`); + defaultRuntime.error(formatCliError(err)); defaultRuntime.exit(1); - return; } - file.version = 1; - const next = await saveSnapshot(opts, nodeId, file, snapshot.hash); - const payload = opts.json ? JSON.stringify(next) : JSON.stringify(next, null, 2); - defaultRuntime.log(payload); }); nodesCallOpts(setCmd); @@ -253,10 +329,13 @@ export function registerExecApprovalsCli(program: Command) { () => `\n${theme.heading("Examples:")}\n${formatExample( 'clawdbot approvals allowlist add "~/Projects/**/bin/rg"', - "Allowlist a local binary pattern for the default agent.", + "Allowlist a local binary pattern for the main agent.", )}\n${formatExample( 'clawdbot approvals allowlist add --agent main --node "/usr/bin/uptime"', "Allowlist on a specific node/agent.", + )}\n${formatExample( + 'clawdbot approvals allowlist add --agent "*" "/usr/bin/uname"', + "Allowlist for all agents (wildcard).", )}\n${formatExample( 'clawdbot approvals allowlist remove "~/Projects/**/bin/rg"', "Remove an allowlist pattern.", @@ -266,86 +345,116 @@ export function registerExecApprovalsCli(program: Command) { const allowlistAdd = allowlist .command("add ") .description("Add a glob pattern to an allowlist") - .option("--node ", "Target node id/name/IP (defaults to gateway)") - .option("--agent ", 'Agent id (defaults to "default")') + .option("--node ", "Target node id/name/IP") + .option("--gateway", "Force gateway approvals", false) + .option("--agent ", 'Agent id (defaults to "main")') .action(async (pattern: string, opts: ExecApprovalsCliOpts) => { - const trimmed = pattern.trim(); - if (!trimmed) { - defaultRuntime.error("Pattern required."); + try { + const trimmed = pattern.trim(); + if (!trimmed) { + defaultRuntime.error("Pattern required."); + defaultRuntime.exit(1); + return; + } + const { snapshot, nodeId, source } = await loadSnapshotTarget(opts); + if (source === "local") { + defaultRuntime.log(theme.muted("Writing local approvals.")); + } + const targetLabel = source === "local" ? "local" : nodeId ? `node:${nodeId}` : "gateway"; + if (!snapshot.hash) { + defaultRuntime.error("Exec approvals hash missing; reload and retry."); + defaultRuntime.exit(1); + return; + } + const file = snapshot.file ?? { version: 1 }; + file.version = 1; + const agentKey = resolveAgentKey(opts.agent); + const agent = ensureAgent(file, agentKey); + const allowlistEntries = Array.isArray(agent.allowlist) ? agent.allowlist : []; + if (allowlistEntries.some((entry) => normalizeAllowlistEntry(entry) === trimmed)) { + defaultRuntime.log("Already allowlisted."); + return; + } + allowlistEntries.push({ pattern: trimmed, lastUsedAt: Date.now() }); + agent.allowlist = allowlistEntries; + file.agents = { ...file.agents, [agentKey]: agent }; + const next = + source === "local" + ? saveSnapshotLocal(file) + : await saveSnapshot(opts, nodeId, file, snapshot.hash); + const payload = opts.json ? JSON.stringify(next) : JSON.stringify(next, null, 2); + if (!opts.json) { + defaultRuntime.log(theme.muted(`Target: ${targetLabel}`)); + } + defaultRuntime.log(payload); + } catch (err) { + defaultRuntime.error(formatCliError(err)); defaultRuntime.exit(1); - return; } - const nodeId = await resolveTargetNodeId(opts); - const snapshot = await loadSnapshot(opts, nodeId); - if (!snapshot.hash) { - defaultRuntime.error("Exec approvals hash missing; reload and retry."); - defaultRuntime.exit(1); - return; - } - const file = snapshot.file ?? { version: 1 }; - file.version = 1; - const agentKey = resolveAgentKey(opts.agent); - const agent = ensureAgent(file, agentKey); - const allowlistEntries = Array.isArray(agent.allowlist) ? agent.allowlist : []; - if (allowlistEntries.some((entry) => normalizeAllowlistEntry(entry) === trimmed)) { - defaultRuntime.log("Already allowlisted."); - return; - } - allowlistEntries.push({ pattern: trimmed, lastUsedAt: Date.now() }); - agent.allowlist = allowlistEntries; - file.agents = { ...file.agents, [agentKey]: agent }; - const next = await saveSnapshot(opts, nodeId, file, snapshot.hash); - const payload = opts.json ? JSON.stringify(next) : JSON.stringify(next, null, 2); - defaultRuntime.log(payload); }); nodesCallOpts(allowlistAdd); const allowlistRemove = allowlist .command("remove ") .description("Remove a glob pattern from an allowlist") - .option("--node ", "Target node id/name/IP (defaults to gateway)") - .option("--agent ", 'Agent id (defaults to "default")') + .option("--node ", "Target node id/name/IP") + .option("--gateway", "Force gateway approvals", false) + .option("--agent ", 'Agent id (defaults to "main")') .action(async (pattern: string, opts: ExecApprovalsCliOpts) => { - const trimmed = pattern.trim(); - if (!trimmed) { - defaultRuntime.error("Pattern required."); + try { + const trimmed = pattern.trim(); + if (!trimmed) { + defaultRuntime.error("Pattern required."); + defaultRuntime.exit(1); + return; + } + const { snapshot, nodeId, source } = await loadSnapshotTarget(opts); + if (source === "local") { + defaultRuntime.log(theme.muted("Writing local approvals.")); + } + const targetLabel = source === "local" ? "local" : nodeId ? `node:${nodeId}` : "gateway"; + if (!snapshot.hash) { + defaultRuntime.error("Exec approvals hash missing; reload and retry."); + defaultRuntime.exit(1); + return; + } + const file = snapshot.file ?? { version: 1 }; + file.version = 1; + const agentKey = resolveAgentKey(opts.agent); + const agent = ensureAgent(file, agentKey); + const allowlistEntries = Array.isArray(agent.allowlist) ? agent.allowlist : []; + const nextEntries = allowlistEntries.filter( + (entry) => normalizeAllowlistEntry(entry) !== trimmed, + ); + if (nextEntries.length === allowlistEntries.length) { + defaultRuntime.log("Pattern not found."); + return; + } + if (nextEntries.length === 0) { + delete agent.allowlist; + } else { + agent.allowlist = nextEntries; + } + if (isEmptyAgent(agent)) { + const agents = { ...file.agents }; + delete agents[agentKey]; + file.agents = Object.keys(agents).length > 0 ? agents : undefined; + } else { + file.agents = { ...file.agents, [agentKey]: agent }; + } + const next = + source === "local" + ? saveSnapshotLocal(file) + : await saveSnapshot(opts, nodeId, file, snapshot.hash); + const payload = opts.json ? JSON.stringify(next) : JSON.stringify(next, null, 2); + if (!opts.json) { + defaultRuntime.log(theme.muted(`Target: ${targetLabel}`)); + } + defaultRuntime.log(payload); + } catch (err) { + defaultRuntime.error(formatCliError(err)); defaultRuntime.exit(1); - return; } - const nodeId = await resolveTargetNodeId(opts); - const snapshot = await loadSnapshot(opts, nodeId); - if (!snapshot.hash) { - defaultRuntime.error("Exec approvals hash missing; reload and retry."); - defaultRuntime.exit(1); - return; - } - const file = snapshot.file ?? { version: 1 }; - file.version = 1; - const agentKey = resolveAgentKey(opts.agent); - const agent = ensureAgent(file, agentKey); - const allowlistEntries = Array.isArray(agent.allowlist) ? agent.allowlist : []; - const nextEntries = allowlistEntries.filter( - (entry) => normalizeAllowlistEntry(entry) !== trimmed, - ); - if (nextEntries.length === allowlistEntries.length) { - defaultRuntime.log("Pattern not found."); - return; - } - if (nextEntries.length === 0) { - delete agent.allowlist; - } else { - agent.allowlist = nextEntries; - } - if (isEmptyAgent(agent)) { - const agents = { ...file.agents }; - delete agents[agentKey]; - file.agents = Object.keys(agents).length > 0 ? agents : undefined; - } else { - file.agents = { ...file.agents, [agentKey]: agent }; - } - const next = await saveSnapshot(opts, nodeId, file, snapshot.hash); - const payload = opts.json ? JSON.stringify(next) : JSON.stringify(next, null, 2); - defaultRuntime.log(payload); }); nodesCallOpts(allowlistRemove); } diff --git a/src/infra/exec-approvals.test.ts b/src/infra/exec-approvals.test.ts index c3321aebf..9d910ff31 100644 --- a/src/infra/exec-approvals.test.ts +++ b/src/infra/exec-approvals.test.ts @@ -9,6 +9,7 @@ import { maxAsk, minSecurity, resolveCommandResolution, + resolveExecApprovals, type ExecAllowlistEntry, } from "./exec-approvals.js"; @@ -106,3 +107,36 @@ describe("exec approvals policy helpers", () => { expect(maxAsk("on-miss", "off")).toBe("on-miss"); }); }); + +describe("exec approvals wildcard agent", () => { + it("merges wildcard allowlist entries with agent entries", () => { + const dir = makeTempDir(); + const oldHome = process.env.HOME; + process.env.HOME = dir; + + const approvalsPath = path.join(dir, ".clawdbot", "exec-approvals.json"); + fs.mkdirSync(path.dirname(approvalsPath), { recursive: true }); + fs.writeFileSync( + approvalsPath, + JSON.stringify( + { + version: 1, + agents: { + "*": { allowlist: [{ pattern: "/bin/hostname" }] }, + main: { allowlist: [{ pattern: "/usr/bin/uname" }] }, + }, + }, + null, + 2, + ), + ); + + const resolved = resolveExecApprovals("main"); + expect(resolved.allowlist.map((entry) => entry.pattern)).toEqual([ + "/bin/hostname", + "/usr/bin/uname", + ]); + + process.env.HOME = oldHome; + }); +}); diff --git a/src/infra/exec-approvals.ts b/src/infra/exec-approvals.ts index cedbab2e7..5dd5fe26a 100644 --- a/src/infra/exec-approvals.ts +++ b/src/infra/exec-approvals.ts @@ -213,6 +213,7 @@ export function resolveExecApprovals( const defaults = file.defaults ?? {}; const agentKey = agentId ?? "default"; const agent = file.agents?.[agentKey] ?? {}; + const wildcard = file.agents?.["*"] ?? {}; const fallbackSecurity = overrides?.security ?? DEFAULT_SECURITY; const fallbackAsk = overrides?.ask ?? DEFAULT_ASK; const fallbackAskFallback = overrides?.askFallback ?? DEFAULT_ASK_FALLBACK; @@ -228,17 +229,22 @@ export function resolveExecApprovals( }; const resolvedAgent: Required = { security: normalizeSecurity( - agent.security ?? resolvedDefaults.security, + agent.security ?? wildcard.security ?? resolvedDefaults.security, resolvedDefaults.security, ), - ask: normalizeAsk(agent.ask ?? resolvedDefaults.ask, resolvedDefaults.ask), + ask: normalizeAsk(agent.ask ?? wildcard.ask ?? resolvedDefaults.ask, resolvedDefaults.ask), askFallback: normalizeSecurity( - agent.askFallback ?? resolvedDefaults.askFallback, + agent.askFallback ?? wildcard.askFallback ?? resolvedDefaults.askFallback, resolvedDefaults.askFallback, ), - autoAllowSkills: Boolean(agent.autoAllowSkills ?? resolvedDefaults.autoAllowSkills), + autoAllowSkills: Boolean( + agent.autoAllowSkills ?? wildcard.autoAllowSkills ?? resolvedDefaults.autoAllowSkills, + ), }; - const allowlist = Array.isArray(agent.allowlist) ? agent.allowlist : []; + const allowlist = [ + ...(Array.isArray(wildcard.allowlist) ? wildcard.allowlist : []), + ...(Array.isArray(agent.allowlist) ? agent.allowlist : []), + ]; return { path: resolveExecApprovalsPath(), socketPath: expandHome(file.socket?.path ?? resolveExecApprovalsSocketPath()),