feat: improve exec approvals defaults and wildcard
This commit is contained in:
@@ -4,6 +4,10 @@ Docs: https://docs.clawd.bot
|
|||||||
|
|
||||||
## 2026.1.21
|
## 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
|
### Fixes
|
||||||
- UI: remove the chat stop button and keep the composer aligned to the bottom edge.
|
- UI: remove the chat stop button and keep the composer aligned to the bottom edge.
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ read_when:
|
|||||||
|
|
||||||
# `clawdbot approvals`
|
# `clawdbot approvals`
|
||||||
|
|
||||||
Manage exec approvals for the **gateway host** or a **node host**.
|
Manage exec approvals for the **local host**, **gateway host**, or a **node host**.
|
||||||
By default, commands target the gateway. Use `--node` to edit a node’s approvals.
|
By default, commands target the local approvals file on disk. Use `--gateway` to target the gateway, or `--node` to target a specific node.
|
||||||
|
|
||||||
Related:
|
Related:
|
||||||
- Exec approvals: [Exec approvals](/tools/exec-approvals)
|
- Exec approvals: [Exec approvals](/tools/exec-approvals)
|
||||||
@@ -19,6 +19,7 @@ Related:
|
|||||||
```bash
|
```bash
|
||||||
clawdbot approvals get
|
clawdbot approvals get
|
||||||
clawdbot approvals get --node <id|name|ip>
|
clawdbot approvals get --node <id|name|ip>
|
||||||
|
clawdbot approvals get --gateway
|
||||||
```
|
```
|
||||||
|
|
||||||
## Replace approvals from a file
|
## Replace approvals from a file
|
||||||
@@ -26,6 +27,7 @@ clawdbot approvals get --node <id|name|ip>
|
|||||||
```bash
|
```bash
|
||||||
clawdbot approvals set --file ./exec-approvals.json
|
clawdbot approvals set --file ./exec-approvals.json
|
||||||
clawdbot approvals set --node <id|name|ip> --file ./exec-approvals.json
|
clawdbot approvals set --node <id|name|ip> --file ./exec-approvals.json
|
||||||
|
clawdbot approvals set --gateway --file ./exec-approvals.json
|
||||||
```
|
```
|
||||||
|
|
||||||
## Allowlist helpers
|
## Allowlist helpers
|
||||||
@@ -33,6 +35,7 @@ clawdbot approvals set --node <id|name|ip> --file ./exec-approvals.json
|
|||||||
```bash
|
```bash
|
||||||
clawdbot approvals allowlist add "~/Projects/**/bin/rg"
|
clawdbot approvals allowlist add "~/Projects/**/bin/rg"
|
||||||
clawdbot approvals allowlist add --agent main --node <id|name|ip> "/usr/bin/uptime"
|
clawdbot approvals allowlist add --agent main --node <id|name|ip> "/usr/bin/uptime"
|
||||||
|
clawdbot approvals allowlist add --agent "*" "/usr/bin/uname"
|
||||||
|
|
||||||
clawdbot approvals allowlist remove "~/Projects/**/bin/rg"
|
clawdbot approvals allowlist remove "~/Projects/**/bin/rg"
|
||||||
```
|
```
|
||||||
@@ -40,5 +43,6 @@ clawdbot approvals allowlist remove "~/Projects/**/bin/rg"
|
|||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- `--node` uses the same resolver as `clawdbot nodes` (id, name, ip, or id prefix).
|
- `--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).
|
- 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`.
|
- Approvals files are stored per host at `~/.clawdbot/exec-approvals.json`.
|
||||||
|
|||||||
@@ -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", () => ({
|
vi.mock("./gateway-rpc.js", () => ({
|
||||||
callGatewayFromCli: (method: string, opts: unknown, params?: unknown) =>
|
callGatewayFromCli: (method: string, opts: unknown, params?: unknown) =>
|
||||||
callGatewayFromCli(method, opts, params),
|
callGatewayFromCli(method, opts, params),
|
||||||
@@ -40,8 +48,19 @@ vi.mock("../runtime.js", () => ({
|
|||||||
defaultRuntime,
|
defaultRuntime,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../infra/exec-approvals.js", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("../infra/exec-approvals.js")>(
|
||||||
|
"../infra/exec-approvals.js",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
readExecApprovalsSnapshot: () => localSnapshot,
|
||||||
|
saveExecApprovals: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
describe("exec approvals CLI", () => {
|
describe("exec approvals CLI", () => {
|
||||||
it("loads gateway approvals by default", async () => {
|
it("loads local approvals by default", async () => {
|
||||||
runtimeLogs.length = 0;
|
runtimeLogs.length = 0;
|
||||||
runtimeErrors.length = 0;
|
runtimeErrors.length = 0;
|
||||||
callGatewayFromCli.mockClear();
|
callGatewayFromCli.mockClear();
|
||||||
@@ -53,6 +72,22 @@ describe("exec approvals CLI", () => {
|
|||||||
|
|
||||||
await program.parseAsync(["approvals", "get"], { from: "user" });
|
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(callGatewayFromCli).toHaveBeenCalledWith("exec.approvals.get", expect.anything(), {});
|
||||||
expect(runtimeErrors).toHaveLength(0);
|
expect(runtimeErrors).toHaveLength(0);
|
||||||
});
|
});
|
||||||
@@ -74,4 +109,22 @@ describe("exec approvals CLI", () => {
|
|||||||
});
|
});
|
||||||
expect(runtimeErrors).toHaveLength(0);
|
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<string, unknown> } };
|
||||||
|
expect(params.file.agents?.["*"]).toBeTruthy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ import fs from "node:fs/promises";
|
|||||||
import JSON5 from "json5";
|
import JSON5 from "json5";
|
||||||
import type { Command } from "commander";
|
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 { defaultRuntime } from "../runtime.js";
|
||||||
import { formatDocsLink } from "../terminal/links.js";
|
import { formatDocsLink } from "../terminal/links.js";
|
||||||
import { isRich, theme } from "../terminal/theme.js";
|
import { isRich, theme } from "../terminal/theme.js";
|
||||||
@@ -20,6 +25,7 @@ type ExecApprovalsSnapshot = {
|
|||||||
|
|
||||||
type ExecApprovalsCliOpts = NodesRpcOpts & {
|
type ExecApprovalsCliOpts = NodesRpcOpts & {
|
||||||
node?: string;
|
node?: string;
|
||||||
|
gateway?: boolean;
|
||||||
file?: string;
|
file?: string;
|
||||||
stdin?: boolean;
|
stdin?: boolean;
|
||||||
agent?: string;
|
agent?: string;
|
||||||
@@ -45,6 +51,7 @@ async function readStdin(): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function resolveTargetNodeId(opts: ExecApprovalsCliOpts): Promise<string | null> {
|
async function resolveTargetNodeId(opts: ExecApprovalsCliOpts): Promise<string | null> {
|
||||||
|
if (opts.gateway) return null;
|
||||||
const raw = opts.node?.trim() ?? "";
|
const raw = opts.node?.trim() ?? "";
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
return await resolveNodeId(opts as NodesRpcOpts, raw);
|
return await resolveNodeId(opts as NodesRpcOpts, raw);
|
||||||
@@ -60,6 +67,39 @@ async function loadSnapshot(
|
|||||||
return snapshot;
|
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(
|
async function saveSnapshot(
|
||||||
opts: ExecApprovalsCliOpts,
|
opts: ExecApprovalsCliOpts,
|
||||||
nodeId: string | null,
|
nodeId: string | null,
|
||||||
@@ -74,7 +114,7 @@ async function saveSnapshot(
|
|||||||
|
|
||||||
function resolveAgentKey(value?: string | null): string {
|
function resolveAgentKey(value?: string | null): string {
|
||||||
const trimmed = value?.trim() ?? "";
|
const trimmed = value?.trim() ?? "";
|
||||||
return trimmed ? trimmed : "default";
|
return trimmed ? trimmed : "*";
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeAllowlistEntry(entry: { pattern?: string } | null): string | null {
|
function normalizeAllowlistEntry(entry: { pattern?: string } | null): string | null {
|
||||||
@@ -117,131 +157,167 @@ export function registerExecApprovalsCli(program: Command) {
|
|||||||
const getCmd = approvals
|
const getCmd = approvals
|
||||||
.command("get")
|
.command("get")
|
||||||
.description("Fetch exec approvals snapshot")
|
.description("Fetch exec approvals snapshot")
|
||||||
.option("--node <node>", "Target node id/name/IP (defaults to gateway)")
|
.option("--node <node>", "Target node id/name/IP")
|
||||||
|
.option("--gateway", "Force gateway approvals", false)
|
||||||
.action(async (opts: ExecApprovalsCliOpts) => {
|
.action(async (opts: ExecApprovalsCliOpts) => {
|
||||||
const nodeId = await resolveTargetNodeId(opts);
|
try {
|
||||||
const snapshot = await loadSnapshot(opts, nodeId);
|
const { snapshot, nodeId, source } = await loadSnapshotTarget(opts);
|
||||||
if (opts.json) {
|
if (opts.json) {
|
||||||
defaultRuntime.log(JSON.stringify(snapshot));
|
defaultRuntime.log(JSON.stringify(snapshot));
|
||||||
return;
|
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"),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const summaryRows = [
|
const rich = isRich();
|
||||||
{ Field: "Path", Value: snapshot.path },
|
const heading = (text: string) => (rich ? theme.heading(text) : text);
|
||||||
{ Field: "Exists", Value: snapshot.exists ? "yes" : "no" },
|
const muted = (text: string) => (rich ? theme.muted(text) : text);
|
||||||
{ Field: "Hash", Value: snapshot.hash },
|
if (source === "local") {
|
||||||
{ Field: "Version", Value: String(file.version ?? 1) },
|
defaultRuntime.log(muted("Showing local approvals."));
|
||||||
{ Field: "Socket", Value: file.socket?.path ?? "default" },
|
defaultRuntime.log("");
|
||||||
{ Field: "Defaults", Value: defaultsParts.length > 0 ? defaultsParts.join(", ") : "none" },
|
}
|
||||||
{ Field: "Agents", Value: String(Object.keys(agents).length) },
|
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
|
||||||
{ Field: "Allowlist", Value: String(allowlistRows.length) },
|
|
||||||
];
|
|
||||||
|
|
||||||
defaultRuntime.log(heading("Approvals"));
|
const file = snapshot.file ?? { version: 1 };
|
||||||
defaultRuntime.log(
|
const defaults = file.defaults ?? {};
|
||||||
renderTable({
|
const defaultsParts = [
|
||||||
width: tableWidth,
|
defaults.security ? `security=${defaults.security}` : null,
|
||||||
columns: [
|
defaults.ask ? `ask=${defaults.ask}` : null,
|
||||||
{ key: "Field", header: "Field", minWidth: 8 },
|
defaults.askFallback ? `askFallback=${defaults.askFallback}` : null,
|
||||||
{ key: "Value", header: "Value", minWidth: 24, flex: true },
|
typeof defaults.autoAllowSkills === "boolean"
|
||||||
],
|
? `autoAllowSkills=${defaults.autoAllowSkills ? "on" : "off"}`
|
||||||
rows: summaryRows,
|
: null,
|
||||||
}).trimEnd(),
|
].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("");
|
||||||
defaultRuntime.log(muted("No allowlist entries."));
|
defaultRuntime.log(heading("Allowlist"));
|
||||||
return;
|
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);
|
nodesCallOpts(getCmd);
|
||||||
|
|
||||||
const setCmd = approvals
|
const setCmd = approvals
|
||||||
.command("set")
|
.command("set")
|
||||||
.description("Replace exec approvals with a JSON file")
|
.description("Replace exec approvals with a JSON file")
|
||||||
.option("--node <node>", "Target node id/name/IP (defaults to gateway)")
|
.option("--node <node>", "Target node id/name/IP")
|
||||||
|
.option("--gateway", "Force gateway approvals", false)
|
||||||
.option("--file <path>", "Path to JSON file to upload")
|
.option("--file <path>", "Path to JSON file to upload")
|
||||||
.option("--stdin", "Read JSON from stdin", false)
|
.option("--stdin", "Read JSON from stdin", false)
|
||||||
.action(async (opts: ExecApprovalsCliOpts) => {
|
.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 {
|
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) {
|
} catch (err) {
|
||||||
defaultRuntime.error(`Failed to parse approvals JSON: ${String(err)}`);
|
defaultRuntime.error(formatCliError(err));
|
||||||
defaultRuntime.exit(1);
|
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);
|
nodesCallOpts(setCmd);
|
||||||
|
|
||||||
@@ -253,10 +329,13 @@ export function registerExecApprovalsCli(program: Command) {
|
|||||||
() =>
|
() =>
|
||||||
`\n${theme.heading("Examples:")}\n${formatExample(
|
`\n${theme.heading("Examples:")}\n${formatExample(
|
||||||
'clawdbot approvals allowlist add "~/Projects/**/bin/rg"',
|
'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(
|
)}\n${formatExample(
|
||||||
'clawdbot approvals allowlist add --agent main --node <id|name|ip> "/usr/bin/uptime"',
|
'clawdbot approvals allowlist add --agent main --node <id|name|ip> "/usr/bin/uptime"',
|
||||||
"Allowlist on a specific node/agent.",
|
"Allowlist on a specific node/agent.",
|
||||||
|
)}\n${formatExample(
|
||||||
|
'clawdbot approvals allowlist add --agent "*" "/usr/bin/uname"',
|
||||||
|
"Allowlist for all agents (wildcard).",
|
||||||
)}\n${formatExample(
|
)}\n${formatExample(
|
||||||
'clawdbot approvals allowlist remove "~/Projects/**/bin/rg"',
|
'clawdbot approvals allowlist remove "~/Projects/**/bin/rg"',
|
||||||
"Remove an allowlist pattern.",
|
"Remove an allowlist pattern.",
|
||||||
@@ -266,86 +345,116 @@ export function registerExecApprovalsCli(program: Command) {
|
|||||||
const allowlistAdd = allowlist
|
const allowlistAdd = allowlist
|
||||||
.command("add <pattern>")
|
.command("add <pattern>")
|
||||||
.description("Add a glob pattern to an allowlist")
|
.description("Add a glob pattern to an allowlist")
|
||||||
.option("--node <node>", "Target node id/name/IP (defaults to gateway)")
|
.option("--node <node>", "Target node id/name/IP")
|
||||||
.option("--agent <id>", 'Agent id (defaults to "default")')
|
.option("--gateway", "Force gateway approvals", false)
|
||||||
|
.option("--agent <id>", 'Agent id (defaults to "main")')
|
||||||
.action(async (pattern: string, opts: ExecApprovalsCliOpts) => {
|
.action(async (pattern: string, opts: ExecApprovalsCliOpts) => {
|
||||||
const trimmed = pattern.trim();
|
try {
|
||||||
if (!trimmed) {
|
const trimmed = pattern.trim();
|
||||||
defaultRuntime.error("Pattern required.");
|
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);
|
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);
|
nodesCallOpts(allowlistAdd);
|
||||||
|
|
||||||
const allowlistRemove = allowlist
|
const allowlistRemove = allowlist
|
||||||
.command("remove <pattern>")
|
.command("remove <pattern>")
|
||||||
.description("Remove a glob pattern from an allowlist")
|
.description("Remove a glob pattern from an allowlist")
|
||||||
.option("--node <node>", "Target node id/name/IP (defaults to gateway)")
|
.option("--node <node>", "Target node id/name/IP")
|
||||||
.option("--agent <id>", 'Agent id (defaults to "default")')
|
.option("--gateway", "Force gateway approvals", false)
|
||||||
|
.option("--agent <id>", 'Agent id (defaults to "main")')
|
||||||
.action(async (pattern: string, opts: ExecApprovalsCliOpts) => {
|
.action(async (pattern: string, opts: ExecApprovalsCliOpts) => {
|
||||||
const trimmed = pattern.trim();
|
try {
|
||||||
if (!trimmed) {
|
const trimmed = pattern.trim();
|
||||||
defaultRuntime.error("Pattern required.");
|
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);
|
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);
|
nodesCallOpts(allowlistRemove);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
maxAsk,
|
maxAsk,
|
||||||
minSecurity,
|
minSecurity,
|
||||||
resolveCommandResolution,
|
resolveCommandResolution,
|
||||||
|
resolveExecApprovals,
|
||||||
type ExecAllowlistEntry,
|
type ExecAllowlistEntry,
|
||||||
} from "./exec-approvals.js";
|
} from "./exec-approvals.js";
|
||||||
|
|
||||||
@@ -106,3 +107,36 @@ describe("exec approvals policy helpers", () => {
|
|||||||
expect(maxAsk("on-miss", "off")).toBe("on-miss");
|
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;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -213,6 +213,7 @@ export function resolveExecApprovals(
|
|||||||
const defaults = file.defaults ?? {};
|
const defaults = file.defaults ?? {};
|
||||||
const agentKey = agentId ?? "default";
|
const agentKey = agentId ?? "default";
|
||||||
const agent = file.agents?.[agentKey] ?? {};
|
const agent = file.agents?.[agentKey] ?? {};
|
||||||
|
const wildcard = file.agents?.["*"] ?? {};
|
||||||
const fallbackSecurity = overrides?.security ?? DEFAULT_SECURITY;
|
const fallbackSecurity = overrides?.security ?? DEFAULT_SECURITY;
|
||||||
const fallbackAsk = overrides?.ask ?? DEFAULT_ASK;
|
const fallbackAsk = overrides?.ask ?? DEFAULT_ASK;
|
||||||
const fallbackAskFallback = overrides?.askFallback ?? DEFAULT_ASK_FALLBACK;
|
const fallbackAskFallback = overrides?.askFallback ?? DEFAULT_ASK_FALLBACK;
|
||||||
@@ -228,17 +229,22 @@ export function resolveExecApprovals(
|
|||||||
};
|
};
|
||||||
const resolvedAgent: Required<ExecApprovalsDefaults> = {
|
const resolvedAgent: Required<ExecApprovalsDefaults> = {
|
||||||
security: normalizeSecurity(
|
security: normalizeSecurity(
|
||||||
agent.security ?? resolvedDefaults.security,
|
agent.security ?? wildcard.security ?? resolvedDefaults.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(
|
askFallback: normalizeSecurity(
|
||||||
agent.askFallback ?? resolvedDefaults.askFallback,
|
agent.askFallback ?? wildcard.askFallback ?? resolvedDefaults.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 {
|
return {
|
||||||
path: resolveExecApprovalsPath(),
|
path: resolveExecApprovalsPath(),
|
||||||
socketPath: expandHome(file.socket?.path ?? resolveExecApprovalsSocketPath()),
|
socketPath: expandHome(file.socket?.path ?? resolveExecApprovalsSocketPath()),
|
||||||
|
|||||||
Reference in New Issue
Block a user