mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-21 16:41:56 +00:00
fix(gateway): enforce owner boundary for agent runs
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -177,6 +177,7 @@ export async function runBootOnce(params: {
|
||||
sessionKey,
|
||||
sessionId,
|
||||
deliver: false,
|
||||
senderIsOwner: true,
|
||||
},
|
||||
bootRuntime,
|
||||
params.deps,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user