Skip to content

Commit 8d925bc

Browse files
committed
feat: add MCP client access policy
1 parent 6ccefbf commit 8d925bc

8 files changed

Lines changed: 356 additions & 1 deletion

File tree

.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ 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 declared MCP client policy. This blocks cooperative clients by their
22+
# initialize.clientInfo declaration; it is not cryptographic surface identity.
23+
# DEVSPACE_CLIENT_ACCESS_MODE=enforce
24+
# DEVSPACE_ALLOWED_CLIENTS=chatgpt
25+
# DEVSPACE_DENIED_CLIENTS=codex
2126
DEVSPACE_LOG_LEVEL=info
2227
DEVSPACE_LOG_FORMAT=json
2328
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/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.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/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.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: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import assert from "node:assert/strict";
2+
import {
3+
evaluateClientAccess,
4+
extractDeclaredClient,
5+
type ClientAccessConfig,
6+
} from "./client-access.js";
7+
8+
const enforceChatGptOnly: ClientAccessConfig = {
9+
mode: "enforce",
10+
allowedClients: ["chatgpt"],
11+
deniedClients: ["codex"],
12+
};
13+
14+
assert.deepEqual(
15+
extractDeclaredClient({
16+
jsonrpc: "2.0",
17+
id: 0,
18+
method: "initialize",
19+
params: {
20+
clientInfo: {
21+
name: "codex-mcp-client",
22+
title: "Codex",
23+
version: "0.108.0-alpha.12",
24+
},
25+
},
26+
}),
27+
{
28+
name: "codex-mcp-client",
29+
title: "Codex",
30+
version: "0.108.0-alpha.12",
31+
identities: ["codex-mcp-client", "codex"],
32+
},
33+
);
34+
35+
assert.deepEqual(
36+
extractDeclaredClient({
37+
method: "initialize",
38+
params: { clientInfo: { name: "ChatGPT", version: "1.2.3" } },
39+
}),
40+
{
41+
name: "ChatGPT",
42+
version: "1.2.3",
43+
identities: ["chatgpt"],
44+
},
45+
);
46+
47+
assert.equal(
48+
evaluateClientAccess(enforceChatGptOnly, {
49+
name: "ChatGPT",
50+
version: "1.2.3",
51+
identities: ["chatgpt"],
52+
}).allowed,
53+
true,
54+
);
55+
56+
assert.deepEqual(
57+
evaluateClientAccess(enforceChatGptOnly, {
58+
name: "codex-mcp-client",
59+
title: "Codex",
60+
version: "0.145.0",
61+
identities: ["codex-mcp-client", "codex"],
62+
}),
63+
{
64+
allowed: false,
65+
enforced: true,
66+
reason: "denied_client",
67+
matchedClient: "codex",
68+
},
69+
);
70+
71+
assert.deepEqual(
72+
evaluateClientAccess(enforceChatGptOnly, {
73+
name: "ChatGPT Codex bridge",
74+
identities: ["chatgpt-codex-bridge", "chatgpt", "codex"],
75+
}),
76+
{
77+
allowed: false,
78+
enforced: true,
79+
reason: "denied_client",
80+
matchedClient: "codex",
81+
},
82+
);
83+
84+
assert.deepEqual(evaluateClientAccess(enforceChatGptOnly, undefined), {
85+
allowed: false,
86+
enforced: true,
87+
reason: "client_not_allowed",
88+
});
89+
90+
assert.deepEqual(
91+
evaluateClientAccess(
92+
{
93+
mode: "audit",
94+
allowedClients: ["chatgpt"],
95+
deniedClients: ["codex"],
96+
},
97+
{
98+
name: "codex-mcp-client",
99+
identities: ["codex-mcp-client", "codex"],
100+
},
101+
),
102+
{
103+
allowed: true,
104+
enforced: false,
105+
reason: "denied_client",
106+
matchedClient: "codex",
107+
},
108+
);
109+
110+
assert.deepEqual(
111+
evaluateClientAccess(
112+
{ mode: "enforce", allowedClients: [], deniedClients: ["codex"] },
113+
{ name: "Other MCP Client", identities: ["other-mcp-client"] },
114+
),
115+
{
116+
allowed: true,
117+
enforced: true,
118+
reason: "allowed",
119+
},
120+
);
121+
122+
assert.equal(extractDeclaredClient({ method: "tools/list", params: {} }), undefined);
123+
assert.equal(
124+
extractDeclaredClient({ method: "initialize", params: { clientInfo: { name: "" } } }),
125+
undefined,
126+
);

src/client-access.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
export type ClientAccessMode = "off" | "audit" | "enforce";
2+
3+
export interface ClientAccessConfig {
4+
mode: ClientAccessMode;
5+
allowedClients: string[];
6+
deniedClients: string[];
7+
}
8+
9+
export interface DeclaredClient {
10+
name: string;
11+
title?: string;
12+
version?: string;
13+
identities: string[];
14+
}
15+
16+
export interface ClientAccessDecision {
17+
allowed: boolean;
18+
enforced: boolean;
19+
reason: "disabled" | "allowed" | "denied_client" | "client_not_allowed";
20+
matchedClient?: string;
21+
}
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 evaluateClientAccess(
45+
config: ClientAccessConfig,
46+
client: DeclaredClient | undefined,
47+
): ClientAccessDecision {
48+
if (config.mode === "off") {
49+
return { allowed: true, enforced: false, reason: "disabled" };
50+
}
51+
52+
const identities = new Set(client?.identities ?? []);
53+
const deniedClient = normalizedPolicyEntries(config.deniedClients).find((entry) =>
54+
identities.has(entry),
55+
);
56+
if (deniedClient) {
57+
return {
58+
allowed: config.mode !== "enforce",
59+
enforced: config.mode === "enforce",
60+
reason: "denied_client",
61+
matchedClient: deniedClient,
62+
};
63+
}
64+
65+
const allowedClients = normalizedPolicyEntries(config.allowedClients);
66+
if (allowedClients.length > 0 && !allowedClients.some((entry) => identities.has(entry))) {
67+
return {
68+
allowed: config.mode !== "enforce",
69+
enforced: config.mode === "enforce",
70+
reason: "client_not_allowed",
71+
};
72+
}
73+
74+
return {
75+
allowed: true,
76+
enforced: config.mode === "enforce",
77+
reason: "allowed",
78+
};
79+
}
80+
81+
function clientIdentities(name: string, title?: string): string[] {
82+
const identities = new Set<string>();
83+
const normalizedName = normalizeClientName(name);
84+
if (normalizedName) identities.add(normalizedName);
85+
86+
const combined = `${name} ${title ?? ""}`.toLowerCase();
87+
if (/\bcodex\b/.test(combined) || normalizedName.includes("codex")) identities.add("codex");
88+
if (combined.includes("chatgpt") || normalizedName.includes("chatgpt")) identities.add("chatgpt");
89+
90+
return [...identities];
91+
}
92+
93+
function normalizedPolicyEntries(entries: string[]): string[] {
94+
return [...new Set(entries.map(normalizeClientName).filter(Boolean))];
95+
}
96+
97+
function normalizeClientName(value: string): string {
98+
return value
99+
.trim()
100+
.toLowerCase()
101+
.replace(/[^a-z0-9]+/g, "-")
102+
.replace(/^-+|-+$/g, "");
103+
}
104+
105+
function sanitizeText(value: unknown): string | undefined {
106+
if (typeof value !== "string") return undefined;
107+
const normalized = value.replace(/[\r\n\t]+/g, " ").trim();
108+
if (!normalized) return undefined;
109+
return normalized.slice(0, 160);
110+
}
111+
112+
function isRecord(value: unknown): value is Record<string, unknown> {
113+
return typeof value === "object" && value !== null && !Array.isArray(value);
114+
}

src/config.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,24 @@ 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+
allowedClients: [],
32+
deniedClients: [],
33+
});
34+
assert.deepEqual(
35+
loadConfig({
36+
...baseEnv,
37+
DEVSPACE_CLIENT_ACCESS_MODE: "enforce",
38+
DEVSPACE_ALLOWED_CLIENTS: "chatgpt, ChatGPT ",
39+
DEVSPACE_DENIED_CLIENTS: "codex",
40+
}).clientAccess,
41+
{
42+
mode: "enforce",
43+
allowedClients: ["chatgpt", "ChatGPT"],
44+
deniedClients: ["codex"],
45+
},
46+
);
2947
assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SKILLS: "0" }).skillsEnabled, false);
3048
assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SKILLS: "1" }).skillsEnabled, true);
3149
assert.equal(
@@ -60,6 +78,10 @@ assert.throws(
6078
() => loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "invalid" }),
6179
/Invalid DEVSPACE_TOOL_MODE: invalid/,
6280
);
81+
assert.throws(
82+
() => loadConfig({ ...baseEnv, DEVSPACE_CLIENT_ACCESS_MODE: "strict" }),
83+
/Invalid DEVSPACE_CLIENT_ACCESS_MODE: strict/,
84+
);
6385

6486
assert.deepEqual(loadConfig(baseEnv).logging, {
6587
level: "info",
@@ -163,6 +185,11 @@ writeFileSync(
163185
allowedRoots: [process.cwd()],
164186
publicBaseUrl: "https://devspace.example.com",
165187
subagents: true,
188+
clientAccess: {
189+
mode: "audit",
190+
allowedClients: ["chatgpt"],
191+
deniedClients: ["codex"],
192+
},
166193
}),
167194
);
168195
writeFileSync(
@@ -177,6 +204,11 @@ 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: "audit",
209+
allowedClients: ["chatgpt"],
210+
deniedClients: ["codex"],
211+
});
180212
assert.deepEqual(fileConfig.allowedHosts, [
181213
"localhost",
182214
"127.0.0.1",

src/config.ts

Lines changed: 33 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,36 @@ 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 === "audit" || 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+
allowedClients?: string[];
141+
deniedClients?: string[];
142+
}
143+
| undefined,
144+
): ClientAccessConfig {
145+
const mode = parseClientAccessMode(env.DEVSPACE_CLIENT_ACCESS_MODE ?? fileConfig?.mode);
146+
return {
147+
mode,
148+
allowedClients:
149+
env.DEVSPACE_ALLOWED_CLIENTS !== undefined
150+
? parseStringList(env.DEVSPACE_ALLOWED_CLIENTS, [])
151+
: [...(fileConfig?.allowedClients ?? [])],
152+
deniedClients:
153+
env.DEVSPACE_DENIED_CLIENTS !== undefined
154+
? parseStringList(env.DEVSPACE_DENIED_CLIENTS, [])
155+
: [...(fileConfig?.deniedClients ?? [])],
156+
};
157+
}
158+
127159
function parsePositiveInteger(value: string | undefined, fallback: number, name: string): number {
128160
if (!value) return fallback;
129161

@@ -236,6 +268,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig {
236268
: parseBoolean(env.DEVSPACE_SUBAGENTS),
237269
agentDir: resolve(expandHomePath(env.DEVSPACE_AGENT_DIR ?? files.config.agentDir ?? defaultAgentDir())),
238270
logging: parseLoggingConfig(env),
271+
clientAccess: parseClientAccessConfig(env, files.config.clientAccess),
239272
};
240273
}
241274

0 commit comments

Comments
 (0)