fix(gateway): enforce owner boundary for agent runs

This commit is contained in:
Peter Steinberger
2026-03-02 00:27:32 +00:00
parent 9005e8bc0a
commit 58659b931b
10 changed files with 123 additions and 1 deletions

View File

@@ -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:

View File

@@ -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:

View File

@@ -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`

View File

@@ -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");

View File

@@ -163,6 +163,7 @@ function runAgentAttempt(params: {
onAgentEvent: (evt: { stream: string; data?: Record<string, unknown> }) => 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,

View File

@@ -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. */

View File

@@ -177,6 +177,7 @@ export async function runBootOnce(params: {
sessionKey,
sessionId,
deliver: false,
senderIsOwner: true,
},
bootRuntime,
params.deps,

View File

@@ -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();

View File

@@ -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,

View File

@@ -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,