Skip to content

Commit 1241885

Browse files
committed
refactor(gateway): trim attach grant implementation
1 parent 2deb696 commit 1241885

7 files changed

Lines changed: 15 additions & 72 deletions

File tree

src/gateway/mcp-grant-store.test.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
sweepExpiredAttachGrants,
1010
} from "./mcp-grant-store.js";
1111

12-
const T0 = 1_000_000_000_000; // fixed epoch for deterministic TTL tests
12+
const T0 = 1_000_000_000_000;
1313

1414
describe("mcp-grant-store", () => {
1515
beforeEach(() => resetAttachGrantsForTest());
@@ -30,7 +30,6 @@ describe("mcp-grant-store", () => {
3030
const g = mintAttachGrant({ sessionKey: "agent:main:x", ttlMs: 1_000, nowMs: T0 });
3131
expect(resolveAttachGrant(g.token, T0)?.sessionKey).toBe("agent:main:x");
3232
expect(resolveAttachGrant(g.token, T0 + 999)?.sessionKey).toBe("agent:main:x");
33-
// at/after expiry → undefined, and the lookup self-sweeps it
3433
expect(resolveAttachGrant(g.token, T0 + 1_000)).toBeUndefined();
3534
expect(resolveAttachGrant(g.token, T0 + 1_001)).toBeUndefined();
3635
expect(attachGrantStoreSize()).toBe(0);
@@ -43,7 +42,6 @@ describe("mcp-grant-store", () => {
4342
it("binds the sessionKey to the grant (token carries scope identity, not the caller)", () => {
4443
const a = mintAttachGrant({ sessionKey: "agent:main:telegram:1", nowMs: T0 });
4544
const b = mintAttachGrant({ sessionKey: "agent:main:telegram:2", nowMs: T0 });
46-
// each token resolves only to its own bound session — the basis for header-spoof resistance
4745
expect(resolveAttachGrant(a.token, T0)?.sessionKey).toBe("agent:main:telegram:1");
4846
expect(resolveAttachGrant(b.token, T0)?.sessionKey).toBe("agent:main:telegram:2");
4947
expect(a.token).not.toBe(b.token);
@@ -83,8 +81,6 @@ describe("mcp-grant-store", () => {
8381
it("evicts expired grants on mint, bounding the store (no accumulation)", () => {
8482
mintAttachGrant({ sessionKey: "s", ttlMs: 1_000, nowMs: T0 });
8583
expect(attachGrantStoreSize()).toBe(1);
86-
// a later mint past the first's expiry sweeps the stale entry before inserting — without
87-
// sweep-on-mint the never-looked-up grant would linger and the size would be 2.
8884
mintAttachGrant({ sessionKey: "s", ttlMs: 1_000, nowMs: T0 + 5_000 });
8985
expect(attachGrantStoreSize()).toBe(1);
9086
});

src/gateway/mcp-grant-store.ts

Lines changed: 2 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,3 @@
1-
/**
2-
* Per-session MCP loopback **attach grants**.
3-
*
4-
* The loopback MCP server (`mcp-http.ts`) authenticates the gateway-spawned cli-backend with two
5-
* process-global bearer tokens (owner / non-owner) and scopes tools from client-supplied headers —
6-
* adequate because that client is cooperative and gateway-launched. An **attach** grant is the
7-
* primitive for a *less-trusted* external/interactive harness (Claude Code, OpenCode, or a harness
8-
* reached via a node/companion app over its existing gateway connection): a short-lived, revocable
9-
* bearer whose **scope is bound to the grant**, not to the request headers.
10-
*
11-
* Security properties:
12-
* - The bound `sessionKey` comes from the grant, so an attach caller cannot scope-shop by setting
13-
* `x-session-key` (the request layer ignores the header when a grant matches).
14-
* - Grants are always treated as **non-owner** (`senderIsOwner=false`) — the loopback surface
15-
* already excludes the destructive native tools (`read/write/edit/apply_patch/exec/process`).
16-
* - TTL + explicit revoke bound the blast radius of a leaked token.
17-
*
18-
* Transport-independent: the same grant is presented whether the harness reaches the loopback over
19-
* 127.0.0.1 (gateway host) or tunnelled in over a node/app's existing authenticated channel.
20-
*/
211
import crypto from "node:crypto";
222

233
export interface McpAttachGrant {
@@ -32,7 +12,7 @@ export interface McpAttachGrant {
3212
}
3313

3414
const DEFAULT_TTL_MS = 60 * 60 * 1000; // 1h
35-
const MAX_TTL_MS = 12 * 60 * 60 * 1000; // hard ceiling so a caller can't request a forever-grant
15+
const MAX_TTL_MS = 12 * 60 * 60 * 1000;
3616

3717
const grantsByToken = new Map<string, McpAttachGrant>();
3818

@@ -43,7 +23,6 @@ function clampTtlMs(ttlMs: number | undefined): number {
4323
return Math.min(ttlMs as number, MAX_TTL_MS);
4424
}
4525

46-
/** Mint a grant bound to `sessionKey`. Returns the grant (the caller hands `token` to the harness). */
4726
export function mintAttachGrant(params: {
4827
sessionKey: string;
4928
ttlMs?: number;
@@ -54,8 +33,7 @@ export function mintAttachGrant(params: {
5433
throw new Error("mintAttachGrant: sessionKey is required");
5534
}
5635
const nowMs = params.nowMs ?? Date.now();
57-
// Sweep on mint so grants that are minted but never looked up again (harness never connects)
58-
// don't accumulate — lookup self-sweeps only the token it touches, so this bounds the map.
36+
// Mint sweeps stale entries so abandoned grants do not accumulate.
5937
sweepExpiredAttachGrants(nowMs);
6038
const grant: McpAttachGrant = {
6139
token: crypto.randomBytes(32).toString("hex"),
@@ -67,11 +45,6 @@ export function mintAttachGrant(params: {
6745
return grant;
6846
}
6947

70-
/**
71-
* Resolve a bearer token to a live grant, or `undefined` if unknown/expired. An expired grant is
72-
* dropped on lookup (lazy sweep). Lookup is by full-token map key: a caller must already hold the
73-
* complete 256-bit token to get a hit, so there is no partial-match timing oracle to defend.
74-
*/
7548
export function resolveAttachGrant(
7649
token: string,
7750
nowMs: number = Date.now(),
@@ -87,12 +60,10 @@ export function resolveAttachGrant(
8760
return grant;
8861
}
8962

90-
/** Revoke a grant by token. Returns true if a grant was removed. */
9163
export function revokeAttachGrant(token: string): boolean {
9264
return grantsByToken.delete(token);
9365
}
9466

95-
/** Revoke every live grant for a session (e.g. on session teardown). Returns the count removed. */
9667
export function revokeAttachGrantsForSession(sessionKey: string): number {
9768
const key = sessionKey.trim();
9869
let removed = 0;
@@ -105,7 +76,6 @@ export function revokeAttachGrantsForSession(sessionKey: string): number {
10576
return removed;
10677
}
10778

108-
/** Drop expired grants. Returns the count swept. Call opportunistically; lookup also self-sweeps. */
10979
export function sweepExpiredAttachGrants(nowMs: number = Date.now()): number {
11080
let removed = 0;
11181
for (const [token, grant] of grantsByToken) {
@@ -117,12 +87,10 @@ export function sweepExpiredAttachGrants(nowMs: number = Date.now()): number {
11787
return removed;
11888
}
11989

120-
/** Number of entries currently held (test/diagnostics). Does not sweep; reflects raw store size. */
12190
export function attachGrantStoreSize(): number {
12291
return grantsByToken.size;
12392
}
12493

125-
/** Clear all grants (test isolation only). */
12694
export function resetAttachGrantsForTest(): void {
12795
grantsByToken.clear();
12896
}

src/gateway/mcp-http.request.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -120,9 +120,6 @@ function resolveMcpSender(params: {
120120
if (ownerTokenMatched || nonOwnerTokenMatched) {
121121
return { senderIsOwner: ownerTokenMatched };
122122
}
123-
// Attach grant: an external/interactive harness presents a per-session grant token (mcp-grant-store).
124-
// Always non-owner, and its scope is bound to the grant's sessionKey so a grant holder cannot widen
125-
// scope via the x-session-key header — resolveMcpRequestContext honors boundSessionKey instead.
126123
const grantToken = authHeader.startsWith("Bearer ") ? authHeader.slice("Bearer ".length) : "";
127124
const grant = grantToken ? resolveAttachGrant(grantToken) : undefined;
128125
if (grant) {
@@ -368,12 +365,8 @@ export function resolveMcpRequestContext(
368365
cfg: OpenClawConfig,
369366
auth: { senderIsOwner: boolean; boundSessionKey?: string },
370367
): McpRequestContext {
371-
// An attach grant is a lower-trust boundary: bind the session server-side AND ignore every
372-
// caller-supplied delivery/action context header (message channel, account, current channel/
373-
// thread/message, inbound-audio, event-kind, source-reply mode, explicit-target). Those headers
374-
// feed scoped tools and the message tool, so a grant holder must not be able to spoof them —
375-
// only the grant's pinned sessionKey is trusted. Owner/non-owner (the cooperative, gateway-
376-
// launched cli-backend) keep header-driven context.
368+
// Grant-authenticated callers get only their server-bound session; spoofable
369+
// delivery/action headers stay reserved for the gateway-launched loopback client.
377370
if (auth.boundSessionKey) {
378371
return {
379372
sessionKey: auth.boundSessionKey,

src/gateway/mcp-http.test.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -738,7 +738,6 @@ describe("mcp loopback server", () => {
738738
port: serverPort,
739739
token: grant.token,
740740
headers: jsonHeaders({
741-
// spoof the full delivery/action context, not just the session key
742741
"x-session-key": "agent:main:SPOOFED-other-session",
743742
"x-openclaw-message-channel": "telegram",
744743
"x-openclaw-account-id": "victim-account",
@@ -752,12 +751,9 @@ describe("mcp loopback server", () => {
752751

753752
expect(response.status).toBe(200);
754753
const call = getScopedToolsCall(0);
755-
// session is grant-bound, NOT the spoofed header
756754
expect(call.sessionKey).toBe("agent:main:attach-host");
757755
expect(call.senderIsOwner).toBe(false);
758756
expect(call.surface).toBe("loopback");
759-
// a grant is a lower-trust boundary: every other caller-supplied context header is ignored
760-
// (fail-closed), so a grant holder cannot spoof delivery/action context into scoped tools.
761757
expect(call.messageProvider).toBeUndefined();
762758
expect(call.accountId).toBeUndefined();
763759
expect(call.currentChannelId).toBeUndefined();

src/gateway/methods/core-descriptors.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -218,9 +218,6 @@ export const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [
218218
{ name: "poll", scope: "operator.write", advertise: false },
219219
{ name: "sessions.steer", scope: "operator.write", advertise: false },
220220
{ name: "push.test", scope: "operator.write", advertise: false },
221-
// Appended at the end of the advertised methods to preserve the legacy advertised order
222-
// (server-methods-list.test.ts locks the prefix/middle). grant mints process-global state so it
223-
// is control-plane rate-limited; revoke is intentionally not (cheap, idempotent, frees memory).
224221
{ name: "attach.grant", scope: "operator.admin", controlPlaneWrite: true },
225222
{ name: "attach.revoke", scope: "operator.admin" },
226223
{ name: "push.web.vapidPublicKey", scope: "operator.write", advertise: false },

src/gateway/server-methods/attach.test.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,6 @@ describe("attach gateway methods", () => {
3737
expect(body.mcpConfig).toBeTruthy();
3838
expect(body.env.OPENCLAW_MCP_TOKEN).toBe(body.token);
3939
expect(body.env.OPENCLAW_MCP_SESSION_KEY).toBe("agent:main:attach-method");
40-
// the minted token resolves to the bound session in the shared store
4140
expect(resolveAttachGrant(body.token)?.sessionKey).toBe("agent:main:attach-method");
4241
});
4342

@@ -78,12 +77,12 @@ describe("attach gateway methods", () => {
7877

7978
const r2 = vi.fn();
8079
await attachHandlers["attach.grant"]({
81-
params: { sessionKey: "agent:main:ttl2", ttlMs: -5 }, // non-positive → default ttl
80+
params: { sessionKey: "agent:main:ttl2", ttlMs: -5 },
8281
respond: r2,
8382
context: { getRuntimeConfig: () => ({}) },
8483
} as unknown as GatewayRequestHandlerOptions);
8584
const b2 = r2.mock.calls[0][1] as { expiresAtMs: number };
86-
expect(b2.expiresAtMs).toBeGreaterThan(Date.now() + 50 * 60_000); // ~1h default window
85+
expect(b2.expiresAtMs).toBeGreaterThan(Date.now() + 50 * 60_000);
8786
});
8887

8988
it("attach.revoke treats non-object params as a missing token (INVALID_REQUEST)", async () => {

src/gateway/server-methods/attach.ts

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
// Gateway RPC handlers for attach grants: mint a per-session, scoped, revocable MCP loopback grant
2-
// so an external/interactive harness can reach the gateway's scoped tools, and revoke it on detach.
31
import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js";
42
import { resolveMainSessionKey } from "../../config/sessions.js";
53
import { mintAttachGrant, revokeAttachGrant } from "../mcp-grant-store.js";
@@ -14,20 +12,19 @@ function paramRecord(params: unknown): Record<string, unknown> {
1412
return params && typeof params === "object" ? (params as Record<string, unknown>) : {};
1513
}
1614

17-
function readString(params: unknown, key: string): string | undefined {
18-
const value = paramRecord(params)[key];
15+
function readString(params: Record<string, unknown>, key: string): string | undefined {
16+
const value = params[key];
1917
return typeof value === "string" && value.trim() ? value.trim() : undefined;
2018
}
2119

22-
function readPositiveNumber(params: unknown, key: string): number | undefined {
23-
const value = paramRecord(params)[key];
20+
function readPositiveNumber(params: Record<string, unknown>, key: string): number | undefined {
21+
const value = params[key];
2422
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
2523
}
2624

2725
export const attachHandlers: GatewayRequestHandlers = {
28-
// Mint a grant bound to a session, returning the loopback MCP config + the token env the harness
29-
// needs. ensureMcpLoopbackServer lazily brings the singleton up if no cli-backend turn started it.
3026
"attach.grant": async ({ params, respond, context }) => {
27+
const grantParams = paramRecord(params);
3128
await ensureMcpLoopbackServer();
3229
const runtime = getActiveMcpLoopbackRuntime();
3330
if (!runtime) {
@@ -39,24 +36,21 @@ export const attachHandlers: GatewayRequestHandlers = {
3936
return;
4037
}
4138
const sessionKey =
42-
readString(params, "sessionKey") ?? resolveMainSessionKey(context.getRuntimeConfig());
43-
const grant = mintAttachGrant({ sessionKey, ttlMs: readPositiveNumber(params, "ttlMs") });
39+
readString(grantParams, "sessionKey") ?? resolveMainSessionKey(context.getRuntimeConfig());
40+
const grant = mintAttachGrant({ sessionKey, ttlMs: readPositiveNumber(grantParams, "ttlMs") });
4441
respond(true, {
4542
sessionKey: grant.sessionKey,
4643
token: grant.token,
4744
expiresAtMs: grant.expiresAtMs,
48-
// The harness writes mcpConfig to its MCP client config and sets env so the ${...} placeholders
49-
// resolve. Loopback today; node/app conduits reuse the same client config over their channel.
5045
mcpConfig: createMcpLoopbackServerConfig(runtime.port),
5146
env: {
5247
OPENCLAW_MCP_TOKEN: grant.token,
5348
OPENCLAW_MCP_SESSION_KEY: grant.sessionKey,
5449
},
5550
});
5651
},
57-
// Revoke a previously minted grant. Idempotent: an unknown/already-expired token reports revoked=false.
5852
"attach.revoke": async ({ params, respond }) => {
59-
const token = readString(params, "token");
53+
const token = readString(paramRecord(params), "token");
6054
if (!token) {
6155
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "token is required"));
6256
return;

0 commit comments

Comments
 (0)