diff --git a/CHANGELOG.md b/CHANGELOG.md index 2691cde9b..acb3cb33a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.clawd.bot - CLI: exec approvals mutations render tables instead of raw JSON. - Exec approvals: support wildcard agent allowlists (`*`) across all agents. - Nodes: expose node PATH in status/describe and bootstrap PATH for node-host execution. +- Nodes: run always uses exec approvals + defaults, with raw shell mode and ask/security overrides. https://docs.clawd.bot/cli/nodes - 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. - Sessions: add per-channel reset overrides via `session.resetByChannel`. (#1353) Thanks @cash-echo-bot. diff --git a/docs/cli/nodes.md b/docs/cli/nodes.md index b76bf4d36..4064006ee 100644 --- a/docs/cli/nodes.md +++ b/docs/cli/nodes.md @@ -36,4 +36,19 @@ filter to nodes that connected within a duration (e.g. `24h`, `7d`). ```bash clawdbot nodes invoke --node --command --params clawdbot nodes run --node +clawdbot nodes run --raw "git status" +clawdbot nodes run --agent main --node --raw "git status" ``` + +### Exec-style defaults + +`nodes run` mirrors the model’s exec behavior (defaults + approvals): + +- Reads `tools.exec.*` (plus `agents.list[].tools.exec.*` overrides). +- Uses exec approvals (`exec.approval.request`) before invoking `system.run`. +- `--node` can be omitted when `tools.exec.node` is set. + +Flags: +- `--raw `: run a shell string (`/bin/sh -lc` or `cmd.exe /c`). +- `--agent `: agent-scoped approvals/allowlists (defaults to configured agent). +- `--ask `, `--security `: overrides. diff --git a/src/cli/nodes-cli.coverage.test.ts b/src/cli/nodes-cli.coverage.test.ts index 8c7af5c03..7e6e482dd 100644 --- a/src/cli/nodes-cli.coverage.test.ts +++ b/src/cli/nodes-cli.coverage.test.ts @@ -27,6 +27,25 @@ const callGateway = vi.fn(async (opts: { method?: string }) => { }, }; } + if (opts.method === "exec.approvals.get") { + return { + path: "/tmp/exec-approvals.json", + exists: true, + hash: "hash", + file: { + version: 1, + defaults: { + security: "allowlist", + ask: "on-miss", + askFallback: "deny", + }, + agents: {}, + }, + }; + } + if (opts.method === "exec.approval.request") { + return { decision: "allow-once" }; + } return { ok: true }; }); @@ -51,6 +70,10 @@ vi.mock("../runtime.js", () => ({ defaultRuntime, })); +vi.mock("../config/config.js", () => ({ + loadConfig: () => ({}), +})); + describe("nodes-cli coverage", () => { it("lists nodes via node.list", async () => { runtimeLogs.length = 0; @@ -112,10 +135,43 @@ describe("nodes-cli coverage", () => { env: { FOO: "bar" }, timeoutMs: 1200, needsScreenRecording: true, + agentId: "main", + approved: true, + approvalDecision: "allow-once", }); expect(invoke?.params?.timeoutMs).toBe(5000); }); + it("invokes system.run with raw command", async () => { + runtimeLogs.length = 0; + runtimeErrors.length = 0; + callGateway.mockClear(); + randomIdempotencyKey.mockClear(); + + const { registerNodesCli } = await import("./nodes-cli.js"); + const program = new Command(); + program.exitOverride(); + registerNodesCli(program); + + await program.parseAsync( + ["nodes", "run", "--agent", "main", "--node", "mac-1", "--raw", "echo hi"], + { from: "user" }, + ); + + const invoke = callGateway.mock.calls.find((call) => call[0]?.method === "node.invoke")?.[0]; + + expect(invoke).toBeTruthy(); + expect(invoke?.params?.idempotencyKey).toBe("rk_test"); + expect(invoke?.params?.command).toBe("system.run"); + expect(invoke?.params?.params).toMatchObject({ + command: ["/bin/sh", "-lc", "echo hi"], + rawCommand: "echo hi", + agentId: "main", + approved: true, + approvalDecision: "allow-once", + }); + }); + it("invokes system.notify with provided fields", async () => { runtimeLogs.length = 0; runtimeErrors.length = 0; diff --git a/src/cli/nodes-cli/register.invoke.ts b/src/cli/nodes-cli/register.invoke.ts index 940e579a3..9e893b9f5 100644 --- a/src/cli/nodes-cli/register.invoke.ts +++ b/src/cli/nodes-cli/register.invoke.ts @@ -1,10 +1,122 @@ +import path from "node:path"; import type { Command } from "commander"; import { randomIdempotencyKey } from "../../gateway/call.js"; import { defaultRuntime } from "../../runtime.js"; import { parseEnvPairs, parseTimeoutMs } from "../nodes-run.js"; import { getNodesTheme, runNodesCommand } from "./cli-utils.js"; +import { parseNodeList } from "./format.js"; import { callGatewayCli, nodesCallOpts, resolveNodeId, unauthorizedHintForMessage } from "./rpc.js"; import type { NodesRpcOpts } from "./types.js"; +import { loadConfig } from "../../config/config.js"; +import { resolveAgentConfig, resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { + type ExecApprovalsFile, + type ExecAsk, + type ExecSecurity, + maxAsk, + minSecurity, + resolveExecApprovalsFromFile, +} from "../../infra/exec-approvals.js"; +import { buildNodeShellCommand } from "../../infra/node-shell.js"; + +type NodesRunOpts = NodesRpcOpts & { + node?: string; + cwd?: string; + env?: string[]; + commandTimeout?: string; + needsScreenRecording?: boolean; + invokeTimeout?: string; + idempotencyKey?: string; + agent?: string; + ask?: string; + security?: string; + raw?: string; +}; + +type ExecDefaults = { + security?: ExecSecurity; + ask?: ExecAsk; + node?: string; + pathPrepend?: string[]; +}; + +function normalizeExecSecurity(value?: string | null): ExecSecurity | null { + const normalized = value?.trim().toLowerCase(); + if (normalized === "deny" || normalized === "allowlist" || normalized === "full") { + return normalized; + } + return null; +} + +function normalizeExecAsk(value?: string | null): ExecAsk | null { + const normalized = value?.trim().toLowerCase(); + if (normalized === "off" || normalized === "on-miss" || normalized === "always") { + return normalized as ExecAsk; + } + return null; +} + +function mergePathPrepend(existing: string | undefined, prepend: string[]) { + if (prepend.length === 0) return existing; + const partsExisting = (existing ?? "") + .split(path.delimiter) + .map((part) => part.trim()) + .filter(Boolean); + const merged: string[] = []; + const seen = new Set(); + for (const part of [...prepend, ...partsExisting]) { + if (seen.has(part)) continue; + seen.add(part); + merged.push(part); + } + return merged.join(path.delimiter); +} + +function applyPathPrepend( + env: Record, + prepend: string[] | undefined, + options?: { requireExisting?: boolean }, +) { + if (!Array.isArray(prepend) || prepend.length === 0) return; + if (options?.requireExisting && !env.PATH) return; + const merged = mergePathPrepend(env.PATH, prepend); + if (merged) env.PATH = merged; +} + +function resolveExecDefaults( + cfg: ReturnType | undefined, + agentId: string | undefined, +): ExecDefaults | undefined { + const globalExec = cfg?.tools?.exec; + if (!agentId) { + return globalExec + ? { + security: globalExec.security, + ask: globalExec.ask, + node: globalExec.node, + pathPrepend: globalExec.pathPrepend, + } + : undefined; + } + const agentExec = resolveAgentConfig(cfg, agentId)?.tools?.exec; + return { + security: agentExec?.security ?? globalExec?.security, + ask: agentExec?.ask ?? globalExec?.ask, + node: agentExec?.node ?? globalExec?.node, + pathPrepend: agentExec?.pathPrepend ?? globalExec?.pathPrepend, + }; +} + +async function resolveNodePlatform(opts: NodesRpcOpts, nodeId: string): Promise { + try { + const res = (await callGatewayCli("node.list", opts, {})) as unknown; + const nodes = parseNodeList(res); + const match = nodes.find((node) => node.nodeId === nodeId); + return typeof match?.platform === "string" ? match.platform : null; + } catch { + return null; + } +} export function registerNodesInvokeCommands(nodes: Command) { nodesCallOpts( @@ -52,39 +164,157 @@ export function registerNodesInvokeCommands(nodes: Command) { nodes .command("run") .description("Run a shell command on a node (mac only)") - .requiredOption("--node ", "Node id, name, or IP") + .option("--node ", "Node id, name, or IP") .option("--cwd ", "Working directory") .option( "--env ", "Environment override (repeatable)", (value: string, prev: string[] = []) => [...prev, value], ) + .option("--raw ", "Run a raw shell command string (sh -lc / cmd.exe /c)") + .option("--agent ", "Agent id (default: configured default agent)") + .option("--ask ", "Exec ask mode (off|on-miss|always)") + .option("--security ", "Exec security mode (deny|allowlist|full)") .option("--command-timeout ", "Command timeout (ms)") .option("--needs-screen-recording", "Require screen recording permission") .option("--invoke-timeout ", "Node invoke timeout in ms (default 30000)", "30000") - .argument("", "Command and args") - .action(async (command: string[], opts: NodesRpcOpts) => { + .argument("[command...]", "Command and args") + .action(async (command: string[], opts: NodesRunOpts) => { await runNodesCommand("run", async () => { - const nodeId = await resolveNodeId(opts, String(opts.node ?? "")); - if (!Array.isArray(command) || command.length === 0) { + const cfg = loadConfig(); + const agentId = opts.agent?.trim() || resolveDefaultAgentId(cfg); + const execDefaults = resolveExecDefaults(cfg, agentId); + const raw = typeof opts.raw === "string" ? opts.raw.trim() : ""; + if (raw && Array.isArray(command) && command.length > 0) { + throw new Error("use --raw or argv, not both"); + } + if (!raw && (!Array.isArray(command) || command.length === 0)) { throw new Error("command required"); } + + const nodeQuery = String(opts.node ?? "").trim() || execDefaults?.node?.trim() || ""; + if (!nodeQuery) { + throw new Error("node required (set --node or tools.exec.node)"); + } + const nodeId = await resolveNodeId(opts, nodeQuery); + const env = parseEnvPairs(opts.env); const timeoutMs = parseTimeoutMs(opts.commandTimeout); const invokeTimeout = parseTimeoutMs(opts.invokeTimeout); + let argv = Array.isArray(command) ? command : []; + let rawCommand: string | undefined; + if (raw) { + rawCommand = raw; + const platform = await resolveNodePlatform(opts, nodeId); + argv = buildNodeShellCommand(rawCommand, platform ?? undefined); + } + + const nodeEnv = env ? { ...env } : undefined; + if (nodeEnv) { + applyPathPrepend(nodeEnv, execDefaults?.pathPrepend, { requireExisting: true }); + } + + let approvedByAsk = false; + let approvalDecision: "allow-once" | "allow-always" | null = null; + const configuredSecurity = normalizeExecSecurity(execDefaults?.security) ?? "allowlist"; + const requestedSecurity = normalizeExecSecurity(opts.security); + if (opts.security && !requestedSecurity) { + throw new Error("invalid --security (use deny|allowlist|full)"); + } + const configuredAsk = normalizeExecAsk(execDefaults?.ask) ?? "on-miss"; + const requestedAsk = normalizeExecAsk(opts.ask); + if (opts.ask && !requestedAsk) { + throw new Error("invalid --ask (use off|on-miss|always)"); + } + const security = minSecurity(configuredSecurity, requestedSecurity ?? configuredSecurity); + const ask = maxAsk(configuredAsk, requestedAsk ?? configuredAsk); + + const approvalsSnapshot = (await callGatewayCli("exec.approvals.get", opts, {})) as { + file?: unknown; + } | null; + const approvalsFile = + approvalsSnapshot && typeof approvalsSnapshot === "object" + ? approvalsSnapshot.file + : undefined; + if (!approvalsFile || typeof approvalsFile !== "object") { + throw new Error("exec approvals unavailable"); + } + const approvals = resolveExecApprovalsFromFile({ + file: approvalsFile as ExecApprovalsFile, + agentId, + overrides: { security: "allowlist" }, + }); + const hostSecurity = minSecurity(security, approvals.agent.security); + const hostAsk = maxAsk(ask, approvals.agent.ask); + const askFallback = approvals.agent.askFallback; + + if (hostSecurity === "deny") { + throw new Error("exec denied: host=node security=deny"); + } + + const requiresAsk = hostAsk === "always" || hostAsk === "on-miss"; + if (requiresAsk) { + const decisionResult = (await callGatewayCli("exec.approval.request", opts, { + command: rawCommand ?? argv.join(" "), + cwd: opts.cwd, + host: "node", + security: hostSecurity, + ask: hostAsk, + agentId, + resolvedPath: null, + sessionKey: null, + timeoutMs: 120_000, + })) as { decision?: string } | null; + const decision = + decisionResult && typeof decisionResult === "object" + ? (decisionResult.decision ?? null) + : null; + if (decision === "deny") { + throw new Error("exec denied: user denied"); + } + if (!decision) { + if (askFallback === "full") { + approvedByAsk = true; + approvalDecision = "allow-once"; + } else if (askFallback === "allowlist") { + // defer allowlist enforcement to node host + } else { + throw new Error("exec denied: approval required (approval UI not available)"); + } + } + if (decision === "allow-once") { + approvedByAsk = true; + approvalDecision = "allow-once"; + } + if (decision === "allow-always") { + approvedByAsk = true; + approvalDecision = "allow-always"; + } + } + const invokeParams: Record = { nodeId, command: "system.run", params: { - command, + command: argv, cwd: opts.cwd, - env, + env: nodeEnv, timeoutMs, needsScreenRecording: opts.needsScreenRecording === true, }, idempotencyKey: String(opts.idempotencyKey ?? randomIdempotencyKey()), }; + if (agentId) { + (invokeParams.params as Record).agentId = agentId; + } + if (rawCommand) { + (invokeParams.params as Record).rawCommand = rawCommand; + } + (invokeParams.params as Record).approved = approvedByAsk; + if (approvalDecision) { + (invokeParams.params as Record).approvalDecision = approvalDecision; + } if (invokeTimeout !== undefined) { invokeParams.timeoutMs = invokeTimeout; } diff --git a/src/infra/exec-approvals.ts b/src/infra/exec-approvals.ts index 5dd5fe26a..ad4846e9c 100644 --- a/src/infra/exec-approvals.ts +++ b/src/infra/exec-approvals.ts @@ -210,14 +210,33 @@ export function resolveExecApprovals( overrides?: ExecApprovalsDefaultOverrides, ): ExecApprovalsResolved { const file = ensureExecApprovals(); + return resolveExecApprovalsFromFile({ + file, + agentId, + overrides, + path: resolveExecApprovalsPath(), + socketPath: expandHome(file.socket?.path ?? resolveExecApprovalsSocketPath()), + token: file.socket?.token ?? "", + }); +} + +export function resolveExecApprovalsFromFile(params: { + file: ExecApprovalsFile; + agentId?: string; + overrides?: ExecApprovalsDefaultOverrides; + path?: string; + socketPath?: string; + token?: string; +}): ExecApprovalsResolved { + const file = normalizeExecApprovals(params.file); const defaults = file.defaults ?? {}; - const agentKey = agentId ?? "default"; + const agentKey = params.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; - const fallbackAutoAllowSkills = overrides?.autoAllowSkills ?? DEFAULT_AUTO_ALLOW_SKILLS; + const fallbackSecurity = params.overrides?.security ?? DEFAULT_SECURITY; + const fallbackAsk = params.overrides?.ask ?? DEFAULT_ASK; + const fallbackAskFallback = params.overrides?.askFallback ?? DEFAULT_ASK_FALLBACK; + const fallbackAutoAllowSkills = params.overrides?.autoAllowSkills ?? DEFAULT_AUTO_ALLOW_SKILLS; const resolvedDefaults: Required = { security: normalizeSecurity(defaults.security, fallbackSecurity), ask: normalizeAsk(defaults.ask, fallbackAsk), @@ -246,9 +265,11 @@ export function resolveExecApprovals( ...(Array.isArray(agent.allowlist) ? agent.allowlist : []), ]; return { - path: resolveExecApprovalsPath(), - socketPath: expandHome(file.socket?.path ?? resolveExecApprovalsSocketPath()), - token: file.socket?.token ?? "", + path: params.path ?? resolveExecApprovalsPath(), + socketPath: expandHome( + params.socketPath ?? file.socket?.path ?? resolveExecApprovalsSocketPath(), + ), + token: params.token ?? file.socket?.token ?? "", defaults: resolvedDefaults, agent: resolvedAgent, allowlist,