Skip to content

Commit 5a28200

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

9 files changed

Lines changed: 389 additions & 2 deletions

.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/client-access-http.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-http.test.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import assert from "node:assert/strict";
2+
import { once } from "node:events";
3+
import type { AddressInfo } from "node:net";
4+
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
5+
import express from "express";
6+
import type { ClientAccessConfig } from "./client-access.js";
7+
import { handleClientAccessInitialize } from "./server.js";
8+
9+
const config: ClientAccessConfig = {
10+
mode: "enforce",
11+
deniedClients: ["codex"],
12+
};
13+
14+
const app = express();
15+
app.use(express.json());
16+
17+
let continuedRequests = 0;
18+
app.post("/mcp", (req, res) => {
19+
assert.equal(isInitializeRequest(req.body), true);
20+
const { clientAccess } = handleClientAccessInitialize(res, req.body, config);
21+
if (!clientAccess.allowed) return;
22+
23+
continuedRequests += 1;
24+
res.sendStatus(204);
25+
});
26+
27+
const server = app.listen(0, "127.0.0.1");
28+
await once(server, "listening");
29+
30+
try {
31+
const address = server.address() as AddressInfo;
32+
const endpoint = `http://127.0.0.1:${address.port}/mcp`;
33+
34+
const deniedResponse = await fetch(endpoint, {
35+
method: "POST",
36+
headers: { "content-type": "application/json", connection: "close" },
37+
body: JSON.stringify({
38+
jsonrpc: "2.0",
39+
id: 0,
40+
method: "initialize",
41+
params: {
42+
protocolVersion: "2025-03-26",
43+
capabilities: {},
44+
clientInfo: { name: "codex-mcp-client", version: "0.145.0" },
45+
},
46+
}),
47+
});
48+
49+
assert.equal(deniedResponse.status, 403);
50+
assert.deepEqual(await deniedResponse.json(), {
51+
jsonrpc: "2.0",
52+
error: { code: -32003, message: "MCP client not allowed" },
53+
id: 0,
54+
});
55+
assert.equal(continuedRequests, 0);
56+
57+
const allowedResponse = await fetch(endpoint, {
58+
method: "POST",
59+
headers: { "content-type": "application/json", connection: "close" },
60+
body: JSON.stringify({
61+
jsonrpc: "2.0",
62+
id: 1,
63+
method: "initialize",
64+
params: {
65+
protocolVersion: "2025-03-26",
66+
capabilities: {},
67+
clientInfo: { name: "", version: "" },
68+
},
69+
}),
70+
});
71+
72+
assert.equal(allowedResponse.status, 204);
73+
assert.equal(continuedRequests, 1);
74+
} finally {
75+
await new Promise<void>((resolve, reject) => {
76+
server.close((error) => {
77+
if (error) reject(error);
78+
else resolve();
79+
});
80+
});
81+
}

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

src/config.test.ts

Lines changed: 31 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,14 @@ 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+
);
82+
assert.throws(
83+
() => loadConfig({ ...baseEnv, DEVSPACE_CLIENT_ACCESS_MODE: "" }),
84+
/Invalid DEVSPACE_CLIENT_ACCESS_MODE:/,
85+
);
6386

6487
assert.deepEqual(loadConfig(baseEnv).logging, {
6588
level: "info",
@@ -163,6 +186,10 @@ writeFileSync(
163186
allowedRoots: [process.cwd()],
164187
publicBaseUrl: "https://devspace.example.com",
165188
subagents: true,
189+
clientAccess: {
190+
mode: "enforce",
191+
deniedClients: ["codex"],
192+
},
166193
}),
167194
);
168195
writeFileSync(
@@ -177,6 +204,10 @@ assert.equal(fileConfig.port, 8787);
177204
assert.equal(fileConfig.oauth.ownerToken, "persisted-owner-token-long-enough");
178205
assert.equal(fileConfig.publicBaseUrl, "https://devspace.example.com");
179206
assert.equal(fileConfig.subagents, true);
207+
assert.deepEqual(fileConfig.clientAccess, {
208+
mode: "enforce",
209+
deniedClients: ["codex"],
210+
});
180211
assert.deepEqual(fileConfig.allowedHosts, [
181212
"localhost",
182213
"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 === undefined || 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

0 commit comments

Comments
 (0)