refactor: share exec host approval helpers

This commit is contained in:
Peter Steinberger 2026-03-14 02:37:43 +00:00
parent 3bc9d9177d
commit 6720bf5be0
3 changed files with 279 additions and 226 deletions

View File

@ -1,5 +1,4 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import { buildExecApprovalUnavailableReplyPayload } from "../infra/exec-approval-reply.js";
import {
addAllowlistEntry,
type ExecAsk,
@ -14,20 +13,22 @@ import { detectCommandObfuscation } from "../infra/exec-obfuscation-detect.js";
import type { SafeBinProfile } from "../infra/exec-safe-bin-policy.js";
import { logInfo } from "../logger.js";
import { markBackgrounded, tail } from "./bash-process-registry.js";
import { sendExecApprovalFollowup } from "./bash-tools.exec-approval-followup.js";
import {
buildExecApprovalRequesterContext,
buildExecApprovalTurnSourceContext,
registerExecApprovalRequestForHostOrThrow,
} from "./bash-tools.exec-approval-request.js";
import {
buildDefaultExecApprovalRequestArgs,
buildExecApprovalFollowupTarget,
buildExecApprovalPendingToolResult,
createExecApprovalDecisionState,
createAndRegisterDefaultExecApprovalRequest,
resolveBaseExecApprovalDecision,
resolveApprovalDecisionOrUndefined,
resolveExecHostApprovalContext,
sendExecApprovalFollowupResult,
} from "./bash-tools.exec-host-shared.js";
import {
buildApprovalPendingMessage,
DEFAULT_NOTIFY_TAIL_CHARS,
createApprovalSlug,
normalizeNotifyOutput,
@ -140,22 +141,14 @@ export async function processGatewayAllowlist(
}
if (requiresAsk) {
const {
approvalId,
approvalSlug,
warningText,
expiresAtMs,
preResolvedDecision,
initiatingSurface,
sentApproverDms,
unavailableReason,
} = await createAndRegisterDefaultExecApprovalRequest({
const requestArgs = buildDefaultExecApprovalRequestArgs({
warnings: params.warnings,
approvalRunningNoticeMs: params.approvalRunningNoticeMs,
createApprovalSlug,
turnSourceChannel: params.turnSourceChannel,
turnSourceAccountId: params.turnSourceAccountId,
register: async (approvalId) =>
});
const registerGatewayApproval = async (approvalId: string) =>
await registerExecApprovalRequestForHostOrThrow({
approvalId,
command: params.command,
@ -169,38 +162,57 @@ export async function processGatewayAllowlist(
}),
resolvedPath: allowlistEval.segments[0]?.resolution?.resolvedPath,
...buildExecApprovalTurnSourceContext(params),
}),
});
const {
approvalId,
approvalSlug,
warningText,
expiresAtMs,
preResolvedDecision,
initiatingSurface,
sentApproverDms,
unavailableReason,
} = await createAndRegisterDefaultExecApprovalRequest({
...requestArgs,
register: registerGatewayApproval,
});
const resolvedPath = allowlistEval.segments[0]?.resolution?.resolvedPath;
const effectiveTimeout =
typeof params.timeoutSec === "number" ? params.timeoutSec : params.defaultTimeoutSec;
void (async () => {
const decision = await resolveApprovalDecisionOrUndefined({
approvalId,
preResolvedDecision,
onFailure: () =>
void sendExecApprovalFollowup({
const followupTarget = buildExecApprovalFollowupTarget({
approvalId,
sessionKey: params.notifySessionKey,
turnSourceChannel: params.turnSourceChannel,
turnSourceTo: params.turnSourceTo,
turnSourceAccountId: params.turnSourceAccountId,
turnSourceThreadId: params.turnSourceThreadId,
resultText: `Exec denied (gateway id=${approvalId}, approval-request-failed): ${params.command}`,
}),
});
void (async () => {
const decision = await resolveApprovalDecisionOrUndefined({
approvalId,
preResolvedDecision,
onFailure: () =>
void sendExecApprovalFollowupResult(
followupTarget,
`Exec denied (gateway id=${approvalId}, approval-request-failed): ${params.command}`,
),
});
if (decision === undefined) {
return;
}
const baseDecision = resolveBaseExecApprovalDecision({
const {
baseDecision,
approvedByAsk: initialApprovedByAsk,
deniedReason: initialDeniedReason,
} = createExecApprovalDecisionState({
decision,
askFallback,
obfuscationDetected: obfuscation.detected,
});
let approvedByAsk = baseDecision.approvedByAsk;
let deniedReason = baseDecision.deniedReason;
let approvedByAsk = initialApprovedByAsk;
let deniedReason = initialDeniedReason;
if (baseDecision.timedOut && askFallback === "allowlist") {
if (!analysisOk || !allowlistSatisfied) {
@ -232,15 +244,10 @@ export async function processGatewayAllowlist(
}
if (deniedReason) {
await sendExecApprovalFollowup({
approvalId,
sessionKey: params.notifySessionKey,
turnSourceChannel: params.turnSourceChannel,
turnSourceTo: params.turnSourceTo,
turnSourceAccountId: params.turnSourceAccountId,
turnSourceThreadId: params.turnSourceThreadId,
resultText: `Exec denied (gateway id=${approvalId}, ${deniedReason}): ${params.command}`,
}).catch(() => {});
await sendExecApprovalFollowupResult(
followupTarget,
`Exec denied (gateway id=${approvalId}, ${deniedReason}): ${params.command}`,
);
return;
}
@ -266,15 +273,10 @@ export async function processGatewayAllowlist(
timeoutSec: effectiveTimeout,
});
} catch {
await sendExecApprovalFollowup({
approvalId,
sessionKey: params.notifySessionKey,
turnSourceChannel: params.turnSourceChannel,
turnSourceTo: params.turnSourceTo,
turnSourceAccountId: params.turnSourceAccountId,
turnSourceThreadId: params.turnSourceThreadId,
resultText: `Exec denied (gateway id=${approvalId}, spawn-failed): ${params.command}`,
}).catch(() => {});
await sendExecApprovalFollowupResult(
followupTarget,
`Exec denied (gateway id=${approvalId}, spawn-failed): ${params.command}`,
);
return;
}
@ -288,63 +290,22 @@ export async function processGatewayAllowlist(
const summary = output
? `Exec finished (gateway id=${approvalId}, session=${run.session.id}, ${exitLabel})\n${output}`
: `Exec finished (gateway id=${approvalId}, session=${run.session.id}, ${exitLabel})`;
await sendExecApprovalFollowup({
approvalId,
sessionKey: params.notifySessionKey,
turnSourceChannel: params.turnSourceChannel,
turnSourceTo: params.turnSourceTo,
turnSourceAccountId: params.turnSourceAccountId,
turnSourceThreadId: params.turnSourceThreadId,
resultText: summary,
}).catch(() => {});
await sendExecApprovalFollowupResult(followupTarget, summary);
})();
return {
pendingResult: {
content: [
{
type: "text",
text:
unavailableReason !== null
? (buildExecApprovalUnavailableReplyPayload({
warningText,
reason: unavailableReason,
channelLabel: initiatingSurface.channelLabel,
sentApproverDms,
}).text ?? "")
: buildApprovalPendingMessage({
warningText,
approvalSlug,
approvalId,
command: params.command,
cwd: params.workdir,
host: "gateway",
}),
},
],
details:
unavailableReason !== null
? ({
status: "approval-unavailable",
reason: unavailableReason,
channelLabel: initiatingSurface.channelLabel,
sentApproverDms,
pendingResult: buildExecApprovalPendingToolResult({
host: "gateway",
command: params.command,
cwd: params.workdir,
warningText,
} satisfies ExecToolDetails)
: ({
status: "approval-pending",
approvalId,
approvalSlug,
expiresAtMs,
host: "gateway",
command: params.command,
cwd: params.workdir,
warningText,
} satisfies ExecToolDetails),
},
initiatingSurface,
sentApproverDms,
unavailableReason,
}),
};
}

View File

@ -1,6 +1,5 @@
import crypto from "node:crypto";
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import { buildExecApprovalUnavailableReplyPayload } from "../infra/exec-approval-reply.js";
import {
type ExecApprovalsFile,
type ExecAsk,
@ -13,20 +12,13 @@ import { detectCommandObfuscation } from "../infra/exec-obfuscation-detect.js";
import { buildNodeShellCommand } from "../infra/node-shell.js";
import { parsePreparedSystemRunPayload } from "../infra/system-run-approval-context.js";
import { logInfo } from "../logger.js";
import { sendExecApprovalFollowup } from "./bash-tools.exec-approval-followup.js";
import {
buildExecApprovalRequesterContext,
buildExecApprovalTurnSourceContext,
registerExecApprovalRequestForHostOrThrow,
} from "./bash-tools.exec-approval-request.js";
import * as execHostShared from "./bash-tools.exec-host-shared.js";
import {
createAndRegisterDefaultExecApprovalRequest,
resolveBaseExecApprovalDecision,
resolveApprovalDecisionOrUndefined,
resolveExecHostApprovalContext,
} from "./bash-tools.exec-host-shared.js";
import {
buildApprovalPendingMessage,
DEFAULT_NOTIFY_TAIL_CHARS,
createApprovalSlug,
normalizeNotifyOutput,
@ -61,7 +53,7 @@ export type ExecuteNodeHostCommandParams = {
export async function executeNodeHostCommand(
params: ExecuteNodeHostCommandParams,
): Promise<AgentToolResult<ExecToolDetails>> {
const { hostSecurity, hostAsk, askFallback } = resolveExecHostApprovalContext({
const { hostSecurity, hostAsk, askFallback } = execHostShared.resolveExecHostApprovalContext({
agentId: params.agentId,
security: params.security,
ask: params.ask,
@ -216,22 +208,14 @@ export async function executeNodeHostCommand(
}) satisfies Record<string, unknown>;
if (requiresAsk) {
const {
approvalId,
approvalSlug,
warningText,
expiresAtMs,
preResolvedDecision,
initiatingSurface,
sentApproverDms,
unavailableReason,
} = await createAndRegisterDefaultExecApprovalRequest({
const requestArgs = execHostShared.buildDefaultExecApprovalRequestArgs({
warnings: params.warnings,
approvalRunningNoticeMs: params.approvalRunningNoticeMs,
createApprovalSlug,
turnSourceChannel: params.turnSourceChannel,
turnSourceAccountId: params.turnSourceAccountId,
register: async (approvalId) =>
});
const registerNodeApproval = async (approvalId: string) =>
await registerExecApprovalRequestForHostOrThrow({
approvalId,
systemRunPlan: prepared.plan,
@ -246,36 +230,55 @@ export async function executeNodeHostCommand(
sessionKey: runSessionKey,
}),
...buildExecApprovalTurnSourceContext(params),
}),
});
void (async () => {
const decision = await resolveApprovalDecisionOrUndefined({
const {
approvalId,
approvalSlug,
warningText,
expiresAtMs,
preResolvedDecision,
onFailure: () =>
void sendExecApprovalFollowup({
initiatingSurface,
sentApproverDms,
unavailableReason,
} = await execHostShared.createAndRegisterDefaultExecApprovalRequest({
...requestArgs,
register: registerNodeApproval,
});
const followupTarget = execHostShared.buildExecApprovalFollowupTarget({
approvalId,
sessionKey: params.notifySessionKey,
turnSourceChannel: params.turnSourceChannel,
turnSourceTo: params.turnSourceTo,
turnSourceAccountId: params.turnSourceAccountId,
turnSourceThreadId: params.turnSourceThreadId,
resultText: `Exec denied (node=${nodeId} id=${approvalId}, approval-request-failed): ${params.command}`,
}),
});
void (async () => {
const decision = await execHostShared.resolveApprovalDecisionOrUndefined({
approvalId,
preResolvedDecision,
onFailure: () =>
void execHostShared.sendExecApprovalFollowupResult(
followupTarget,
`Exec denied (node=${nodeId} id=${approvalId}, approval-request-failed): ${params.command}`,
),
});
if (decision === undefined) {
return;
}
const baseDecision = resolveBaseExecApprovalDecision({
const {
baseDecision,
approvedByAsk: initialApprovedByAsk,
deniedReason: initialDeniedReason,
} = execHostShared.createExecApprovalDecisionState({
decision,
askFallback,
obfuscationDetected: obfuscation.detected,
});
let approvedByAsk = baseDecision.approvedByAsk;
let approvedByAsk = initialApprovedByAsk;
let approvalDecision: "allow-once" | "allow-always" | null = null;
let deniedReason = baseDecision.deniedReason;
let deniedReason = initialDeniedReason;
if (baseDecision.timedOut && askFallback === "full" && approvedByAsk) {
approvalDecision = "allow-once";
@ -288,15 +291,10 @@ export async function executeNodeHostCommand(
}
if (deniedReason) {
await sendExecApprovalFollowup({
approvalId,
sessionKey: params.notifySessionKey,
turnSourceChannel: params.turnSourceChannel,
turnSourceTo: params.turnSourceTo,
turnSourceAccountId: params.turnSourceAccountId,
turnSourceThreadId: params.turnSourceThreadId,
resultText: `Exec denied (node=${nodeId} id=${approvalId}, ${deniedReason}): ${params.command}`,
}).catch(() => {});
await execHostShared.sendExecApprovalFollowupResult(
followupTarget,
`Exec denied (node=${nodeId} id=${approvalId}, ${deniedReason}): ${params.command}`,
);
return;
}
@ -330,76 +328,28 @@ export async function executeNodeHostCommand(
const summary = output
? `Exec finished (node=${nodeId} id=${approvalId}, ${exitLabel})\n${output}`
: `Exec finished (node=${nodeId} id=${approvalId}, ${exitLabel})`;
await sendExecApprovalFollowup({
approvalId,
sessionKey: params.notifySessionKey,
turnSourceChannel: params.turnSourceChannel,
turnSourceTo: params.turnSourceTo,
turnSourceAccountId: params.turnSourceAccountId,
turnSourceThreadId: params.turnSourceThreadId,
resultText: summary,
}).catch(() => {});
await execHostShared.sendExecApprovalFollowupResult(followupTarget, summary);
} catch {
await sendExecApprovalFollowup({
approvalId,
sessionKey: params.notifySessionKey,
turnSourceChannel: params.turnSourceChannel,
turnSourceTo: params.turnSourceTo,
turnSourceAccountId: params.turnSourceAccountId,
turnSourceThreadId: params.turnSourceThreadId,
resultText: `Exec denied (node=${nodeId} id=${approvalId}, invoke-failed): ${params.command}`,
}).catch(() => {});
await execHostShared.sendExecApprovalFollowupResult(
followupTarget,
`Exec denied (node=${nodeId} id=${approvalId}, invoke-failed): ${params.command}`,
);
}
})();
return {
content: [
{
type: "text",
text:
unavailableReason !== null
? (buildExecApprovalUnavailableReplyPayload({
warningText,
reason: unavailableReason,
channelLabel: initiatingSurface.channelLabel,
sentApproverDms,
}).text ?? "")
: buildApprovalPendingMessage({
warningText,
approvalSlug,
approvalId,
command: prepared.plan.commandText,
cwd: runCwd,
host: "node",
nodeId,
}),
},
],
details:
unavailableReason !== null
? ({
status: "approval-unavailable",
reason: unavailableReason,
channelLabel: initiatingSurface.channelLabel,
sentApproverDms,
return execHostShared.buildExecApprovalPendingToolResult({
host: "node",
command: params.command,
cwd: params.workdir,
nodeId,
warningText,
} satisfies ExecToolDetails)
: ({
status: "approval-pending",
approvalId,
approvalSlug,
expiresAtMs,
host: "node",
command: params.command,
cwd: params.workdir,
initiatingSurface,
sentApproverDms,
unavailableReason,
nodeId,
warningText,
} satisfies ExecToolDetails),
};
});
}
const startedAt = Date.now();

View File

@ -1,5 +1,7 @@
import crypto from "node:crypto";
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import { loadConfig } from "../config/config.js";
import { buildExecApprovalUnavailableReplyPayload } from "../infra/exec-approval-reply.js";
import {
hasConfiguredExecApprovalDmRoute,
type ExecApprovalInitiatingSurfaceState,
@ -12,11 +14,14 @@ import {
type ExecAsk,
type ExecSecurity,
} from "../infra/exec-approvals.js";
import { sendExecApprovalFollowup } from "./bash-tools.exec-approval-followup.js";
import {
type ExecApprovalRegistration,
resolveRegisteredExecApprovalDecision,
} from "./bash-tools.exec-approval-request.js";
import { buildApprovalPendingMessage } from "./bash-tools.exec-runtime.js";
import { DEFAULT_APPROVAL_TIMEOUT_MS } from "./bash-tools.exec-runtime.js";
import type { ExecToolDetails } from "./bash-tools.exec-types.js";
type ResolvedExecApprovals = ReturnType<typeof resolveExecApprovals>;
@ -53,6 +58,23 @@ export type RegisteredExecApprovalRequestContext = {
unavailableReason: ExecApprovalUnavailableReason | null;
};
export type ExecApprovalFollowupTarget = {
approvalId: string;
sessionKey?: string;
turnSourceChannel?: string;
turnSourceTo?: string;
turnSourceAccountId?: string;
turnSourceThreadId?: string | number;
};
export type DefaultExecApprovalRequestArgs = {
warnings: string[];
approvalRunningNoticeMs: number;
createApprovalSlug: (approvalId: string) => string;
turnSourceChannel?: string;
turnSourceAccountId?: string;
};
export function createExecApprovalPendingState(params: {
warnings: string[];
timeoutMs: number;
@ -257,3 +279,123 @@ export async function createAndRegisterDefaultExecApprovalRequest(params: {
unavailableReason,
};
}
export function buildDefaultExecApprovalRequestArgs(
params: DefaultExecApprovalRequestArgs,
): DefaultExecApprovalRequestArgs {
return {
warnings: params.warnings,
approvalRunningNoticeMs: params.approvalRunningNoticeMs,
createApprovalSlug: params.createApprovalSlug,
turnSourceChannel: params.turnSourceChannel,
turnSourceAccountId: params.turnSourceAccountId,
};
}
export function buildExecApprovalFollowupTarget(
params: ExecApprovalFollowupTarget,
): ExecApprovalFollowupTarget {
return {
approvalId: params.approvalId,
sessionKey: params.sessionKey,
turnSourceChannel: params.turnSourceChannel,
turnSourceTo: params.turnSourceTo,
turnSourceAccountId: params.turnSourceAccountId,
turnSourceThreadId: params.turnSourceThreadId,
};
}
export function createExecApprovalDecisionState(params: {
decision: string | null | undefined;
askFallback: ResolvedExecApprovals["agent"]["askFallback"];
obfuscationDetected: boolean;
}) {
const baseDecision = resolveBaseExecApprovalDecision({
decision: params.decision ?? null,
askFallback: params.askFallback,
obfuscationDetected: params.obfuscationDetected,
});
return {
baseDecision,
approvedByAsk: baseDecision.approvedByAsk,
deniedReason: baseDecision.deniedReason,
};
}
export async function sendExecApprovalFollowupResult(
target: ExecApprovalFollowupTarget,
resultText: string,
): Promise<void> {
await sendExecApprovalFollowup({
approvalId: target.approvalId,
sessionKey: target.sessionKey,
turnSourceChannel: target.turnSourceChannel,
turnSourceTo: target.turnSourceTo,
turnSourceAccountId: target.turnSourceAccountId,
turnSourceThreadId: target.turnSourceThreadId,
resultText,
}).catch(() => {});
}
export function buildExecApprovalPendingToolResult(params: {
host: "gateway" | "node";
command: string;
cwd: string;
warningText: string;
approvalId: string;
approvalSlug: string;
expiresAtMs: number;
initiatingSurface: ExecApprovalInitiatingSurfaceState;
sentApproverDms: boolean;
unavailableReason: ExecApprovalUnavailableReason | null;
nodeId?: string;
}): AgentToolResult<ExecToolDetails> {
return {
content: [
{
type: "text",
text:
params.unavailableReason !== null
? (buildExecApprovalUnavailableReplyPayload({
warningText: params.warningText,
reason: params.unavailableReason,
channelLabel: params.initiatingSurface.channelLabel,
sentApproverDms: params.sentApproverDms,
}).text ?? "")
: buildApprovalPendingMessage({
warningText: params.warningText,
approvalSlug: params.approvalSlug,
approvalId: params.approvalId,
command: params.command,
cwd: params.cwd,
host: params.host,
nodeId: params.nodeId,
}),
},
],
details:
params.unavailableReason !== null
? ({
status: "approval-unavailable",
reason: params.unavailableReason,
channelLabel: params.initiatingSurface.channelLabel,
sentApproverDms: params.sentApproverDms,
host: params.host,
command: params.command,
cwd: params.cwd,
nodeId: params.nodeId,
warningText: params.warningText,
} satisfies ExecToolDetails)
: ({
status: "approval-pending",
approvalId: params.approvalId,
approvalSlug: params.approvalSlug,
expiresAtMs: params.expiresAtMs,
host: params.host,
command: params.command,
cwd: params.cwd,
nodeId: params.nodeId,
warningText: params.warningText,
} satisfies ExecToolDetails),
};
}