feat: add exec host approvals flow
This commit is contained in:
@@ -1,7 +1,21 @@
|
||||
import crypto from "node:crypto";
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
import {
|
||||
type ExecAsk,
|
||||
type ExecHost,
|
||||
type ExecSecurity,
|
||||
addAllowlistEntry,
|
||||
matchAllowlist,
|
||||
maxAsk,
|
||||
minSecurity,
|
||||
recordAllowlistUse,
|
||||
requestExecApprovalViaSocket,
|
||||
resolveCommandResolution,
|
||||
resolveExecApprovals,
|
||||
} from "../infra/exec-approvals.js";
|
||||
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
|
||||
import { enqueueSystemEvent } from "../infra/system-events.js";
|
||||
import { logInfo } from "../logger.js";
|
||||
@@ -28,6 +42,8 @@ import {
|
||||
resolveWorkdir,
|
||||
truncateMiddle,
|
||||
} from "./bash-tools.shared.js";
|
||||
import { callGatewayTool } from "./tools/gateway.js";
|
||||
import { listNodes, resolveNodeIdFromList } from "./tools/nodes-utils.js";
|
||||
import { getShellConfig, sanitizeBinaryOutput } from "./shell-utils.js";
|
||||
import { buildCursorPositionResponse, stripDsrRequests } from "./pty-dsr.js";
|
||||
|
||||
@@ -68,6 +84,11 @@ type PtySpawn = (
|
||||
) => PtyHandle;
|
||||
|
||||
export type ExecToolDefaults = {
|
||||
host?: ExecHost;
|
||||
security?: ExecSecurity;
|
||||
ask?: ExecAsk;
|
||||
node?: string;
|
||||
agentId?: string;
|
||||
backgroundMs?: number;
|
||||
timeoutSec?: number;
|
||||
sandbox?: BashSandboxConfig;
|
||||
@@ -114,6 +135,26 @@ const execSchema = Type.Object({
|
||||
description: "Run on the host with elevated permissions (if allowed)",
|
||||
}),
|
||||
),
|
||||
host: Type.Optional(
|
||||
Type.String({
|
||||
description: "Exec host (sandbox|gateway|node).",
|
||||
}),
|
||||
),
|
||||
security: Type.Optional(
|
||||
Type.String({
|
||||
description: "Exec security mode (deny|allowlist|full).",
|
||||
}),
|
||||
),
|
||||
ask: Type.Optional(
|
||||
Type.String({
|
||||
description: "Exec ask mode (off|on-miss|always).",
|
||||
}),
|
||||
),
|
||||
node: Type.Optional(
|
||||
Type.String({
|
||||
description: "Node id/name for host=node.",
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export type ExecToolDetails =
|
||||
@@ -133,6 +174,34 @@ export type ExecToolDetails =
|
||||
cwd?: string;
|
||||
};
|
||||
|
||||
function normalizeExecHost(value?: string | null): ExecHost | null {
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
if (normalized === "sandbox" || normalized === "gateway" || normalized === "node") {
|
||||
return normalized;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
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 renderExecHostLabel(host: ExecHost) {
|
||||
return host === "sandbox" ? "sandbox" : host === "gateway" ? "gateway" : "node";
|
||||
}
|
||||
|
||||
function normalizeNotifyOutput(value: string) {
|
||||
return value.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
@@ -189,6 +258,10 @@ export function createExecTool(
|
||||
timeout?: number;
|
||||
pty?: boolean;
|
||||
elevated?: boolean;
|
||||
host?: string;
|
||||
security?: string;
|
||||
ask?: string;
|
||||
node?: string;
|
||||
};
|
||||
|
||||
if (!params.command) {
|
||||
@@ -255,8 +328,33 @@ export function createExecTool(
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
const configuredHost = defaults?.host ?? "sandbox";
|
||||
const requestedHost = normalizeExecHost(params.host) ?? null;
|
||||
let host: ExecHost = requestedHost ?? configuredHost;
|
||||
if (!elevatedRequested && requestedHost && requestedHost !== configuredHost) {
|
||||
throw new Error(
|
||||
`exec host not allowed (requested ${renderExecHostLabel(requestedHost)}; ` +
|
||||
`configure tools.exec.host=${renderExecHostLabel(configuredHost)} to allow).`,
|
||||
);
|
||||
}
|
||||
if (elevatedRequested) {
|
||||
host = "gateway";
|
||||
}
|
||||
|
||||
const sandbox = elevatedRequested ? undefined : defaults?.sandbox;
|
||||
const configuredSecurity = defaults?.security ?? "deny";
|
||||
const requestedSecurity = normalizeExecSecurity(params.security);
|
||||
let security = minSecurity(
|
||||
configuredSecurity,
|
||||
requestedSecurity ?? configuredSecurity,
|
||||
);
|
||||
if (elevatedRequested) {
|
||||
security = "full";
|
||||
}
|
||||
const configuredAsk = defaults?.ask ?? "on-miss";
|
||||
const requestedAsk = normalizeExecAsk(params.ask);
|
||||
let ask = maxAsk(configuredAsk, requestedAsk ?? configuredAsk);
|
||||
|
||||
const sandbox = host === "sandbox" ? defaults?.sandbox : undefined;
|
||||
const rawWorkdir = params.workdir?.trim() || defaults?.cwd || process.cwd();
|
||||
let workdir = rawWorkdir;
|
||||
let containerWorkdir = sandbox?.containerWorkdir;
|
||||
@@ -283,6 +381,155 @@ export function createExecTool(
|
||||
containerWorkdir: containerWorkdir ?? sandbox.containerWorkdir,
|
||||
})
|
||||
: mergedEnv;
|
||||
|
||||
if (host === "node") {
|
||||
if (security === "deny") {
|
||||
throw new Error("exec denied: host=node security=deny");
|
||||
}
|
||||
const boundNode = defaults?.node?.trim();
|
||||
const requestedNode = params.node?.trim();
|
||||
if (boundNode && requestedNode && boundNode !== requestedNode) {
|
||||
throw new Error(`exec node not allowed (bound to ${boundNode})`);
|
||||
}
|
||||
const nodeQuery = boundNode || requestedNode;
|
||||
const nodes = await listNodes({});
|
||||
if (nodes.length === 0) {
|
||||
throw new Error(
|
||||
"exec host=node requires a paired node (none available). This requires the macOS companion app.",
|
||||
);
|
||||
}
|
||||
let nodeId: string;
|
||||
try {
|
||||
nodeId = resolveNodeIdFromList(nodes, nodeQuery, !nodeQuery);
|
||||
} catch (err) {
|
||||
if (!nodeQuery && String(err).includes("node required")) {
|
||||
throw new Error(
|
||||
"exec host=node requires a node id when multiple nodes are available (set tools.exec.node or exec.node).",
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const nodeInfo = nodes.find((entry) => entry.nodeId === nodeId);
|
||||
const supportsSystemRun = Array.isArray(nodeInfo?.commands)
|
||||
? nodeInfo?.commands?.includes("system.run")
|
||||
: false;
|
||||
if (!supportsSystemRun) {
|
||||
throw new Error("exec host=node requires a node that supports system.run.");
|
||||
}
|
||||
const argv = ["/bin/sh", "-lc", params.command];
|
||||
const invokeParams: Record<string, unknown> = {
|
||||
nodeId,
|
||||
command: "system.run",
|
||||
params: {
|
||||
command: argv,
|
||||
cwd: workdir,
|
||||
env: params.env,
|
||||
timeoutMs: typeof params.timeout === "number" ? params.timeout * 1000 : undefined,
|
||||
agentId: defaults?.agentId,
|
||||
sessionKey: defaults?.sessionKey,
|
||||
},
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
};
|
||||
const raw = (await callGatewayTool("node.invoke", {}, invokeParams)) as {
|
||||
payload?: {
|
||||
exitCode?: number;
|
||||
timedOut?: boolean;
|
||||
success?: boolean;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
error?: string | null;
|
||||
};
|
||||
};
|
||||
const payload = raw?.payload ?? {};
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: payload.stdout || payload.stderr || payload.error || "",
|
||||
},
|
||||
],
|
||||
details: {
|
||||
status: payload.success ? "completed" : "failed",
|
||||
exitCode: payload.exitCode ?? null,
|
||||
durationMs: Date.now() - startedAt,
|
||||
aggregated: [payload.stdout, payload.stderr, payload.error].filter(Boolean).join("\n"),
|
||||
cwd: workdir,
|
||||
} satisfies ExecToolDetails,
|
||||
};
|
||||
}
|
||||
|
||||
if (host === "gateway") {
|
||||
const approvals = resolveExecApprovals(defaults?.agentId);
|
||||
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=gateway security=deny");
|
||||
}
|
||||
|
||||
const resolution = resolveCommandResolution(params.command, workdir, env);
|
||||
const allowlistMatch =
|
||||
hostSecurity === "allowlist" ? matchAllowlist(approvals.allowlist, resolution) : null;
|
||||
const requiresAsk =
|
||||
hostAsk === "always" ||
|
||||
(hostAsk === "on-miss" && hostSecurity === "allowlist" && !allowlistMatch);
|
||||
|
||||
if (requiresAsk) {
|
||||
const decision =
|
||||
(await requestExecApprovalViaSocket({
|
||||
socketPath: approvals.socketPath,
|
||||
token: approvals.token,
|
||||
request: {
|
||||
command: params.command,
|
||||
cwd: workdir,
|
||||
host: "gateway",
|
||||
security: hostSecurity,
|
||||
ask: hostAsk,
|
||||
agentId: defaults?.agentId,
|
||||
resolvedPath: resolution?.resolvedPath ?? null,
|
||||
},
|
||||
})) ?? null;
|
||||
|
||||
if (decision === "deny") {
|
||||
throw new Error("exec denied: user denied");
|
||||
}
|
||||
if (!decision) {
|
||||
if (askFallback === "deny") {
|
||||
throw new Error(
|
||||
"exec denied: approval required (companion app approval UI not available)",
|
||||
);
|
||||
}
|
||||
if (askFallback === "allowlist") {
|
||||
if (!allowlistMatch) {
|
||||
throw new Error(
|
||||
"exec denied: approval required (companion app approval UI not available)",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (decision === "allow-always" && hostSecurity === "allowlist") {
|
||||
const pattern =
|
||||
resolution?.resolvedPath ??
|
||||
resolution?.rawExecutable ??
|
||||
params.command.split(/\s+/).shift() ??
|
||||
"";
|
||||
if (pattern) {
|
||||
addAllowlistEntry(approvals.file, defaults?.agentId, pattern);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (allowlistMatch) {
|
||||
recordAllowlistUse(
|
||||
approvals.file,
|
||||
defaults?.agentId,
|
||||
allowlistMatch,
|
||||
params.command,
|
||||
resolution?.resolvedPath,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const usePty = params.pty === true && !sandbox;
|
||||
let child: ChildProcessWithoutNullStreams | null = null;
|
||||
let pty: PtyHandle | null = null;
|
||||
|
||||
@@ -74,6 +74,22 @@ function isApplyPatchAllowedForModel(params: {
|
||||
});
|
||||
}
|
||||
|
||||
function resolveExecConfig(cfg: ClawdbotConfig | undefined, agentId?: string | null) {
|
||||
const globalExec = cfg?.tools?.exec;
|
||||
const agentExec = cfg?.agents?.list?.find((entry) => entry.id === agentId)?.tools?.exec;
|
||||
return {
|
||||
host: agentExec?.host ?? globalExec?.host,
|
||||
security: agentExec?.security ?? globalExec?.security,
|
||||
ask: agentExec?.ask ?? globalExec?.ask,
|
||||
node: agentExec?.node ?? globalExec?.node,
|
||||
backgroundMs: agentExec?.backgroundMs ?? globalExec?.backgroundMs,
|
||||
timeoutSec: agentExec?.timeoutSec ?? globalExec?.timeoutSec,
|
||||
cleanupMs: agentExec?.cleanupMs ?? globalExec?.cleanupMs,
|
||||
notifyOnExit: agentExec?.notifyOnExit ?? globalExec?.notifyOnExit,
|
||||
applyPatch: agentExec?.applyPatch ?? globalExec?.applyPatch,
|
||||
};
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
cleanToolSchemaForGemini,
|
||||
normalizeToolParams,
|
||||
@@ -146,6 +162,7 @@ export function createClawdbotCodingTools(options?: {
|
||||
sandbox?.tools,
|
||||
subagentPolicy,
|
||||
]);
|
||||
const execConfig = resolveExecConfig(options?.config, agentId);
|
||||
const sandboxRoot = sandbox?.workspaceDir;
|
||||
const allowWorkspaceWrites = sandbox?.workspaceAccess !== "ro";
|
||||
const workspaceRoot = options?.workspaceDir ?? process.cwd();
|
||||
@@ -184,11 +201,20 @@ export function createClawdbotCodingTools(options?: {
|
||||
});
|
||||
const execTool = createExecTool({
|
||||
...options?.exec,
|
||||
host: options?.exec?.host ?? execConfig.host,
|
||||
security: options?.exec?.security ?? execConfig.security,
|
||||
ask: options?.exec?.ask ?? execConfig.ask,
|
||||
node: options?.exec?.node ?? execConfig.node,
|
||||
agentId,
|
||||
cwd: options?.workspaceDir,
|
||||
allowBackground,
|
||||
scopeKey,
|
||||
sessionKey: options?.sessionKey,
|
||||
messageProvider: options?.messageProvider,
|
||||
backgroundMs: options?.exec?.backgroundMs ?? execConfig.backgroundMs,
|
||||
timeoutSec: options?.exec?.timeoutSec ?? execConfig.timeoutSec,
|
||||
cleanupMs: options?.exec?.cleanupMs ?? execConfig.cleanupMs,
|
||||
notifyOnExit: options?.exec?.notifyOnExit ?? execConfig.notifyOnExit,
|
||||
sandbox: sandbox
|
||||
? {
|
||||
containerName: sandbox.containerName,
|
||||
|
||||
@@ -92,6 +92,7 @@ export function createNodesTool(options?: {
|
||||
agentSessionKey?: string;
|
||||
config?: ClawdbotConfig;
|
||||
}): AnyAgentTool {
|
||||
const sessionKey = options?.agentSessionKey?.trim() || undefined;
|
||||
const agentId = resolveSessionAgentId({
|
||||
sessionKey: options?.agentSessionKey,
|
||||
config: options?.config,
|
||||
@@ -430,6 +431,7 @@ export function createNodesTool(options?: {
|
||||
timeoutMs: commandTimeoutMs,
|
||||
needsScreenRecording,
|
||||
agentId,
|
||||
sessionKey,
|
||||
},
|
||||
timeoutMs: invokeTimeoutMs,
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
|
||||
@@ -140,6 +140,10 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
"tools.exec.applyPatch.enabled": "Enable apply_patch",
|
||||
"tools.exec.applyPatch.allowModels": "apply_patch Model Allowlist",
|
||||
"tools.exec.notifyOnExit": "Exec Notify On Exit",
|
||||
"tools.exec.host": "Exec Host",
|
||||
"tools.exec.security": "Exec Security",
|
||||
"tools.exec.ask": "Exec Ask",
|
||||
"tools.exec.node": "Exec Node Binding",
|
||||
"tools.message.allowCrossContextSend": "Allow Cross-Context Messaging",
|
||||
"tools.message.crossContext.allowWithinProvider": "Allow Cross-Context (Same Provider)",
|
||||
"tools.message.crossContext.allowAcrossProviders": "Allow Cross-Context (Across Providers)",
|
||||
|
||||
@@ -339,6 +339,14 @@ export type ToolsConfig = {
|
||||
};
|
||||
/** Exec tool defaults. */
|
||||
exec?: {
|
||||
/** Exec host routing (default: sandbox). */
|
||||
host?: "sandbox" | "gateway" | "node";
|
||||
/** Exec security mode (default: deny). */
|
||||
security?: "deny" | "allowlist" | "full";
|
||||
/** Exec ask mode (default: on-miss). */
|
||||
ask?: "off" | "on-miss" | "always";
|
||||
/** Default node binding for exec.host=node (node id/name). */
|
||||
node?: string;
|
||||
/** Default time (ms) before an exec command auto-backgrounds. */
|
||||
backgroundMs?: number;
|
||||
/** Default timeout (seconds) before auto-killing exec commands. */
|
||||
|
||||
@@ -356,6 +356,10 @@ export const ToolsSchema = z
|
||||
.optional(),
|
||||
exec: z
|
||||
.object({
|
||||
host: z.enum(["sandbox", "gateway", "node"]).optional(),
|
||||
security: z.enum(["deny", "allowlist", "full"]).optional(),
|
||||
ask: z.enum(["off", "on-miss", "always"]).optional(),
|
||||
node: z.string().optional(),
|
||||
backgroundMs: z.number().int().positive().optional(),
|
||||
timeoutSec: z.number().int().positive().optional(),
|
||||
cleanupMs: z.number().int().positive().optional(),
|
||||
|
||||
@@ -3,6 +3,8 @@ import { normalizeChannelId } from "../channels/plugins/index.js";
|
||||
import { agentCommand } from "../commands/agent.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { updateSessionStore } from "../config/sessions.js";
|
||||
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
|
||||
import { enqueueSystemEvent } from "../infra/system-events.js";
|
||||
import { normalizeMainKey } from "../routing/session-key.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import type { BridgeEvent, BridgeHandlersContext } from "./server-bridge-types.js";
|
||||
@@ -172,6 +174,47 @@ export const handleBridgeEvent = async (
|
||||
ctx.bridgeUnsubscribe(nodeId, sessionKey);
|
||||
return;
|
||||
}
|
||||
case "exec.started":
|
||||
case "exec.finished":
|
||||
case "exec.denied": {
|
||||
if (!evt.payloadJSON) return;
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = JSON.parse(evt.payloadJSON) as unknown;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const obj =
|
||||
typeof payload === "object" && payload !== null ? (payload as Record<string, unknown>) : {};
|
||||
const sessionKey =
|
||||
typeof obj.sessionKey === "string" ? obj.sessionKey.trim() : `node-${nodeId}`;
|
||||
if (!sessionKey) return;
|
||||
const runId = typeof obj.runId === "string" ? obj.runId.trim() : "";
|
||||
const command = typeof obj.command === "string" ? obj.command.trim() : "";
|
||||
const exitCode =
|
||||
typeof obj.exitCode === "number" && Number.isFinite(obj.exitCode) ? obj.exitCode : undefined;
|
||||
const timedOut = obj.timedOut === true;
|
||||
const success = obj.success === true;
|
||||
const output = typeof obj.output === "string" ? obj.output.trim() : "";
|
||||
const reason = typeof obj.reason === "string" ? obj.reason.trim() : "";
|
||||
|
||||
let text = "";
|
||||
if (evt.event === "exec.started") {
|
||||
text = `Exec started (node=${nodeId}${runId ? ` id=${runId}` : ""})`;
|
||||
if (command) text += `: ${command}`;
|
||||
} else if (evt.event === "exec.finished") {
|
||||
const exitLabel = timedOut ? "timeout" : `code ${exitCode ?? "?"}`;
|
||||
text = `Exec finished (node=${nodeId}${runId ? ` id=${runId}` : ""}, ${exitLabel})`;
|
||||
if (output) text += `\\n${output}`;
|
||||
} else {
|
||||
text = `Exec denied (node=${nodeId}${runId ? ` id=${runId}` : ""}${reason ? `, ${reason}` : ""})`;
|
||||
if (command) text += `: ${command}`;
|
||||
}
|
||||
|
||||
enqueueSystemEvent(text, { sessionKey, contextKey: runId ? `exec:${runId}` : "exec" });
|
||||
requestHeartbeatNow({ reason: "exec-event" });
|
||||
return;
|
||||
}
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
402
src/infra/exec-approvals.ts
Normal file
402
src/infra/exec-approvals.ts
Normal file
@@ -0,0 +1,402 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
export type ExecHost = "sandbox" | "gateway" | "node";
|
||||
export type ExecSecurity = "deny" | "allowlist" | "full";
|
||||
export type ExecAsk = "off" | "on-miss" | "always";
|
||||
|
||||
export type ExecApprovalsDefaults = {
|
||||
security?: ExecSecurity;
|
||||
ask?: ExecAsk;
|
||||
askFallback?: ExecSecurity;
|
||||
autoAllowSkills?: boolean;
|
||||
};
|
||||
|
||||
export type ExecAllowlistEntry = {
|
||||
pattern: string;
|
||||
lastUsedAt?: number;
|
||||
lastUsedCommand?: string;
|
||||
lastResolvedPath?: string;
|
||||
};
|
||||
|
||||
export type ExecApprovalsAgent = ExecApprovalsDefaults & {
|
||||
allowlist?: ExecAllowlistEntry[];
|
||||
};
|
||||
|
||||
export type ExecApprovalsFile = {
|
||||
version: 1;
|
||||
socket?: {
|
||||
path?: string;
|
||||
token?: string;
|
||||
};
|
||||
defaults?: ExecApprovalsDefaults;
|
||||
agents?: Record<string, ExecApprovalsAgent>;
|
||||
};
|
||||
|
||||
export type ExecApprovalsResolved = {
|
||||
path: string;
|
||||
socketPath: string;
|
||||
token: string;
|
||||
defaults: Required<ExecApprovalsDefaults>;
|
||||
agent: Required<ExecApprovalsDefaults>;
|
||||
allowlist: ExecAllowlistEntry[];
|
||||
file: ExecApprovalsFile;
|
||||
};
|
||||
|
||||
const DEFAULT_SECURITY: ExecSecurity = "deny";
|
||||
const DEFAULT_ASK: ExecAsk = "on-miss";
|
||||
const DEFAULT_ASK_FALLBACK: ExecSecurity = "deny";
|
||||
const DEFAULT_AUTO_ALLOW_SKILLS = false;
|
||||
const DEFAULT_SOCKET = "~/.clawdbot/exec-approvals.sock";
|
||||
const DEFAULT_FILE = "~/.clawdbot/exec-approvals.json";
|
||||
|
||||
function expandHome(value: string): string {
|
||||
if (!value) return value;
|
||||
if (value === "~") return os.homedir();
|
||||
if (value.startsWith("~/")) return path.join(os.homedir(), value.slice(2));
|
||||
return value;
|
||||
}
|
||||
|
||||
export function resolveExecApprovalsPath(): string {
|
||||
return expandHome(DEFAULT_FILE);
|
||||
}
|
||||
|
||||
export function resolveExecApprovalsSocketPath(): string {
|
||||
return expandHome(DEFAULT_SOCKET);
|
||||
}
|
||||
|
||||
function ensureDir(filePath: string) {
|
||||
const dir = path.dirname(filePath);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
function normalizeExecApprovals(file: ExecApprovalsFile): ExecApprovalsFile {
|
||||
const socketPath = file.socket?.path?.trim();
|
||||
const token = file.socket?.token?.trim();
|
||||
const normalized: ExecApprovalsFile = {
|
||||
version: 1,
|
||||
socket: {
|
||||
path: socketPath && socketPath.length > 0 ? socketPath : undefined,
|
||||
token: token && token.length > 0 ? token : undefined,
|
||||
},
|
||||
defaults: {
|
||||
security: file.defaults?.security,
|
||||
ask: file.defaults?.ask,
|
||||
askFallback: file.defaults?.askFallback,
|
||||
autoAllowSkills: file.defaults?.autoAllowSkills,
|
||||
},
|
||||
agents: file.agents ?? {},
|
||||
};
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function generateToken(): string {
|
||||
return crypto.randomBytes(24).toString("base64url");
|
||||
}
|
||||
|
||||
export function loadExecApprovals(): ExecApprovalsFile {
|
||||
const filePath = resolveExecApprovalsPath();
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return normalizeExecApprovals({ version: 1, agents: {} });
|
||||
}
|
||||
const raw = fs.readFileSync(filePath, "utf8");
|
||||
const parsed = JSON.parse(raw) as ExecApprovalsFile;
|
||||
if (parsed?.version !== 1) {
|
||||
return normalizeExecApprovals({ version: 1, agents: parsed?.agents ?? {} });
|
||||
}
|
||||
return normalizeExecApprovals(parsed);
|
||||
} catch {
|
||||
return normalizeExecApprovals({ version: 1, agents: {} });
|
||||
}
|
||||
}
|
||||
|
||||
export function saveExecApprovals(file: ExecApprovalsFile) {
|
||||
const filePath = resolveExecApprovalsPath();
|
||||
ensureDir(filePath);
|
||||
fs.writeFileSync(filePath, JSON.stringify(file, null, 2));
|
||||
}
|
||||
|
||||
export function ensureExecApprovals(): ExecApprovalsFile {
|
||||
const loaded = loadExecApprovals();
|
||||
const next = normalizeExecApprovals(loaded);
|
||||
const socketPath = next.socket?.path?.trim();
|
||||
const token = next.socket?.token?.trim();
|
||||
const updated: ExecApprovalsFile = {
|
||||
...next,
|
||||
socket: {
|
||||
path: socketPath && socketPath.length > 0 ? socketPath : resolveExecApprovalsSocketPath(),
|
||||
token: token && token.length > 0 ? token : generateToken(),
|
||||
},
|
||||
};
|
||||
saveExecApprovals(updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
function normalizeSecurity(value?: ExecSecurity): ExecSecurity {
|
||||
if (value === "allowlist" || value === "full" || value === "deny") return value;
|
||||
return DEFAULT_SECURITY;
|
||||
}
|
||||
|
||||
function normalizeAsk(value?: ExecAsk): ExecAsk {
|
||||
if (value === "always" || value === "off" || value === "on-miss") return value;
|
||||
return DEFAULT_ASK;
|
||||
}
|
||||
|
||||
export function resolveExecApprovals(agentId?: string): ExecApprovalsResolved {
|
||||
const file = ensureExecApprovals();
|
||||
const defaults = file.defaults ?? {};
|
||||
const agentKey = agentId ?? "default";
|
||||
const agent = file.agents?.[agentKey] ?? {};
|
||||
const resolvedDefaults: Required<ExecApprovalsDefaults> = {
|
||||
security: normalizeSecurity(defaults.security),
|
||||
ask: normalizeAsk(defaults.ask),
|
||||
askFallback: normalizeSecurity(defaults.askFallback ?? DEFAULT_ASK_FALLBACK),
|
||||
autoAllowSkills: Boolean(defaults.autoAllowSkills ?? DEFAULT_AUTO_ALLOW_SKILLS),
|
||||
};
|
||||
const resolvedAgent: Required<ExecApprovalsDefaults> = {
|
||||
security: normalizeSecurity(agent.security ?? resolvedDefaults.security),
|
||||
ask: normalizeAsk(agent.ask ?? resolvedDefaults.ask),
|
||||
askFallback: normalizeSecurity(agent.askFallback ?? resolvedDefaults.askFallback),
|
||||
autoAllowSkills: Boolean(agent.autoAllowSkills ?? resolvedDefaults.autoAllowSkills),
|
||||
};
|
||||
const allowlist = Array.isArray(agent.allowlist) ? agent.allowlist : [];
|
||||
return {
|
||||
path: resolveExecApprovalsPath(),
|
||||
socketPath: expandHome(file.socket?.path ?? resolveExecApprovalsSocketPath()),
|
||||
token: file.socket?.token ?? "",
|
||||
defaults: resolvedDefaults,
|
||||
agent: resolvedAgent,
|
||||
allowlist,
|
||||
file,
|
||||
};
|
||||
}
|
||||
|
||||
type CommandResolution = {
|
||||
rawExecutable: string;
|
||||
resolvedPath?: string;
|
||||
executableName: string;
|
||||
};
|
||||
|
||||
function parseFirstToken(command: string): string | null {
|
||||
const trimmed = command.trim();
|
||||
if (!trimmed) return null;
|
||||
const first = trimmed[0];
|
||||
if (first === "\"" || first === "'") {
|
||||
const end = trimmed.indexOf(first, 1);
|
||||
if (end > 1) return trimmed.slice(1, end);
|
||||
return trimmed.slice(1);
|
||||
}
|
||||
const match = /^[^\\s]+/.exec(trimmed);
|
||||
return match ? match[0] : null;
|
||||
}
|
||||
|
||||
function resolveExecutablePath(rawExecutable: string, cwd?: string, env?: NodeJS.ProcessEnv) {
|
||||
const expanded = rawExecutable.startsWith("~") ? expandHome(rawExecutable) : rawExecutable;
|
||||
if (expanded.includes("/") || expanded.includes("\\")) {
|
||||
if (path.isAbsolute(expanded)) return expanded;
|
||||
const base = cwd && cwd.trim() ? cwd.trim() : process.cwd();
|
||||
return path.resolve(base, expanded);
|
||||
}
|
||||
const envPath = env?.PATH ?? process.env.PATH ?? "";
|
||||
const entries = envPath.split(path.delimiter).filter(Boolean);
|
||||
for (const entry of entries) {
|
||||
const candidate = path.join(entry, expanded);
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveCommandResolution(
|
||||
command: string,
|
||||
cwd?: string,
|
||||
env?: NodeJS.ProcessEnv,
|
||||
): CommandResolution | null {
|
||||
const rawExecutable = parseFirstToken(command);
|
||||
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 {
|
||||
return value.replace(/\\\\/g, "/").toLowerCase();
|
||||
}
|
||||
|
||||
function globToRegExp(pattern: string): RegExp {
|
||||
let regex = "^";
|
||||
let i = 0;
|
||||
while (i < pattern.length) {
|
||||
const ch = pattern[i];
|
||||
if (ch === "*") {
|
||||
const next = pattern[i + 1];
|
||||
if (next === "*") {
|
||||
regex += ".*";
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
regex += "[^/]*";
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === "?") {
|
||||
regex += ".";
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
regex += ch.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&");
|
||||
i += 1;
|
||||
}
|
||||
regex += "$";
|
||||
return new RegExp(regex, "i");
|
||||
}
|
||||
|
||||
function matchesPattern(pattern: string, target: string): boolean {
|
||||
const trimmed = pattern.trim();
|
||||
if (!trimmed) return false;
|
||||
const expanded = trimmed.startsWith("~") ? expandHome(trimmed) : trimmed;
|
||||
const normalizedPattern = normalizeMatchTarget(expanded);
|
||||
const normalizedTarget = normalizeMatchTarget(target);
|
||||
const regex = globToRegExp(normalizedPattern);
|
||||
return regex.test(normalizedTarget);
|
||||
}
|
||||
|
||||
export function matchAllowlist(
|
||||
entries: ExecAllowlistEntry[],
|
||||
resolution: CommandResolution | null,
|
||||
): ExecAllowlistEntry | null {
|
||||
if (!entries.length || !resolution) return null;
|
||||
const rawExecutable = resolution.rawExecutable;
|
||||
const resolvedPath = resolution.resolvedPath;
|
||||
const executableName = resolution.executableName;
|
||||
for (const entry of entries) {
|
||||
const pattern = entry.pattern?.trim();
|
||||
if (!pattern) continue;
|
||||
const hasPath = pattern.includes("/") || pattern.includes("\\") || pattern.includes("~");
|
||||
if (hasPath) {
|
||||
const target = resolvedPath ?? rawExecutable;
|
||||
if (target && matchesPattern(pattern, target)) return entry;
|
||||
continue;
|
||||
}
|
||||
if (executableName && matchesPattern(pattern, executableName)) return entry;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function recordAllowlistUse(
|
||||
approvals: ExecApprovalsFile,
|
||||
agentId: string | undefined,
|
||||
entry: ExecAllowlistEntry,
|
||||
command: string,
|
||||
resolvedPath?: string,
|
||||
) {
|
||||
const target = agentId ?? "default";
|
||||
const agents = approvals.agents ?? {};
|
||||
const existing = agents[target] ?? {};
|
||||
const allowlist = Array.isArray(existing.allowlist) ? existing.allowlist : [];
|
||||
const nextAllowlist = allowlist.map((item) =>
|
||||
item.pattern === entry.pattern
|
||||
? {
|
||||
...item,
|
||||
lastUsedAt: Date.now(),
|
||||
lastUsedCommand: command,
|
||||
lastResolvedPath: resolvedPath,
|
||||
}
|
||||
: item,
|
||||
);
|
||||
agents[target] = { ...existing, allowlist: nextAllowlist };
|
||||
approvals.agents = agents;
|
||||
saveExecApprovals(approvals);
|
||||
}
|
||||
|
||||
export function addAllowlistEntry(
|
||||
approvals: ExecApprovalsFile,
|
||||
agentId: string | undefined,
|
||||
pattern: string,
|
||||
) {
|
||||
const target = agentId ?? "default";
|
||||
const agents = approvals.agents ?? {};
|
||||
const existing = agents[target] ?? {};
|
||||
const allowlist = Array.isArray(existing.allowlist) ? existing.allowlist : [];
|
||||
const trimmed = pattern.trim();
|
||||
if (!trimmed) return;
|
||||
if (allowlist.some((entry) => entry.pattern === trimmed)) return;
|
||||
allowlist.push({ pattern: trimmed, lastUsedAt: Date.now() });
|
||||
agents[target] = { ...existing, allowlist };
|
||||
approvals.agents = agents;
|
||||
saveExecApprovals(approvals);
|
||||
}
|
||||
|
||||
export function minSecurity(a: ExecSecurity, b: ExecSecurity): ExecSecurity {
|
||||
const order: Record<ExecSecurity, number> = { deny: 0, allowlist: 1, full: 2 };
|
||||
return order[a] <= order[b] ? a : b;
|
||||
}
|
||||
|
||||
export function maxAsk(a: ExecAsk, b: ExecAsk): ExecAsk {
|
||||
const order: Record<ExecAsk, number> = { off: 0, "on-miss": 1, always: 2 };
|
||||
return order[a] >= order[b] ? a : b;
|
||||
}
|
||||
|
||||
export type ExecApprovalDecision = "allow-once" | "allow-always" | "deny";
|
||||
|
||||
export async function requestExecApprovalViaSocket(params: {
|
||||
socketPath: string;
|
||||
token: string;
|
||||
request: Record<string, unknown>;
|
||||
timeoutMs?: number;
|
||||
}): Promise<ExecApprovalDecision | null> {
|
||||
const { socketPath, token, request } = params;
|
||||
if (!socketPath || !token) return null;
|
||||
const timeoutMs = params.timeoutMs ?? 15_000;
|
||||
return await new Promise((resolve) => {
|
||||
const client = new net.Socket();
|
||||
let settled = false;
|
||||
let buffer = "";
|
||||
const finish = (value: ExecApprovalDecision | null) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try {
|
||||
client.destroy();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => finish(null), timeoutMs);
|
||||
const payload = JSON.stringify({
|
||||
type: "request",
|
||||
token,
|
||||
id: crypto.randomUUID(),
|
||||
request,
|
||||
});
|
||||
|
||||
client.on("error", () => finish(null));
|
||||
client.connect(socketPath, () => {
|
||||
client.write(`${payload}\n`);
|
||||
});
|
||||
client.on("data", (data) => {
|
||||
buffer += data.toString("utf8");
|
||||
let idx = buffer.indexOf("\n");
|
||||
while (idx !== -1) {
|
||||
const line = buffer.slice(0, idx).trim();
|
||||
buffer = buffer.slice(idx + 1);
|
||||
idx = buffer.indexOf("\n");
|
||||
if (!line) continue;
|
||||
try {
|
||||
const msg = JSON.parse(line) as { type?: string; decision?: ExecApprovalDecision };
|
||||
if (msg?.type === "decision" && msg.decision) {
|
||||
clearTimeout(timer);
|
||||
finish(msg.decision);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user