Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ DEVSPACE_TOOL_MODE=full
# off | changes | full. Defaults to changes.
# changes creates one aggregate review widget via review_changes instead of per-tool iframes.
DEVSPACE_WIDGETS=changes
# Optional cooperative-client guard based on initialize.clientInfo.
# Start with a denylist because some allowed clients may omit clientInfo.
# DEVSPACE_CLIENT_ACCESS_MODE=enforce
# DEVSPACE_DENIED_CLIENTS=codex
DEVSPACE_LOG_LEVEL=info
DEVSPACE_LOG_FORMAT=json
DEVSPACE_LOG_REQUESTS=1
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
"dev": "node scripts/dev-server.mjs",
"postinstall": "node scripts/fix-node-pty-permissions.mjs",
"start": "node dist/cli.js serve",
"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",
"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",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"keywords": [],
Expand Down
81 changes: 81 additions & 0 deletions src/client-access-http.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import assert from "node:assert/strict";
import { once } from "node:events";
import type { AddressInfo } from "node:net";
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
import express from "express";
import type { ClientAccessConfig } from "./client-access.js";
import { handleClientAccessInitialize } from "./server.js";

const config: ClientAccessConfig = {
mode: "enforce",
deniedClients: ["codex"],
};

const app = express();
app.use(express.json());

let continuedRequests = 0;
app.post("/mcp", (req, res) => {
assert.equal(isInitializeRequest(req.body), true);
const { clientAccess } = handleClientAccessInitialize(res, req.body, config);
if (!clientAccess.allowed) return;

continuedRequests += 1;
res.sendStatus(204);
});

const server = app.listen(0, "127.0.0.1");
await once(server, "listening");

try {
const address = server.address() as AddressInfo;
const endpoint = `http://127.0.0.1:${address.port}/mcp`;

const deniedResponse = await fetch(endpoint, {
method: "POST",
headers: { "content-type": "application/json", connection: "close" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 0,
method: "initialize",
params: {
protocolVersion: "2025-03-26",
capabilities: {},
clientInfo: { name: "codex-mcp-client", version: "0.145.0" },
},
}),
});

assert.equal(deniedResponse.status, 403);
assert.deepEqual(await deniedResponse.json(), {
jsonrpc: "2.0",
error: { code: -32003, message: "MCP client not allowed" },
id: 0,
});
assert.equal(continuedRequests, 0);

const allowedResponse = await fetch(endpoint, {
method: "POST",
headers: { "content-type": "application/json", connection: "close" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
protocolVersion: "2025-03-26",
capabilities: {},
clientInfo: { name: "", version: "" },
},
}),
});

assert.equal(allowedResponse.status, 204);
assert.equal(continuedRequests, 1);
} finally {
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) reject(error);
else resolve();
});
});
}
60 changes: 60 additions & 0 deletions src/client-access.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import assert from "node:assert/strict";
import {
evaluateClientAccess,
extractDeclaredClient,
type ClientAccessConfig,
} from "./client-access.js";

const enforceCodexDenylist: ClientAccessConfig = {
mode: "enforce",
deniedClients: ["codex"],
};

assert.deepEqual(
extractDeclaredClient({
jsonrpc: "2.0",
id: 0,
method: "initialize",
params: {
clientInfo: {
name: "codex-mcp-client",
title: "Codex",
version: "0.108.0-alpha.12",
},
},
}),
{
name: "codex-mcp-client",
title: "Codex",
version: "0.108.0-alpha.12",
identities: ["codex-mcp-client", "codex"],
},
);

assert.deepEqual(
evaluateClientAccess(enforceCodexDenylist, {
name: "codex-mcp-client",
title: "Codex",
version: "0.145.0",
identities: ["codex-mcp-client", "codex"],
}),
{
allowed: false,
reason: "denied_client",
matchedClient: "codex",
},
);

assert.deepEqual(
evaluateClientAccess(enforceCodexDenylist, undefined),
{
allowed: true,
reason: "allowed",
},
);

assert.equal(extractDeclaredClient({ method: "tools/list", params: {} }), undefined);
assert.equal(
extractDeclaredClient({ method: "initialize", params: { clientInfo: { name: "" } } }),
undefined,
);
109 changes: 109 additions & 0 deletions src/client-access.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
export type ClientAccessMode = "off" | "enforce";

export interface ClientAccessConfig {
mode: ClientAccessMode;
deniedClients: string[];
}

export interface DeclaredClient {
name: string;
title?: string;
version?: string;
identities: string[];
}

export interface ClientAccessDecision {
allowed: boolean;
reason: "disabled" | "allowed" | "denied_client";
matchedClient?: string;
}

export type JsonRpcId = string | number | null;

export function extractDeclaredClient(body: unknown): DeclaredClient | undefined {
if (!isRecord(body) || body.method !== "initialize") return undefined;
const params = body.params;
if (!isRecord(params)) return undefined;
const clientInfo = params.clientInfo;
if (!isRecord(clientInfo)) return undefined;

const name = sanitizeText(clientInfo.name);
if (!name) return undefined;
const title = sanitizeText(clientInfo.title);
const version = sanitizeText(clientInfo.version);
const identities = clientIdentities(name, title);

return {
name,
...(title ? { title } : {}),
...(version ? { version } : {}),
identities,
};
}

export function extractJsonRpcId(body: unknown): JsonRpcId {
if (!isRecord(body)) return null;
const id = body.id;
return typeof id === "string" || typeof id === "number" ? id : null;
}

export function evaluateClientAccess(
config: ClientAccessConfig,
client: DeclaredClient | undefined,
): ClientAccessDecision {
if (config.mode === "off") {
return { allowed: true, reason: "disabled" };
}

const identities = new Set(client?.identities ?? []);
const deniedClient = normalizedPolicyEntries(config.deniedClients).find((entry) =>
identities.has(entry),
);
if (deniedClient) {
return {
allowed: false,
reason: "denied_client",
matchedClient: deniedClient,
};
}

return {
allowed: true,
reason: "allowed",
};
}

function clientIdentities(name: string, title?: string): string[] {
const identities = new Set<string>();
const normalizedName = normalizeClientName(name);
if (normalizedName) identities.add(normalizedName);

const combined = `${name} ${title ?? ""}`.toLowerCase();
if (/\bcodex\b/.test(combined) || normalizedName.includes("codex")) identities.add("codex");
if (combined.includes("chatgpt") || normalizedName.includes("chatgpt")) identities.add("chatgpt");

return [...identities];
}

function normalizedPolicyEntries(entries: string[]): string[] {
return [...new Set(entries.map(normalizeClientName).filter(Boolean))];
}

function normalizeClientName(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}

function sanitizeText(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const normalized = value.replace(/[\r\n\t]+/g, " ").trim();
if (!normalized) return undefined;
return normalized.slice(0, 160);
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
31 changes: 31 additions & 0 deletions src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,21 @@ assert.equal(loadConfig(baseEnv).skillsEnabled, true);
assert.equal(loadConfig(baseEnv).devspaceSkillsDir, join(emptyConfigDir, "skills"));
assert.equal(loadConfig(baseEnv).devspaceAgentsDir, join(emptyConfigDir, "agents"));
assert.equal(loadConfig(baseEnv).subagents, false);
assert.deepEqual(loadConfig(baseEnv).clientAccess, {
mode: "off",
deniedClients: [],
});
assert.deepEqual(
loadConfig({
...baseEnv,
DEVSPACE_CLIENT_ACCESS_MODE: "enforce",
DEVSPACE_DENIED_CLIENTS: "codex",
}).clientAccess,
{
mode: "enforce",
deniedClients: ["codex"],
},
);
assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SKILLS: "0" }).skillsEnabled, false);
assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SKILLS: "1" }).skillsEnabled, true);
assert.equal(
Expand Down Expand Up @@ -60,6 +75,14 @@ assert.throws(
() => loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "invalid" }),
/Invalid DEVSPACE_TOOL_MODE: invalid/,
);
assert.throws(
() => loadConfig({ ...baseEnv, DEVSPACE_CLIENT_ACCESS_MODE: "audit" }),
/Invalid DEVSPACE_CLIENT_ACCESS_MODE: audit/,
);
assert.throws(
() => loadConfig({ ...baseEnv, DEVSPACE_CLIENT_ACCESS_MODE: "" }),
/Invalid DEVSPACE_CLIENT_ACCESS_MODE:/,
);

assert.deepEqual(loadConfig(baseEnv).logging, {
level: "info",
Expand Down Expand Up @@ -163,6 +186,10 @@ writeFileSync(
allowedRoots: [process.cwd()],
publicBaseUrl: "https://devspace.example.com",
subagents: true,
clientAccess: {
mode: "enforce",
deniedClients: ["codex"],
},
}),
);
writeFileSync(
Expand All @@ -177,6 +204,10 @@ assert.equal(fileConfig.port, 8787);
assert.equal(fileConfig.oauth.ownerToken, "persisted-owner-token-long-enough");
assert.equal(fileConfig.publicBaseUrl, "https://devspace.example.com");
assert.equal(fileConfig.subagents, true);
assert.deepEqual(fileConfig.clientAccess, {
mode: "enforce",
deniedClients: ["codex"],
});
assert.deepEqual(fileConfig.allowedHosts, [
"localhost",
"127.0.0.1",
Expand Down
28 changes: 28 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { homedir } from "node:os";
import { join, resolve } from "node:path";
import { expandHomePath } from "./roots.js";
import type { ClientAccessConfig, ClientAccessMode } from "./client-access.js";
import type { LoggingConfig, LogFormat, LogLevel } from "./logger.js";
import type { OAuthConfig } from "./oauth-provider.js";
import { devspaceAgentsDir, devspaceSkillsDir, loadDevspaceFiles } from "./user-config.js";
Expand Down Expand Up @@ -28,6 +29,7 @@ export interface ServerConfig {
subagents: boolean;
agentDir: string;
logging: LoggingConfig;
clientAccess: ClientAccessConfig;
}

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

function parseClientAccessMode(value: string | undefined): ClientAccessMode {
if (value === undefined || value === "off") return "off";
if (value === "enforce") return value;
throw new Error(`Invalid DEVSPACE_CLIENT_ACCESS_MODE: ${value}`);
}

function parseClientAccessConfig(
env: NodeJS.ProcessEnv,
fileConfig:
| {
mode?: ClientAccessMode;
deniedClients?: string[];
}
| undefined,
): ClientAccessConfig {
const mode = parseClientAccessMode(env.DEVSPACE_CLIENT_ACCESS_MODE ?? fileConfig?.mode);
return {
mode,
deniedClients:
env.DEVSPACE_DENIED_CLIENTS !== undefined
? parseStringList(env.DEVSPACE_DENIED_CLIENTS, [])
: [...(fileConfig?.deniedClients ?? [])],
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};
}

function parsePositiveInteger(value: string | undefined, fallback: number, name: string): number {
if (!value) return fallback;

Expand Down Expand Up @@ -236,6 +263,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig {
: parseBoolean(env.DEVSPACE_SUBAGENTS),
agentDir: resolve(expandHomePath(env.DEVSPACE_AGENT_DIR ?? files.config.agentDir ?? defaultAgentDir())),
logging: parseLoggingConfig(env),
clientAccess: parseClientAccessConfig(env, files.config.clientAccess),
};
}

Expand Down
Loading
Loading