feat: add exec host approvals flow

This commit is contained in:
Peter Steinberger
2026-01-18 04:27:33 +00:00
parent fa1079214b
commit efdb33c975
30 changed files with 2344 additions and 855 deletions

View File

@@ -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;

View File

@@ -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,

View File

@@ -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(),

View File

@@ -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)",

View File

@@ -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. */

View File

@@ -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(),

View File

@@ -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
View 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
}
}
});
});
}