diff --git a/src/agents/bash-tools.exec-runtime.test.ts b/src/agents/bash-tools.exec-runtime.test.ts index 6d606aa2bde..3d22c70bb49 100644 --- a/src/agents/bash-tools.exec-runtime.test.ts +++ b/src/agents/bash-tools.exec-runtime.test.ts @@ -86,18 +86,39 @@ describe("resolveExecTarget", () => { }); }); - it("rejects host overrides when configured host is auto", () => { - expect(() => + it("allows per-call host=node override when configured host is auto", () => { + expect( resolveExecTarget({ configuredTarget: "auto", requestedTarget: "node", elevatedRequested: false, sandboxAvailable: false, }), - ).toThrow("exec host not allowed"); + ).toMatchObject({ + configuredTarget: "auto", + requestedTarget: "node", + selectedTarget: "node", + effectiveHost: "node", + }); }); - it("also rejects gateway override when configured host is auto", () => { + it("allows per-call host=gateway override when configured host is auto and no sandbox", () => { + expect( + resolveExecTarget({ + configuredTarget: "auto", + requestedTarget: "gateway", + elevatedRequested: false, + sandboxAvailable: false, + }), + ).toMatchObject({ + configuredTarget: "auto", + requestedTarget: "gateway", + selectedTarget: "gateway", + effectiveHost: "gateway", + }); + }); + + it("rejects per-call host=gateway override from auto when sandbox is available", () => { expect(() => resolveExecTarget({ configuredTarget: "auto", @@ -108,6 +129,33 @@ describe("resolveExecTarget", () => { ).toThrow("exec host not allowed"); }); + it("allows per-call host=sandbox override when configured host is auto", () => { + expect( + resolveExecTarget({ + configuredTarget: "auto", + requestedTarget: "sandbox", + elevatedRequested: false, + sandboxAvailable: true, + }), + ).toMatchObject({ + configuredTarget: "auto", + requestedTarget: "sandbox", + selectedTarget: "sandbox", + effectiveHost: "sandbox", + }); + }); + + it("rejects cross-host override when configured target is a concrete host", () => { + expect(() => + resolveExecTarget({ + configuredTarget: "node", + requestedTarget: "gateway", + elevatedRequested: false, + sandboxAvailable: false, + }), + ).toThrow("exec host not allowed"); + }); + it("allows explicit auto request when configured host is auto", () => { expect( resolveExecTarget({ @@ -151,7 +199,7 @@ describe("resolveExecTarget", () => { }); }); - it("still forces elevated requests onto the gateway host", () => { + it("forces elevated requests onto the gateway host when configured target is auto", () => { expect( resolveExecTarget({ configuredTarget: "auto", @@ -166,6 +214,56 @@ describe("resolveExecTarget", () => { effectiveHost: "gateway", }); }); + + it("honours node target for elevated requests when configured target is node", () => { + expect( + resolveExecTarget({ + configuredTarget: "node", + requestedTarget: "node", + elevatedRequested: true, + sandboxAvailable: false, + }), + ).toMatchObject({ + configuredTarget: "node", + requestedTarget: "node", + selectedTarget: "node", + effectiveHost: "node", + }); + }); + + it("routes to node for elevated when configured=node and no per-call override", () => { + expect( + resolveExecTarget({ + configuredTarget: "node", + elevatedRequested: true, + sandboxAvailable: false, + }), + ).toMatchObject({ + configuredTarget: "node", + requestedTarget: null, + selectedTarget: "node", + effectiveHost: "node", + }); + }); + + it("silently discards mismatched requestedTarget under elevated+node", () => { + // When elevated is requested and configuredTarget is "node", the elevated + // path always returns "node" regardless of requestedTarget. This is + // intentional — elevated overrides per-call host selection. + expect( + resolveExecTarget({ + configuredTarget: "node", + requestedTarget: "gateway", + elevatedRequested: true, + sandboxAvailable: false, + }), + ).toMatchObject({ + configuredTarget: "node", + requestedTarget: "gateway", + selectedTarget: "node", + effectiveHost: "node", + }); + }); }); describe("emitExecSystemEvent", () => { diff --git a/src/agents/bash-tools.exec-runtime.ts b/src/agents/bash-tools.exec-runtime.ts index bde8085b501..491623932c2 100644 --- a/src/agents/bash-tools.exec-runtime.ts +++ b/src/agents/bash-tools.exec-runtime.ts @@ -220,11 +220,31 @@ export function renderExecTargetLabel(target: ExecTarget) { export function isRequestedExecTargetAllowed(params: { configuredTarget: ExecTarget; requestedTarget: ExecTarget; + sandboxAvailable?: boolean; }) { - // `auto` is a routing strategy, not a wildcard allowlist. Keep per-call host - // selection pinned to the configured/session-selected target so a sandboxed - // session cannot silently hop to gateway or node. - return params.requestedTarget === params.configuredTarget; + // Exact match is always allowed (e.g. configured=node, requested=node). + if (params.requestedTarget === params.configuredTarget) { + return true; + } + // `auto` is a routing strategy that selects a host at runtime. Per-call + // overrides to a concrete host are allowed so agents and directives can + // pin individual commands to a specific target without requiring a global + // config change. + // + // However, when a sandbox runtime is available the session is implicitly + // sandboxed — allowing a per-call jump to gateway would bypass sandbox + // confinement. Only overrides *to* sandbox (or node, which has its own + // approval layer) are safe in that scenario. The ask/approval flow + // remains the primary security gate for all non-sandbox hosts. + if (params.configuredTarget === "auto") { + if (params.sandboxAvailable && params.requestedTarget === "gateway") { + return false; + } + return true; + } + // Non-auto configured targets require an exact match to prevent silent + // host-hopping (e.g. a node-pinned session should not route to gateway). + return false; } export function resolveExecTarget(params: { @@ -236,11 +256,17 @@ export function resolveExecTarget(params: { const configuredTarget = params.configuredTarget ?? "auto"; const requestedTarget = params.requestedTarget ?? null; if (params.elevatedRequested) { + // Elevated execution runs with host-level permissions. When the target is + // explicitly pinned to "node", honour that binding — the node's own + // approval/security layer handles elevated semantics on the remote host. + // Only redirect to gateway when the configured target is auto/sandbox + // (i.e. the intent is local elevated execution on the gateway machine). + const elevatedTarget = configuredTarget === "node" ? ("node" as const) : ("gateway" as const); return { configuredTarget, requestedTarget, - selectedTarget: "gateway" as const, - effectiveHost: "gateway" as const, + selectedTarget: elevatedTarget, + effectiveHost: elevatedTarget, }; } if ( @@ -248,6 +274,7 @@ export function resolveExecTarget(params: { !isRequestedExecTargetAllowed({ configuredTarget, requestedTarget, + sandboxAvailable: params.sandboxAvailable, }) ) { throw new Error(