Security: gate gateway model overrides separately

This commit is contained in:
Josh Lehman
2026-03-16 21:07:58 -07:00
parent c675c80fce
commit cf7f401228
6 changed files with 72 additions and 5 deletions

View File

@@ -117,6 +117,7 @@ function buildAgentCommandInput(params: {
bestEffortDeliver: false as const,
// HTTP API callers are authenticated operator clients for this gateway context.
senderIsOwner: true as const,
allowModelOverride: true as const,
};
}

View File

@@ -256,6 +256,7 @@ async function runResponsesAgentCommand(params: {
bestEffortDeliver: false,
// HTTP API callers are authenticated operator clients for this gateway context.
senderIsOwner: true,
allowModelOverride: true,
},
defaultRuntime,
params.deps,

View File

@@ -334,8 +334,10 @@ describe("gateway agent handler", () => {
);
});
it("does not forward provider and model overrides for write-scoped callers", async () => {
it("rejects provider and model overrides for write-scoped callers", async () => {
primeMainAgentRun();
mocks.agentCommand.mockClear();
const respond = vi.fn();
await invokeAgent(
{
@@ -353,14 +355,51 @@ describe("gateway agent handler", () => {
scopes: ["operator.write"],
},
} as AgentHandlerArgs["client"],
respond,
},
);
expect(mocks.agentCommand).not.toHaveBeenCalled();
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({
message: "provider/model overrides are not authorized for this caller.",
}),
);
});
it("forwards provider and model overrides when internal override authorization is set", async () => {
primeMainAgentRun();
await invokeAgent(
{
message: "test override",
agentId: "main",
sessionKey: "agent:main:main",
provider: "anthropic",
model: "claude-haiku-4-6",
idempotencyKey: "test-idem-model-override-internal",
},
{
reqId: "test-idem-model-override-internal",
client: {
connect: {
scopes: ["operator.write"],
},
internal: {
allowModelOverride: true,
},
} as AgentHandlerArgs["client"],
},
);
const lastCall = mocks.agentCommand.mock.calls.at(-1);
expect(lastCall?.[0]).toEqual(
expect.objectContaining({
provider: undefined,
model: undefined,
provider: "anthropic",
model: "claude-haiku-4-6",
senderIsOwner: false,
}),
);
});

View File

@@ -71,6 +71,12 @@ function resolveSenderIsOwnerFromClient(client: GatewayRequestHandlerOptions["cl
return scopes.includes(ADMIN_SCOPE);
}
function resolveAllowModelOverrideFromClient(
client: GatewayRequestHandlerOptions["client"],
): boolean {
return resolveSenderIsOwnerFromClient(client) || client?.internal?.allowModelOverride === true;
}
async function runSessionResetFromAgent(params: {
key: string;
reason: "new" | "reset";
@@ -194,8 +200,21 @@ export const agentHandlers: GatewayRequestHandlers = {
inputProvenance?: InputProvenance;
};
const senderIsOwner = resolveSenderIsOwnerFromClient(client);
const providerOverride = senderIsOwner ? request.provider : undefined;
const modelOverride = senderIsOwner ? request.model : undefined;
const allowModelOverride = resolveAllowModelOverrideFromClient(client);
const requestedModelOverride = Boolean(request.provider || request.model);
if (requestedModelOverride && !allowModelOverride) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"provider/model overrides are not authorized for this caller.",
),
);
return;
}
const providerOverride = allowModelOverride ? request.provider : undefined;
const modelOverride = allowModelOverride ? request.model : undefined;
const cfg = loadConfig();
const idem = request.idempotencyKey;
const normalizedSpawned = normalizeSpawnedRunMetadata({
@@ -625,6 +644,7 @@ export const agentHandlers: GatewayRequestHandlers = {
workspaceDir: sessionEntry?.spawnedWorkspaceDir,
}),
senderIsOwner,
allowModelOverride,
},
runId,
idempotencyKey: idem,

View File

@@ -21,6 +21,10 @@ export type GatewayClient = {
canvasHostUrl?: string;
canvasCapability?: string;
canvasCapabilityExpiresAtMs?: number;
/** Internal-only auth context that cannot be supplied through gateway RPC payloads. */
internal?: {
allowModelOverride?: boolean;
};
};
export type RespondFn = (

View File

@@ -310,6 +310,7 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
sourceTool: "gateway.voice.transcript",
},
senderIsOwner: false,
allowModelOverride: false,
},
defaultRuntime,
ctx.deps,
@@ -441,6 +442,7 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
typeof link?.timeoutSeconds === "number" ? link.timeoutSeconds.toString() : undefined,
messageChannel: "node",
senderIsOwner: false,
allowModelOverride: false,
},
defaultRuntime,
ctx.deps,