feat: tighten exec allowlist gating

This commit is contained in:
Peter Steinberger
2026-01-21 21:44:28 +00:00
parent acd40e1780
commit 3b18efdd25
12 changed files with 541 additions and 57 deletions

View File

@@ -8,6 +8,7 @@ Docs: https://docs.clawd.bot
- CLI: default exec approvals to the local host, add gateway/node targeting flags, and show target details in allowlist output. - CLI: default exec approvals to the local host, add gateway/node targeting flags, and show target details in allowlist output.
- CLI: exec approvals mutations render tables instead of raw JSON. - CLI: exec approvals mutations render tables instead of raw JSON.
- Exec approvals: support wildcard agent allowlists (`*`) across all agents. - Exec approvals: support wildcard agent allowlists (`*`) across all agents.
- Exec approvals: allowlist matches resolved binary paths only, add safe stdin-only bins, and tighten allowlist shell parsing.
- Nodes: expose node PATH in status/describe and bootstrap PATH for node-host execution. - Nodes: expose node PATH in status/describe and bootstrap PATH for node-host execution.
- CLI: flatten node service commands under `clawdbot node` and remove `service node` docs. - CLI: flatten node service commands under `clawdbot node` and remove `service node` docs.
- CLI: move gateway service commands under `clawdbot gateway` and add `gateway probe` for reachability. - CLI: move gateway service commands under `clawdbot gateway` and add `gateway probe` for reachability.

View File

@@ -87,6 +87,7 @@ If a prompt is required but no UI is reachable, fallback decides:
Allowlists are **per agent**. If multiple agents exist, switch which agent youre Allowlists are **per agent**. If multiple agents exist, switch which agent youre
editing in the macOS app. Patterns are **case-insensitive glob matches**. editing in the macOS app. Patterns are **case-insensitive glob matches**.
Patterns should resolve to **binary paths** (basename-only entries are ignored).
Examples: Examples:
- `~/Projects/**/bin/bird` - `~/Projects/**/bin/bird`
@@ -104,6 +105,15 @@ When **Auto-allow skill CLIs** is enabled, executables referenced by known skill
are treated as allowlisted on nodes (macOS node or headless node host). This uses the Bridge RPC to ask the are treated as allowlisted on nodes (macOS node or headless node host). This uses the Bridge RPC to ask the
gateway for the skill bin list. Disable this if you want strict manual allowlists. gateway for the skill bin list. Disable this if you want strict manual allowlists.
## Safe bins (stdin-only)
`tools.exec.safeBins` defines a small list of **stdin-only** binaries (for example `jq`)
that can run in allowlist mode **without** explicit allowlist entries. Safe bins reject
positional file args and path-like tokens, so they can only operate on the incoming stream.
Shell chaining and redirections are not auto-allowed in allowlist mode.
Default safe bins: `jq`, `grep`, `cut`, `sort`, `uniq`, `head`, `tail`, `tr`, `wc`.
## Control UI editing ## Control UI editing
Use the **Control UI → Nodes → Exec approvals** card to edit defaults, peragent Use the **Control UI → Nodes → Exec approvals** card to edit defaults, peragent

View File

@@ -43,6 +43,7 @@ Notes:
- `tools.exec.ask` (default: `on-miss`) - `tools.exec.ask` (default: `on-miss`)
- `tools.exec.node` (default: unset) - `tools.exec.node` (default: unset)
- `tools.exec.pathPrepend`: list of directories to prepend to `PATH` for exec runs. - `tools.exec.pathPrepend`: list of directories to prepend to `PATH` for exec runs.
- `tools.exec.safeBins`: stdin-only safe binaries that can run without explicit allowlist entries.
Example: Example:
```json5 ```json5
@@ -64,7 +65,8 @@ Example:
- `host=sandbox`: runs `sh -lc` (login shell) inside the container, so `/etc/profile` may reset `PATH`. - `host=sandbox`: runs `sh -lc` (login shell) inside the container, so `/etc/profile` may reset `PATH`.
Clawdbot prepends `env.PATH` after profile sourcing; `tools.exec.pathPrepend` applies here too. Clawdbot prepends `env.PATH` after profile sourcing; `tools.exec.pathPrepend` applies here too.
- `host=node`: only env overrides you pass are sent to the node. `tools.exec.pathPrepend` only applies - `host=node`: only env overrides you pass are sent to the node. `tools.exec.pathPrepend` only applies
if the exec call already sets `env.PATH`. if the exec call already sets `env.PATH`. Node PATH overrides are accepted only when they prepend
the node host PATH (no replacement).
Per-agent node binding (use the agent list index in config): Per-agent node binding (use the agent list index in config):
@@ -90,6 +92,13 @@ Example:
Sandboxed agents can require per-request approval before `exec` runs on the gateway or node host. Sandboxed agents can require per-request approval before `exec` runs on the gateway or node host.
See [Exec approvals](/tools/exec-approvals) for the policy, allowlist, and UI flow. See [Exec approvals](/tools/exec-approvals) for the policy, allowlist, and UI flow.
## Allowlist + safe bins
Allowlist enforcement matches **resolved binary paths only** (no basename matches). When
`security=allowlist`, shell commands are auto-allowed only if every pipeline segment is
allowlisted or a safe bin. Chaining (`;`, `&&`, `||`) and redirections are rejected in
allowlist mode.
## Examples ## Examples
Foreground: Foreground:

View File

@@ -8,12 +8,15 @@ import {
type ExecAsk, type ExecAsk,
type ExecHost, type ExecHost,
type ExecSecurity, type ExecSecurity,
type ExecAllowlistEntry,
addAllowlistEntry, addAllowlistEntry,
analyzeShellCommand,
isSafeBinUsage,
matchAllowlist, matchAllowlist,
maxAsk, maxAsk,
minSecurity, minSecurity,
resolveSafeBins,
recordAllowlistUse, recordAllowlistUse,
resolveCommandResolution,
resolveExecApprovals, resolveExecApprovals,
} from "../infra/exec-approvals.js"; } from "../infra/exec-approvals.js";
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js"; import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
@@ -94,6 +97,7 @@ export type ExecToolDefaults = {
ask?: ExecAsk; ask?: ExecAsk;
node?: string; node?: string;
pathPrepend?: string[]; pathPrepend?: string[];
safeBins?: string[];
agentId?: string; agentId?: string;
backgroundMs?: number; backgroundMs?: number;
timeoutSec?: number; timeoutSec?: number;
@@ -298,6 +302,7 @@ export function createExecTool(
? defaults.timeoutSec ? defaults.timeoutSec
: 1800; : 1800;
const defaultPathPrepend = normalizePathPrepend(defaults?.pathPrepend); const defaultPathPrepend = normalizePathPrepend(defaults?.pathPrepend);
const safeBins = resolveSafeBins(defaults?.safeBins);
const notifyOnExit = defaults?.notifyOnExit !== false; const notifyOnExit = defaults?.notifyOnExit !== false;
const notifySessionKey = defaults?.sessionKey?.trim() || undefined; const notifySessionKey = defaults?.sessionKey?.trim() || undefined;
@@ -593,13 +598,27 @@ export function createExecTool(
if (hostSecurity === "deny") { if (hostSecurity === "deny") {
throw new Error("exec denied: host=gateway security=deny"); throw new Error("exec denied: host=gateway security=deny");
} }
const analysis = analyzeShellCommand({ command: params.command, cwd: workdir, env });
const resolution = resolveCommandResolution(params.command, workdir, env); const allowlistMatches: ExecAllowlistEntry[] = [];
const allowlistMatch = let allowlistSatisfied = false;
hostSecurity === "allowlist" ? matchAllowlist(approvals.allowlist, resolution) : null; if (hostSecurity === "allowlist" && analysis.ok && analysis.segments.length > 0) {
allowlistSatisfied = analysis.segments.every((segment) => {
const match = matchAllowlist(approvals.allowlist, segment.resolution);
if (match) allowlistMatches.push(match);
const safe = isSafeBinUsage({
argv: segment.argv,
resolution: segment.resolution,
safeBins,
cwd: workdir,
});
return Boolean(match || safe);
});
}
const requiresAsk = const requiresAsk =
hostAsk === "always" || hostAsk === "always" ||
(hostAsk === "on-miss" && hostSecurity === "allowlist" && !allowlistMatch); (hostAsk === "on-miss" &&
hostSecurity === "allowlist" &&
(!analysis.ok || !allowlistSatisfied));
let approvedByAsk = false; let approvedByAsk = false;
if (requiresAsk) { if (requiresAsk) {
@@ -613,7 +632,7 @@ export function createExecTool(
security: hostSecurity, security: hostSecurity,
ask: hostAsk, ask: hostAsk,
agentId: defaults?.agentId, agentId: defaults?.agentId,
resolvedPath: resolution?.resolvedPath ?? null, resolvedPath: analysis.segments[0]?.resolution?.resolvedPath ?? null,
sessionKey: defaults?.sessionKey ?? null, sessionKey: defaults?.sessionKey ?? null,
timeoutMs: 120_000, timeoutMs: 120_000,
}, },
@@ -630,7 +649,7 @@ export function createExecTool(
if (askFallback === "full") { if (askFallback === "full") {
approvedByAsk = true; approvedByAsk = true;
} else if (askFallback === "allowlist") { } else if (askFallback === "allowlist") {
if (!allowlistMatch) { if (!analysis.ok || !allowlistSatisfied) {
throw new Error("exec denied: approval required (approval UI not available)"); throw new Error("exec denied: approval required (approval UI not available)");
} }
approvedByAsk = true; approvedByAsk = true;
@@ -644,30 +663,37 @@ export function createExecTool(
if (decision === "allow-always") { if (decision === "allow-always") {
approvedByAsk = true; approvedByAsk = true;
if (hostSecurity === "allowlist") { if (hostSecurity === "allowlist") {
const pattern = for (const segment of analysis.segments) {
resolution?.resolvedPath ?? const pattern = segment.resolution?.resolvedPath ?? "";
resolution?.rawExecutable ?? if (pattern) {
params.command.split(/\s+/).shift() ?? addAllowlistEntry(approvals.file, defaults?.agentId, pattern);
""; }
if (pattern) {
addAllowlistEntry(approvals.file, defaults?.agentId, pattern);
} }
} }
} }
} }
if (hostSecurity === "allowlist" && !allowlistMatch && !approvedByAsk) { if (
hostSecurity === "allowlist" &&
(!analysis.ok || !allowlistSatisfied) &&
!approvedByAsk
) {
throw new Error("exec denied: allowlist miss"); throw new Error("exec denied: allowlist miss");
} }
if (allowlistMatch) { if (allowlistMatches.length > 0) {
recordAllowlistUse( const seen = new Set<string>();
approvals.file, for (const match of allowlistMatches) {
defaults?.agentId, if (seen.has(match.pattern)) continue;
allowlistMatch, seen.add(match.pattern);
params.command, recordAllowlistUse(
resolution?.resolvedPath, approvals.file,
); defaults?.agentId,
match,
params.command,
analysis.segments[0]?.resolution?.resolvedPath,
);
}
} }
} }

View File

@@ -27,7 +27,7 @@ const callGateway = vi.fn(async (opts: { method?: string }) => {
}, },
}; };
} }
if (opts.method === "exec.approvals.get") { if (opts.method === "exec.approvals.node.get") {
return { return {
path: "/tmp/exec-approvals.json", path: "/tmp/exec-approvals.json",
exists: true, exists: true,

View File

@@ -38,6 +38,7 @@ type ExecDefaults = {
ask?: ExecAsk; ask?: ExecAsk;
node?: string; node?: string;
pathPrepend?: string[]; pathPrepend?: string[];
safeBins?: string[];
}; };
function normalizeExecSecurity(value?: string | null): ExecSecurity | null { function normalizeExecSecurity(value?: string | null): ExecSecurity | null {
@@ -95,6 +96,7 @@ function resolveExecDefaults(
ask: globalExec.ask, ask: globalExec.ask,
node: globalExec.node, node: globalExec.node,
pathPrepend: globalExec.pathPrepend, pathPrepend: globalExec.pathPrepend,
safeBins: globalExec.safeBins,
} }
: undefined; : undefined;
} }
@@ -104,6 +106,7 @@ function resolveExecDefaults(
ask: agentExec?.ask ?? globalExec?.ask, ask: agentExec?.ask ?? globalExec?.ask,
node: agentExec?.node ?? globalExec?.node, node: agentExec?.node ?? globalExec?.node,
pathPrepend: agentExec?.pathPrepend ?? globalExec?.pathPrepend, pathPrepend: agentExec?.pathPrepend ?? globalExec?.pathPrepend,
safeBins: agentExec?.safeBins ?? globalExec?.safeBins,
}; };
} }
@@ -230,7 +233,9 @@ export function registerNodesInvokeCommands(nodes: Command) {
const security = minSecurity(configuredSecurity, requestedSecurity ?? configuredSecurity); const security = minSecurity(configuredSecurity, requestedSecurity ?? configuredSecurity);
const ask = maxAsk(configuredAsk, requestedAsk ?? configuredAsk); const ask = maxAsk(configuredAsk, requestedAsk ?? configuredAsk);
const approvalsSnapshot = (await callGatewayCli("exec.approvals.get", opts, {})) as { const approvalsSnapshot = (await callGatewayCli("exec.approvals.node.get", opts, {
nodeId,
})) as {
file?: unknown; file?: unknown;
} | null; } | null;
const approvalsFile = const approvalsFile =

View File

@@ -166,6 +166,7 @@ const FIELD_LABELS: Record<string, string> = {
"tools.exec.ask": "Exec Ask", "tools.exec.ask": "Exec Ask",
"tools.exec.node": "Exec Node Binding", "tools.exec.node": "Exec Node Binding",
"tools.exec.pathPrepend": "Exec PATH Prepend", "tools.exec.pathPrepend": "Exec PATH Prepend",
"tools.exec.safeBins": "Exec Safe Bins",
"tools.message.allowCrossContextSend": "Allow Cross-Context Messaging", "tools.message.allowCrossContextSend": "Allow Cross-Context Messaging",
"tools.message.crossContext.allowWithinProvider": "Allow Cross-Context (Same Provider)", "tools.message.crossContext.allowWithinProvider": "Allow Cross-Context (Same Provider)",
"tools.message.crossContext.allowAcrossProviders": "Allow Cross-Context (Across Providers)", "tools.message.crossContext.allowAcrossProviders": "Allow Cross-Context (Across Providers)",
@@ -367,6 +368,8 @@ const FIELD_HELP: Record<string, string> = {
"tools.exec.notifyOnExit": "tools.exec.notifyOnExit":
"When true (default), backgrounded exec sessions enqueue a system event and request a heartbeat on exit.", "When true (default), backgrounded exec sessions enqueue a system event and request a heartbeat on exit.",
"tools.exec.pathPrepend": "Directories to prepend to PATH for exec runs (gateway/sandbox).", "tools.exec.pathPrepend": "Directories to prepend to PATH for exec runs (gateway/sandbox).",
"tools.exec.safeBins":
"Allow stdin-only safe binaries to run without explicit allowlist entries.",
"tools.message.allowCrossContextSend": "tools.message.allowCrossContextSend":
"Legacy override: allow cross-context sends across all providers.", "Legacy override: allow cross-context sends across all providers.",
"tools.message.crossContext.allowWithinProvider": "tools.message.crossContext.allowWithinProvider":

View File

@@ -131,6 +131,8 @@ export type ExecToolConfig = {
node?: string; node?: string;
/** Directories to prepend to PATH when running exec (gateway/sandbox). */ /** Directories to prepend to PATH when running exec (gateway/sandbox). */
pathPrepend?: string[]; pathPrepend?: string[];
/** Safe stdin-only binaries that can run without allowlist entries. */
safeBins?: string[];
/** Default time (ms) before an exec command auto-backgrounds. */ /** Default time (ms) before an exec command auto-backgrounds. */
backgroundMs?: number; backgroundMs?: number;
/** Default timeout (seconds) before auto-killing exec commands. */ /** Default timeout (seconds) before auto-killing exec commands. */

View File

@@ -257,6 +257,7 @@ export const AgentToolsSchema = z
ask: z.enum(["off", "on-miss", "always"]).optional(), ask: z.enum(["off", "on-miss", "always"]).optional(),
node: z.string().optional(), node: z.string().optional(),
pathPrepend: z.array(z.string()).optional(), pathPrepend: z.array(z.string()).optional(),
safeBins: z.array(z.string()).optional(),
backgroundMs: z.number().int().positive().optional(), backgroundMs: z.number().int().positive().optional(),
timeoutSec: z.number().int().positive().optional(), timeoutSec: z.number().int().positive().optional(),
cleanupMs: z.number().int().positive().optional(), cleanupMs: z.number().int().positive().optional(),
@@ -485,6 +486,7 @@ export const ToolsSchema = z
ask: z.enum(["off", "on-miss", "always"]).optional(), ask: z.enum(["off", "on-miss", "always"]).optional(),
node: z.string().optional(), node: z.string().optional(),
pathPrepend: z.array(z.string()).optional(), pathPrepend: z.array(z.string()).optional(),
safeBins: z.array(z.string()).optional(),
backgroundMs: z.number().int().positive().optional(), backgroundMs: z.number().int().positive().optional(),
timeoutSec: z.number().int().positive().optional(), timeoutSec: z.number().int().positive().optional(),
cleanupMs: z.number().int().positive().optional(), cleanupMs: z.number().int().positive().optional(),

View File

@@ -5,9 +5,13 @@ import path from "node:path";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { import {
analyzeArgvCommand,
analyzeShellCommand,
isSafeBinUsage,
matchAllowlist, matchAllowlist,
maxAsk, maxAsk,
minSecurity, minSecurity,
normalizeSafeBins,
resolveCommandResolution, resolveCommandResolution,
resolveExecApprovals, resolveExecApprovals,
type ExecAllowlistEntry, type ExecAllowlistEntry,
@@ -18,7 +22,7 @@ function makeTempDir() {
} }
describe("exec approvals allowlist matching", () => { describe("exec approvals allowlist matching", () => {
it("matches by executable name (case-insensitive)", () => { it("ignores basename-only patterns", () => {
const resolution = { const resolution = {
rawExecutable: "rg", rawExecutable: "rg",
resolvedPath: "/opt/homebrew/bin/rg", resolvedPath: "/opt/homebrew/bin/rg",
@@ -26,7 +30,7 @@ describe("exec approvals allowlist matching", () => {
}; };
const entries: ExecAllowlistEntry[] = [{ pattern: "RG" }]; const entries: ExecAllowlistEntry[] = [{ pattern: "RG" }];
const match = matchAllowlist(entries, resolution); const match = matchAllowlist(entries, resolution);
expect(match?.pattern).toBe("RG"); expect(match).toBeNull();
}); });
it("matches by resolved path with **", () => { it("matches by resolved path with **", () => {
@@ -51,7 +55,7 @@ describe("exec approvals allowlist matching", () => {
expect(match).toBeNull(); expect(match).toBeNull();
}); });
it("falls back to raw executable when no resolved path", () => { it("requires a resolved path", () => {
const resolution = { const resolution = {
rawExecutable: "bin/rg", rawExecutable: "bin/rg",
resolvedPath: undefined, resolvedPath: undefined,
@@ -59,7 +63,7 @@ describe("exec approvals allowlist matching", () => {
}; };
const entries: ExecAllowlistEntry[] = [{ pattern: "bin/rg" }]; const entries: ExecAllowlistEntry[] = [{ pattern: "bin/rg" }];
const match = matchAllowlist(entries, resolution); const match = matchAllowlist(entries, resolution);
expect(match?.pattern).toBe("bin/rg"); expect(match).toBeNull();
}); });
}); });
@@ -70,6 +74,7 @@ describe("exec approvals command resolution", () => {
fs.mkdirSync(binDir, { recursive: true }); fs.mkdirSync(binDir, { recursive: true });
const exe = path.join(binDir, "rg"); const exe = path.join(binDir, "rg");
fs.writeFileSync(exe, ""); fs.writeFileSync(exe, "");
fs.chmodSync(exe, 0o755);
const res = resolveCommandResolution("rg -n foo", undefined, { PATH: binDir }); const res = resolveCommandResolution("rg -n foo", undefined, { PATH: binDir });
expect(res?.resolvedPath).toBe(exe); expect(res?.resolvedPath).toBe(exe);
expect(res?.executableName).toBe("rg"); expect(res?.executableName).toBe("rg");
@@ -81,6 +86,7 @@ describe("exec approvals command resolution", () => {
const script = path.join(cwd, "scripts", "run.sh"); const script = path.join(cwd, "scripts", "run.sh");
fs.mkdirSync(path.dirname(script), { recursive: true }); fs.mkdirSync(path.dirname(script), { recursive: true });
fs.writeFileSync(script, ""); fs.writeFileSync(script, "");
fs.chmodSync(script, 0o755);
const res = resolveCommandResolution("./scripts/run.sh --flag", cwd, undefined); const res = resolveCommandResolution("./scripts/run.sh --flag", cwd, undefined);
expect(res?.resolvedPath).toBe(script); expect(res?.resolvedPath).toBe(script);
}); });
@@ -91,11 +97,81 @@ describe("exec approvals command resolution", () => {
const script = path.join(cwd, "bin", "tool"); const script = path.join(cwd, "bin", "tool");
fs.mkdirSync(path.dirname(script), { recursive: true }); fs.mkdirSync(path.dirname(script), { recursive: true });
fs.writeFileSync(script, ""); fs.writeFileSync(script, "");
fs.chmodSync(script, 0o755);
const res = resolveCommandResolution('"./bin/tool" --version', cwd, undefined); const res = resolveCommandResolution('"./bin/tool" --version', cwd, undefined);
expect(res?.resolvedPath).toBe(script); expect(res?.resolvedPath).toBe(script);
}); });
}); });
describe("exec approvals shell parsing", () => {
it("parses simple pipelines", () => {
const res = analyzeShellCommand({ command: "echo ok | jq .foo" });
expect(res.ok).toBe(true);
expect(res.segments.map((seg) => seg.argv[0])).toEqual(["echo", "jq"]);
});
it("rejects chained commands", () => {
const res = analyzeShellCommand({ command: "ls && rm -rf /" });
expect(res.ok).toBe(false);
});
it("parses argv commands", () => {
const res = analyzeArgvCommand({ argv: ["/bin/echo", "ok"] });
expect(res.ok).toBe(true);
expect(res.segments[0]?.argv).toEqual(["/bin/echo", "ok"]);
});
});
describe("exec approvals safe bins", () => {
it("allows safe bins with non-path args", () => {
const dir = makeTempDir();
const binDir = path.join(dir, "bin");
fs.mkdirSync(binDir, { recursive: true });
const exe = path.join(binDir, "jq");
fs.writeFileSync(exe, "");
fs.chmodSync(exe, 0o755);
const res = analyzeShellCommand({
command: "jq .foo",
cwd: dir,
env: { PATH: binDir },
});
expect(res.ok).toBe(true);
const segment = res.segments[0];
const ok = isSafeBinUsage({
argv: segment.argv,
resolution: segment.resolution,
safeBins: normalizeSafeBins(["jq"]),
cwd: dir,
});
expect(ok).toBe(true);
});
it("blocks safe bins with file args", () => {
const dir = makeTempDir();
const binDir = path.join(dir, "bin");
fs.mkdirSync(binDir, { recursive: true });
const exe = path.join(binDir, "jq");
fs.writeFileSync(exe, "");
fs.chmodSync(exe, 0o755);
const file = path.join(dir, "secret.json");
fs.writeFileSync(file, "{}");
const res = analyzeShellCommand({
command: "jq .foo secret.json",
cwd: dir,
env: { PATH: binDir },
});
expect(res.ok).toBe(true);
const segment = res.segments[0];
const ok = isSafeBinUsage({
argv: segment.argv,
resolution: segment.resolution,
safeBins: normalizeSafeBins(["jq"]),
cwd: dir,
});
expect(ok).toBe(false);
});
});
describe("exec approvals policy helpers", () => { describe("exec approvals policy helpers", () => {
it("minSecurity returns the more restrictive value", () => { it("minSecurity returns the more restrictive value", () => {
expect(minSecurity("deny", "full")).toBe("deny"); expect(minSecurity("deny", "full")).toBe("deny");

View File

@@ -60,6 +60,7 @@ const DEFAULT_ASK_FALLBACK: ExecSecurity = "deny";
const DEFAULT_AUTO_ALLOW_SKILLS = false; const DEFAULT_AUTO_ALLOW_SKILLS = false;
const DEFAULT_SOCKET = "~/.clawdbot/exec-approvals.sock"; const DEFAULT_SOCKET = "~/.clawdbot/exec-approvals.sock";
const DEFAULT_FILE = "~/.clawdbot/exec-approvals.json"; const DEFAULT_FILE = "~/.clawdbot/exec-approvals.json";
export const DEFAULT_SAFE_BINS = ["jq", "grep", "cut", "sort", "uniq", "head", "tail", "tr", "wc"];
function hashExecApprovalsRaw(raw: string | null): string { function hashExecApprovalsRaw(raw: string | null): string {
return crypto return crypto
@@ -283,6 +284,19 @@ type CommandResolution = {
executableName: string; executableName: string;
}; };
function isExecutableFile(filePath: string): boolean {
try {
const stat = fs.statSync(filePath);
if (!stat.isFile()) return false;
if (process.platform !== "win32") {
fs.accessSync(filePath, fs.constants.X_OK);
}
return true;
} catch {
return false;
}
}
function parseFirstToken(command: string): string | null { function parseFirstToken(command: string): string | null {
const trimmed = command.trim(); const trimmed = command.trim();
if (!trimmed) return null; if (!trimmed) return null;
@@ -299,15 +313,26 @@ function parseFirstToken(command: string): string | null {
function resolveExecutablePath(rawExecutable: string, cwd?: string, env?: NodeJS.ProcessEnv) { function resolveExecutablePath(rawExecutable: string, cwd?: string, env?: NodeJS.ProcessEnv) {
const expanded = rawExecutable.startsWith("~") ? expandHome(rawExecutable) : rawExecutable; const expanded = rawExecutable.startsWith("~") ? expandHome(rawExecutable) : rawExecutable;
if (expanded.includes("/") || expanded.includes("\\")) { if (expanded.includes("/") || expanded.includes("\\")) {
if (path.isAbsolute(expanded)) return expanded; if (path.isAbsolute(expanded)) {
return isExecutableFile(expanded) ? expanded : undefined;
}
const base = cwd && cwd.trim() ? cwd.trim() : process.cwd(); const base = cwd && cwd.trim() ? cwd.trim() : process.cwd();
return path.resolve(base, expanded); const candidate = path.resolve(base, expanded);
return isExecutableFile(candidate) ? candidate : undefined;
} }
const envPath = env?.PATH ?? process.env.PATH ?? ""; const envPath = env?.PATH ?? process.env.PATH ?? "";
const entries = envPath.split(path.delimiter).filter(Boolean); const entries = envPath.split(path.delimiter).filter(Boolean);
const extensions =
process.platform === "win32"
? (env?.PATHEXT ?? process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM")
.split(";")
.map((ext) => ext.toLowerCase())
: [""];
for (const entry of entries) { for (const entry of entries) {
const candidate = path.join(entry, expanded); for (const ext of extensions) {
if (fs.existsSync(candidate)) return candidate; const candidate = path.join(entry, expanded + ext);
if (isExecutableFile(candidate)) return candidate;
}
} }
return undefined; return undefined;
} }
@@ -324,6 +349,18 @@ export function resolveCommandResolution(
return { rawExecutable, resolvedPath, executableName }; return { rawExecutable, resolvedPath, executableName };
} }
export function resolveCommandResolutionFromArgv(
argv: string[],
cwd?: string,
env?: NodeJS.ProcessEnv,
): CommandResolution | null {
const rawExecutable = argv[0]?.trim();
if (!rawExecutable) return null;
const resolvedPath = resolveExecutablePath(rawExecutable, cwd, env);
const executableName = resolvedPath ? path.basename(resolvedPath) : rawExecutable;
return { rawExecutable, resolvedPath, executableName };
}
function normalizeMatchTarget(value: string): string { function normalizeMatchTarget(value: string): string {
return value.replace(/\\\\/g, "/").toLowerCase(); return value.replace(/\\\\/g, "/").toLowerCase();
} }
@@ -370,24 +407,284 @@ export function matchAllowlist(
entries: ExecAllowlistEntry[], entries: ExecAllowlistEntry[],
resolution: CommandResolution | null, resolution: CommandResolution | null,
): ExecAllowlistEntry | null { ): ExecAllowlistEntry | null {
if (!entries.length || !resolution) return null; if (!entries.length || !resolution?.resolvedPath) return null;
const rawExecutable = resolution.rawExecutable;
const resolvedPath = resolution.resolvedPath; const resolvedPath = resolution.resolvedPath;
const executableName = resolution.executableName;
for (const entry of entries) { for (const entry of entries) {
const pattern = entry.pattern?.trim(); const pattern = entry.pattern?.trim();
if (!pattern) continue; if (!pattern) continue;
const hasPath = pattern.includes("/") || pattern.includes("\\") || pattern.includes("~"); const hasPath = pattern.includes("/") || pattern.includes("\\") || pattern.includes("~");
if (hasPath) { if (!hasPath) continue;
const target = resolvedPath ?? rawExecutable; if (matchesPattern(pattern, resolvedPath)) return entry;
if (target && matchesPattern(pattern, target)) return entry;
continue;
}
if (executableName && matchesPattern(pattern, executableName)) return entry;
} }
return null; return null;
} }
export type ExecCommandSegment = {
raw: string;
argv: string[];
resolution: CommandResolution | null;
};
export type ExecCommandAnalysis = {
ok: boolean;
reason?: string;
segments: ExecCommandSegment[];
};
const DISALLOWED_TOKENS = new Set([";", "&", ">", "<", "`", "\n", "\r", "(", ")"]);
function splitShellPipeline(command: string): { ok: boolean; reason?: string; segments: string[] } {
const segments: string[] = [];
let buf = "";
let inSingle = false;
let inDouble = false;
let escaped = false;
const pushSegment = () => {
const trimmed = buf.trim();
if (!trimmed) {
return false;
}
segments.push(trimmed);
buf = "";
return true;
};
for (let i = 0; i < command.length; i += 1) {
const ch = command[i];
if (escaped) {
buf += ch;
escaped = false;
continue;
}
if (!inSingle && ch === "\\") {
escaped = true;
buf += ch;
continue;
}
if (inSingle) {
if (ch === "'") inSingle = false;
buf += ch;
continue;
}
if (inDouble) {
if (ch === '"') inDouble = false;
buf += ch;
continue;
}
if (ch === "'") {
inSingle = true;
buf += ch;
continue;
}
if (ch === '"') {
inDouble = true;
buf += ch;
continue;
}
if (ch === "|" && command[i + 1] === "|") {
return { ok: false, reason: "unsupported shell token: ||", segments: [] };
}
if (ch === "|" && command[i + 1] === "&") {
return { ok: false, reason: "unsupported shell token: |&", segments: [] };
}
if (ch === "|") {
if (!pushSegment()) {
return { ok: false, reason: "empty pipeline segment", segments: [] };
}
continue;
}
if (DISALLOWED_TOKENS.has(ch)) {
return { ok: false, reason: `unsupported shell token: ${ch}`, segments: [] };
}
if (ch === "$" && command[i + 1] === "(") {
return { ok: false, reason: "unsupported shell token: $()", segments: [] };
}
buf += ch;
}
if (escaped || inSingle || inDouble) {
return { ok: false, reason: "unterminated shell quote/escape", segments: [] };
}
if (!pushSegment()) {
return { ok: false, reason: "empty command", segments: [] };
}
return { ok: true, segments };
}
function tokenizeShellSegment(segment: string): string[] | null {
const tokens: string[] = [];
let buf = "";
let inSingle = false;
let inDouble = false;
let escaped = false;
const pushToken = () => {
if (buf.length > 0) {
tokens.push(buf);
buf = "";
}
};
for (let i = 0; i < segment.length; i += 1) {
const ch = segment[i];
if (escaped) {
buf += ch;
escaped = false;
continue;
}
if (!inSingle && ch === "\\") {
escaped = true;
continue;
}
if (inSingle) {
if (ch === "'") {
inSingle = false;
} else {
buf += ch;
}
continue;
}
if (inDouble) {
if (ch === '"') {
inDouble = false;
} else {
buf += ch;
}
continue;
}
if (ch === "'") {
inSingle = true;
continue;
}
if (ch === '"') {
inDouble = true;
continue;
}
if (/\s/.test(ch)) {
pushToken();
continue;
}
buf += ch;
}
if (escaped || inSingle || inDouble) {
return null;
}
pushToken();
return tokens;
}
export function analyzeShellCommand(params: {
command: string;
cwd?: string;
env?: NodeJS.ProcessEnv;
}): ExecCommandAnalysis {
const split = splitShellPipeline(params.command);
if (!split.ok) {
return { ok: false, reason: split.reason, segments: [] };
}
const segments: ExecCommandSegment[] = [];
for (const raw of split.segments) {
const argv = tokenizeShellSegment(raw);
if (!argv || argv.length === 0) {
return { ok: false, reason: "unable to parse shell segment", segments: [] };
}
segments.push({
raw,
argv,
resolution: resolveCommandResolutionFromArgv(argv, params.cwd, params.env),
});
}
return { ok: true, segments };
}
export function analyzeArgvCommand(params: {
argv: string[];
cwd?: string;
env?: NodeJS.ProcessEnv;
}): ExecCommandAnalysis {
const argv = params.argv.filter((entry) => entry.trim().length > 0);
if (argv.length === 0) {
return { ok: false, reason: "empty argv", segments: [] };
}
return {
ok: true,
segments: [
{
raw: argv.join(" "),
argv,
resolution: resolveCommandResolutionFromArgv(argv, params.cwd, params.env),
},
],
};
}
function isPathLikeToken(value: string): boolean {
const trimmed = value.trim();
if (!trimmed) return false;
if (trimmed === "-") return false;
if (trimmed.startsWith("./") || trimmed.startsWith("../") || trimmed.startsWith("~")) return true;
if (trimmed.startsWith("/")) return true;
return /^[A-Za-z]:[\\/]/.test(trimmed);
}
function defaultFileExists(filePath: string): boolean {
try {
return fs.existsSync(filePath);
} catch {
return false;
}
}
export function normalizeSafeBins(entries?: string[]): Set<string> {
if (!Array.isArray(entries)) return new Set();
const normalized = entries
.map((entry) => entry.trim().toLowerCase())
.filter((entry) => entry.length > 0);
return new Set(normalized);
}
export function resolveSafeBins(entries?: string[] | null): Set<string> {
if (entries === undefined) return normalizeSafeBins(DEFAULT_SAFE_BINS);
return normalizeSafeBins(entries ?? []);
}
export function isSafeBinUsage(params: {
argv: string[];
resolution: CommandResolution | null;
safeBins: Set<string>;
cwd?: string;
fileExists?: (filePath: string) => boolean;
}): boolean {
if (params.safeBins.size === 0) return false;
const resolution = params.resolution;
const execName = resolution?.executableName?.toLowerCase();
if (!execName || !params.safeBins.has(execName)) return false;
if (!resolution?.resolvedPath) return false;
const cwd = params.cwd ?? process.cwd();
const exists = params.fileExists ?? defaultFileExists;
const argv = params.argv.slice(1);
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i];
if (!token) continue;
if (token === "-") continue;
if (token.startsWith("-")) {
const eqIndex = token.indexOf("=");
if (eqIndex > 0) {
const value = token.slice(eqIndex + 1);
if (value && (isPathLikeToken(value) || exists(path.resolve(cwd, value)))) {
return false;
}
}
continue;
}
if (isPathLikeToken(token)) return false;
if (exists(path.resolve(cwd, token))) return false;
}
return true;
}
export function recordAllowlistUse( export function recordAllowlistUse(
approvals: ExecApprovalsFile, approvals: ExecApprovalsFile,
agentId: string | undefined, agentId: string | undefined,

View File

@@ -4,12 +4,16 @@ import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { import {
type ExecAllowlistEntry,
addAllowlistEntry, addAllowlistEntry,
analyzeArgvCommand,
analyzeShellCommand,
isSafeBinUsage,
matchAllowlist, matchAllowlist,
normalizeExecApprovals, normalizeExecApprovals,
recordAllowlistUse, recordAllowlistUse,
resolveCommandResolution,
resolveExecApprovals, resolveExecApprovals,
resolveSafeBins,
ensureExecApprovals, ensureExecApprovals,
readExecApprovalsSnapshot, readExecApprovalsSnapshot,
resolveExecApprovalsSocketPath, resolveExecApprovalsSocketPath,
@@ -25,6 +29,7 @@ import {
import { getMachineDisplayName } from "../infra/machine-name.js"; import { getMachineDisplayName } from "../infra/machine-name.js";
import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js"; import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
import { loadConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js";
import { resolveAgentConfig } from "../agents/agent-scope.js";
import { ensureClawdbotCliOnPath } from "../infra/path-env.js"; import { ensureClawdbotCliOnPath } from "../infra/path-env.js";
import { VERSION } from "../version.js"; import { VERSION } from "../version.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
@@ -110,7 +115,6 @@ const execHostFallbackAllowed =
process.env.CLAWDBOT_NODE_EXEC_FALLBACK?.trim().toLowerCase() !== "0"; process.env.CLAWDBOT_NODE_EXEC_FALLBACK?.trim().toLowerCase() !== "0";
const blockedEnvKeys = new Set([ const blockedEnvKeys = new Set([
"PATH",
"NODE_OPTIONS", "NODE_OPTIONS",
"PYTHONHOME", "PYTHONHOME",
"PYTHONPATH", "PYTHONPATH",
@@ -156,10 +160,24 @@ function sanitizeEnv(
): Record<string, string> | undefined { ): Record<string, string> | undefined {
if (!overrides) return undefined; if (!overrides) return undefined;
const merged = { ...process.env } as Record<string, string>; const merged = { ...process.env } as Record<string, string>;
const basePath = process.env.PATH ?? DEFAULT_NODE_PATH;
for (const [rawKey, value] of Object.entries(overrides)) { for (const [rawKey, value] of Object.entries(overrides)) {
const key = rawKey.trim(); const key = rawKey.trim();
if (!key) continue; if (!key) continue;
const upper = key.toUpperCase(); const upper = key.toUpperCase();
if (upper === "PATH") {
const trimmed = value.trim();
if (!trimmed) continue;
if (!basePath || trimmed === basePath) {
merged[key] = trimmed;
continue;
}
const suffix = `${path.delimiter}${basePath}`;
if (trimmed.endsWith(suffix)) {
merged[key] = trimmed;
}
continue;
}
if (blockedEnvKeys.has(upper)) continue; if (blockedEnvKeys.has(upper)) continue;
if (blockedEnvPrefixes.some((prefix) => upper.startsWith(prefix))) continue; if (blockedEnvPrefixes.some((prefix) => upper.startsWith(prefix))) continue;
merged[key] = value; merged[key] = value;
@@ -567,12 +585,32 @@ async function handleInvoke(
const sessionKey = params.sessionKey?.trim() || "node"; const sessionKey = params.sessionKey?.trim() || "node";
const runId = crypto.randomUUID(); const runId = crypto.randomUUID();
const env = sanitizeEnv(params.env ?? undefined); const env = sanitizeEnv(params.env ?? undefined);
const resolution = resolveCommandResolution(cmdText, params.cwd ?? undefined, env); const analysis = rawCommand
const allowlistMatch = ? analyzeShellCommand({ command: rawCommand, cwd: params.cwd ?? undefined, env })
security === "allowlist" ? matchAllowlist(approvals.allowlist, resolution) : null; : analyzeArgvCommand({ argv, cwd: params.cwd ?? undefined, env });
const cfg = loadConfig();
const agentExec = resolveAgentConfig(cfg, agentId)?.tools?.exec;
const safeBins = resolveSafeBins(agentExec?.safeBins ?? cfg.tools?.exec?.safeBins);
const allowlistMatches: ExecAllowlistEntry[] = [];
const bins = autoAllowSkills ? await skillBins.current() : new Set<string>(); const bins = autoAllowSkills ? await skillBins.current() : new Set<string>();
const skillAllow = const allowlistSatisfied =
autoAllowSkills && resolution?.executableName ? bins.has(resolution.executableName) : false; security === "allowlist" && analysis.ok && analysis.segments.length > 0
? analysis.segments.every((segment) => {
const match = matchAllowlist(approvals.allowlist, segment.resolution);
if (match) allowlistMatches.push(match);
const safe = isSafeBinUsage({
argv: segment.argv,
resolution: segment.resolution,
safeBins,
cwd: params.cwd ?? undefined,
});
const skillAllow =
autoAllowSkills && segment.resolution?.executableName
? bins.has(segment.resolution.executableName)
: false;
return Boolean(match || safe || skillAllow);
})
: false;
const useMacAppExec = process.platform === "darwin"; const useMacAppExec = process.platform === "darwin";
if (useMacAppExec) { if (useMacAppExec) {
@@ -678,7 +716,7 @@ async function handleInvoke(
const requiresAsk = const requiresAsk =
ask === "always" || ask === "always" ||
(ask === "on-miss" && security === "allowlist" && !allowlistMatch && !skillAllow); (ask === "on-miss" && security === "allowlist" && (!analysis.ok || !allowlistSatisfied));
const approvalDecision = const approvalDecision =
params.approvalDecision === "allow-once" || params.approvalDecision === "allow-always" params.approvalDecision === "allow-once" || params.approvalDecision === "allow-always"
@@ -704,11 +742,15 @@ async function handleInvoke(
return; return;
} }
if (approvalDecision === "allow-always" && security === "allowlist") { if (approvalDecision === "allow-always" && security === "allowlist") {
const pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? argv[0] ?? ""; if (analysis.ok) {
if (pattern) addAllowlistEntry(approvals.file, agentId, pattern); for (const segment of analysis.segments) {
const pattern = segment.resolution?.resolvedPath ?? "";
if (pattern) addAllowlistEntry(approvals.file, agentId, pattern);
}
}
} }
if (security === "allowlist" && !allowlistMatch && !skillAllow && !approvedByAsk) { if (security === "allowlist" && (!analysis.ok || !allowlistSatisfied) && !approvedByAsk) {
await sendNodeEvent( await sendNodeEvent(
client, client,
"exec.denied", "exec.denied",
@@ -727,8 +769,19 @@ async function handleInvoke(
return; return;
} }
if (allowlistMatch) { if (allowlistMatches.length > 0) {
recordAllowlistUse(approvals.file, agentId, allowlistMatch, cmdText, resolution?.resolvedPath); const seen = new Set<string>();
for (const match of allowlistMatches) {
if (!match?.pattern || seen.has(match.pattern)) continue;
seen.add(match.pattern);
recordAllowlistUse(
approvals.file,
agentId,
match,
cmdText,
analysis.segments[0]?.resolution?.resolvedPath,
);
}
} }
if (params.needsScreenRecording === true) { if (params.needsScreenRecording === true) {