Skip to content
Merged
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
8 changes: 6 additions & 2 deletions vscode/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@ import type {
UiConnectionStatus,
UiWorkspace,
} from "./webview/protocol";
import { isAllowedOrpcPath, sanitizeWebviewOrpcInput } from "./orpcAllowlist";
import {
isAllowedOrpcPath,
redactWebviewOrpcResult,
sanitizeWebviewOrpcInput,
} from "./orpcAllowlist";
import { parseWebviewToExtensionMessage } from "./parseWebviewToExtensionMessage";
import { openWorkspace } from "./workspaceOpener";

Expand Down Expand Up @@ -1667,7 +1671,7 @@ class XumChatViewProvider implements vscode.WebviewViewProvider, vscode.Disposab
requestId: args.requestId,
ok: true,
kind: "value",
value: result,
value: redactWebviewOrpcResult(args.path, result),
});
} catch (error) {
if (controller.signal.aborted) {
Expand Down
53 changes: 52 additions & 1 deletion vscode/src/orpcAllowlist.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { describe, expect, test } from "bun:test";

import { isAllowedOrpcPath, sanitizeWebviewOrpcInput } from "./orpcAllowlist";
import {
isAllowedOrpcPath,
redactWebviewOrpcResult,
sanitizeWebviewOrpcInput,
} from "./orpcAllowlist";

describe("isAllowedOrpcPath", () => {
test("allows known procedures", () => {
Expand Down Expand Up @@ -79,3 +83,50 @@ describe("agents.list (#4751)", () => {
});
});
});

describe("policy (#4739)", () => {
test("allows reading the effective policy and its change signal only", () => {
expect(isAllowedOrpcPath(["policy", "get"])).toBe(true);
expect(isAllowedOrpcPath(["policy", "onChanged"])).toBe(true);
expect(isAllowedOrpcPath(["policy", "refresh"])).toBe(false);
});

test("strips provider forcedBaseUrl from policy.get and keeps everything else", () => {
const response = {
source: "governor",
status: { state: "enforced" },
policy: {
policyFormatVersion: "0.1",
providerAccess: [
{
id: "openai",
forcedBaseUrl: "https://user:token@gateway.corp.example/v1",
allowedModels: ["gpt-5.6-terra"],
},
{ id: "anthropic", allowedModels: null },
],
mcp: { allowUserDefined: { stdio: false, remote: true } },
runtimes: ["worktree"],
},
};
expect(redactWebviewOrpcResult(["policy", "get"], response)).toEqual({
...response,
policy: {
...response.policy,
providerAccess: [
{ id: "openai", allowedModels: ["gpt-5.6-terra"] },
{ id: "anthropic", allowedModels: null },
],
},
});
// The input object is not mutated.
expect(response.policy.providerAccess[0].forcedBaseUrl).toBeDefined();
});

test("passes other results and policy-less responses through unchanged", () => {
const noPolicy = { source: "none", status: { state: "disabled" }, policy: null };
expect(redactWebviewOrpcResult(["policy", "get"], noPolicy)).toEqual(noPolicy);
const other = { forcedBaseUrl: "kept" };
expect(redactWebviewOrpcResult(["providers", "getConfig"], other)).toBe(other);
});
});
40 changes: 40 additions & 0 deletions vscode/src/orpcAllowlist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ const ALLOWED_PROCEDURES = {
// the agent picker and agent-cycle shortcut (#4751). agents.get (full prompt bodies) stays
// blocked, and sanitizeWebviewOrpcInput limits the input to workspaces the webview is shown.
agents: new Set(["list"]),
// Read-only admin policy (provider/model allowlists, runtime and MCP flags) so the model list
// matches what the backend enforces (#4739); onChanged only emits empty change signals.
// redactWebviewOrpcResult strips provider forcedBaseUrl before policy.get reaches the webview.
policy: new Set(["get", "onChanged"]),
Comment thread
ThomasK33 marked this conversation as resolved.
} as const;

export function isAllowedOrpcPath(path: string[]): boolean {
Expand All @@ -61,11 +65,47 @@ export function isAllowedOrpcPath(path: string[]): boolean {
return ALLOWED_PROCEDURES.providers.has(procedure);
case "agents":
return ALLOWED_PROCEDURES.agents.has(procedure);
case "policy":
return ALLOWED_PROCEDURES.policy.has(procedure);
default:
return false;
}
}

/**
* Removes fields the webview does not need from results before they cross the bridge.
*
* policy.get: a provider's forcedBaseUrl is an internal gateway URL that could embed credentials,
* and no webview code reads it; only the allowlists and flags are forwarded. The input is not
* mutated. Every other result passes through unchanged.
*/
export function redactWebviewOrpcResult(path: string[], value: unknown): unknown {
if (path[0] !== "policy" || path[1] !== "get") {
return value;
}
if (typeof value !== "object" || value === null) {
return value;
}
const response = value as { policy?: unknown };
const policy = response.policy as { providerAccess?: unknown } | null | undefined;
if (typeof policy !== "object" || policy === null || !Array.isArray(policy.providerAccess)) {
return value;
}
return {
...response,
policy: {
...policy,
providerAccess: policy.providerAccess.map((entry: unknown) => {
if (typeof entry !== "object" || entry === null) {
return entry;
}
const { forcedBaseUrl: _forcedBaseUrl, ...rest } = entry as Record<string, unknown>;
Comment thread
ThomasK33 marked this conversation as resolved.
return rest;
}),
},
};
}

export type SanitizedOrpcInput = { ok: true; input: unknown } | { ok: false; error: string };

/**
Expand Down
Loading