Files
Moltbot/src/agents/bash-tools.exec-host-gateway.ts
2026-02-22 12:58:55 +01:00

343 lines
11 KiB
TypeScript

import crypto from "node:crypto";
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import {
addAllowlistEntry,
type ExecAsk,
type ExecSecurity,
buildSafeBinsShellCommand,
buildSafeShellCommand,
evaluateShellAllowlist,
maxAsk,
minSecurity,
recordAllowlistUse,
requiresExecApproval,
resolveAllowAlwaysPatterns,
resolveExecApprovals,
} from "../infra/exec-approvals.js";
import type { SafeBinProfile } from "../infra/exec-safe-bin-policy.js";
import { markBackgrounded, tail } from "./bash-process-registry.js";
import { requestExecApprovalDecision } from "./bash-tools.exec-approval-request.js";
import {
DEFAULT_APPROVAL_TIMEOUT_MS,
DEFAULT_NOTIFY_TAIL_CHARS,
createApprovalSlug,
emitExecSystemEvent,
normalizeNotifyOutput,
runExecProcess,
} from "./bash-tools.exec-runtime.js";
import type { ExecToolDetails } from "./bash-tools.exec-types.js";
export type ProcessGatewayAllowlistParams = {
command: string;
workdir: string;
env: Record<string, string>;
pty: boolean;
timeoutSec?: number;
defaultTimeoutSec: number;
security: ExecSecurity;
ask: ExecAsk;
safeBins: Set<string>;
safeBinProfiles: Readonly<Record<string, SafeBinProfile>>;
agentId?: string;
sessionKey?: string;
scopeKey?: string;
warnings: string[];
notifySessionKey?: string;
approvalRunningNoticeMs: number;
maxOutput: number;
pendingMaxOutput: number;
trustedSafeBinDirs?: ReadonlySet<string>;
};
export type ProcessGatewayAllowlistResult = {
execCommandOverride?: string;
pendingResult?: AgentToolResult<ExecToolDetails>;
};
export async function processGatewayAllowlist(
params: ProcessGatewayAllowlistParams,
): Promise<ProcessGatewayAllowlistResult> {
const approvals = resolveExecApprovals(params.agentId, {
security: params.security,
ask: params.ask,
});
const hostSecurity = minSecurity(params.security, approvals.agent.security);
const hostAsk = maxAsk(params.ask, approvals.agent.ask);
const askFallback = approvals.agent.askFallback;
if (hostSecurity === "deny") {
throw new Error("exec denied: host=gateway security=deny");
}
const allowlistEval = evaluateShellAllowlist({
command: params.command,
allowlist: approvals.allowlist,
safeBins: params.safeBins,
safeBinProfiles: params.safeBinProfiles,
cwd: params.workdir,
env: params.env,
platform: process.platform,
trustedSafeBinDirs: params.trustedSafeBinDirs,
});
const allowlistMatches = allowlistEval.allowlistMatches;
const analysisOk = allowlistEval.analysisOk;
const allowlistSatisfied =
hostSecurity === "allowlist" && analysisOk ? allowlistEval.allowlistSatisfied : false;
const hasHeredocSegment = allowlistEval.segments.some((segment) =>
segment.argv.some((token) => token.startsWith("<<")),
);
const requiresHeredocApproval =
hostSecurity === "allowlist" && analysisOk && allowlistSatisfied && hasHeredocSegment;
const requiresAsk =
requiresExecApproval({
ask: hostAsk,
security: hostSecurity,
analysisOk,
allowlistSatisfied,
}) || requiresHeredocApproval;
if (requiresHeredocApproval) {
params.warnings.push(
"Warning: heredoc execution requires explicit approval in allowlist mode.",
);
}
if (requiresAsk) {
const approvalId = crypto.randomUUID();
const approvalSlug = createApprovalSlug(approvalId);
const expiresAtMs = Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS;
const contextKey = `exec:${approvalId}`;
const resolvedPath = allowlistEval.segments[0]?.resolution?.resolvedPath;
const noticeSeconds = Math.max(1, Math.round(params.approvalRunningNoticeMs / 1000));
const effectiveTimeout =
typeof params.timeoutSec === "number" ? params.timeoutSec : params.defaultTimeoutSec;
const warningText = params.warnings.length ? `${params.warnings.join("\n")}\n\n` : "";
void (async () => {
let decision: string | null = null;
try {
decision = await requestExecApprovalDecision({
id: approvalId,
command: params.command,
cwd: params.workdir,
host: "gateway",
security: hostSecurity,
ask: hostAsk,
agentId: params.agentId,
resolvedPath,
sessionKey: params.sessionKey,
});
} catch {
emitExecSystemEvent(
`Exec denied (gateway id=${approvalId}, approval-request-failed): ${params.command}`,
{
sessionKey: params.notifySessionKey,
contextKey,
},
);
return;
}
let approvedByAsk = false;
let deniedReason: string | null = null;
if (decision === "deny") {
deniedReason = "user-denied";
} else if (!decision) {
if (askFallback === "full") {
approvedByAsk = true;
} else if (askFallback === "allowlist") {
if (!analysisOk || !allowlistSatisfied) {
deniedReason = "approval-timeout (allowlist-miss)";
} else {
approvedByAsk = true;
}
} else {
deniedReason = "approval-timeout";
}
} else if (decision === "allow-once") {
approvedByAsk = true;
} else if (decision === "allow-always") {
approvedByAsk = true;
if (hostSecurity === "allowlist") {
const patterns = resolveAllowAlwaysPatterns({
segments: allowlistEval.segments,
cwd: params.workdir,
env: params.env,
platform: process.platform,
});
for (const pattern of patterns) {
if (pattern) {
addAllowlistEntry(approvals.file, params.agentId, pattern);
}
}
}
}
if (hostSecurity === "allowlist" && (!analysisOk || !allowlistSatisfied) && !approvedByAsk) {
deniedReason = deniedReason ?? "allowlist-miss";
}
if (deniedReason) {
emitExecSystemEvent(
`Exec denied (gateway id=${approvalId}, ${deniedReason}): ${params.command}`,
{
sessionKey: params.notifySessionKey,
contextKey,
},
);
return;
}
if (allowlistMatches.length > 0) {
const seen = new Set<string>();
for (const match of allowlistMatches) {
if (seen.has(match.pattern)) {
continue;
}
seen.add(match.pattern);
recordAllowlistUse(
approvals.file,
params.agentId,
match,
params.command,
resolvedPath ?? undefined,
);
}
}
let run: Awaited<ReturnType<typeof runExecProcess>> | null = null;
try {
run = await runExecProcess({
command: params.command,
workdir: params.workdir,
env: params.env,
sandbox: undefined,
containerWorkdir: null,
usePty: params.pty,
warnings: params.warnings,
maxOutput: params.maxOutput,
pendingMaxOutput: params.pendingMaxOutput,
notifyOnExit: false,
notifyOnExitEmptySuccess: false,
scopeKey: params.scopeKey,
sessionKey: params.notifySessionKey,
timeoutSec: effectiveTimeout,
});
} catch {
emitExecSystemEvent(
`Exec denied (gateway id=${approvalId}, spawn-failed): ${params.command}`,
{
sessionKey: params.notifySessionKey,
contextKey,
},
);
return;
}
markBackgrounded(run.session);
let runningTimer: NodeJS.Timeout | null = null;
if (params.approvalRunningNoticeMs > 0) {
runningTimer = setTimeout(() => {
emitExecSystemEvent(
`Exec running (gateway id=${approvalId}, session=${run?.session.id}, >${noticeSeconds}s): ${params.command}`,
{ sessionKey: params.notifySessionKey, contextKey },
);
}, params.approvalRunningNoticeMs);
}
const outcome = await run.promise;
if (runningTimer) {
clearTimeout(runningTimer);
}
const output = normalizeNotifyOutput(
tail(outcome.aggregated || "", DEFAULT_NOTIFY_TAIL_CHARS),
);
const exitLabel = outcome.timedOut ? "timeout" : `code ${outcome.exitCode ?? "?"}`;
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})`;
emitExecSystemEvent(summary, { sessionKey: params.notifySessionKey, contextKey });
})();
return {
pendingResult: {
content: [
{
type: "text",
text:
`${warningText}Approval required (id ${approvalSlug}). ` +
"Approve to run; updates will arrive after completion.",
},
],
details: {
status: "approval-pending",
approvalId,
approvalSlug,
expiresAtMs,
host: "gateway",
command: params.command,
cwd: params.workdir,
},
},
};
}
if (hostSecurity === "allowlist" && (!analysisOk || !allowlistSatisfied)) {
throw new Error("exec denied: allowlist miss");
}
let execCommandOverride: string | undefined;
// If allowlist uses safeBins, sanitize only those stdin-only segments:
// disable glob/var expansion by forcing argv tokens to be literal via single-quoting.
if (
hostSecurity === "allowlist" &&
analysisOk &&
allowlistSatisfied &&
allowlistEval.segmentSatisfiedBy.some((by) => by === "safeBins")
) {
const safe = buildSafeBinsShellCommand({
command: params.command,
segments: allowlistEval.segments,
segmentSatisfiedBy: allowlistEval.segmentSatisfiedBy,
platform: process.platform,
});
if (!safe.ok || !safe.command) {
// Fallback: quote everything (safe, but may change glob behavior).
const fallback = buildSafeShellCommand({
command: params.command,
platform: process.platform,
});
if (!fallback.ok || !fallback.command) {
throw new Error(`exec denied: safeBins sanitize failed (${safe.reason ?? "unknown"})`);
}
params.warnings.push(
"Warning: safeBins hardening used fallback quoting due to parser mismatch.",
);
execCommandOverride = fallback.command;
} else {
params.warnings.push(
"Warning: safeBins hardening disabled glob/variable expansion for stdin-only segments.",
);
execCommandOverride = safe.command;
}
}
if (allowlistMatches.length > 0) {
const seen = new Set<string>();
for (const match of allowlistMatches) {
if (seen.has(match.pattern)) {
continue;
}
seen.add(match.pattern);
recordAllowlistUse(
approvals.file,
params.agentId,
match,
params.command,
allowlistEval.segments[0]?.resolution?.resolvedPath,
);
}
}
return { execCommandOverride };
}