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;