Skip to content

Commit e2e032a

Browse files
committed
feat: add MCP client access policy
1 parent 80423b5 commit e2e032a

8 files changed

Lines changed: 262 additions & 1 deletion

File tree

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ DEVSPACE_TOOL_MODE=full
1818
# off | changes | full. Defaults to changes.
1919
# changes creates one aggregate review widget via review_changes instead of per-tool iframes.
2020
DEVSPACE_WIDGETS=changes
21+
# Optional cooperative-client guard based on initialize.clientInfo.
22+
# Start with a denylist because some allowed clients may omit clientInfo.
23+
# DEVSPACE_CLIENT_ACCESS_MODE=enforce
24+
# DEVSPACE_DENIED_CLIENTS=codex
2125
DEVSPACE_LOG_LEVEL=info
2226
DEVSPACE_LOG_FORMAT=json
2327
DEVSPACE_LOG_REQUESTS=1

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
"dev": "node scripts/dev-server.mjs",
2929
"postinstall": "node scripts/fix-node-pty-permissions.mjs",
3030
"start": "node dist/cli.js serve",
31-
"test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
31+
"test": "tsx src/client-access.test.ts && tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
3232
"typecheck": "tsc -p tsconfig.json --noEmit"
3333
},
3434
"keywords": [],

src/client-access.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import assert from "node:assert/strict";
2+
import {
3+
evaluateClientAccess,
4+
extractDeclaredClient,
5+
type ClientAccessConfig,
6+
} from "./client-access.js";
7+
8+
const enforceCodexDenylist: ClientAccessConfig = {
9+
mode: "enforce",
10+
deniedClients: ["codex"],
11+
};
12+
13+
assert.deepEqual(
14+
extractDeclaredClient({
15+
jsonrpc: "2.0",
16+
id: 0,
17+
method: "initialize",
18+
params: {
19+
clientInfo: {
20+
name: "codex-mcp-client",
21+
title: "Codex",
22+
version: "0.108.0-alpha.12",
23+
},
24+
},
25+
}),
26+
{
27+
name: "codex-mcp-client",
28+
title: "Codex",
29+
version: "0.108.0-alpha.12",
30+
identities: ["codex-mcp-client", "codex"],
31+
},
32+
);
33+
34+
assert.deepEqual(
35+
evaluateClientAccess(enforceCodexDenylist, {
36+
name: "codex-mcp-client",
37+
title: "Codex",
38+
version: "0.145.0",
39+
identities: ["codex-mcp-client", "codex"],
40+
}),
41+
{
42+
allowed: false,
43+
reason: "denied_client",
44+
matchedClient: "codex",
45+
},
46+
);
47+
48+
assert.deepEqual(
49+
evaluateClientAccess(enforceCodexDenylist, undefined),
50+
{
51+
allowed: true,
52+
reason: "allowed",
53+
},
54+
);
55+
56+
assert.equal(extractDeclaredClient({ method: "tools/list", params: {} }), undefined);
57+
assert.equal(
58+
extractDeclaredClient({ method: "initialize", params: { clientInfo: { name: "" } } }),
59+
undefined,
60+
);

src/client-access.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
export type ClientAccessMode = "off" | "enforce";
2+
3+
export interface ClientAccessConfig {
4+
mode: ClientAccessMode;
5+
deniedClients: string[];
6+
}
7+
8+
export interface DeclaredClient {
9+
name: string;
10+
title?: string;
11+
version?: string;
12+
identities: string[];
13+
}
14+
15+
export interface ClientAccessDecision {
16+
allowed: boolean;
17+
reason: "disabled" | "allowed" | "denied_client";
18+
matchedClient?: string;
19+
}
20+
21+
export function extractDeclaredClient(body: unknown): DeclaredClient | undefined {
22+
if (!isRecord(body) || body.method !== "initialize") return undefined;
23+
const params = body.params;
24+
if (!isRecord(params)) return undefined;
25+
const clientInfo = params.clientInfo;
26+
if (!isRecord(clientInfo)) return undefined;
27+
28+
const name = sanitizeText(clientInfo.name);
29+
if (!name) return undefined;
30+
const title = sanitizeText(clientInfo.title);
31+
const version = sanitizeText(clientInfo.version);
32+
const identities = clientIdentities(name, title);
33+
34+
return {
35+
name,
36+
...(title ? { title } : {}),
37+
...(version ? { version } : {}),
38+
identities,
39+
};
40+
}
41+
42+
export function evaluateClientAccess(
43+
config: ClientAccessConfig,
44+
client: DeclaredClient | undefined,
45+
): ClientAccessDecision {
46+
if (config.mode === "off") {
47+
return { allowed: true, reason: "disabled" };
48+
}
49+
50+
const identities = new Set(client?.identities ?? []);
51+
const deniedClient = normalizedPolicyEntries(config.deniedClients).find((entry) =>
52+
identities.has(entry),
53+
);
54+
if (deniedClient) {
55+
return {
56+
allowed: false,
57+
reason: "denied_client",
58+
matchedClient: deniedClient,
59+
};
60+
}
61+
62+
return {
63+
allowed: true,
64+
reason: "allowed",
65+
};
66+
}
67+
68+
function clientIdentities(name: string, title?: string): string[] {
69+
const identities = new Set<string>();
70+
const normalizedName = normalizeClientName(name);
71+
if (normalizedName) identities.add(normalizedName);
72+
73+
const combined = `${name} ${title ?? ""}`.toLowerCase();
74+
if (/\bcodex\b/.test(combined) || normalizedName.includes("codex")) identities.add("codex");
75+
if (combined.includes("chatgpt") || normalizedName.includes("chatgpt")) identities.add("chatgpt");
76+
77+
return [...identities];
78+
}
79+
80+
function normalizedPolicyEntries(entries: string[]): string[] {
81+
return [...new Set(entries.map(normalizeClientName).filter(Boolean))];
82+
}
83+
84+
function normalizeClientName(value: string): string {
85+
return value
86+
.trim()
87+
.toLowerCase()
88+
.replace(/[^a-z0-9]+/g, "-")
89+
.replace(/^-+|-+$/g, "");
90+
}
91+
92+
function sanitizeText(value: unknown): string | undefined {
93+
if (typeof value !== "string") return undefined;
94+
const normalized = value.replace(/[\r\n\t]+/g, " ").trim();
95+
if (!normalized) return undefined;
96+
return normalized.slice(0, 160);
97+
}
98+
99+
function isRecord(value: unknown): value is Record<string, unknown> {
100+
return typeof value === "object" && value !== null && !Array.isArray(value);
101+
}

src/config.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,21 @@ assert.equal(loadConfig(baseEnv).skillsEnabled, true);
2626
assert.equal(loadConfig(baseEnv).devspaceSkillsDir, join(emptyConfigDir, "skills"));
2727
assert.equal(loadConfig(baseEnv).devspaceAgentsDir, join(emptyConfigDir, "agents"));
2828
assert.equal(loadConfig(baseEnv).subagents, false);
29+
assert.deepEqual(loadConfig(baseEnv).clientAccess, {
30+
mode: "off",
31+
deniedClients: [],
32+
});
33+
assert.deepEqual(
34+
loadConfig({
35+
...baseEnv,
36+
DEVSPACE_CLIENT_ACCESS_MODE: "enforce",
37+
DEVSPACE_DENIED_CLIENTS: "codex",
38+
}).clientAccess,
39+
{
40+
mode: "enforce",
41+
deniedClients: ["codex"],
42+
},
43+
);
2944
assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SKILLS: "0" }).skillsEnabled, false);
3045
assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SKILLS: "1" }).skillsEnabled, true);
3146
assert.equal(
@@ -60,6 +75,10 @@ assert.throws(
6075
() => loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "invalid" }),
6176
/Invalid DEVSPACE_TOOL_MODE: invalid/,
6277
);
78+
assert.throws(
79+
() => loadConfig({ ...baseEnv, DEVSPACE_CLIENT_ACCESS_MODE: "audit" }),
80+
/Invalid DEVSPACE_CLIENT_ACCESS_MODE: audit/,
81+
);
6382

6483
assert.deepEqual(loadConfig(baseEnv).logging, {
6584
level: "info",
@@ -163,6 +182,10 @@ writeFileSync(
163182
allowedRoots: [process.cwd()],
164183
publicBaseUrl: "https://devspace.example.com",
165184
subagents: true,
185+
clientAccess: {
186+
mode: "enforce",
187+
deniedClients: ["codex"],
188+
},
166189
}),
167190
);
168191
writeFileSync(
@@ -177,6 +200,10 @@ assert.equal(fileConfig.port, 8787);
177200
assert.equal(fileConfig.oauth.ownerToken, "persisted-owner-token-long-enough");
178201
assert.equal(fileConfig.publicBaseUrl, "https://devspace.example.com");
179202
assert.equal(fileConfig.subagents, true);
203+
assert.deepEqual(fileConfig.clientAccess, {
204+
mode: "enforce",
205+
deniedClients: ["codex"],
206+
});
180207
assert.deepEqual(fileConfig.allowedHosts, [
181208
"localhost",
182209
"127.0.0.1",

src/config.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { homedir } from "node:os";
22
import { join, resolve } from "node:path";
33
import { expandHomePath } from "./roots.js";
4+
import type { ClientAccessConfig, ClientAccessMode } from "./client-access.js";
45
import type { LoggingConfig, LogFormat, LogLevel } from "./logger.js";
56
import type { OAuthConfig } from "./oauth-provider.js";
67
import { devspaceAgentsDir, devspaceSkillsDir, loadDevspaceFiles } from "./user-config.js";
@@ -28,6 +29,7 @@ export interface ServerConfig {
2829
subagents: boolean;
2930
agentDir: string;
3031
logging: LoggingConfig;
32+
clientAccess: ClientAccessConfig;
3133
}
3234

3335
function parsePort(value: string | number | undefined): number {
@@ -124,6 +126,31 @@ function parseStringList(value: string | undefined, fallback: string[]): string[
124126
return entries && entries.length > 0 ? entries : fallback;
125127
}
126128

129+
function parseClientAccessMode(value: string | undefined): ClientAccessMode {
130+
if (!value || value === "off") return "off";
131+
if (value === "enforce") return value;
132+
throw new Error(`Invalid DEVSPACE_CLIENT_ACCESS_MODE: ${value}`);
133+
}
134+
135+
function parseClientAccessConfig(
136+
env: NodeJS.ProcessEnv,
137+
fileConfig:
138+
| {
139+
mode?: ClientAccessMode;
140+
deniedClients?: string[];
141+
}
142+
| undefined,
143+
): ClientAccessConfig {
144+
const mode = parseClientAccessMode(env.DEVSPACE_CLIENT_ACCESS_MODE ?? fileConfig?.mode);
145+
return {
146+
mode,
147+
deniedClients:
148+
env.DEVSPACE_DENIED_CLIENTS !== undefined
149+
? parseStringList(env.DEVSPACE_DENIED_CLIENTS, [])
150+
: [...(fileConfig?.deniedClients ?? [])],
151+
};
152+
}
153+
127154
function parsePositiveInteger(value: string | undefined, fallback: number, name: string): number {
128155
if (!value) return fallback;
129156

@@ -236,6 +263,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig {
236263
: parseBoolean(env.DEVSPACE_SUBAGENTS),
237264
agentDir: resolve(expandHomePath(env.DEVSPACE_AGENT_DIR ?? files.config.agentDir ?? defaultAgentDir())),
238265
logging: parseLoggingConfig(env),
266+
clientAccess: parseClientAccessConfig(env, files.config.clientAccess),
239267
};
240268
}
241269

src/server.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import express from "express";
1818
import type { Request, Response } from "express";
1919
import * as z from "zod/v4";
2020
import { applyPatch } from "./apply-patch.js";
21+
import { evaluateClientAccess, extractDeclaredClient } from "./client-access.js";
2122
import { loadConfig, type ServerConfig, type WidgetMode } from "./config.js";
2223
import {
2324
logEvent,
@@ -1714,6 +1715,7 @@ export function createServer(config = loadConfig()): RunningServer {
17141715
const requestId = res.locals.requestId as string | undefined;
17151716
const sessionId = req.header("mcp-session-id");
17161717
const initializeRequest = req.method === "POST" && isInitializeRequest(req.body);
1718+
const declaredClient = initializeRequest ? extractDeclaredClient(req.body) : undefined;
17171719

17181720
await new Promise<void>((resolve, reject) => {
17191721
bearerAuth(req, res, (error?: unknown) => {
@@ -1735,6 +1737,35 @@ export function createServer(config = loadConfig()): RunningServer {
17351737
return;
17361738
}
17371739

1740+
if (initializeRequest) {
1741+
const clientAccess = evaluateClientAccess(config.clientAccess, declaredClient);
1742+
const clientFields = {
1743+
requestId,
1744+
clientName: declaredClient?.name,
1745+
clientTitle: declaredClient?.title,
1746+
clientVersion: declaredClient?.version,
1747+
clientIdentities: declaredClient?.identities ?? [],
1748+
oauthClientIdPrefix: req.auth.clientId?.slice(0, 8),
1749+
accessMode: config.clientAccess.mode,
1750+
accessReason: clientAccess.reason,
1751+
matchedClient: clientAccess.matchedClient,
1752+
...requestLogFields(req, config),
1753+
};
1754+
1755+
logEvent(
1756+
config.logging,
1757+
clientAccess.reason === "allowed" || clientAccess.reason === "disabled" ? "info" : "warn",
1758+
"mcp_client_observed",
1759+
clientFields,
1760+
);
1761+
1762+
if (!clientAccess.allowed) {
1763+
logEvent(config.logging, "warn", "mcp_client_denied", clientFields);
1764+
sendJsonRpcError(res, 403, -32003, "MCP client not allowed");
1765+
return;
1766+
}
1767+
}
1768+
17381769
logEvent(config.logging, "debug", "mcp_request", {
17391770
requestId,
17401771
method: req.method,
@@ -1839,6 +1870,12 @@ if (await isMainModule()) {
18391870
console.log(`request logging: ${config.logging.requests ? "enabled" : "disabled"}`);
18401871
console.log(`asset logging: ${config.logging.assets ? "enabled" : "disabled"}`);
18411872
console.log(`trust proxy: ${config.logging.trustProxy ? "enabled" : "disabled"}`);
1873+
console.log(
1874+
`client access: ${config.clientAccess.mode}` +
1875+
(config.clientAccess.deniedClients.length > 0
1876+
? ` deny=${config.clientAccess.deniedClients.join(",")}`
1877+
: ""),
1878+
);
18421879
if (config.subagents) {
18431880
console.log(`subagent providers: ${formatLocalAgentProviderAvailabilitySummary(localAgentProviders)}`);
18441881
}

src/user-config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ export interface DevspaceUserConfig {
1919
worktreeRoot?: string;
2020
agentDir?: string;
2121
subagents?: boolean;
22+
clientAccess?: {
23+
mode?: "off" | "enforce";
24+
deniedClients?: string[];
25+
};
2226
}
2327

2428
export interface DevspaceAuthConfig {

0 commit comments

Comments
 (0)