/** * Gateway-host exec approval tests. * Covers allowlist misses, auto-review, strict inline eval, diagnostics * follow-ups, and gateway approval result routing. */ import { beforeAll, beforeEach, describe, expect, it, vi, type Mock } from "vitest"; import { onInternalDiagnosticEvent, resetDiagnosticEventsForTest, type DiagnosticSecurityEvent, } from "../infra/diagnostic-events.js"; import type { ExecApprovalDecision, ExecCommandSegment, ExecSegmentSatisfiedBy, } from "../infra/exec-approvals.js"; import { planShellAuthorization, type ExecAuthorizationPlan, } from "../infra/exec-authorization-plan.js"; import type { ExecApprovalFollowupTarget } from "./bash-tools.exec-host-shared.js"; import type { ExecApprovalFollowupFactory } from "./bash-tools.exec-types.js"; type StrictInlineEvalBoundary = typeof import("./bash-tools.exec-host-shared.js").enforceStrictInlineEvalApprovalBoundary; type SendExecApprovalFollowupResult = typeof import("./bash-tools.exec-host-shared.js").sendExecApprovalFollowupResult; type ExecAutoReviewer = typeof import("../infra/exec-auto-review.js").defaultExecAutoReviewer; type BuildExecApprovalFollowupTargetMock = ( value: ExecApprovalFollowupTarget, ) => ExecApprovalFollowupTarget | null; type MockAllowlistSegment = Omit & { raw?: string }; type MockAllowlistResult = { allowlistMatches: unknown[]; analysisOk: boolean; allowlistSatisfied: boolean; segments: MockAllowlistSegment[]; segmentAllowlistEntries: unknown[]; segmentSatisfiedBy?: ExecSegmentSatisfiedBy[]; authorizationPlan?: ExecAuthorizationPlan; }; const INLINE_EVAL_HIT = { executable: "python3", normalizedExecutable: "python3", flag: "-c", argv: ["python3", "-c", "print(1)"], }; const createAndRegisterDefaultExecApprovalRequestMock = vi.hoisted(() => vi.fn()); const buildExecApprovalPendingToolResultMock = vi.hoisted(() => vi.fn()); const buildExecApprovalFollowupTargetMock = vi.hoisted(() => vi.fn(() => null), ); const createExecApprovalDecisionStateMock = vi.hoisted(() => vi.fn( (): { baseDecision: { timedOut: boolean }; approvedByAsk: boolean; deniedReason: string | null; } => ({ baseDecision: { timedOut: false }, approvedByAsk: false, deniedReason: "approval-required", }), ), ); const evaluateShellAllowlistWithAuthorizationMock = vi.hoisted(() => vi.fn( (): MockAllowlistResult => ({ allowlistMatches: [], analysisOk: true, allowlistSatisfied: true, segments: [{ resolution: null, argv: ["echo", "ok"] }], segmentAllowlistEntries: [{ pattern: "/usr/bin/echo", source: "allow-always" }], segmentSatisfiedBy: [], }), ), ); const hasDurableExecApprovalMock = vi.hoisted(() => vi.fn(() => true)); const hasExactCommandDurableExecApprovalMock = vi.hoisted(() => vi.fn(() => false)); const requiresExecApprovalMock = vi.hoisted(() => vi.fn(() => false)); const resolveExecApprovalAllowedDecisionsMock = vi.hoisted(() => vi.fn( (params?: { ask?: string | null; allowAlwaysPersistence?: { kind: string } | null; }): readonly ExecApprovalDecision[] => params?.ask === "always" || params?.allowAlwaysPersistence?.kind === "one-shot" ? ["allow-once", "deny"] : ["allow-once", "allow-always", "deny"], ), ); const resolveExecApprovalUnavailableDecisionsMock = vi.hoisted(() => vi.fn( (params?: { ask?: string | null; allowAlwaysPersistence?: { kind: string } | null; }): readonly ["allow-always"] | readonly [] => params?.ask === "always" || params?.allowAlwaysPersistence?.kind === "one-shot" ? ["allow-always"] : [], ), ); const buildEnforcedShellCommandMock = vi.hoisted(() => vi.fn((): { ok: boolean; reason?: string; command?: string } => ({ ok: false, reason: "segment execution plan unavailable", })), ); const defaultExecAutoReviewerMock = vi.hoisted(() => vi.fn(async () => ({ decision: "allow-once", risk: "low", rationale: "allowed", })), ); const recordAllowlistMatchesUseMock = vi.hoisted(() => vi.fn()); const resolveApprovalDecisionOrUndefinedMock = vi.hoisted(() => vi.fn(async (): Promise => undefined), ); const resolveExecHostApprovalContextMock = vi.hoisted(() => vi.fn(() => ({ approvals: { allowlist: [], file: { version: 1, agents: {} } }, hostSecurity: "allowlist", hostAsk: "off", askFallback: "deny", })), ); const runExecProcessMock = vi.hoisted(() => vi.fn()); const sendExecApprovalFollowupResultMock = vi.hoisted(() => vi.fn(async () => undefined), ); const shouldResolveExecApprovalUnavailableInlineMock = vi.hoisted(() => vi.fn(() => false)); const enforceStrictInlineEvalApprovalBoundaryMock = vi.hoisted(() => vi.fn((value) => ({ approvedByAsk: value.approvedByAsk, deniedReason: value.deniedReason, })), ); const detectInterpreterInlineEvalArgvMock = vi.hoisted(() => vi.fn( (): { executable: string; normalizedExecutable: string; flag: string; argv: string[]; } | null => null, ), ); vi.mock("../infra/exec-approvals.js", async (importOriginal) => ({ ...(await importOriginal()), evaluateShellAllowlistWithAuthorization: evaluateShellAllowlistWithAuthorizationMock, hasDurableExecApproval: hasDurableExecApprovalMock, hasExactCommandDurableExecApproval: hasExactCommandDurableExecApprovalMock, buildEnforcedShellCommand: buildEnforcedShellCommandMock, requiresExecApproval: requiresExecApprovalMock, recordAllowlistUse: vi.fn(), recordAllowlistMatchesUse: recordAllowlistMatchesUseMock, resolveApprovalAuditTrustPath: vi.fn(() => null), resolveAllowAlwaysPatterns: vi.fn(() => []), resolveExecApprovalAllowedDecisions: resolveExecApprovalAllowedDecisionsMock, resolveExecApprovalUnavailableDecisions: resolveExecApprovalUnavailableDecisionsMock, addAllowlistEntry: vi.fn(), addDurableCommandApproval: vi.fn(), })); vi.mock("../infra/exec-auto-review.js", () => ({ defaultExecAutoReviewer: defaultExecAutoReviewerMock, })); vi.mock("./bash-tools.exec-approval-request.js", () => ({ buildExecApprovalRequesterContext: vi.fn(() => ({})), buildExecApprovalTurnSourceContext: vi.fn(() => ({})), registerExecApprovalRequestForHostOrThrow: vi.fn(async () => undefined), })); vi.mock("./bash-tools.exec-host-shared.js", () => ({ resolveExecHostApprovalContext: resolveExecHostApprovalContextMock, buildDefaultExecApprovalRequestArgs: vi.fn(() => ({})), buildHeadlessExecApprovalDeniedMessage: vi.fn(() => "denied"), buildExecApprovalFollowupTarget: buildExecApprovalFollowupTargetMock, buildExecApprovalPendingToolResult: buildExecApprovalPendingToolResultMock, createExecApprovalDecisionState: createExecApprovalDecisionStateMock, createAndRegisterDefaultExecApprovalRequest: createAndRegisterDefaultExecApprovalRequestMock, enforceStrictInlineEvalApprovalBoundary: enforceStrictInlineEvalApprovalBoundaryMock, resolveApprovalDecisionOrUndefined: resolveApprovalDecisionOrUndefinedMock, sendExecApprovalFollowupResult: sendExecApprovalFollowupResultMock, shouldResolveExecApprovalUnavailableInline: shouldResolveExecApprovalUnavailableInlineMock, })); vi.mock("./bash-tools.exec-runtime.js", () => ({ DEFAULT_NOTIFY_TAIL_CHARS: 1000, createApprovalSlug: vi.fn(() => "slug"), normalizeNotifyOutput: vi.fn((value) => value), runExecProcess: runExecProcessMock, })); vi.mock("./bash-process-registry.js", () => ({ markBackgrounded: vi.fn(), tail: vi.fn((value) => value), })); vi.mock("../infra/command-analysis/inline-eval.js", async (importOriginal) => ({ ...(await importOriginal()), describeInterpreterInlineEval: vi.fn(() => "python -c"), detectInterpreterInlineEvalArgv: detectInterpreterInlineEvalArgvMock, })); let processGatewayAllowlist: typeof import("./bash-tools.exec-host-gateway.js").processGatewayAllowlist; type GatewayAllowlistParams = Parameters[0]; function requireBuildFollowupTargetInput(callIndex: number): ExecApprovalFollowupTarget { const call = buildExecApprovalFollowupTargetMock.mock.calls[callIndex]; if (!call) { throw new Error(`expected build followup target call ${callIndex}`); } return call[0]; } function requireSentFollowupTarget( callIndex: number, ): Parameters[0] { const call = sendExecApprovalFollowupResultMock.mock.calls[callIndex]; if (!call) { throw new Error(`expected sent followup call ${callIndex}`); } return call[0]; } function requireSentFollowupText(callIndex: number): string { const call = sendExecApprovalFollowupResultMock.mock.calls[callIndex]; if (!call) { throw new Error(`expected sent followup call ${callIndex}`); } return call[1] ?? ""; } function requireApprovalFollowupInput( mock: Mock, callIndex: number, ): Parameters[0] { const call = mock.mock.calls[callIndex]; if (!call) { throw new Error(`expected approval followup call ${callIndex}`); } return call[0]; } function captureSecurityEvents(): { events: DiagnosticSecurityEvent[]; stop: () => void; } { const events: DiagnosticSecurityEvent[] = []; const stop = onInternalDiagnosticEvent((event, metadata) => { if (metadata.trusted && event.type === "security.event") { events.push(event); } }); return { events, stop }; } describe("processGatewayAllowlist", () => { beforeAll(async () => { ({ processGatewayAllowlist } = await import("./bash-tools.exec-host-gateway.js")); }); beforeEach(() => { resetDiagnosticEventsForTest(); buildExecApprovalPendingToolResultMock.mockReset(); buildExecApprovalFollowupTargetMock.mockReset(); buildExecApprovalFollowupTargetMock.mockReturnValue(null); createExecApprovalDecisionStateMock.mockReset(); createExecApprovalDecisionStateMock.mockReturnValue({ baseDecision: { timedOut: false }, approvedByAsk: false, deniedReason: "approval-required", }); evaluateShellAllowlistWithAuthorizationMock.mockReset(); evaluateShellAllowlistWithAuthorizationMock.mockReturnValue({ allowlistMatches: [], analysisOk: true, allowlistSatisfied: true, segments: [{ resolution: null, argv: ["echo", "ok"] }], segmentAllowlistEntries: [{ pattern: "/usr/bin/echo", source: "allow-always" }], segmentSatisfiedBy: [], }); hasDurableExecApprovalMock.mockReset(); hasDurableExecApprovalMock.mockReturnValue(true); hasExactCommandDurableExecApprovalMock.mockReset(); hasExactCommandDurableExecApprovalMock.mockReturnValue(false); requiresExecApprovalMock.mockReset(); requiresExecApprovalMock.mockReturnValue(false); resolveExecApprovalAllowedDecisionsMock.mockClear(); buildEnforcedShellCommandMock.mockReset(); buildEnforcedShellCommandMock.mockReturnValue({ ok: false, reason: "segment execution plan unavailable", }); defaultExecAutoReviewerMock.mockReset(); defaultExecAutoReviewerMock.mockResolvedValue({ decision: "allow-once", risk: "low", rationale: "allowed", }); recordAllowlistMatchesUseMock.mockReset(); resolveApprovalDecisionOrUndefinedMock.mockReset(); resolveApprovalDecisionOrUndefinedMock.mockResolvedValue(undefined); shouldResolveExecApprovalUnavailableInlineMock.mockReset(); shouldResolveExecApprovalUnavailableInlineMock.mockReturnValue(false); resolveExecHostApprovalContextMock.mockReset(); resolveExecHostApprovalContextMock.mockReturnValue({ approvals: { allowlist: [], file: { version: 1, agents: {} } }, hostSecurity: "allowlist", hostAsk: "off", askFallback: "deny", }); runExecProcessMock.mockReset(); sendExecApprovalFollowupResultMock.mockReset(); enforceStrictInlineEvalApprovalBoundaryMock.mockReset(); enforceStrictInlineEvalApprovalBoundaryMock.mockImplementation((value) => ({ approvedByAsk: value.approvedByAsk, deniedReason: value.deniedReason, })); detectInterpreterInlineEvalArgvMock.mockReset(); detectInterpreterInlineEvalArgvMock.mockReturnValue(null); resolveExecApprovalUnavailableDecisionsMock.mockClear(); buildExecApprovalPendingToolResultMock.mockReturnValue({ details: { status: "approval-pending" }, content: [], }); createAndRegisterDefaultExecApprovalRequestMock.mockReset(); createAndRegisterDefaultExecApprovalRequestMock.mockResolvedValue({ approvalId: "req-1", approvalSlug: "slug-1", warningText: "", expiresAtMs: Date.now() + 60_000, preResolvedDecision: null, initiatingSurface: "origin", sentApproverDms: false, unavailableReason: null, }); }); function runGatewayAllowlist( overrides: Partial & Pick, ) { const { command, ...rest } = overrides; return processGatewayAllowlist({ command, workdir: process.cwd(), env: process.env as Record, pty: false, defaultTimeoutSec: 30, security: "allowlist", ask: "off", safeBins: new Set(), safeBinProfiles: {}, warnings: [], approvalRunningNoticeMs: 0, maxOutput: 1000, pendingMaxOutput: 1000, ...rest, }); } async function runTimedOutStrictInlineEval(params: { security: "full" | "allowlist"; askFallback: "full" | "allowlist"; approvedByAsk: boolean; }) { buildExecApprovalFollowupTargetMock.mockImplementation((value) => value); resolveExecHostApprovalContextMock.mockReturnValue({ approvals: { allowlist: [], file: { version: 1, agents: {} } }, hostSecurity: params.security, hostAsk: "always", askFallback: params.askFallback, }); detectInterpreterInlineEvalArgvMock.mockReturnValue(INLINE_EVAL_HIT); resolveApprovalDecisionOrUndefinedMock.mockResolvedValue(null); createExecApprovalDecisionStateMock.mockReturnValue({ baseDecision: { timedOut: true }, approvedByAsk: params.approvedByAsk, deniedReason: null, }); enforceStrictInlineEvalApprovalBoundaryMock.mockReturnValue({ approvedByAsk: false, deniedReason: "approval-timeout", }); return runGatewayAllowlist({ command: "python3 -c 'print(1)'", security: params.security, ask: "always", strictInlineEval: true, sessionKey: "agent:main:main", }); } it("still requires approval when allowlist execution plan is unavailable despite durable trust", async () => { const result = await runGatewayAllowlist({ command: "echo ok", }); expect(createAndRegisterDefaultExecApprovalRequestMock).toHaveBeenCalledTimes(1); expect(result.pendingResult?.details.status).toBe("approval-pending"); }); it("emits security events for gateway exec approval requests and denials", async () => { resolveApprovalDecisionOrUndefinedMock.mockResolvedValue("deny"); createExecApprovalDecisionStateMock.mockReturnValue({ baseDecision: { timedOut: false }, approvedByAsk: false, deniedReason: "user-denied", }); const captured = captureSecurityEvents(); let result: Awaited>; try { result = await runGatewayAllowlist({ command: "deploy --token raw-secret-value", turnSourceChannel: "webchat", agentId: "agent-1", }); } finally { captured.stop(); } expect(result!.deniedResult?.details.status).toBe("failed"); expect(captured.events).toHaveLength(2); expect(captured.events[0]).toMatchObject({ action: "exec.approval.requested", outcome: "success", severity: "low", category: "approval", actor: { kind: "agent" }, target: { kind: "tool", name: "system.exec", owner: "gateway" }, policy: { id: "exec.approval", decision: "ask" }, control: { id: "exec.approval", family: "approval" }, attributes: { host: "gateway", security: "allowlist", ask: "off", segment_count: 1, has_agent_id: true, }, }); expect(captured.events[1]).toMatchObject({ action: "exec.approval.denied", outcome: "denied", severity: "medium", reason: "user-denied", policy: { id: "exec.approval", decision: "deny", reason: "user-denied" }, attributes: { decision: "deny", has_agent_id: true, }, }); const serialized = JSON.stringify(captured.events); expect(serialized).not.toContain("deploy"); expect(serialized).not.toContain("raw-secret-value"); expect(serialized).not.toContain("agent-1"); }); it("emits a denied security event for inline unavailable approval denials", async () => { shouldResolveExecApprovalUnavailableInlineMock.mockReturnValue(true); createExecApprovalDecisionStateMock.mockReturnValue({ baseDecision: { timedOut: false }, approvedByAsk: false, deniedReason: "user-denied", }); enforceStrictInlineEvalApprovalBoundaryMock.mockReturnValue({ approvedByAsk: false, deniedReason: "user-denied", }); const captured = captureSecurityEvents(); try { await expect( runGatewayAllowlist({ command: "deploy --token raw-secret-value", agentId: "agent-1", }), ).rejects.toThrow("denied"); } finally { captured.stop(); } expect(captured.events).toHaveLength(2); expect(captured.events[1]).toMatchObject({ action: "exec.approval.denied", outcome: "denied", severity: "medium", reason: "user-denied", policy: { id: "exec.approval", decision: "deny", reason: "user-denied" }, attributes: { has_agent_id: true, }, }); const serialized = JSON.stringify(captured.events); expect(serialized).not.toContain("deploy"); expect(serialized).not.toContain("raw-secret-value"); expect(serialized).not.toContain("agent-1"); }); it("emits an approved security event for inline unavailable approval approvals", async () => { shouldResolveExecApprovalUnavailableInlineMock.mockReturnValue(true); createExecApprovalDecisionStateMock.mockReturnValue({ baseDecision: { timedOut: false }, approvedByAsk: true, deniedReason: null, }); enforceStrictInlineEvalApprovalBoundaryMock.mockReturnValue({ approvedByAsk: true, deniedReason: null, }); const captured = captureSecurityEvents(); let result: Awaited>; try { result = await runGatewayAllowlist({ command: "echo ok", agentId: "agent-1", }); } finally { captured.stop(); } expect(result!).toEqual({ execCommandOverride: undefined, allowWithoutEnforcedCommand: true, }); expect(captured.events).toHaveLength(2); expect(captured.events[1]).toMatchObject({ action: "exec.approval.approved", outcome: "success", severity: "medium", policy: { id: "exec.approval", decision: "allow" }, attributes: { has_agent_id: true, }, }); expect(JSON.stringify(captured.events)).not.toContain("agent-1"); }); it("auto-reviews simple read-only approval misses without prompting", async () => { requiresExecApprovalMock.mockReturnValue(true); evaluateShellAllowlistWithAuthorizationMock.mockReturnValue({ allowlistMatches: [], analysisOk: true, allowlistSatisfied: false, segments: [{ resolution: null, argv: ["echo", "ok"] }], segmentAllowlistEntries: [], }); resolveExecHostApprovalContextMock.mockReturnValue({ approvals: { allowlist: [], file: { version: 1, agents: {} } }, hostSecurity: "allowlist", hostAsk: "on-miss", askFallback: "deny", }); const result = await runGatewayAllowlist({ command: "echo ok", ask: "on-miss", autoReview: true, }); expect(defaultExecAutoReviewerMock).toHaveBeenCalledWith( expect.objectContaining({ command: "echo ok", argv: ["echo", "ok"], host: "gateway", reason: "approval-required", }), ); expect(createAndRegisterDefaultExecApprovalRequestMock).not.toHaveBeenCalled(); expect(result).toEqual({ execCommandOverride: undefined, allowWithoutEnforcedCommand: true, }); }); it("auto-reviews heredoc commands instead of forcing human approval", async () => { const command = "python3 - <<'PY'\nprint('ok')\nPY"; requiresExecApprovalMock.mockReturnValue(false); buildEnforcedShellCommandMock.mockReturnValue({ ok: true, command, }); evaluateShellAllowlistWithAuthorizationMock.mockReturnValue({ allowlistMatches: [], analysisOk: true, allowlistSatisfied: true, segments: [ { raw: command, resolution: null, argv: ["python3", "-", "<<'PY'"], }, ], segmentAllowlistEntries: [], }); resolveExecHostApprovalContextMock.mockReturnValue({ approvals: { allowlist: [], file: { version: 1, agents: {} } }, hostSecurity: "allowlist", hostAsk: "on-miss", askFallback: "deny", }); const result = await runGatewayAllowlist({ command, ask: "on-miss", autoReview: true, }); expect(defaultExecAutoReviewerMock).toHaveBeenCalledWith( expect.objectContaining({ command, argv: ["python3", "-", "<<'PY'"], host: "gateway", reason: "heredoc", }), ); expect(createAndRegisterDefaultExecApprovalRequestMock).not.toHaveBeenCalled(); expect(result).toEqual({ execCommandOverride: undefined, allowWithoutEnforcedCommand: true, }); }); it("auto-reviews strict inline-eval commands instead of forcing human approval", async () => { const command = "python3 -c 'print(1)'"; requiresExecApprovalMock.mockReturnValue(false); detectInterpreterInlineEvalArgvMock.mockReturnValue(INLINE_EVAL_HIT); buildEnforcedShellCommandMock.mockReturnValue({ ok: true, command, }); evaluateShellAllowlistWithAuthorizationMock.mockReturnValue({ allowlistMatches: [], analysisOk: true, allowlistSatisfied: true, segments: [ { raw: command, resolution: null, argv: ["python3", "-c", "print(1)"], }, ], segmentAllowlistEntries: [], }); resolveExecHostApprovalContextMock.mockReturnValue({ approvals: { allowlist: [], file: { version: 1, agents: {} } }, hostSecurity: "allowlist", hostAsk: "on-miss", askFallback: "deny", }); const warnings: string[] = []; const result = await runGatewayAllowlist({ command, ask: "on-miss", autoReview: true, strictInlineEval: true, warnings, }); expect(defaultExecAutoReviewerMock).toHaveBeenCalledWith( expect.objectContaining({ command, argv: ["python3", "-c", "print(1)"], host: "gateway", reason: "strict-inline-eval", analysis: expect.objectContaining({ inlineEval: true, }), }), ); expect(createAndRegisterDefaultExecApprovalRequestMock).not.toHaveBeenCalled(); expect(warnings[0]).toContain("reviewer or explicit approval"); expect(result).toEqual({ execCommandOverride: undefined, allowWithoutEnforcedCommand: true, }); }); it("uses a plan-backed enforced command when the allowlist plan is usable", async () => { const command = "head -c 16"; const authorizationPlan = await planShellAuthorization({ command, env: { PATH: "/usr/bin:/bin" }, }); expect(authorizationPlan.ok).toBe(true); if (!authorizationPlan.ok) { throw new Error(authorizationPlan.reason); } requiresExecApprovalMock.mockReturnValue(false); evaluateShellAllowlistWithAuthorizationMock.mockReturnValue({ allowlistMatches: [], analysisOk: true, allowlistSatisfied: true, segments: [{ raw: command, resolution: null, argv: ["head", "-c", "16"] }], segmentAllowlistEntries: [], segmentSatisfiedBy: ["safeBins"], authorizationPlan, }); resolveExecHostApprovalContextMock.mockReturnValue({ approvals: { allowlist: [], file: { version: 1, agents: {} } }, hostSecurity: "allowlist", hostAsk: "off", askFallback: "deny", }); const result = await runGatewayAllowlist({ command, ask: "off", }); expect(result).toEqual({ execCommandOverride: "/usr/bin/head -c 16", }); }); it("auto-reviews allowlist plan misses instead of forcing human approval", async () => { const command = "echo ok"; requiresExecApprovalMock.mockReturnValue(false); buildEnforcedShellCommandMock.mockReturnValue({ ok: false, reason: "segment execution plan unavailable", }); evaluateShellAllowlistWithAuthorizationMock.mockReturnValue({ allowlistMatches: [], analysisOk: true, allowlistSatisfied: true, segments: [{ raw: command, resolution: null, argv: ["echo", "ok"] }], segmentAllowlistEntries: [], }); resolveExecHostApprovalContextMock.mockReturnValue({ approvals: { allowlist: [], file: { version: 1, agents: {} } }, hostSecurity: "allowlist", hostAsk: "on-miss", askFallback: "deny", }); const result = await runGatewayAllowlist({ command, ask: "on-miss", autoReview: true, }); expect(defaultExecAutoReviewerMock).toHaveBeenCalledWith( expect.objectContaining({ command, argv: ["echo", "ok"], host: "gateway", reason: "execution-plan-miss", }), ); expect(createAndRegisterDefaultExecApprovalRequestMock).not.toHaveBeenCalled(); expect(result).toEqual({ execCommandOverride: undefined, allowWithoutEnforcedCommand: true, }); }); it("omits allow-always when allowlist execution cannot persist reusable patterns", async () => { const command = "ls *.ts"; const authorizationPlan = await planShellAuthorization({ command, env: { PATH: "/usr/bin:/bin" }, }); expect(authorizationPlan.ok).toBe(true); if (!authorizationPlan.ok) { throw new Error(authorizationPlan.reason); } requiresExecApprovalMock.mockReturnValue(false); hasDurableExecApprovalMock.mockReturnValue(false); evaluateShellAllowlistWithAuthorizationMock.mockReturnValue({ allowlistMatches: [], analysisOk: true, allowlistSatisfied: true, segments: authorizationPlan.groups.flatMap((group) => group.candidates.map((candidate) => candidate.sourceSegment), ), segmentAllowlistEntries: [{ pattern: "/usr/bin/ls", source: "allow-always" }], segmentSatisfiedBy: ["allowlist"], authorizationPlan, }); resolveExecHostApprovalContextMock.mockReturnValue({ approvals: { allowlist: [], file: { version: 1, agents: {} } }, hostSecurity: "allowlist", hostAsk: "on-miss", askFallback: "deny", }); const result = await runGatewayAllowlist({ command, ask: "on-miss", autoReview: false, }); expect(result.pendingResult?.details.status).toBe("approval-pending"); expect(resolveExecApprovalAllowedDecisionsMock).toHaveBeenCalledWith({ ask: "on-miss", allowAlwaysPersistence: { kind: "one-shot", reasons: ["no-reusable-pattern"], }, }); expect(buildExecApprovalPendingToolResultMock).toHaveBeenCalledWith( expect.objectContaining({ allowedDecisions: ["allow-once", "deny"], }), ); }); it("honors durable exact-command trust for unenforceable allowlisted commands", async () => { const command = "ls *.ts"; const authorizationPlan = await planShellAuthorization({ command, env: { PATH: "/usr/bin:/bin" }, }); expect(authorizationPlan.ok).toBe(true); if (!authorizationPlan.ok) { throw new Error(authorizationPlan.reason); } requiresExecApprovalMock.mockReturnValue(false); hasDurableExecApprovalMock.mockReturnValue(true); hasExactCommandDurableExecApprovalMock.mockReturnValue(true); evaluateShellAllowlistWithAuthorizationMock.mockReturnValue({ allowlistMatches: [], analysisOk: true, allowlistSatisfied: true, segments: authorizationPlan.groups.flatMap((group) => group.candidates.map((candidate) => candidate.sourceSegment), ), segmentAllowlistEntries: [{ pattern: "/usr/bin/ls", source: "allow-always" }], segmentSatisfiedBy: ["allowlist"], authorizationPlan, }); resolveExecHostApprovalContextMock.mockReturnValue({ approvals: { allowlist: [], file: { version: 1, agents: {} } }, hostSecurity: "allowlist", hostAsk: "on-miss", askFallback: "deny", }); const result = await runGatewayAllowlist({ command, ask: "on-miss", autoReview: false, }); expect(createAndRegisterDefaultExecApprovalRequestMock).not.toHaveBeenCalled(); expect(result).toEqual({ execCommandOverride: undefined }); }); it("offers allow-always for shell-wrapper misses with reusable executable patterns", async () => { if (process.platform === "win32") { return; } const command = "sh -c 'git status'"; const env = { PATH: "/usr/bin:/bin" }; const approvalsFile = { version: 1, agents: {} }; const authorizationPlan = await planShellAuthorization({ command, env }); expect(authorizationPlan.ok).toBe(true); if (!authorizationPlan.ok) { throw new Error(authorizationPlan.reason); } requiresExecApprovalMock.mockReturnValue(true); evaluateShellAllowlistWithAuthorizationMock.mockReturnValue({ allowlistMatches: [], analysisOk: true, allowlistSatisfied: false, segments: authorizationPlan.groups.flatMap((group) => group.candidates.map((candidate) => candidate.sourceSegment), ), segmentAllowlistEntries: [], segmentSatisfiedBy: [null], authorizationPlan, }); resolveExecHostApprovalContextMock.mockReturnValue({ approvals: { allowlist: [], file: approvalsFile }, hostSecurity: "allowlist", hostAsk: "on-miss", askFallback: "deny", }); resolveApprovalDecisionOrUndefinedMock.mockResolvedValue("allow-always"); const result = await runGatewayAllowlist({ command, ask: "on-miss", env, autoReview: false, }); expect(result.pendingResult?.details.status).toBe("approval-pending"); expect(resolveExecApprovalAllowedDecisionsMock).toHaveBeenCalledWith({ ask: "on-miss", allowAlwaysPersistence: { kind: "patterns", commandText: "sh -c 'git status'", patterns: [{ pattern: "/usr/bin/git", argPattern: undefined }], }, }); expect(buildExecApprovalPendingToolResultMock).toHaveBeenCalledWith( expect.objectContaining({ allowedDecisions: ["allow-once", "allow-always", "deny"], }), ); expect(approvalsFile.agents).toEqual({ main: { allowlist: [ expect.objectContaining({ pattern: "/usr/bin/git", source: "allow-always", }), expect.objectContaining({ pattern: expect.stringMatching(/^=node-command:[0-9a-f]{16}$/), source: "allow-always", }), ], }, }); }); it("requests human approval when auto-review asks on an approval miss", async () => { requiresExecApprovalMock.mockReturnValue(true); evaluateShellAllowlistWithAuthorizationMock.mockReturnValue({ allowlistMatches: [], analysisOk: true, allowlistSatisfied: false, segments: [{ resolution: null, argv: ["echo", "ok"] }], segmentAllowlistEntries: [], }); defaultExecAutoReviewerMock.mockResolvedValue({ decision: "ask", risk: "medium", rationale: "needs a person", }); const warnings: string[] = []; resolveExecHostApprovalContextMock.mockReturnValue({ approvals: { allowlist: [], file: { version: 1, agents: {} } }, hostSecurity: "allowlist", hostAsk: "on-miss", askFallback: "deny", }); const result = await runGatewayAllowlist({ command: "echo ok", ask: "on-miss", autoReview: true, warnings, }); expect(defaultExecAutoReviewerMock).toHaveBeenCalledTimes(1); expect(createAndRegisterDefaultExecApprovalRequestMock).toHaveBeenCalledTimes(1); expect(warnings.join("\n")).toContain("needs a person"); expect(result.pendingResult?.details.status).toBe("approval-pending"); }); it("requests human approval when auto-review cannot bind a single parsed command", async () => { requiresExecApprovalMock.mockReturnValue(true); evaluateShellAllowlistWithAuthorizationMock.mockReturnValue({ allowlistMatches: [], analysisOk: true, allowlistSatisfied: false, segments: [ { raw: "echo ok", resolution: null, argv: ["echo", "ok"] }, { raw: "pwd", resolution: null, argv: ["pwd"] }, ], segmentAllowlistEntries: [], }); resolveExecHostApprovalContextMock.mockReturnValue({ approvals: { allowlist: [], file: { version: 1, agents: {} } }, hostSecurity: "allowlist", hostAsk: "on-miss", askFallback: "deny", }); const result = await runGatewayAllowlist({ command: "echo ok; pwd", ask: "on-miss", autoReview: true, }); expect(defaultExecAutoReviewerMock).not.toHaveBeenCalled(); expect(createAndRegisterDefaultExecApprovalRequestMock).toHaveBeenCalledTimes(1); expect(result.pendingResult?.details.status).toBe("approval-pending"); }); it("does not use fallback-full when auto-review cannot parse the command", async () => { requiresExecApprovalMock.mockReturnValue(true); evaluateShellAllowlistWithAuthorizationMock.mockReturnValue({ allowlistMatches: [], analysisOk: false, allowlistSatisfied: false, segments: [], segmentAllowlistEntries: [], }); resolveExecHostApprovalContextMock.mockReturnValue({ approvals: { allowlist: [], file: { version: 1, agents: {} } }, hostSecurity: "allowlist", hostAsk: "on-miss", askFallback: "full", }); createExecApprovalDecisionStateMock.mockReturnValue({ baseDecision: { timedOut: true }, approvedByAsk: true, deniedReason: null, }); resolveApprovalDecisionOrUndefinedMock.mockResolvedValue(null); enforceStrictInlineEvalApprovalBoundaryMock.mockImplementation((value) => value.requiresAutoReviewHumanApproval === true && value.baseDecision.timedOut ? { approvedByAsk: false, deniedReason: "approval-timeout" } : { approvedByAsk: value.approvedByAsk, deniedReason: value.deniedReason }, ); const result = await runGatewayAllowlist({ command: "echo 'unterminated", ask: "on-miss", autoReview: true, turnSourceChannel: "webchat", }); expect(defaultExecAutoReviewerMock).not.toHaveBeenCalled(); expect(enforceStrictInlineEvalApprovalBoundaryMock).toHaveBeenCalledWith( expect.objectContaining({ requiresAutoReviewHumanApproval: true, }), ); expect(result.deniedResult?.details.status).toBe("failed"); expect(result.deniedResult?.content[0]).toEqual( expect.objectContaining({ text: "Exec denied (gateway id=req-1, approval-timeout): echo 'unterminated", }), ); }); it("does not use fallback-full when auto-review asks for human approval", async () => { requiresExecApprovalMock.mockReturnValue(true); evaluateShellAllowlistWithAuthorizationMock.mockReturnValue({ allowlistMatches: [], analysisOk: true, allowlistSatisfied: false, segments: [{ resolution: null, argv: ["echo", "ok"] }], segmentAllowlistEntries: [], }); defaultExecAutoReviewerMock.mockResolvedValue({ decision: "ask", risk: "medium", rationale: "needs a person", }); resolveExecHostApprovalContextMock.mockReturnValue({ approvals: { allowlist: [], file: { version: 1, agents: {} } }, hostSecurity: "allowlist", hostAsk: "on-miss", askFallback: "full", }); createExecApprovalDecisionStateMock.mockReturnValue({ baseDecision: { timedOut: true }, approvedByAsk: true, deniedReason: null, }); resolveApprovalDecisionOrUndefinedMock.mockResolvedValue(null); enforceStrictInlineEvalApprovalBoundaryMock.mockImplementation((value) => value.requiresAutoReviewHumanApproval === true && value.baseDecision.timedOut ? { approvedByAsk: false, deniedReason: "approval-timeout" } : { approvedByAsk: value.approvedByAsk, deniedReason: value.deniedReason }, ); const result = await runGatewayAllowlist({ command: "echo ok", ask: "on-miss", autoReview: true, turnSourceChannel: "webchat", }); expect(enforceStrictInlineEvalApprovalBoundaryMock).toHaveBeenCalledWith( expect.objectContaining({ requiresAutoReviewHumanApproval: true, }), ); expect(result.deniedResult?.details.status).toBe("failed"); expect(result.deniedResult?.content[0]).toEqual( expect.objectContaining({ text: "Exec denied (gateway id=req-1, approval-timeout): echo ok", }), ); }); it("requires approval for security audit suppression edits unless yolo mode is active", async () => { resolveExecHostApprovalContextMock.mockReturnValue({ approvals: { allowlist: [], file: { version: 1, agents: {} } }, hostSecurity: "full", hostAsk: "on-miss", askFallback: "deny", }); const result = await runGatewayAllowlist({ command: "openclaw config set security.audit.suppressions '[]'", security: "full", ask: "on-miss", }); expect(createAndRegisterDefaultExecApprovalRequestMock).toHaveBeenCalledTimes(1); expect(result.pendingResult?.details.status).toBe("approval-pending"); }); it("keeps security audit suppression edits off the auto-review path", async () => { const warnings: string[] = []; resolveExecHostApprovalContextMock.mockReturnValue({ approvals: { allowlist: [], file: { version: 1, agents: {} } }, hostSecurity: "full", hostAsk: "on-miss", askFallback: "deny", }); const result = await runGatewayAllowlist({ command: "openclaw config set security.audit.suppressions '[]'", security: "full", ask: "on-miss", autoReview: true, warnings, }); expect(defaultExecAutoReviewerMock).not.toHaveBeenCalled(); expect(createAndRegisterDefaultExecApprovalRequestMock).toHaveBeenCalledTimes(1); expect(warnings[0]).toContain("explicit approval"); expect(result.pendingResult?.details.status).toBe("approval-pending"); }); it("does not require approval for security audit suppression edits in yolo mode", async () => { resolveExecHostApprovalContextMock.mockReturnValue({ approvals: { allowlist: [], file: { version: 1, agents: {} } }, hostSecurity: "full", hostAsk: "off", askFallback: "deny", }); await runGatewayAllowlist({ command: "openclaw config set security.audit.suppressions '[]'", security: "full", ask: "off", }); expect(createAndRegisterDefaultExecApprovalRequestMock).not.toHaveBeenCalled(); }); it("does not require suppression edit approval for read-only suppression inspection", async () => { evaluateShellAllowlistWithAuthorizationMock.mockReturnValue({ allowlistMatches: [], analysisOk: true, allowlistSatisfied: true, segments: [ { resolution: null, argv: ["openclaw", "config", "get", "security.audit.suppressions"] }, ], segmentAllowlistEntries: [], }); resolveExecHostApprovalContextMock.mockReturnValue({ approvals: { allowlist: [], file: { version: 1, agents: {} } }, hostSecurity: "full", hostAsk: "on-miss", askFallback: "deny", }); await runGatewayAllowlist({ command: "openclaw config get security.audit.suppressions", security: "full", ask: "on-miss", }); expect(createAndRegisterDefaultExecApprovalRequestMock).not.toHaveBeenCalled(); }); it("does not require suppression edit approval for profile-scoped read-only inspection", async () => { evaluateShellAllowlistWithAuthorizationMock.mockReturnValue({ allowlistMatches: [], analysisOk: true, allowlistSatisfied: true, segments: [ { resolution: null, argv: ["openclaw", "--profile", "rescue", "config", "get", "security.audit.suppressions"], }, ], segmentAllowlistEntries: [], }); resolveExecHostApprovalContextMock.mockReturnValue({ approvals: { allowlist: [], file: { version: 1, agents: {} } }, hostSecurity: "full", hostAsk: "on-miss", askFallback: "deny", }); await runGatewayAllowlist({ command: "openclaw --profile rescue config get security.audit.suppressions", security: "full", ask: "on-miss", }); expect(createAndRegisterDefaultExecApprovalRequestMock).not.toHaveBeenCalled(); }); it("requires suppression edit approval when a mutating segment follows read-only inspection", async () => { evaluateShellAllowlistWithAuthorizationMock.mockReturnValue({ allowlistMatches: [], analysisOk: true, allowlistSatisfied: true, segments: [ { resolution: null, argv: ["openclaw", "config", "get", "security.audit.suppressions"] }, { resolution: null, argv: ["openclaw", "config", "set", "security.audit.suppressions", "[]"], }, ], segmentAllowlistEntries: [], }); resolveExecHostApprovalContextMock.mockReturnValue({ approvals: { allowlist: [], file: { version: 1, agents: {} } }, hostSecurity: "full", hostAsk: "on-miss", askFallback: "deny", }); const result = await runGatewayAllowlist({ command: "openclaw config get security.audit.suppressions; openclaw config set security.audit.suppressions '[]'", security: "full", ask: "on-miss", }); expect(createAndRegisterDefaultExecApprovalRequestMock).toHaveBeenCalledTimes(1); expect(result.pendingResult?.details.status).toBe("approval-pending"); }); it("requires suppression edit approval when allowlist analysis only returns a read-only prefix", async () => { evaluateShellAllowlistWithAuthorizationMock.mockReturnValue({ allowlistMatches: [], analysisOk: true, allowlistSatisfied: false, segments: [ { resolution: null, argv: ["openclaw", "config", "get", "security.audit.suppressions"] }, ], segmentAllowlistEntries: [], }); resolveExecHostApprovalContextMock.mockReturnValue({ approvals: { allowlist: [], file: { version: 1, agents: {} } }, hostSecurity: "full", hostAsk: "on-miss", askFallback: "deny", }); const result = await runGatewayAllowlist({ command: "openclaw config get security.audit.suppressions; openclaw config set security.audit.suppressions '[]'", security: "full", ask: "on-miss", }); expect(createAndRegisterDefaultExecApprovalRequestMock).toHaveBeenCalledTimes(1); expect(result.pendingResult?.details.status).toBe("approval-pending"); }); it("requires suppression edit approval when a heredoc patch follows read-only inspection", async () => { evaluateShellAllowlistWithAuthorizationMock.mockReturnValue({ allowlistMatches: [], analysisOk: true, allowlistSatisfied: false, segments: [ { raw: "openclaw config get security.audit.suppressions", resolution: null, argv: ["openclaw", "config", "get", "security.audit.suppressions"], }, { raw: "openclaw config patch --stdin <<'EOF'", resolution: null, argv: ["openclaw", "config", "patch", "--stdin"], }, ], segmentAllowlistEntries: [], }); resolveExecHostApprovalContextMock.mockReturnValue({ approvals: { allowlist: [], file: { version: 1, agents: {} } }, hostSecurity: "full", hostAsk: "on-miss", askFallback: "deny", }); const result = await runGatewayAllowlist({ command: `openclaw config get security.audit.suppressions; openclaw config patch --stdin <<'EOF' {"security":{"audit":{"suppressions":[]}}} EOF`, security: "full", ask: "on-miss", }); expect(createAndRegisterDefaultExecApprovalRequestMock).toHaveBeenCalledTimes(1); expect(result.pendingResult?.details.status).toBe("approval-pending"); }); it("allows durable exact-command trust to bypass the synchronous allowlist miss", async () => { evaluateShellAllowlistWithAuthorizationMock.mockReturnValue({ allowlistMatches: [], analysisOk: false, allowlistSatisfied: false, segments: [{ resolution: null, argv: ["node", "--version"] }], segmentAllowlistEntries: [], }); hasDurableExecApprovalMock.mockReturnValue(true); buildEnforcedShellCommandMock.mockReturnValue({ ok: true, command: "node --version", }); const result = await runGatewayAllowlist({ command: "node --version", }); expect(createAndRegisterDefaultExecApprovalRequestMock).not.toHaveBeenCalled(); expect(result).toEqual({ execCommandOverride: undefined }); }); it("keeps denying allowlist misses when durable trust does not match", async () => { evaluateShellAllowlistWithAuthorizationMock.mockReturnValue({ allowlistMatches: [], analysisOk: false, allowlistSatisfied: false, segments: [{ resolution: null, argv: ["node", "--version"] }], segmentAllowlistEntries: [], }); hasDurableExecApprovalMock.mockReturnValue(false); await expect( runGatewayAllowlist({ command: "node --version", }), ).rejects.toThrow("exec denied: allowlist miss"); }); it("uses sessionKey for followups when notifySessionKey is absent", async () => { await runGatewayAllowlist({ command: "echo ok", sessionKey: "agent:main:telegram:direct:123", }); expect(requireBuildFollowupTargetInput(0).sessionKey).toBe("agent:main:telegram:direct:123"); }); it("keeps webchat diagnostics approvals as direct pasteable followups", async () => { resolveApprovalDecisionOrUndefinedMock.mockResolvedValue("allow-once"); createExecApprovalDecisionStateMock.mockReturnValue({ baseDecision: { timedOut: false }, approvedByAsk: false, deniedReason: null, }); const outcome = { status: "completed" as const, exitCode: 0, exitSignal: null, durationMs: 12, timedOut: false, aggregated: JSON.stringify({ path: "/tmp/openclaw-diagnostics.zip", bytes: 1234, manifest: { generatedAt: "2026-04-28T20:58:29.311Z", openclawVersion: "2026.4.27", contents: [ { path: "diagnostics.json", bytes: 100 }, { path: "summary.md", bytes: 200 }, ], privacy: { payloadFree: true, rawLogsIncluded: false, notes: ["Logs keep operational summaries."], }, }, }), }; runExecProcessMock.mockResolvedValue({ session: { id: "sess-1" }, promise: Promise.resolve(outcome), }); buildExecApprovalFollowupTargetMock.mockImplementation((value) => value); const approvalFollowup = vi.fn(async () => [ "OpenAI Codex harness:", "Codex diagnostics sent to OpenAI servers:", "Session 1", "Channel: telegram", "OpenClaw session id: `session-1`", "Codex thread id: `thread-1`", ].join("\n"), ); const result = await runGatewayAllowlist({ command: "openclaw gateway diagnostics export --json", trigger: "diagnostics", approvalFollowupMode: "direct", approvalFollowup, turnSourceChannel: "webchat", }); expect(result.pendingResult?.details.status).toBe("approval-pending"); await vi.waitFor(() => { expect(sendExecApprovalFollowupResultMock).toHaveBeenCalledTimes(1); }); expect(requireBuildFollowupTargetInput(0).direct).toBe(true); const followupTarget = requireSentFollowupTarget(0); expect(followupTarget?.direct).toBe(true); const followupText = requireSentFollowupText(0); expect(followupText).toContain("Diagnostics export created."); expect(followupText).toContain("Path: /tmp/openclaw-diagnostics.zip"); expect(followupText).toContain("Contents (2 files):"); expect(followupText).toContain("OpenAI Codex harness:"); expect(followupText).toContain("Codex diagnostics sent to OpenAI servers:"); expect(followupText).toContain("Codex thread id: `thread-1`"); const approvalInput = requireApprovalFollowupInput(approvalFollowup, 0); expect(approvalInput?.approvalId).toBe("req-1"); expect(approvalInput?.sessionId).toBe("sess-1"); expect(approvalInput?.trigger).toBe("diagnostics"); expect(approvalInput?.outcome?.status).toBe("completed"); expect(approvalInput?.outcome?.exitCode).toBe(0); }); it("uses async agent followups for explicit webchat approval mode", async () => { buildExecApprovalFollowupTargetMock.mockImplementation((value) => value); resolveExecHostApprovalContextMock.mockReturnValue({ approvals: { allowlist: [], file: { version: 1, agents: {} } }, hostSecurity: "allowlist", hostAsk: "always", askFallback: "deny", }); resolveApprovalDecisionOrUndefinedMock.mockResolvedValue("allow-once"); createExecApprovalDecisionStateMock.mockReturnValue({ baseDecision: { timedOut: false }, approvedByAsk: true, deniedReason: null, }); runExecProcessMock.mockResolvedValue({ session: { id: "sess-1" }, promise: Promise.resolve({ status: "completed", exitCode: 0, timedOut: false, aggregated: "done", }), }); const result = await runGatewayAllowlist({ command: "openclaw sessions export-trajectory --json", approvalFollowupMode: "agent", sessionId: "approval-session", sessionStore: "/tmp/openclaw-sessions.json", turnSourceChannel: "webchat", }); expect(result.pendingResult?.details.status).toBe("approval-pending"); await vi.waitFor(() => { expect(sendExecApprovalFollowupResultMock).toHaveBeenCalledTimes(1); }); expect(requireBuildFollowupTargetInput(0)).toMatchObject({ direct: false, expectedSessionId: "approval-session", sessionStore: "/tmp/openclaw-sessions.json", }); expect(requireSentFollowupTarget(0)?.direct).toBe(false); expect(requireSentFollowupText(0)).toContain("done"); }); it("waits inline for webchat approval so the exec tool can return real output to the model", async () => { resolveApprovalDecisionOrUndefinedMock.mockResolvedValue("allow-once"); createExecApprovalDecisionStateMock.mockReturnValue({ baseDecision: { timedOut: false }, approvedByAsk: true, deniedReason: null, }); const result = await runGatewayAllowlist({ command: "pwd && df -h", turnSourceChannel: "webchat", }); expect(result.pendingResult).toBeUndefined(); expect(result.deniedResult).toBeUndefined(); expect(result.allowWithoutEnforcedCommand).toBe(true); expect(runExecProcessMock).not.toHaveBeenCalled(); expect(buildExecApprovalFollowupTargetMock).not.toHaveBeenCalled(); expect(sendExecApprovalFollowupResultMock).not.toHaveBeenCalled(); }); it.each([ ["telegram"], ["slack"], ["discord"], ["signal"], ["whatsapp"], ["imessage"], ["matrix"], ["googlechat"], ["qqbot"], ])( "waits inline for native chat approval (%s) so the exec tool returns real output (issue #93918)", async (turnSourceChannel) => { resolveApprovalDecisionOrUndefinedMock.mockResolvedValue("allow-once"); createExecApprovalDecisionStateMock.mockReturnValue({ baseDecision: { timedOut: false }, approvedByAsk: true, deniedReason: null, }); const result = await runGatewayAllowlist({ command: "find . -maxdepth 1", turnSourceChannel, }); expect(result.pendingResult).toBeUndefined(); expect(result.deniedResult).toBeUndefined(); expect(result.allowWithoutEnforcedCommand).toBe(true); expect(runExecProcessMock).not.toHaveBeenCalled(); expect(buildExecApprovalFollowupTargetMock).not.toHaveBeenCalled(); expect(sendExecApprovalFollowupResultMock).not.toHaveBeenCalled(); }, ); it.each([ ["telegram"], ["slack"], ["discord"], ["signal"], ["whatsapp"], ["imessage"], ["matrix"], ["googlechat"], ["qqbot"], ])( "returns native chat approval denials (%s) as the foreground tool result (issue #93918)", async (turnSourceChannel) => { resolveApprovalDecisionOrUndefinedMock.mockResolvedValue("deny"); createExecApprovalDecisionStateMock.mockReturnValue({ baseDecision: { timedOut: false }, approvedByAsk: false, deniedReason: "user-denied", }); const result = await runGatewayAllowlist({ command: "find . -maxdepth 1", turnSourceChannel, }); expect(result.pendingResult).toBeUndefined(); expect(result.deniedResult?.details.status).toBe("failed"); expect(result.deniedResult?.content[0]).toEqual( expect.objectContaining({ text: "Exec denied (gateway id=req-1, user-denied): find . -maxdepth 1", }), ); expect(runExecProcessMock).not.toHaveBeenCalled(); expect(sendExecApprovalFollowupResultMock).not.toHaveBeenCalled(); }, ); it("keeps the fire-and-forget path for channels without native approval clients", async () => { resolveApprovalDecisionOrUndefinedMock.mockResolvedValue("allow-once"); createExecApprovalDecisionStateMock.mockReturnValue({ baseDecision: { timedOut: false }, approvedByAsk: true, deniedReason: null, }); runExecProcessMock.mockResolvedValue({ session: { id: "sess-1" }, promise: Promise.resolve({ status: "completed", exitCode: 0, timedOut: false, aggregated: "done", }), }); buildExecApprovalFollowupTargetMock.mockImplementation((value) => value); const result = await runGatewayAllowlist({ command: "find . -maxdepth 1", turnSourceChannel: "feishu", }); expect(result.pendingResult?.details.status).toBe("approval-pending"); await vi.waitFor(() => { expect(sendExecApprovalFollowupResultMock).toHaveBeenCalledTimes(1); }); expect(runExecProcessMock).toHaveBeenCalledTimes(1); }); it("keeps the fire-and-forget path for headless cron approval followups", async () => { resolveApprovalDecisionOrUndefinedMock.mockResolvedValue("allow-once"); createExecApprovalDecisionStateMock.mockReturnValue({ baseDecision: { timedOut: false }, approvedByAsk: true, deniedReason: null, }); runExecProcessMock.mockResolvedValue({ session: { id: "sess-1" }, promise: Promise.resolve({ status: "completed", exitCode: 0, timedOut: false, aggregated: "done", }), }); buildExecApprovalFollowupTargetMock.mockImplementation((value) => value); const result = await runGatewayAllowlist({ command: "find . -maxdepth 1", turnSourceChannel: "telegram", approvalFollowupMode: "agent", }); expect(result.pendingResult?.details.status).toBe("approval-pending"); await vi.waitFor(() => { expect(sendExecApprovalFollowupResultMock).toHaveBeenCalledTimes(1); }); expect(requireBuildFollowupTargetInput(0).direct).toBe(false); }); it("returns webchat approval denials as the foreground tool result", async () => { resolveApprovalDecisionOrUndefinedMock.mockResolvedValue("deny"); createExecApprovalDecisionStateMock.mockReturnValue({ baseDecision: { timedOut: false }, approvedByAsk: false, deniedReason: "user-denied", }); const result = await runGatewayAllowlist({ command: "pwd && df -h", turnSourceChannel: "webchat", }); expect(result.pendingResult).toBeUndefined(); expect(result.deniedResult?.details.status).toBe("failed"); expect(result.deniedResult?.content[0]).toEqual( expect.objectContaining({ text: "Exec denied (gateway id=req-1, user-denied): pwd && df -h", }), ); expect(runExecProcessMock).not.toHaveBeenCalled(); expect(sendExecApprovalFollowupResultMock).not.toHaveBeenCalled(); }); it("denies timed-out inline-eval requests instead of auto-running them", async () => { const result = await runTimedOutStrictInlineEval({ security: "full", askFallback: "full", approvedByAsk: true, }); expect(result.pendingResult?.details.status).toBe("approval-pending"); await vi.waitFor(() => { expect(sendExecApprovalFollowupResultMock).toHaveBeenCalledWith( expect.objectContaining({ approvalId: "req-1", sessionKey: "agent:main:main", turnSourceChannel: undefined, direct: false, }), "Exec denied (gateway id=req-1, approval-timeout): python3 -c 'print(1)'", ); }); expect(sendExecApprovalFollowupResultMock).toHaveBeenCalledTimes(1); expect(runExecProcessMock).not.toHaveBeenCalled(); }); it("denies allowlist timeout fallback for strict inline-eval commands", async () => { const result = await runTimedOutStrictInlineEval({ security: "allowlist", askFallback: "allowlist", approvedByAsk: false, }); expect(result.pendingResult?.details.status).toBe("approval-pending"); await vi.waitFor(() => { expect(sendExecApprovalFollowupResultMock).toHaveBeenCalledWith( expect.objectContaining({ approvalId: "req-1", sessionKey: "agent:main:main", turnSourceChannel: undefined, direct: false, }), "Exec denied (gateway id=req-1, approval-timeout): python3 -c 'print(1)'", ); }); expect(sendExecApprovalFollowupResultMock).toHaveBeenCalledTimes(1); expect(runExecProcessMock).not.toHaveBeenCalled(); }); });