From 58659b931b823397cae9884292a05ba9d4ad97af Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 2 Mar 2026 00:27:32 +0000 Subject: [PATCH] fix(gateway): enforce owner boundary for agent runs --- docs/gateway/openai-http-api.md | 12 ++++++ docs/gateway/openresponses-http-api.md | 12 ++++++ docs/gateway/security/index.md | 6 +++ src/commands/agent.test.ts | 24 +++++++++++ src/commands/agent.ts | 3 +- src/commands/agent/types.ts | 2 + src/gateway/boot.ts | 1 + src/gateway/server-methods/agent.test.ts | 54 ++++++++++++++++++++++++ src/gateway/server-methods/agent.ts | 8 ++++ src/gateway/server-node-events.ts | 2 + 10 files changed, 123 insertions(+), 1 deletion(-) diff --git a/docs/gateway/openai-http-api.md b/docs/gateway/openai-http-api.md index dbaa06fbe39..0d8353d8c79 100644 --- a/docs/gateway/openai-http-api.md +++ b/docs/gateway/openai-http-api.md @@ -28,6 +28,18 @@ Notes: - When `gateway.auth.mode="password"`, use `gateway.auth.password` (or `OPENCLAW_GATEWAY_PASSWORD`). - If `gateway.auth.rateLimit` is configured and too many auth failures occur, the endpoint returns `429` with `Retry-After`. +## Security boundary (important) + +Treat this endpoint as a **full operator-access** surface for the gateway instance. + +- HTTP bearer auth here is not a narrow per-user scope model. +- A valid Gateway token/password for this endpoint should be treated like an owner/operator credential. +- Requests run through the same control-plane agent path as trusted operator actions. +- If the target agent policy allows sensitive tools, this endpoint can use them. +- Keep this endpoint on loopback/tailnet/private ingress only; do not expose it directly to the public internet. + +See [Security](/gateway/security) and [Remote access](/gateway/remote). + ## Choosing an agent No custom headers required: encode the agent id in the OpenAI `model` field: diff --git a/docs/gateway/openresponses-http-api.md b/docs/gateway/openresponses-http-api.md index f0e91f2ba29..d62cc8edb59 100644 --- a/docs/gateway/openresponses-http-api.md +++ b/docs/gateway/openresponses-http-api.md @@ -30,6 +30,18 @@ Notes: - When `gateway.auth.mode="password"`, use `gateway.auth.password` (or `OPENCLAW_GATEWAY_PASSWORD`). - If `gateway.auth.rateLimit` is configured and too many auth failures occur, the endpoint returns `429` with `Retry-After`. +## Security boundary (important) + +Treat this endpoint as a **full operator-access** surface for the gateway instance. + +- HTTP bearer auth here is not a narrow per-user scope model. +- A valid Gateway token/password for this endpoint should be treated like an owner/operator credential. +- Requests run through the same control-plane agent path as trusted operator actions. +- If the target agent policy allows sensitive tools, this endpoint can use them. +- Keep this endpoint on loopback/tailnet/private ingress only; do not expose it directly to the public internet. + +See [Security](/gateway/security) and [Remote access](/gateway/remote). + ## Choosing an agent No custom headers required: encode the agent id in the OpenResponses `model` field: diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index 55e2a076766..d6615b0e345 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -724,6 +724,12 @@ injected by Tailscale. HTTP API endpoints (for example `/v1/*`, `/tools/invoke`, and `/api/channels/*`) still require token/password auth. +Important boundary note: + +- Gateway HTTP bearer auth is effectively all-or-nothing operator access. +- Treat credentials that can call `/v1/chat/completions`, `/v1/responses`, `/tools/invoke`, or `/api/channels/*` as full-access operator secrets for that gateway. +- Do not share these credentials with untrusted callers; prefer separate gateways per trust boundary. + **Trust assumption:** tokenless Serve auth assumes the gateway host is trusted. Do not treat this as protection against hostile same-host processes. If untrusted local code may run on the gateway host, disable `gateway.auth.allowTailscale` diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts index 038e9651777..eca0169c256 100644 --- a/src/commands/agent.test.ts +++ b/src/commands/agent.test.ts @@ -188,6 +188,30 @@ describe("agentCommand", () => { }); }); + it("defaults senderIsOwner to true for local agent runs", async () => { + await withTempHome(async (home) => { + const store = path.join(home, "sessions.json"); + mockConfig(home, store); + + await agentCommand({ message: "hi", to: "+1555" }, runtime); + + const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]; + expect(callArgs?.senderIsOwner).toBe(true); + }); + }); + + it("honors explicit senderIsOwner override", async () => { + await withTempHome(async (home) => { + const store = path.join(home, "sessions.json"); + mockConfig(home, store); + + await agentCommand({ message: "hi", to: "+1555", senderIsOwner: false }, runtime); + + const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]; + expect(callArgs?.senderIsOwner).toBe(false); + }); + }); + it("resumes when session-id is provided", async () => { await withTempHome(async (home) => { const store = path.join(home, "sessions.json"); diff --git a/src/commands/agent.ts b/src/commands/agent.ts index 8eea69ba7e6..4dacd08cb72 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -163,6 +163,7 @@ function runAgentAttempt(params: { onAgentEvent: (evt: { stream: string; data?: Record }) => void; primaryProvider: string; }) { + const senderIsOwner = params.opts.senderIsOwner ?? true; const effectivePrompt = resolveFallbackRetryPrompt({ body: params.body, isFallbackRetry: params.isFallbackRetry, @@ -209,7 +210,7 @@ function runAgentAttempt(params: { currentThreadTs: params.runContext.currentThreadTs, replyToMode: params.runContext.replyToMode, hasRepliedRef: params.runContext.hasRepliedRef, - senderIsOwner: true, + senderIsOwner, sessionFile: params.sessionFile, workspaceDir: params.workspaceDir, config: params.cfg, diff --git a/src/commands/agent/types.ts b/src/commands/agent/types.ts index b82361aec06..7a8e45ca55f 100644 --- a/src/commands/agent/types.ts +++ b/src/commands/agent/types.ts @@ -60,6 +60,8 @@ export type AgentCommandOpts = { accountId?: string; /** Context for embedded run routing (channel/account/thread). */ runContext?: AgentRunContext; + /** Whether this caller is authorized for owner-only tools (defaults true for local CLI calls). */ + senderIsOwner?: boolean; /** Group id for channel-level tool policy resolution. */ groupId?: string | null; /** Group channel label for channel-level tool policy resolution. */ diff --git a/src/gateway/boot.ts b/src/gateway/boot.ts index edf1f2b5310..e76e7bc3d2d 100644 --- a/src/gateway/boot.ts +++ b/src/gateway/boot.ts @@ -177,6 +177,7 @@ export async function runBootOnce(params: { sessionKey, sessionId, deliver: false, + senderIsOwner: true, }, bootRuntime, params.deps, diff --git a/src/gateway/server-methods/agent.test.ts b/src/gateway/server-methods/agent.test.ts index 75efc1c328f..9aec19c04bc 100644 --- a/src/gateway/server-methods/agent.test.ts +++ b/src/gateway/server-methods/agent.test.ts @@ -325,6 +325,60 @@ describe("gateway agent handler", () => { vi.useRealTimers(); }); + it("passes senderIsOwner=false for write-scoped gateway callers", async () => { + primeMainAgentRun(); + + await invokeAgent( + { + message: "owner-tools check", + sessionKey: "agent:main:main", + idempotencyKey: "test-sender-owner-write", + }, + { + client: { + connect: { + role: "operator", + scopes: ["operator.write"], + client: { id: "test-client", mode: "gateway" }, + }, + } as unknown as AgentHandlerArgs["client"], + }, + ); + + await vi.waitFor(() => expect(mocks.agentCommand).toHaveBeenCalled()); + const callArgs = mocks.agentCommand.mock.calls.at(-1)?.[0] as + | { senderIsOwner?: boolean } + | undefined; + expect(callArgs?.senderIsOwner).toBe(false); + }); + + it("passes senderIsOwner=true for admin-scoped gateway callers", async () => { + primeMainAgentRun(); + + await invokeAgent( + { + message: "owner-tools check", + sessionKey: "agent:main:main", + idempotencyKey: "test-sender-owner-admin", + }, + { + client: { + connect: { + role: "operator", + scopes: ["operator.admin"], + client: { id: "test-client", mode: "gateway" }, + }, + } as unknown as AgentHandlerArgs["client"], + }, + ); + + await vi.waitFor(() => expect(mocks.agentCommand).toHaveBeenCalled()); + const callArgs = mocks.agentCommand.mock.calls.at(-1)?.[0] as + | { senderIsOwner?: boolean } + | undefined; + expect(callArgs?.senderIsOwner).toBe(true); + }); + it("respects explicit bestEffortDeliver=false for main session runs", async () => { mocks.agentCommand.mockClear(); primeMainAgentRun(); diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index cba6074030e..c954d439858 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -32,6 +32,7 @@ import { import { resolveAssistantIdentity } from "../assistant-identity.js"; import { parseMessageWithAttachments } from "../chat-attachments.js"; import { resolveAssistantAvatarUrl } from "../control-ui-shared.js"; +import { ADMIN_SCOPE } from "../method-scopes.js"; import { GATEWAY_CLIENT_CAPS, hasGatewayClientCap } from "../protocol/client-info.js"; import { ErrorCodes, @@ -56,6 +57,11 @@ import type { GatewayRequestHandlerOptions, GatewayRequestHandlers } from "./typ const RESET_COMMAND_RE = /^\/(new|reset)(?:\s+([\s\S]*))?$/i; +function resolveSenderIsOwnerFromClient(client: GatewayRequestHandlerOptions["client"]): boolean { + const scopes = Array.isArray(client?.connect?.scopes) ? client.connect.scopes : []; + return scopes.includes(ADMIN_SCOPE); +} + function isGatewayErrorShape(value: unknown): value is { code: string; message: string } { if (!value || typeof value !== "object") { return false; @@ -200,6 +206,7 @@ export const agentHandlers: GatewayRequestHandlers = { spawnedBy?: string; inputProvenance?: InputProvenance; }; + const senderIsOwner = resolveSenderIsOwnerFromClient(client); const cfg = loadConfig(); const idem = request.idempotencyKey; const groupIdRaw = typeof request.groupId === "string" ? request.groupId.trim() : ""; @@ -626,6 +633,7 @@ export const agentHandlers: GatewayRequestHandlers = { extraSystemPrompt: request.extraSystemPrompt, internalEvents: request.internalEvents, inputProvenance, + senderIsOwner, }, defaultRuntime, context.deps, diff --git a/src/gateway/server-node-events.ts b/src/gateway/server-node-events.ts index b196e26cc3f..b402a4f0cd5 100644 --- a/src/gateway/server-node-events.ts +++ b/src/gateway/server-node-events.ts @@ -316,6 +316,7 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt sourceChannel: "voice", sourceTool: "gateway.voice.transcript", }, + senderIsOwner: false, }, defaultRuntime, ctx.deps, @@ -446,6 +447,7 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt timeout: typeof link?.timeoutSeconds === "number" ? link.timeoutSeconds.toString() : undefined, messageChannel: "node", + senderIsOwner: false, }, defaultRuntime, ctx.deps,