diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index eb51c1d67d..6369513e57 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -54,6 +54,17 @@ export const DEFAULT_COLLAPSE_COMMAND_OUTPUT_BY_DEFAULT = false export const DEFAULT_DESTRUCTIVE_COMMAND_GUARD_ENABLED = false +/** + * Whether workspace-scoped command-list layers (`zoo-code.allowedCommands` / + * `zoo-code.deniedCommands` values set in workspace settings) participate in + * command auto-approval at all. Opt-in: while off, workspace-layer entries + * contribute nothing to enforcement or display. The manifest declares the key + * with `scope: "application"`, so only user settings can change it; this + * constant is the single default shared by every consumer reading the + * setting. + */ +export const DEFAULT_WORKSPACE_COMMAND_LISTS_ENABLED = false + /** * Whether commands that are not explicitly auto-approved are automatically * denied (with a structured reason sent to the model) instead of prompting the @@ -180,6 +191,14 @@ export const globalSettingsSchema = z.object({ followupAutoApproveTimeoutMs: z.number().optional(), allowedCommands: z.array(z.string()).optional(), deniedCommands: z.array(z.string()).optional(), + /** + * Gate for the workspace-scoped command-list layers. While false (the + * default), workspace-set `allowedCommands`/`deniedCommands` entries + * contribute nothing; when true they are composed under the global layer, + * which outranks them wherever it matches a command. + * @default false + */ + workspaceCommandListsEnabled: z.boolean().optional(), commandExecutionTimeout: z.number().optional(), commandTimeoutAllowlist: z.array(z.string()).optional(), preventCompletionWithOpenTodos: z.boolean().optional(), diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 40f83fbaa2..bfe5632af6 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -303,6 +303,7 @@ export type ExtensionState = Pick< | "followupAutoApproveTimeoutMs" | "allowedCommands" | "deniedCommands" + | "workspaceCommandListsEnabled" | "allowedMaxRequests" | "allowedMaxCost" | "ttsEnabled" @@ -662,9 +663,7 @@ export interface WebviewMessage { | "getDismissedUpsells" | "openMarkdownPreview" | "updateSettings" - | "allowedCommands" | "getTaskWithAggregatedCosts" - | "deniedCommands" | "openDebugApiHistory" | "openDebugUiHistory" | "downloadErrorDiagnostics" @@ -722,7 +721,6 @@ export interface WebviewMessage { stepIndex?: number isLaunchAction?: boolean forceShow?: boolean - commands?: string[] audioType?: AudioType serverName?: string toolName?: string diff --git a/src/__tests__/extension.spec.ts b/src/__tests__/extension.spec.ts index abcd3c28a0..317e1dba6a 100644 --- a/src/__tests__/extension.spec.ts +++ b/src/__tests__/extension.spec.ts @@ -22,6 +22,7 @@ vi.mock("vscode", () => ({ registerTextDocumentContentProvider: vi.fn(), getConfiguration: vi.fn().mockReturnValue({ get: vi.fn().mockReturnValue([]), + inspect: vi.fn(), }), createFileSystemWatcher: vi.fn().mockReturnValue({ onDidCreate: vi.fn(), @@ -228,15 +229,17 @@ vi.mock("../api/providers/fetchers/modelCache", () => ({ describe("extension.ts", () => { let mockContext: vscode.ExtensionContext + let globalStateValues: Map let settingsUpdatedHandler: ((data: Record) => void | Promise) | undefined beforeEach(() => { vi.clearAllMocks() + globalStateValues = new Map() mockContext = { extensionPath: "/test/path", globalState: { - get: vi.fn().mockReturnValue(undefined), + get: vi.fn((key: string) => globalStateValues.get(key)), update: vi.fn(), }, subscriptions: [], @@ -536,6 +539,80 @@ describe("extension.ts", () => { expect(dotenv.config).toHaveBeenCalledTimes(1) }) + describe("allowedCommands activation seeding", () => { + function setConfigScope( + vscode: typeof import("vscode"), + scope: { globalValue?: string[]; workspaceValue?: string[] }, + ) { + const { globalValue, workspaceValue } = scope + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + // Key-scoped so proxy/other activation reads still see no value. + get: vi.fn((key: string) => + key === "allowedCommands" ? [...(globalValue ?? []), ...(workspaceValue ?? [])] : undefined, + ), + inspect: vi.fn().mockReturnValue({ + key: "allowedCommands", + // Real inspect() always reports the declared package default; + // including it here lets the workspace-override test prove the + // default stays unseeded beside an override, not just absent. + defaultValue: ["git log", "git diff", "git show"], + globalValue, + workspaceValue, + }), + update: vi.fn(), + // The double scripts key-scoped get() and per-scope inspect() + // shapes the shared helper cannot express without obscuring them. + } as unknown as vscode.WorkspaceConfiguration) + } + + beforeEach(() => { + vi.resetModules() + }) + + afterEach(async () => { + const vscode = await import("vscode") + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: vi.fn().mockReturnValue([]), + inspect: vi.fn(), + // Restores the neutral default after the scope fixtures above; + // update is intentionally absent because no test runs after this. + } as unknown as vscode.WorkspaceConfiguration) + }) + + test("does not seed global state from a workspace-scoped override", async () => { + const vscode = await import("vscode") + setConfigScope(vscode, { workspaceValue: ["docker"] }) + + const { activate } = await import("../extension") + await activate(mockContext) + + expect(mockContext.globalState.update).not.toHaveBeenCalledWith("allowedCommands", expect.anything()) + expect(globalStateValues.get("allowedCommands")).toBeUndefined() + }) + + test("seeds global state from the user-scoped value when it is empty", async () => { + const vscode = await import("vscode") + setConfigScope(vscode, { globalValue: ["git"] }) + + const { activate } = await import("../extension") + await activate(mockContext) + + expect(mockContext.globalState.update).toHaveBeenCalledWith("allowedCommands", ["git"]) + }) + + test("does not overwrite global state that is already populated", async () => { + const vscode = await import("vscode") + setConfigScope(vscode, { globalValue: ["git"] }) + globalStateValues.set("allowedCommands", ["npm"]) + + const { activate } = await import("../extension") + await activate(mockContext) + + expect(mockContext.globalState.update).not.toHaveBeenCalledWith("allowedCommands", expect.anything()) + expect(globalStateValues.get("allowedCommands")).toEqual(["npm"]) + }) + }) + describe("cloud organization settings handling", () => { beforeEach(() => { vi.resetModules() diff --git a/src/core/auto-approval/__tests__/commandScope.spec.ts b/src/core/auto-approval/__tests__/commandScope.spec.ts new file mode 100644 index 0000000000..4d1064d23d --- /dev/null +++ b/src/core/auto-approval/__tests__/commandScope.spec.ts @@ -0,0 +1,443 @@ +import * as fs from "fs" +import * as path from "path" + +import { DEFAULT_WORKSPACE_COMMAND_LISTS_ENABLED, globalSettingsSchema } from "@roo-code/types" +import * as vscode from "vscode" + +import { Package } from "../../../shared/package" +import { makeUri, makeWorkspaceConfiguration } from "../../../test-utils/vscode" +import { + readCommandListScopes, + resolveEffectiveCommandLists, + resolveWorkspaceWriteTarget, + splitCommandListScopes, + workspaceCommandListsEnabled, +} from "../commandScope" + +// Scriptable stand-in for `vscode.workspace.workspaceFolders`: the module +// mock's getter reads through this object, so a test can change the open +// folders without re-mocking the module. +const workspaceState = vi.hoisted(() => ({ + workspaceFolders: undefined as { uri: unknown }[] | undefined, +})) + +vi.mock("vscode", () => ({ + ConfigurationTarget: { + Global: 1, + Workspace: 2, + WorkspaceFolder: 3, + }, + workspace: { + get workspaceFolders() { + return workspaceState.workspaceFolders + }, + getConfiguration: vi.fn(), + }, +})) + +interface ConfigDouble { + inspect?: { + key: string + defaultValue?: string[] + globalValue?: string[] + workspaceValue?: string[] + workspaceFolderValue?: string[] + } + effective?: string[] +} + +function mockConfiguration({ inspect, effective = [] }: ConfigDouble = {}) { + const config = makeWorkspaceConfiguration() + // The shared double is scripted after creation so the per-test fixture + // values need no type assertion at the construction site. + vi.mocked(config.get).mockReturnValue(effective) + vi.mocked(config.inspect).mockReturnValue(inspect) + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue(config) +} + +describe("readCommandListScopes", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("reads the workspace layer from the workspace-scoped configuration value", () => { + mockConfiguration({ inspect: { key: "deniedCommands", workspaceValue: ["docker"] } }) + + const scopes = readCommandListScopes("deniedCommands", []) + + expect(scopes.workspace).toEqual(["docker"]) + expect(scopes.merged).toEqual(["docker"]) + }) + + it("prefers the workspace-folder value over the workspace value", () => { + mockConfiguration({ + inspect: { + key: "deniedCommands", + workspaceValue: ["docker"], + workspaceFolderValue: ["pnpm docker"], + }, + }) + + const scopes = readCommandListScopes("deniedCommands", []) + + expect(scopes.workspace).toEqual(["pnpm docker"]) + expect(scopes.merged).toEqual(["pnpm docker"]) + }) + + it("ignores the effective get() value so global-scoped entries are not double-counted", () => { + // A real VS Code get() folds globalValue into the effective result, so + // reading the workspace layer from get() would leak "npm" (a + // user-settings-only entry) into the workspace layer. + mockConfiguration({ + inspect: { key: "allowedCommands", globalValue: ["npm"], workspaceValue: ["docker"] }, + effective: ["npm", "docker"], + }) + + const scopes = readCommandListScopes("allowedCommands", []) + + expect(scopes.workspace).toEqual(["docker"]) + expect(scopes.merged).toEqual(["docker"]) + }) + + it("returns an empty workspace layer when only globalValue is set", () => { + mockConfiguration({ + inspect: { key: "allowedCommands", globalValue: ["npm"] }, + effective: ["npm"], + }) + + const scopes = readCommandListScopes("allowedCommands", []) + + expect(scopes.workspace).toEqual([]) + expect(scopes.merged).toEqual([]) + }) + + it("unions global and workspace layers with deduplication and stable order", () => { + mockConfiguration({ inspect: { key: "deniedCommands", workspaceValue: ["docker", "rm"] } }) + + const scopes = readCommandListScopes("deniedCommands", ["git", "docker"]) + + expect(scopes.global).toEqual(["git", "docker"]) + expect(scopes.workspace).toEqual(["docker", "rm"]) + expect(scopes.merged).toEqual(["git", "docker", "rm"]) + }) + + it("drops blank and non-string entries from both layers", () => { + mockConfiguration({ inspect: { key: "deniedCommands", workspaceValue: ["docker", " "] } }) + + const scopes = readCommandListScopes("deniedCommands", ["git", "", " "] as string[]) + + expect(scopes.global).toEqual(["git"]) + expect(scopes.workspace).toEqual(["docker"]) + expect(scopes.merged).toEqual(["git", "docker"]) + }) + + it("treats a missing inspect result as an empty workspace layer", () => { + mockConfiguration({ inspect: undefined }) + + const scopes = readCommandListScopes("deniedCommands", ["git"]) + + expect(scopes.workspace).toEqual([]) + expect(scopes.merged).toEqual(["git"]) + }) + + it("falls back to an empty workspace layer when the configuration lookup throws", () => { + vi.mocked(vscode.workspace.getConfiguration).mockImplementation(() => { + throw new Error("configuration unavailable") + }) + + const scopes = readCommandListScopes("deniedCommands", ["git"]) + + expect(scopes.workspace).toEqual([]) + expect(scopes.merged).toEqual(["git"]) + }) + + it("takes the workspace layer from a caller-supplied snapshot without reading the configuration", () => { + // The save path threads one captured inspection through every + // derivation, so the snapshot is authoritative: the configuration's own + // value must never be consulted a second time. + mockConfiguration({ inspect: { key: "deniedCommands", workspaceValue: ["stale"] } }) + + const scopes = readCommandListScopes("deniedCommands", [], { workspaceValue: ["docker"] }) + + expect(scopes.workspace).toEqual(["docker"]) + expect(scopes.merged).toEqual(["docker"]) + expect(vscode.workspace.getConfiguration).not.toHaveBeenCalled() + }) +}) + +describe("splitCommandListScopes", () => { + it("keeps workspace entries in the workspace layer and routes new entries to global", () => { + // The issue #1939 leak: the dialog sends the merged list back, and the + // workspace-only entry "docker" must not end up in the global write. + const result = splitCommandListScopes(["docker", "aws"], ["docker"], []) + + expect(result.workspaceWrite).toEqual(["docker"]) + expect(result.globalWrite).toEqual(["aws"]) + }) + + it("keeps a global entry in the global layer", () => { + const result = splitCommandListScopes(["git"], [], ["git"]) + + expect(result.workspaceWrite).toEqual([]) + expect(result.globalWrite).toEqual(["git"]) + }) + + it("keeps an entry present in both layers in both layers when retained", () => { + // Semantics: do not silently delete the user's global entry just + // because the current folder happens to define it too; the entry was + // deliberately authored in both scopes and the user kept it. + const result = splitCommandListScopes(["docker"], ["docker"], ["docker"]) + + expect(result.workspaceWrite).toEqual(["docker"]) + expect(result.globalWrite).toEqual(["docker"]) + }) + + it("removes a deleted entry from the workspace layer without touching global", () => { + // The user kept "git" and deleted workspace-only "docker"; "git" must + // survive at its own (global) layer. + const result = splitCommandListScopes(["git"], ["docker"], ["git"]) + + expect(result.workspaceWrite).toEqual([]) + expect(result.globalWrite).toEqual(["git"]) + }) + + it("removes a deleted entry from every layer that contained it", () => { + // "docker" lived in both layers and the user deleted it; kept "rm" + // stays at the workspace layer only. + const result = splitCommandListScopes(["rm"], ["docker", "rm"], ["docker"]) + + expect(result.workspaceWrite).toEqual(["rm"]) + expect(result.globalWrite).toEqual([]) + }) + + it("routes entries the dialog seeded to the layer they came from", () => { + const result = splitCommandListScopes( + ["git", "docker", "pnpm docker", "aws"], + ["docker", "pnpm docker"], + ["git"], + ) + + expect(result.workspaceWrite).toEqual(["docker", "pnpm docker"]) + expect(result.globalWrite).toEqual(["git", "aws"]) + }) + + it("orders the global write as existing global entries then new entries in dialog order", () => { + const result = splitCommandListScopes(["aws", "git", "npm"], ["docker"], ["git"]) + + expect(result.globalWrite).toEqual(["git", "aws", "npm"]) + }) + + it("drops blank and non-string entries from the incoming list", () => { + // Deliberate wrong runtime type: configuration lists arrive as unknown, + // so the sanitizer must drop non-strings instead of throwing on them. + const result = splitCommandListScopes(["git", "", " ", 42 as unknown as string], [], ["git"]) + + expect(result.globalWrite).toEqual(["git"]) + expect(result.workspaceWrite).toEqual([]) + }) + + it("deduplicates layers so re-writing cannot introduce duplicates", () => { + const result = splitCommandListScopes(["docker", "docker"], ["docker", "docker"], ["docker"]) + + expect(result.workspaceWrite).toEqual(["docker"]) + expect(result.globalWrite).toEqual(["docker"]) + }) +}) + +describe("resolveWorkspaceWriteTarget", () => { + beforeEach(() => { + vi.clearAllMocks() + workspaceState.workspaceFolders = undefined + }) + + it("returns the workspace-folder target when a folder value is set", () => { + mockConfiguration({ + inspect: { key: "deniedCommands", workspaceValue: ["docker"], workspaceFolderValue: ["pnpm docker"] }, + }) + + expect(resolveWorkspaceWriteTarget("deniedCommands")).toBe(vscode.ConfigurationTarget.WorkspaceFolder) + }) + + it("returns the workspace target when only a workspace value is set", () => { + mockConfiguration({ inspect: { key: "deniedCommands", workspaceValue: ["docker"] } }) + + expect(resolveWorkspaceWriteTarget("deniedCommands")).toBe(vscode.ConfigurationTarget.Workspace) + }) + + it("returns undefined when only a global value is set, so no workspace layer is created", () => { + mockConfiguration({ inspect: { key: "deniedCommands", globalValue: ["npm"] } }) + + expect(resolveWorkspaceWriteTarget("deniedCommands")).toBeUndefined() + }) + + it("returns undefined when there is no inspect result", () => { + mockConfiguration({ inspect: undefined }) + + expect(resolveWorkspaceWriteTarget("deniedCommands")).toBeUndefined() + }) + + it("returns undefined when the configuration lookup throws", () => { + vi.mocked(vscode.workspace.getConfiguration).mockImplementation(() => { + throw new Error("configuration unavailable") + }) + + expect(resolveWorkspaceWriteTarget("deniedCommands")).toBeUndefined() + }) + + it("resolves the target from a caller-supplied snapshot without reading the configuration", () => { + // The snapshot alone decides the target, so a folder value the live + // configuration does not report still selects the folder target — + // keeping split, write, and rollback on one captured state. + mockConfiguration({ inspect: { key: "deniedCommands", workspaceValue: ["docker"] } }) + + expect(resolveWorkspaceWriteTarget("deniedCommands", { workspaceFolderValue: ["pnpm docker"] })).toBe( + vscode.ConfigurationTarget.WorkspaceFolder, + ) + expect(vscode.workspace.getConfiguration).not.toHaveBeenCalled() + }) + + it("resolves the target from the configuration scoped to the first workspace folder", () => { + const folderA = makeUri("/mock/folder-a") + const folderB = makeUri("/mock/folder-b") + workspaceState.workspaceFolders = [{ uri: folderA }, { uri: folderB }] + mockConfiguration({ inspect: { key: "deniedCommands", workspaceFolderValue: ["pnpm docker"] } }) + + expect(resolveWorkspaceWriteTarget("deniedCommands")).toBe(vscode.ConfigurationTarget.WorkspaceFolder) + expect(vscode.workspace.getConfiguration).toHaveBeenCalledWith(Package.name, folderA) + // Only the first folder is ever in scope: reading any other folder's + // configuration would silently target the wrong folder's settings file. + expect(vscode.workspace.getConfiguration).not.toHaveBeenCalledWith(Package.name, folderB) + }) + + it.each([undefined, [] as { uri: unknown }[]])( + "falls back to an unscoped configuration when no workspace folder is open (%s)", + (workspaceFolders) => { + workspaceState.workspaceFolders = workspaceFolders + mockConfiguration({ inspect: { key: "deniedCommands", workspaceValue: ["docker"] } }) + + // With no resource to resolve against, no folder value can be + // reported, so the folder target stays unreachable. + expect(resolveWorkspaceWriteTarget("deniedCommands")).toBe(vscode.ConfigurationTarget.Workspace) + expect(vscode.workspace.getConfiguration).toHaveBeenCalledWith(Package.name, undefined) + }, + ) +}) + +function mockGateConfiguration(gate: unknown) { + const config = makeWorkspaceConfiguration() + vi.mocked(config.get).mockReturnValue(gate) + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue(config) + return config +} + +// Raw per-key layers in the shape readCommandListScopes returns them. +const listScopes = (globalList: string[], workspaceList: string[]) => ({ + global: globalList, + workspace: workspaceList, + merged: [...new Set([...globalList, ...workspaceList])], +}) + +it("workspaceCommandListsEnabled: enables the workspace layers only when the gate configuration reads true", () => { + vi.clearAllMocks() + const config = mockGateConfiguration(true) + + expect(workspaceCommandListsEnabled()).toBe(true) + expect(config.get).toHaveBeenCalledWith("workspaceCommandListsEnabled") +}) + +it("workspaceCommandListsEnabled: keeps the workspace layers disabled when the gate lookup throws", () => { + vi.clearAllMocks() + // Fail-closed: a broken configuration read must never widen what the + // workspace layer can auto-approve. + vi.mocked(vscode.workspace.getConfiguration).mockImplementation(() => { + throw new Error("configuration unavailable") + }) + + expect(workspaceCommandListsEnabled()).toBe(DEFAULT_WORKSPACE_COMMAND_LISTS_ENABLED) + expect(workspaceCommandListsEnabled()).toBe(false) +}) + +// The manifest declaration is what makes the gate workspace-unsettable; +// VS Code itself refusing a workspace-layer value is platform behavior. +it("manifest: declares the gate as a default-off, application-scoped boolean", () => { + const manifest = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../package.json"), "utf8")) + const gate = manifest.contributes.configuration.properties["zoo-code.workspaceCommandListsEnabled"] + + expect(gate.type).toBe("boolean") + expect(gate.default).toBe(false) + expect(gate.scope).toBe("application") +}) + +it("manifest: keeps the command-list keys workspace-settable so the gated layer can exist", () => { + // The gate scopes the workspace layers' effect; scoping the list keys + // themselves would remove the workspace layer entirely. + const manifest = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../package.json"), "utf8")) + const properties = manifest.contributes.configuration.properties + + expect(properties["zoo-code.allowedCommands"].scope).toBeUndefined() + expect(properties["zoo-code.deniedCommands"].scope).toBeUndefined() +}) + +it("persisted-settings schema: defaults the gate to disabled", () => { + expect(globalSettingsSchema.parse({}).workspaceCommandListsEnabled).toBeUndefined() + expect(globalSettingsSchema.parse({ workspaceCommandListsEnabled: true }).workspaceCommandListsEnabled).toBe(true) + expect(DEFAULT_WORKSPACE_COMMAND_LISTS_ENABLED).toBe(false) +}) + +it("resolveEffectiveCommandLists: hides every workspace entry, allow and deny alike, while the gate is disabled", () => { + const effective = resolveEffectiveCommandLists(listScopes([], ["docker"]), listScopes([], ["rm"]), false) + + expect(effective.allowed).toEqual([]) + expect(effective.denied).toEqual([]) + expect(effective.hiddenWorkspaceAllow).toEqual(["docker"]) + expect(effective.hiddenWorkspaceDeny).toEqual(["rm"]) +}) + +it("resolveEffectiveCommandLists: treats unshadowed workspace entries as the sole layer when the global layer is empty", () => { + const effective = resolveEffectiveCommandLists(listScopes(["git"], ["docker", "aws"]), listScopes([], ["rm"]), true) + + expect(effective.allowed).toEqual(["git", "docker", "aws"]) + expect(effective.denied).toEqual(["rm"]) + expect(effective.hiddenWorkspaceAllow).toEqual([]) + expect(effective.hiddenWorkspaceDeny).toEqual([]) +}) + +it("resolveEffectiveCommandLists: lets a global allow entry decide a command a longer workspace deny entry also matches", () => { + // Cross-directional shadowing: the workspace deny entry cannot decide + // anything the global allow layer already covers, so it never reaches + // the matcher. + const effective = resolveEffectiveCommandLists(listScopes(["git"], []), listScopes([], ["git push"]), true) + + expect(effective.allowed).toEqual(["git"]) + expect(effective.denied).toEqual([]) + expect(effective.hiddenWorkspaceDeny).toEqual(["git push"]) +}) + +it("resolveEffectiveCommandLists: lets a global deny entry veto a longer workspace allow entry", () => { + const effective = resolveEffectiveCommandLists(listScopes([], ["git push"]), listScopes(["git"], []), true) + + expect(effective.allowed).toEqual([]) + expect(effective.denied).toEqual(["git"]) + expect(effective.hiddenWorkspaceAllow).toEqual(["git push"]) +}) + +it("resolveEffectiveCommandLists: silences every workspace deny entry behind a global wildcard allow entry", () => { + const effective = resolveEffectiveCommandLists(listScopes(["*"], []), listScopes([], ["git push", "rm -rf"]), true) + + expect(effective.allowed).toEqual(["*"]) + expect(effective.denied).toEqual([]) + expect(effective.hiddenWorkspaceDeny).toEqual(["git push", "rm -rf"]) +}) + +it("resolveEffectiveCommandLists: shadows workspace entries case-insensitively, the way the matcher matches", () => { + const effective = resolveEffectiveCommandLists( + listScopes(["Git"], ["git status"]), + listScopes([], ["DOCKER"]), + true, + ) + + expect(effective.allowed).toEqual(["Git"]) + expect(effective.denied).toEqual(["DOCKER"]) + expect(effective.hiddenWorkspaceAllow).toEqual(["git status"]) + expect(effective.hiddenWorkspaceDeny).toEqual([]) +}) diff --git a/src/core/auto-approval/__tests__/commands.spec.ts b/src/core/auto-approval/__tests__/commands.spec.ts index ade53c2b20..87f6f44f5b 100644 --- a/src/core/auto-approval/__tests__/commands.spec.ts +++ b/src/core/auto-approval/__tests__/commands.spec.ts @@ -1,4 +1,10 @@ -import { containsDangerousSubstitution, getCommandDecision, getCommandDecisionDetailed } from "../commands" +import { resolveEffectiveCommandLists } from "../commandScope" +import { + containsDangerousSubstitution, + getCommandDecision, + getCommandDecisionDetailed, + isShadowedByAnyEntry, +} from "../commands" describe("containsDangerousSubstitution", () => { describe("zsh array assignments (should NOT be flagged)", () => { @@ -215,3 +221,82 @@ describe("getCommandDecisionDetailed", () => { } }) }) + +it("isShadowedByAnyEntry: shadows an entry whose every match a prefixing entry also matches", () => { + expect(isShadowedByAnyEntry("git push", ["git"])).toBe(true) + expect(isShadowedByAnyEntry("git push", ["git push"])).toBe(true) +}) + +it("isShadowedByAnyEntry: keeps an entry that decides matches no shadowing entry covers", () => { + expect(isShadowedByAnyEntry("git", ["git push"])).toBe(false) +}) + +it("isShadowedByAnyEntry: shadows every entry behind the universal wildcard", () => { + expect(isShadowedByAnyEntry("git push", ["*"])).toBe(true) + expect(isShadowedByAnyEntry("anything at all", ["*"])).toBe(true) +}) + +it("isShadowedByAnyEntry: compares case-insensitively, the way the matcher matches", () => { + expect(isShadowedByAnyEntry("Git Push", ["git"])).toBe(true) + expect(isShadowedByAnyEntry("git push", ["GIT"])).toBe(true) +}) + +it("isShadowedByAnyEntry: compares entries as stored, the way the matcher compares them", () => { + // The matcher trims commands, never entries, and shadowing mirrors that: + // both sides compare as stored. A trimmed comparison would let an inert + // padded entry erase a decidable one (and the reverse), deciding with a + // rule enforcement never applies. + expect(isShadowedByAnyEntry(" git push ", ["git"])).toBe(false) + expect(isShadowedByAnyEntry("git push", [" git "])).toBe(false) +}) + +it("layered decisions: a padded global deny entry cannot erase a workspace deny entry", () => { + // The padded global entry is inert at enforcement, so it must not shadow + // the workspace entry the matcher does honor: the deny survives. + const scopes = (globalList: string[], workspaceList: string[]) => ({ + global: globalList, + workspace: workspaceList, + merged: [...new Set([...globalList, ...workspaceList])], + }) + const effective = resolveEffectiveCommandLists(scopes([" git"], []), scopes([], ["git push"]), true) + + expect(getCommandDecision("git push origin", effective.allowed, effective.denied)).toBe("auto_deny") +}) + +it("layered decisions: a padded global allow entry cannot erase a workspace allow entry", () => { + // Mirror of the deny side: the inert padded entry leaves the workspace + // allow entry effective, so the command is approved rather than downgraded + // to a prompt for a match no entry can produce. + const scopes = (globalList: string[], workspaceList: string[]) => ({ + global: globalList, + workspace: workspaceList, + merged: [...new Set([...globalList, ...workspaceList])], + }) + const effective = resolveEffectiveCommandLists(scopes([" git"], ["git push"]), scopes([], []), true) + + expect(getCommandDecision("git push origin", effective.allowed, effective.denied)).toBe("auto_approve") +}) + +it("isShadowedByAnyEntry: never treats an empty entry as covered", () => { + // Compared naively, an empty entry prefix-matches every command, so + // against an empty shadow entry it would report itself covered. The + // empty-entry guard keeps such an entry visible instead. Command-list + // layers sanitize blanks before shadowing, so this pins the function's + // contract directly. + expect(isShadowedByAnyEntry("", [""])).toBe(false) + expect(isShadowedByAnyEntry("", ["git"])).toBe(false) +}) + +it("layered decisions: reproduces the global-decides precedence through the scope-blind matcher", () => { + // Global deny "git" vs workspace allow "git push": the composed lists + // drop the shadowed allow entry, so the scope-blind matcher — never told + // about layers — returns the global layer's deny. + const scopes = (globalList: string[], workspaceList: string[]) => ({ + global: globalList, + workspace: workspaceList, + merged: [...new Set([...globalList, ...workspaceList])], + }) + const effective = resolveEffectiveCommandLists(scopes([], ["git push"]), scopes(["git"], []), true) + + expect(getCommandDecision("git push origin", effective.allowed, effective.denied)).toBe("auto_deny") +}) diff --git a/src/core/auto-approval/commandScope.ts b/src/core/auto-approval/commandScope.ts new file mode 100644 index 0000000000..03d464559f --- /dev/null +++ b/src/core/auto-approval/commandScope.ts @@ -0,0 +1,266 @@ +import { DEFAULT_WORKSPACE_COMMAND_LISTS_ENABLED } from "@roo-code/types" +import * as vscode from "vscode" + +import { Package } from "../../shared/package" +import { isShadowedByAnyEntry } from "./commands" + +export type CommandListConfigKey = "allowedCommands" | "deniedCommands" + +export interface CommandListScopes { + global: string[] + workspace: string[] + merged: string[] +} + +/** + * The workspace-scoped layers of a command-list key as reported by + * `WorkspaceConfiguration.inspect()`. Capturing it once lets a save derive + * the read layers, the write target, and a rollback value from a single + * configuration state instead of three independent reads that could disagree. + */ +export interface CommandListInspection { + workspaceValue?: string[] + workspaceFolderValue?: string[] +} + +/** + * The configuration object for every command-list read and write, scoped to + * the first workspace folder. Sharing one scoped access path keeps the two + * sides symmetric: `inspect()` can report `workspaceFolderValue`, and + * `ConfigurationTarget.WorkspaceFolder` updates are legal (the API contract + * throws for that target when the configuration is not scoped to a + * resource). In a multi-root workspace only the first folder's values are in + * scope; with no folder open the configuration is unscoped and reports no + * folder value. + */ +export function commandListConfiguration(): vscode.WorkspaceConfiguration { + return vscode.workspace.getConfiguration(Package.name, vscode.workspace.workspaceFolders?.[0]?.uri) +} + +/** + * Reads an auto-approval command list from both configuration layers and + * composes the list that should be enforced and displayed. + * + * The workspace layer is read via `inspect()` rather than `get()` on purpose: + * `get()` returns the effective value, which already folds `globalValue` into + * the result. Unioning that with the global layer would double-count entries + * and make removals from global settings unable to shrink the enforced set. + * + * @param configKey - The extension configuration key to read. + * @param globalList - The list persisted in extension global state. + * @param inspectionOverride - A snapshot captured by the caller before any + * write; when present it is used as-is and no fresh lookup happens, so a + * save's reads, target resolution, and rollback stay derived from one + * configuration state. Without it the lookup runs guarded (lenient): this is + * the enforcement/display path, where an unavailable workspace layer must + * degrade to empty rather than fail. + * @returns The per-layer lists and their deduplicated set union. + */ +export function readCommandListScopes( + configKey: CommandListConfigKey, + globalList: string[] | undefined, + inspectionOverride?: CommandListInspection, +): CommandListScopes { + const global = sanitizeCommandList(globalList) + + let inspected: CommandListInspection | undefined = inspectionOverride + if (inspected === undefined) { + // Only the configuration lookup itself is guarded: it can fail (for + // example before the workspace is ready), and an absent workspace layer + // must not break enforcement. The value reads below have no failure mode + // of their own, so the catch covers exactly the failure it exists to + // absorb. + try { + inspected = commandListConfiguration().inspect(configKey) + } catch { + inspected = undefined + } + } + + // The folder value wins over the workspace value, matching VS Code's + // folder-over-workspace precedence. An absent inspect result composes to + // an empty workspace layer: enforcement must not break. + const workspace = sanitizeCommandList(inspected?.workspaceFolderValue ?? inspected?.workspaceValue) + + // Set union: an entry present in both layers collapses to one, so + // composition cannot change prefix-match outcomes. + const merged = [...new Set([...global, ...workspace])] + + return { global, workspace, merged } +} + +export interface CommandListWriteScopes { + globalWrite: string[] + workspaceWrite: string[] +} + +/** + * Splits the merged command list the settings dialog saves back into the two + * configuration layers it was composed from, so saving the dialog cannot + * flatten workspace-scoped entries into the user's global settings. + * + * Semantics: + * - An entry the user kept that exists in the workspace layer stays in the + * workspace layer. If the same entry also exists in the global layer it + * stays there too: dropping it from the global write would silently delete + * a user's global entry just because the current folder happens to define + * it as well. + * - An entry the user removed is dropped from every layer that contained it, + * which makes workspace-scoped entries deletable from the dialog. + * - A brand-new entry (in neither layer) lands in the global layer, matching + * the pre-existing behavior of routing dialog edits to + * `ConfigurationTarget.Global`. + * + * @param incoming - The full merged list sent by the webview on save. + * @param workspaceLayer - The current workspace-scope configuration value. + * @param globalLayer - The list currently persisted in extension global state. + * @returns The per-layer values to write back. + */ +export function splitCommandListScopes( + incoming: string[], + workspaceLayer: string[], + globalLayer: string[], +): CommandListWriteScopes { + const kept = new Set(sanitizeCommandList(incoming)) + const workspace = sanitizeCommandList(workspaceLayer) + const global = sanitizeCommandList(globalLayer) + const known = new Set([...workspace, ...global]) + + // Deduplicated so re-writing a layer cannot introduce duplicates. + const workspaceWrite = [...new Set(workspace.filter((command) => kept.has(command)))] + const globalWrite = [ + ...new Set(global.filter((command) => kept.has(command))), + // Preserve the dialog's ordering for entries the user just added. + ...[...kept].filter((command) => !known.has(command)), + ] + + return { globalWrite, workspaceWrite } +} + +/** + * Picks the configuration target that owns the current workspace-scope layer + * for `configKey`. Reading the layers runs through the same + * `commandListConfiguration()` object `readCommandListScopes` uses; because + * that configuration is scoped to the first workspace folder, folder values + * resolve against that folder's settings; with no folder open the + * configuration is unscoped and reports no folder value, so no folder target + * can be selected. + * + * - `workspaceFolderValue` set -> write at `WorkspaceFolder` scope. + * - Otherwise `workspaceValue` set -> write at `Workspace` scope. + * - Neither set -> `undefined`: no workspace layer exists, so none should be + * created (an empty workspace layer must not materialize in settings.json). + * + * @param configKey - The extension configuration key to inspect. + * @param inspectionOverride - A snapshot captured by the caller; when present + * it alone decides the target and no fresh lookup happens, keeping a save's + * split, write, and rollback on one configuration state. Without it the + * lookup is guarded leniently: a failure means no workspace layer exists. + * @returns The workspace-scoped target to write, or `undefined` when no + * workspace layer exists. + */ +export function resolveWorkspaceWriteTarget( + configKey: CommandListConfigKey, + inspectionOverride?: CommandListInspection, +): vscode.ConfigurationTarget.WorkspaceFolder | vscode.ConfigurationTarget.Workspace | undefined { + let inspected: CommandListInspection | undefined = inspectionOverride + if (inspected === undefined) { + // Same defensive posture as readCommandListScopes: only the + // configuration lookup itself may fail, and a failure means we create no + // workspace layer. + try { + inspected = commandListConfiguration().inspect(configKey) + } catch { + inspected = undefined + } + } + + if (inspected?.workspaceFolderValue !== undefined) { + return vscode.ConfigurationTarget.WorkspaceFolder + } + if (inspected?.workspaceValue !== undefined) { + return vscode.ConfigurationTarget.Workspace + } + return undefined +} + +/** + * Reads the `zoo-code.workspaceCommandListsEnabled` gate that decides whether + * the workspace layers of the command-list keys take part in enforcement and + * display at all. The configuration declares the key with `scope: + * "application"`, so only user settings can flip it. The lookup is guarded + * leniently like the command-list reads: a configuration failure keeps the + * workspace layers inert, which cannot widen what auto-approval allows. + */ +export function workspaceCommandListsEnabled(): boolean { + try { + return commandListConfiguration().get("workspaceCommandListsEnabled") === true + } catch { + return DEFAULT_WORKSPACE_COMMAND_LISTS_ENABLED + } +} + +export interface EffectiveCommandLists { + allowed: string[] + denied: string[] + /** + * Workspace allow entries absent from the effective allowlist — hidden by + * the gate or shadowed by a global entry — which a dialog save must not be + * able to delete by omission. + */ + hiddenWorkspaceAllow: string[] + /** Deny-side counterpart of {@link hiddenWorkspaceAllow}. */ + hiddenWorkspaceDeny: string[] +} + +/** + * Composes the raw per-key layers into the pair of lists to enforce and + * display, keeping display byte-equal to enforcement. + * + * Gate off: the workspace layers contribute nothing and the global layers are + * effective verbatim. Gate on: a workspace entry survives only when no entry + * of either global list shadows it, so a global entry decides every command it + * matches while workspace entries apply where the global layer is silent. + * Shadowing is cross-directional because a global allow entry silences a + * workspace deny entry (and vice versa) just as much as a same-list shadow. + * + * Composing here — not inside {@link readCommandListScopes} — keeps the raw + * layers intact for the save path, which derives the write split from them and + * must never see entries filtered out of the workspace layer. + * + * @param allowScopes - Raw `allowedCommands` layers from readCommandListScopes. + * @param denyScopes - Raw `deniedCommands` layers from readCommandListScopes. + * @param enabled - The value of the `workspaceCommandListsEnabled` gate. + * @returns The effective allow/deny lists and the workspace entries hidden + * from each. + */ +export function resolveEffectiveCommandLists( + allowScopes: CommandListScopes, + denyScopes: CommandListScopes, + enabled: boolean, +): EffectiveCommandLists { + const globalEntries = [...allowScopes.global, ...denyScopes.global] + + const keptAllow = enabled + ? allowScopes.workspace.filter((entry) => !isShadowedByAnyEntry(entry, globalEntries)) + : [] + const keptDeny = enabled ? denyScopes.workspace.filter((entry) => !isShadowedByAnyEntry(entry, globalEntries)) : [] + + const allowed = [...new Set([...allowScopes.global, ...keptAllow])] + const denied = [...new Set([...denyScopes.global, ...keptDeny])] + + return { + allowed, + denied, + hiddenWorkspaceAllow: allowScopes.workspace.filter((entry) => !allowed.includes(entry)), + hiddenWorkspaceDeny: denyScopes.workspace.filter((entry) => !denied.includes(entry)), + } +} + +function sanitizeCommandList(list: unknown): string[] { + if (!Array.isArray(list)) { + return [] + } + + return list.filter((command): command is string => typeof command === "string" && command.trim().length > 0) +} diff --git a/src/core/auto-approval/commands.ts b/src/core/auto-approval/commands.ts index 4d28e0fe1b..d772bffb12 100644 --- a/src/core/auto-approval/commands.ts +++ b/src/core/auto-approval/commands.ts @@ -113,6 +113,34 @@ export function findLongestPrefixMatch(command: string, prefixes: string[]): str return longestMatch } +/** + * Whether one command-list entry is covered by any entry of `shadowing`. + * + * "Covered" applies the matcher's comparison — case-insensitive `startsWith` + * with `"*"` as universal prefix — directly to the two stored entries: only + * commands reach the matcher trimmed, entries never are, so an entry whose + * stored form carries leading whitespace matches no command and can shadow + * only other entries that match nothing either. An entry whose every possible + * match is also matched by some `shadowing` entry cannot decide anything on + * its own: that layer's decision stands. Dropping covered entries when + * composing layers reproduces a global-decides precedence without the matcher + * knowing about layers. + * + * @param entry - The entry that may be covered. + * @param shadowing - The entries whose match coverage decides. + * @returns true if some `shadowing` entry covers `entry`. An empty `entry` + * counts as covered only by `"*"`: the guard that stops it prefix-matching + * every command keeps a blank entry visible, as it cannot be enforced. + */ +export function isShadowedByAnyEntry(entry: string, shadowing: string[]): boolean { + const lowerEntry = entry.toLowerCase() + + return shadowing.some((shadow) => { + const lowerShadow = shadow.toLowerCase() + return lowerShadow === "*" || (lowerEntry !== "" && lowerEntry.startsWith(lowerShadow)) + }) +} + /** * Command approval decision types */ diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 7a18fb9bf6..3755aa48b3 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -70,6 +70,7 @@ import { ORGANIZATION_ALLOW_ALL, DEFAULT_MODES, DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, + DEFAULT_WORKSPACE_COMMAND_LISTS_ENABLED, getModelId, isRetiredProvider, modelIdKeys, @@ -134,6 +135,11 @@ import { forceFullModelDetailsLoad, hasLoadedFullDetails } from "../../api/provi import { ContextProxy } from "../config/ContextProxy" import { ProviderSettingsManager } from "../config/ProviderSettingsManager" import { CustomModesManager } from "../config/CustomModesManager" +import { + readCommandListScopes, + resolveEffectiveCommandLists, + workspaceCommandListsEnabled, +} from "../auto-approval/commandScope" import { PendingActionSettlementError, Task } from "../task/Task" import { webviewMessageHandler } from "./webviewMessageHandler" @@ -2994,62 +3000,6 @@ export class ClineProvider } } - /** - * Merges allowed commands from global state and workspace configuration - * with proper validation and deduplication - */ - private mergeAllowedCommands(globalStateCommands?: string[]): string[] { - return this.mergeCommandLists("allowedCommands", "allowed", globalStateCommands) - } - - /** - * Merges denied commands from global state and workspace configuration - * with proper validation and deduplication - */ - private mergeDeniedCommands(globalStateCommands?: string[]): string[] { - return this.mergeCommandLists("deniedCommands", "denied", globalStateCommands) - } - - /** - * Common utility for merging command lists from global state and workspace configuration. - * Implements the Command Denylist feature's merging strategy with proper validation. - * - * @param configKey - VSCode workspace configuration key - * @param commandType - Type of commands for error logging - * @param globalStateCommands - Commands from global state - * @returns Merged and deduplicated command list - */ - private mergeCommandLists( - configKey: "allowedCommands" | "deniedCommands", - commandType: "allowed" | "denied", - globalStateCommands?: string[], - ): string[] { - try { - // Validate and sanitize global state commands - const validGlobalCommands = Array.isArray(globalStateCommands) - ? globalStateCommands.filter((cmd) => typeof cmd === "string" && cmd.trim().length > 0) - : [] - - // Get workspace configuration commands - const workspaceCommands = vscode.workspace.getConfiguration(Package.name).get(configKey) || [] - - // Validate and sanitize workspace commands - const validWorkspaceCommands = Array.isArray(workspaceCommands) - ? workspaceCommands.filter((cmd) => typeof cmd === "string" && cmd.trim().length > 0) - : [] - - // Combine and deduplicate commands - // Global state takes precedence over workspace configuration - const mergedCommands = [...new Set([...validGlobalCommands, ...validWorkspaceCommands])] - - return mergedCommands - } catch (error) { - console.error(`Error merging ${commandType} commands:`, error) - // Return empty array as fallback to prevent crashes - return [] - } - } - async getStateToPostToWebview({ includeTaskHistory = true }: GetStateOptions = {}): Promise { // Ensure the store is initialized before reading task history await this.taskHistoryStore.initialized @@ -3070,6 +3020,7 @@ export class ClineProvider alwaysDenyUnapprovedCommands, allowedCommands, deniedCommands, + workspaceCommandListsEnabled: gateEnabled, alwaysAllowMcp, alwaysAllowModeSwitch, alwaysAllowSubtasks, @@ -3172,8 +3123,6 @@ export class ClineProvider const telemetryKey = process.env.POSTHOG_API_KEY const machineId = vscode.env.machineId const vscodeTelemetryEnabled = vscode.env.isTelemetryEnabled - const mergedAllowedCommands = this.mergeAllowedCommands(allowedCommands) - const mergedDeniedCommands = this.mergeDeniedCommands(deniedCommands) const cwd = this.cwd const currentTask = this.getCurrentTask() let currentTaskMode: string | undefined @@ -3253,8 +3202,11 @@ export class ClineProvider checkpointTimeout: checkpointTimeout ?? DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, shouldShowAnnouncement: telemetrySetting !== "unset" && lastShownAnnouncementId !== this.latestAnnouncementId, - allowedCommands: mergedAllowedCommands, - deniedCommands: mergedDeniedCommands, + // getState() always resolves these to the effective command lists + // (never undefined), so no fallback is needed here. + allowedCommands, + deniedCommands, + workspaceCommandListsEnabled: gateEnabled ?? DEFAULT_WORKSPACE_COMMAND_LISTS_ENABLED, soundVolume: soundVolume ?? 0.5, writeDelayMs: writeDelayMs ?? DEFAULT_WRITE_DELAY_MS, diffFuzzyThreshold: diffFuzzyThreshold ?? DEFAULT_DIFF_FUZZY_THRESHOLD, @@ -3456,6 +3408,14 @@ export class ClineProvider const taskSyncEnabled: boolean = false + // One gate read and one resolver pass per getState() call, so the + // effective lists and the gate value posted together describe a single + // configuration state. + const gateEnabled = workspaceCommandListsEnabled() + const allowScopes = readCommandListScopes("allowedCommands", stateValues.allowedCommands) + const denyScopes = readCommandListScopes("deniedCommands", stateValues.deniedCommands) + const effectiveCommandLists = resolveEffectiveCommandLists(allowScopes, denyScopes, gateEnabled) + // Return the same structure as before. return { apiConfiguration: providerSettings, @@ -3485,8 +3445,13 @@ export class ClineProvider autoCondenseContext: stateValues.autoCondenseContext ?? true, autoCondenseContextPercent: stateValues.autoCondenseContextPercent ?? 100, taskHistory: includeTaskHistory ? this.taskHistoryStore.getAll() : [], - allowedCommands: stateValues.allowedCommands, - deniedCommands: stateValues.deniedCommands, + // Enforce and display the effective lists that + // resolveEffectiveCommandLists composes from the global-state and + // workspace-scope configuration layers; getStateToPostToWebview shows + // exactly these values, so display cannot drift from enforcement. + allowedCommands: effectiveCommandLists.allowed, + deniedCommands: effectiveCommandLists.denied, + workspaceCommandListsEnabled: gateEnabled, soundEnabled: stateValues.soundEnabled ?? false, ttsEnabled: stateValues.ttsEnabled ?? false, ttsSpeed: stateValues.ttsSpeed ?? 1.0, @@ -4217,6 +4182,9 @@ export class ClineProvider if (configuration) { await this.setValues(configuration) + // Deliberately Global-targeted: these writes carry "start this task with + // exactly this auto-approval policy" semantics (e.g. from handoff/delegation + // payloads), unlike the scope-split persistence in the settings dialog. if (configuration.allowedCommands) { await vscode.workspace .getConfiguration(Package.name) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 3ccb5493b3..902c215c0a 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -19,6 +19,7 @@ import { ORGANIZATION_ALLOW_ALL, DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, DEFAULT_DIFF_FUZZY_THRESHOLD, + DEFAULT_WORKSPACE_COMMAND_LISTS_ENABLED, DEFAULT_WRITE_DELAY_MS, providerIdentifiers, openAiModelInfoSaneDefaults, @@ -28,6 +29,7 @@ import { TelemetryService } from "@roo-code/telemetry" import { defaultModeSlug } from "../../../shared/modes" import { experimentDefault } from "../../../shared/experiments" import { setTtsEnabled } from "../../../utils/tts" +import { makeWorkspaceConfiguration } from "../../../test-utils/vscode" import { ContextProxy } from "../../config/ContextProxy" import { WorkspaceIndexingEnablementManager } from "../../../services/code-index/workspace-indexing-enablement-manager" import { Task, TaskOptions } from "../../task/Task" @@ -4061,6 +4063,149 @@ describe("ClineProvider", () => { expect(state.alwaysDenyUnapprovedCommands).toBe(false) }) + describe("command list scope merging", () => { + // getConfiguration overrides must not leak past these tests: + // clearAllMocks does not remove mockReturnValue defaults. + afterEach(() => { + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: vi.fn().mockReturnValue([]), + update: vi.fn(), + } as unknown as vscode.WorkspaceConfiguration) + }) + + // The gate is a synchronous configuration read, so the doubles make the + // `get` lookup key-aware: only the gate key reports true here. + test("getState enforces workspace-scope denied commands even when global state is empty", async () => { + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: vi.fn().mockImplementation((key: string) => key === "workspaceCommandListsEnabled"), + inspect: vi + .fn() + .mockImplementation((key: string) => + key === "deniedCommands" ? { key, workspaceValue: ["docker"] } : undefined, + ), + update: vi.fn(), + } as unknown as vscode.WorkspaceConfiguration) + + const state = await provider.getState() + + expect(state.deniedCommands).toEqual(["docker"]) + }) + + test("getStateToPostToWebview shows the same effective command lists as getState", async () => { + await provider.resolveWebviewView(mockWebviewView) + await provider.contextProxy.setValue("deniedCommands", ["git"]) + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: vi.fn().mockImplementation((key: string) => key === "workspaceCommandListsEnabled"), + inspect: vi + .fn() + .mockImplementation((key: string) => + key === "deniedCommands" ? { key, workspaceValue: ["docker"] } : undefined, + ), + update: vi.fn(), + } as unknown as vscode.WorkspaceConfiguration) + + const state = await provider.getStateToPostToWebview() + + // The workspace entry survives because no global entry shadows it, so + // display is the global layer plus that entry — exactly what + // getState() enforces. + expect(state.deniedCommands).toEqual(["git", "docker"]) + expect(state.allowedCommands).toEqual([]) + }) + + test("getState prefers the workspace-folder value for multi-root workspaces", async () => { + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: vi.fn().mockImplementation((key: string) => key === "workspaceCommandListsEnabled"), + inspect: vi + .fn() + .mockImplementation((key: string) => + key === "deniedCommands" + ? { key, workspaceValue: ["docker"], workspaceFolderValue: ["pnpm docker"] } + : undefined, + ), + update: vi.fn(), + } as unknown as vscode.WorkspaceConfiguration) + + const state = await provider.getState() + + // The folder value replaces (not unions with) the workspace value, and + // the effective get() value is ignored so global-scoped entries do not + // leak into the workspace layer. + expect(state.deniedCommands).toEqual(["pnpm docker"]) + }) + + // A workspace-only allow entry is exactly what the gate decides: enabled + // it joins the effective lists enforcement uses; disabled enforcement ignores it. + function mockGateConfiguration(enabled: boolean) { + // The shared double reports scripted values through `get`, which + // is how the gate reader sees its value; the per-key `inspect` + // override supplies the allow-side workspace layer. + const config = makeWorkspaceConfiguration({ workspaceCommandListsEnabled: enabled }) + const inspect = vi.fn() + inspect.mockImplementation((key: string) => + key === "allowedCommands" ? { key, workspaceValue: ["docker"] } : undefined, + ) + config.inspect = inspect + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue(config) + } + + test("a workspace-only allow entry surfaces through getState and getStateToPostToWebview when the gate is on", async () => { + await provider.resolveWebviewView(mockWebviewView) + mockGateConfiguration(true) + + const state = await provider.getState() + const postedState = await provider.getStateToPostToWebview() + + expect(state.allowedCommands).toEqual(["docker"]) + expect(postedState.allowedCommands).toEqual(["docker"]) + }) + + test("a workspace-only allow entry is absent from getState and getStateToPostToWebview when the gate is off", async () => { + await provider.resolveWebviewView(mockWebviewView) + mockGateConfiguration(false) + + const state = await provider.getState() + const postedState = await provider.getStateToPostToWebview() + + expect(state.allowedCommands).toEqual([]) + expect(postedState.allowedCommands).toEqual([]) + }) + + test("getState returns the saved workspace command-list gate value", async () => { + mockGateConfiguration(true) + + const state = await provider.getState() + + expect(state.workspaceCommandListsEnabled).toBe(true) + }) + + test("getState defaults the workspace command-list gate to the shared constant", async () => { + mockGateConfiguration(false) + + const state = await provider.getState() + + expect(state.workspaceCommandListsEnabled).toBe(DEFAULT_WORKSPACE_COMMAND_LISTS_ENABLED) + }) + + test("getStateToPostToWebview returns the saved workspace command-list gate value", async () => { + await provider.resolveWebviewView(mockWebviewView) + mockGateConfiguration(true) + + const state = await provider.getStateToPostToWebview() + + expect(state.workspaceCommandListsEnabled).toBe(true) + }) + + test("getStateToPostToWebview defaults the workspace command-list gate to the shared constant", async () => { + await provider.resolveWebviewView(mockWebviewView) + mockGateConfiguration(false) + + const state = await provider.getStateToPostToWebview() + + expect(state.workspaceCommandListsEnabled).toBe(DEFAULT_WORKSPACE_COMMAND_LISTS_ENABLED) + }) + }) + test("getState returns the saved allowed read files", async () => { await provider.contextProxy.setValue("allowedReadFiles", ["notes.md"]) diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 685a7b11c1..8a7d0cebc2 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -81,6 +81,8 @@ import type { ModelRecord } from "@roo-code/types" import * as vsCodeLmProvider from "../../../api/providers/vscode-lm" import * as copilotProvider from "../../../api/providers/github-copilot" +import { Package } from "../../../shared/package" +import { makeWorkspaceConfiguration } from "../../../test-utils/vscode" import { webviewMessageHandler } from "../webviewMessageHandler" import { registerCopilotModelBroadcast } from "../../../services/language-models/copilotModelBroadcast" import type { ClineProvider } from "../ClineProvider" @@ -781,6 +783,11 @@ vi.mock("vscode", () => { const showTextDocument = vi.fn().mockResolvedValue(undefined) return { + ConfigurationTarget: { + Global: 1, + Workspace: 2, + WorkspaceFolder: 3, + }, window: { showInformationMessage, showErrorMessage, @@ -2094,6 +2101,428 @@ describe("webviewMessageHandler - allowlists together", () => { }) }) +describe("webviewMessageHandler - command list scope-aware persistence", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // The file-level vscode mock opens one workspace folder, so the command + // list configuration resolves to its uri. + const mockWorkspaceFolderUri = { fsPath: "/mock/workspace" } + + // Mirrors the real VS Code configuration API: `inspect()` exposes the + // per-scope values, and `update()` records the target it was written to. + // Extension global state is left unset (getValue returns undefined), i.e. + // the global layer starts empty. + // + // `updateSequence` / `inspectSequence` let a failure test script what + // successive `update()` / `inspect()` calls return (each `update()` return + // is awaited by the handler, so a rejected promise fails that write), in + // call order; a plain value resolves like the default mock, and an Error + // element makes that call throw. + type InspectedCommandList = { + globalValue?: string[] + workspaceValue?: string[] + workspaceFolderValue?: string[] + } + + // `inspected` may be a function of the requested key so a two-key payload + // test can give each list its own layers. `gateEnabled` scripts the + // `workspaceCommandListsEnabled` configuration read; leaving it unset + // keeps the gate off, matching the extension default. + function mockCommandConfiguration( + inspected: InspectedCommandList | ((key: string) => InspectedCommandList), + updateSequence?: unknown[], + inspectSequence?: unknown[], + gateEnabled?: boolean, + ) { + const update = vi.fn() + if (updateSequence) { + update.mockImplementation(() => updateSequence.shift()) + } + const inspect = vi.fn() + if (inspectSequence) { + inspect.mockImplementation(() => { + const next = inspectSequence.shift() + if (next instanceof Error) { + throw next + } + return next + }) + } else { + inspect.mockImplementation((key: string) => (typeof inspected === "function" ? inspected(key) : inspected)) + } + // The shared structurally complete double keeps the configuration type + // correct; the scripted members are installed per test. + const config = makeWorkspaceConfiguration( + gateEnabled === undefined ? {} : { workspaceCommandListsEnabled: gateEnabled }, + ) + config.update = update + config.inspect = inspect + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue(config) + return { update, inspect } + } + + it("keeps workspace-scoped denied commands out of the global write when saving the merged list", async () => { + const { update, inspect } = mockCommandConfiguration({ workspaceValue: ["docker"] }) + + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: { deniedCommands: ["docker", "aws"] }, + }) + + // "docker" stays at the workspace scope instead of leaking into the + // user's global settings (issue #1939: persist without scope leakage). + expect(update).toHaveBeenCalledWith("deniedCommands", ["docker"], vscode.ConfigurationTarget.Workspace) + expect(update).toHaveBeenCalledWith("deniedCommands", ["aws"], vscode.ConfigurationTarget.Global) + // Extension global state holds only the global layer, so the next save + // cannot double-home the workspace entry. + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("deniedCommands", ["aws"]) + // One snapshot per command-list key: this key's scope read, write + // target, and rollback all derive from the same inspect(), and the + // shadowing check reads the opposite key through its own guarded + // snapshot, so the count is two, never more. + expect(inspect).toHaveBeenCalledTimes(2) + }) + + it("writes the workspace-folder value at the folder target in multi-root workspaces", async () => { + const { update } = mockCommandConfiguration({ workspaceValue: ["npm"], workspaceFolderValue: ["docker"] }) + + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: { deniedCommands: ["docker", "aws"] }, + }) + + // Read, target resolution, and write all run on the configuration + // scoped to the first workspace folder — the folder-target update is + // only legal on a resource-scoped configuration. + expect(vscode.workspace.getConfiguration).toHaveBeenCalledWith(Package.name, mockWorkspaceFolderUri) + expect(update).toHaveBeenCalledWith("deniedCommands", ["docker"], vscode.ConfigurationTarget.WorkspaceFolder) + expect(update).toHaveBeenCalledWith("deniedCommands", ["aws"], vscode.ConfigurationTarget.Global) + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("deniedCommands", ["aws"]) + }) + + it("clears a deleted workspace entry at the workspace scope without adding it to global", async () => { + // Gate on: the entry is visible in the dialog, and only visible entries + // are deletable there (a hidden entry is re-added by the preservation + // rule instead). + const { update } = mockCommandConfiguration({ workspaceValue: ["docker"] }, undefined, undefined, true) + + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: { deniedCommands: [] }, + }) + + // Deleting a project entry must clear it where it lives... + expect(update).toHaveBeenCalledWith("deniedCommands", [], vscode.ConfigurationTarget.Workspace) + // ...and must not copy it into global settings. + expect(update).not.toHaveBeenCalledWith("deniedCommands", ["docker"], vscode.ConfigurationTarget.Global) + expect(update).toHaveBeenCalledWith("deniedCommands", [], vscode.ConfigurationTarget.Global) + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("deniedCommands", []) + }) + + it("does not create a workspace layer when none exists", async () => { + const { update } = mockCommandConfiguration({}) + + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: { allowedCommands: ["git"] }, + }) + + // Only the Global write happens; saving must not materialize an empty + // workspace-scoped value in settings.json. + expect(update).toHaveBeenCalledTimes(1) + expect(update).toHaveBeenCalledWith("allowedCommands", ["git"], vscode.ConfigurationTarget.Global) + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("allowedCommands", ["git"]) + }) + + it("restores the raw workspace value when a workspace-target global write fails", async () => { + // With no folder layer the rewind must read the workspace value, not + // the absent folder value. The raw value holds the entry this save + // dropped, so the restore call is distinguishable from the workspace + // write itself and provably uses the raw inspected value. + const { update } = mockCommandConfiguration({ workspaceValue: ["npm docker", "docker"] }, [ + undefined, + Promise.reject(new Error("disk on fire")), + ]) + + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: { deniedCommands: ["docker", "aws"] }, + }) + + expect(update).toHaveBeenCalledWith( + "deniedCommands", + ["npm docker", "docker"], + vscode.ConfigurationTarget.Workspace, + ) + expect(update).toHaveBeenCalledTimes(3) + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Failed to save deniedCommands: disk on fire") + }) + + it("restores the previous workspace value when the global write fails", async () => { + // update() call order: workspace write succeeds, global write rejects. + // The raw folder-layer value differs from the sanitized post-split layer + // (and from the workspace-layer value), so the restore call is uniquely + // identifiable and provably reads the folder value for a folder target. + const { update } = mockCommandConfiguration( + { workspaceValue: ["npm"], workspaceFolderValue: ["pnpm docker", "docker"] }, + [undefined, Promise.reject(new Error("disk on fire"))], + ) + + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + // A second key pins that only the failed command-list key is skipped. + updatedSettings: { deniedCommands: ["docker", "aws"], alwaysDenyUnapprovedCommands: true }, + }) + + // The capture, the failed write, and the restore all ran on the + // configuration scoped to the first workspace folder. + expect(vscode.workspace.getConfiguration).toHaveBeenCalledWith(Package.name, mockWorkspaceFolderUri) + // The raw previous folder value (entries this save dropped included) is + // written back at the target the failed save chose. + expect(update).toHaveBeenCalledWith( + "deniedCommands", + ["pnpm docker", "docker"], + vscode.ConfigurationTarget.WorkspaceFolder, + ) + expect(update).toHaveBeenCalledTimes(3) + // The failed key's extension global-state write is skipped... + expect(mockClineProvider.contextProxy.setValue).not.toHaveBeenCalledWith("deniedCommands", expect.anything()) + // ...while the other key of the same payload still persists. + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("alwaysDenyUnapprovedCommands", true) + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Failed to save deniedCommands: disk on fire") + expect(mockClineProvider.postStateToWebview).toHaveBeenCalled() + }) + + it("does not write global or extension state when the workspace write fails", async () => { + // The first update() call is the workspace write; it rejects, so the + // global write must never be attempted for this key. The rejection is a + // plain string to pin the non-Error rendering of the failure message. + const { update } = mockCommandConfiguration({ workspaceValue: ["docker"] }, [ + Promise.reject("workspace locked"), + ]) + + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: { deniedCommands: ["docker", "aws"] }, + }) + + // Nothing was changed yet, so there is nothing to restore: only the + // failed workspace write happened, and no compensation write followed. + expect(update).toHaveBeenCalledTimes(1) + expect(update).not.toHaveBeenCalledWith("deniedCommands", expect.anything(), vscode.ConfigurationTarget.Global) + expect(mockClineProvider.contextProxy.setValue).not.toHaveBeenCalled() + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Failed to save deniedCommands: workspace locked") + }) + + it("aborts the key without writing when the configuration inspection has no result", async () => { + // The save derives scopes, write target, and rollback from one inspect() + // snapshot, so a missing snapshot means the workspace layer is unknown — + // proceeding would classify the retained workspace entry "docker" as new + // and re-copy it into global settings (issue #1939's leak). The key is + // aborted instead: no configuration write, no extension global state. + const { update } = mockCommandConfiguration({ workspaceValue: ["docker"] }, undefined, [undefined]) + + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: { deniedCommands: ["docker", "aws"] }, + }) + + expect(update).not.toHaveBeenCalled() + // Leak pin: the workspace entry must not land in the global layer. + expect(update).not.toHaveBeenCalledWith("deniedCommands", ["docker", "aws"], vscode.ConfigurationTarget.Global) + expect(mockClineProvider.contextProxy.setValue).not.toHaveBeenCalled() + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "Failed to save deniedCommands: the configuration value could not be read", + ) + // No exception escaped the handler: the state post still ran. + expect(mockClineProvider.postStateToWebview).toHaveBeenCalled() + }) + + it("aborts the command-list key when the inspection throws, while other keys of the payload still save", async () => { + const { update } = mockCommandConfiguration({ workspaceValue: ["docker"] }, undefined, [ + new Error("configuration unavailable"), + ]) + + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + // A second key pins that only the aborted command-list key is skipped. + updatedSettings: { deniedCommands: ["docker", "aws"], alwaysDenyUnapprovedCommands: true }, + }) + + // The aborted key wrote nothing at any scope... + expect(update).not.toHaveBeenCalled() + expect(mockClineProvider.contextProxy.setValue).not.toHaveBeenCalledWith("deniedCommands", expect.anything()) + // ...while the other key of the same payload still persists. + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("alwaysDenyUnapprovedCommands", true) + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "Failed to save deniedCommands: configuration unavailable", + ) + }) + + it("propagates a configuration lookup failure instead of degrading it to a per-key abort", async () => { + // The commandListConfiguration() lookup sits outside the guarded + // snapshot read on purpose: a lookup that cannot return a configuration + // object at all is a different failure class than an unreadable value, + // so it aborts the entire message rather than the one key. + vi.mocked(vscode.workspace.getConfiguration).mockImplementation(() => { + throw new Error("workspace not ready") + }) + + await expect( + webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + // A second key pins that the whole message aborts, not just the + // command-list key: with the guarded per-key failure paths this + // key would still persist. + updatedSettings: { deniedCommands: ["docker", "aws"], alwaysDenyUnapprovedCommands: true }, + }), + ).rejects.toThrow("workspace not ready") + + // Nothing persisted for any key, no per-key error handling ran, and the + // state post was skipped — the rejection surfaced to the caller instead. + expect(mockClineProvider.contextProxy.setValue).not.toHaveBeenCalled() + expect(vscode.window.showErrorMessage).not.toHaveBeenCalled() + expect(mockClineProvider.postStateToWebview).not.toHaveBeenCalled() + }) + + it("reports partial failure when the compensating restore also fails", async () => { + // update() call order: workspace write succeeds, global write rejects, + // restore write rejects too. The raw workspace value differs from the + // post-split layer so the restore call is uniquely identifiable. + const { update } = mockCommandConfiguration({ workspaceValue: ["docker", "old"] }, [ + undefined, + Promise.reject(new Error("disk on fire")), + Promise.reject(new Error("restore also failed")), + ]) + + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: { deniedCommands: ["docker", "aws"] }, + }) + + // The restore was attempted at the owning target even though it failed. + expect(update).toHaveBeenCalledWith("deniedCommands", ["docker", "old"], vscode.ConfigurationTarget.Workspace) + expect(update).toHaveBeenCalledTimes(3) + expect(mockClineProvider.contextProxy.setValue).not.toHaveBeenCalled() + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "Failed to save deniedCommands: disk on fire. The workspace-scoped restore also failed: Error: restore also failed. Review the command lists in your user and workspace settings.", + ) + // No exception escaped the handler: the state post still ran. + expect(mockClineProvider.postStateToWebview).toHaveBeenCalled() + }) + + // Entries the dialog never displayed — hidden while the gate is off, or + // shadowed by a global entry with it on — cannot be deleted by omission + // from a list the user could not see, so a save re-adds them to the + // workspace layer it rewrites. + it("re-adds gate-hidden workspace entries to the workspace write instead of deleting them", async () => { + const { update } = mockCommandConfiguration({ workspaceValue: ["docker", "npm"] }, undefined, undefined, false) + + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + // The gate is off, so the dialog displayed nothing for this key. + updatedSettings: { deniedCommands: [] }, + }) + + expect(update).toHaveBeenCalledWith("deniedCommands", ["docker", "npm"], vscode.ConfigurationTarget.Workspace) + expect(update).not.toHaveBeenCalledWith("deniedCommands", [], vscode.ConfigurationTarget.Workspace) + }) + + it("keeps hidden workspace entries out of the global write", async () => { + const { update } = mockCommandConfiguration({ workspaceValue: ["docker", "npm"] }, undefined, undefined, false) + + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: { deniedCommands: [] }, + }) + + // Preservation is workspace-side only: re-adding hidden entries must + // not leak them into the user's global settings. + expect(update).not.toHaveBeenCalledWith("deniedCommands", ["docker", "npm"], vscode.ConfigurationTarget.Global) + expect(update).toHaveBeenCalledWith("deniedCommands", [], vscode.ConfigurationTarget.Global) + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("deniedCommands", []) + }) + + it("re-adds a workspace deny entry shadowed by a global allow entry when the gate is on", async () => { + vi.mocked(mockClineProvider.contextProxy.getValue).mockImplementation((key: string) => + key === "allowedCommands" ? ["git"] : undefined, + ) + const { update } = mockCommandConfiguration( + (key) => (key === "deniedCommands" ? { workspaceValue: ["git push"] } : {}), + undefined, + undefined, + true, + ) + + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + // "git push" is shadowed by the global allow "git", so the dialog + // displayed nothing deletable. + updatedSettings: { deniedCommands: [] }, + }) + + expect(update).toHaveBeenCalledWith("deniedCommands", ["git push"], vscode.ConfigurationTarget.Workspace) + expect(update).toHaveBeenCalledWith("deniedCommands", [], vscode.ConfigurationTarget.Global) + }) + + it("re-adds a shadowed workspace allow entry alongside the user's visible deletions", async () => { + vi.mocked(mockClineProvider.contextProxy.getValue).mockImplementation((key: string) => + key === "deniedCommands" ? ["git"] : undefined, + ) + const { update } = mockCommandConfiguration( + (key) => (key === "allowedCommands" ? { workspaceValue: ["git", "docker"] } : {}), + undefined, + undefined, + true, + ) + + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + // "docker" stayed visible and kept; "git" was hidden by the global + // deny, and a workspace entry the user never saw is not deletable. + updatedSettings: { allowedCommands: ["docker"] }, + }) + + expect(update).toHaveBeenCalledWith("allowedCommands", ["docker", "git"], vscode.ConfigurationTarget.Workspace) + expect(update).toHaveBeenCalledWith("allowedCommands", [], vscode.ConfigurationTarget.Global) + }) + + it("does not persist a webview-supplied workspaceCommandListsEnabled value", async () => { + mockCommandConfiguration({}) + + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + // A settings payload is not a second source for the gate: the + // synchronous user-scope configuration read is. + updatedSettings: { workspaceCommandListsEnabled: true, alwaysDenyUnapprovedCommands: true }, + }) + + expect(mockClineProvider.contextProxy.setValue).not.toHaveBeenCalledWith( + "workspaceCommandListsEnabled", + expect.anything(), + ) + // Sibling keys of the same payload are unaffected by the refusal. + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("alwaysDenyUnapprovedCommands", true) + }) + + it("abandons a payload carrying only the gate key without writing configuration", async () => { + const { update } = mockCommandConfiguration({}) + + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: { workspaceCommandListsEnabled: true }, + }) + + expect(update).not.toHaveBeenCalled() + expect(mockClineProvider.contextProxy.setValue).not.toHaveBeenCalled() + // The state post still runs, so the dialog re-reads the effective value + // instead of keeping the refused one. + expect(mockClineProvider.postStateToWebview).toHaveBeenCalled() + }) +}) + describe("webviewMessageHandler - terminalProfile", () => { beforeEach(() => { vi.clearAllMocks() diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 231acdd448..dfb7e2e8a0 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -60,6 +60,15 @@ import { } from "./rulesMessageHandler" import { changeLanguage, t } from "../../i18n" import { Package } from "../../shared/package" +import { + type CommandListInspection, + commandListConfiguration, + readCommandListScopes, + resolveEffectiveCommandLists, + resolveWorkspaceWriteTarget, + splitCommandListScopes, + workspaceCommandListsEnabled, +} from "../auto-approval/commandScope" import { type RouterName, toRouterName } from "../../shared/api" import { MessageEnhancer } from "./messageEnhancer" @@ -767,26 +776,140 @@ export const webviewMessageHandler = async ( if (key === "language") { newValue = value ?? "en" changeLanguage(newValue as Language) - } else if (key === "allowedCommands") { + } else if (key === "workspaceCommandListsEnabled") { + // The gate's effective value is the synchronous + // user-scope configuration read that enforcement and + // display already perform; a value arriving in a + // settings payload is not a second source that can flip + // it, so it is never persisted here. + continue + } else if (key === "allowedCommands" || key === "deniedCommands") { const commands = value ?? [] - newValue = Array.isArray(commands) + const incoming = Array.isArray(commands) ? commands.filter((cmd) => typeof cmd === "string" && cmd.trim().length > 0) : [] - await vscode.workspace - .getConfiguration(Package.name) - .update("allowedCommands", newValue, vscode.ConfigurationTarget.Global) - } else if (key === "deniedCommands") { - const commands = value ?? [] + // The webview always sends the full merged list (it displays + // the union of the global-state and workspace-scope layers). + // Splitting that list by origin scope needs the workspace + // layer, the write target, and the rollback value to describe + // the SAME configuration state, so they are all derived from + // one inspect() snapshot taken before any write. Without a + // snapshot the key's save is aborted rather than guessed at: + // reading the workspace layer as empty would classify a + // workspace-scoped entry as brand new and re-copy it into + // global settings. + // Only the snapshot read is guarded: a failing + // commandListConfiguration() lookup propagates instead of being + // downgraded to a per-key save failure. + const config = commandListConfiguration() + let inspection: CommandListInspection | undefined + let inspectionFailure: string | undefined + try { + inspection = config.inspect(key) + } catch (error) { + inspectionFailure = error instanceof Error ? error.message : String(error) + } + if (inspection === undefined) { + // No snapshot: the workspace layer could only be guessed + // at, and a guessed-empty layer re-copies workspace entries + // into global settings, so this key's save is abandoned + // while the remaining keys of the payload persist normally. + vscode.window.showErrorMessage( + `Failed to save ${key}: ${inspectionFailure ?? "the configuration value could not be read"}`, + ) + continue + } - newValue = Array.isArray(commands) - ? commands.filter((cmd) => typeof cmd === "string" && cmd.trim().length > 0) - : [] + const currentScopes = readCommandListScopes(key, getGlobalState(key), inspection) + const { globalWrite, workspaceWrite: keptWorkspaceWrite } = splitCommandListScopes( + incoming, + currentScopes.workspace, + currentScopes.global, + ) + + // Shadowing is cross-directional, so the hidden-entry + // set needs the opposite key's global layer as input. + // That opposite-key read is a fresh guarded read: it + // only feeds the additive preservation below and never + // this key's split, target, or rollback, which stay on + // the single snapshot. + const otherKey = key === "allowedCommands" ? "deniedCommands" : "allowedCommands" + const otherScopes = readCommandListScopes(otherKey, getGlobalState(otherKey)) + const { hiddenWorkspaceAllow, hiddenWorkspaceDeny } = resolveEffectiveCommandLists( + key === "allowedCommands" ? currentScopes : otherScopes, + key === "deniedCommands" ? currentScopes : otherScopes, + workspaceCommandListsEnabled(), + ) + // Entries hidden from the dialog — while the gate is off, + // or shadowed by a global entry with it on — cannot be + // deleted through a list that never displayed them, so a + // save re-adds them to the workspace layer it rewrites. + // Users remove those directly in the workspace settings + // file. + const hiddenWorkspaceEntries = + key === "allowedCommands" ? hiddenWorkspaceAllow : hiddenWorkspaceDeny + const workspaceWrite = [...new Set([...keptWorkspaceWrite, ...hiddenWorkspaceEntries])] + const workspaceTarget = resolveWorkspaceWriteTarget(key, inspection) + + // The two configuration writes below are not atomic, so the raw + // previous workspace value comes from the same snapshot the split + // used, taken from the target that owns the workspace layer: a + // failed global write rewinds that target to exactly what it held + // beforehand, including the entries this save drops. The raw + // inspected value is used rather than the sanitized layer so the + // rewind reproduces the file contents instead of a cleaned-up + // approximation of them. + const previousWorkspaceValue = + workspaceTarget === vscode.ConfigurationTarget.WorkspaceFolder + ? inspection.workspaceFolderValue + : inspection.workspaceValue + let workspaceWriteApplied = false + try { + // Write the workspace layer first, at the target that owns it. + // When no workspace layer exists the write is skipped entirely + // so saving never materializes one; when entries were deleted + // the `[]` write clears them at that scope. + if (workspaceTarget !== undefined) { + await config.update(key, workspaceWrite, workspaceTarget) + workspaceWriteApplied = true + } + + await config.update(key, globalWrite, vscode.ConfigurationTarget.Global) + } catch (error) { + const failure = error instanceof Error ? error.message : String(error) + + // A failed workspace write leaves nothing changed, so only a + // global-write failure has a changed workspace layer to rewind, + // and the rewind goes to the same target the write used. + if (workspaceWriteApplied) { + try { + await config.update(key, previousWorkspaceValue, workspaceTarget) + } catch (restoreError) { + // The rewind itself failed: the configuration keeps the + // partially written values and the extension global-state + // write is skipped, so stored state never reflects a save + // that did not complete. The user hears about both failures. + vscode.window.showErrorMessage( + `Failed to save ${key}: ${failure}. The workspace-scoped restore also failed: ${restoreError}. Review the command lists in your user and workspace settings.`, + ) + continue + } + } - await vscode.workspace - .getConfiguration(Package.name) - .update("deniedCommands", newValue, vscode.ConfigurationTarget.Global) + // Only this key's save is abandoned; the remaining keys of the + // payload persist normally. + vscode.window.showErrorMessage(`Failed to save ${key}: ${failure}`) + continue + } + + // Extension global state keeps only the global layer: + // enforcement re-adds the workspace layer through + // readCommandListScopes, so storing the merged list here + // would double-home workspace entries and re-leak them on the + // next save. + newValue = globalWrite } else if (key === "allowedReadFiles" || key === "allowedWriteFiles") { const patterns = value ?? [] @@ -1863,38 +1986,6 @@ export const webviewMessageHandler = async ( // Cancel any pending auto-approval timeout for the current task provider.getCurrentTask()?.cancelAutoApprovalTimeout() break - case "allowedCommands": { - // Validate and sanitize the commands array - const commands = message.commands ?? [] - const validCommands = Array.isArray(commands) - ? commands.filter((cmd) => typeof cmd === "string" && cmd.trim().length > 0) - : [] - - await updateGlobalState("allowedCommands", validCommands) - - // Also update workspace settings. - await vscode.workspace - .getConfiguration(Package.name) - .update("allowedCommands", validCommands, vscode.ConfigurationTarget.Global) - - break - } - case "deniedCommands": { - // Validate and sanitize the commands array - const commands = message.commands ?? [] - const validCommands = Array.isArray(commands) - ? commands.filter((cmd) => typeof cmd === "string" && cmd.trim().length > 0) - : [] - - await updateGlobalState("deniedCommands", validCommands) - - // Also update workspace settings. - await vscode.workspace - .getConfiguration(Package.name) - .update("deniedCommands", validCommands, vscode.ConfigurationTarget.Global) - - break - } case "openCustomModesSettings": { const customModesFilePath = await provider.customModesManager.getCustomModesFilePath() diff --git a/src/extension.ts b/src/extension.ts index 6ce42aacd5..8e798e1ad6 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -41,6 +41,7 @@ import { CodeIndexManagerRegistry } from "./services/code-index/code-index-manag import { CodeIndexScope } from "./services/code-index/code-index-scope" import { MdmService } from "./services/mdm/MdmService" import { migrateSettings } from "./utils/migrateSettings" +import { seedAllowedCommands } from "./utils/seedAllowedCommands" import { autoImportSettings } from "./utils/autoImportSettings" import { API } from "./extension/api" @@ -167,13 +168,7 @@ export async function activate(context: vscode.ExtensionContext) { // Initialize Zoo Code auth service for extension session token management. await initZooCodeAuth(context) - // Get default commands from configuration. - const defaultCommands = vscode.workspace.getConfiguration(Package.name).get("allowedCommands") || [] - - // Initialize global state if not already set. - if (!context.globalState.get("allowedCommands")) { - context.globalState.update("allowedCommands", defaultCommands) - } + await seedAllowedCommands(context.globalState) const contextProxy = await ContextProxy.getInstance(context) const updateTelemetryState = () => { diff --git a/src/package.json b/src/package.json index 8a30d614e1..62c9e3371d 100644 --- a/src/package.json +++ b/src/package.json @@ -327,6 +327,13 @@ "default": [], "description": "%commands.deniedCommands.description%" }, + "zoo-code.workspaceCommandListsEnabled": { + "type": "boolean", + "default": false, + "scope": "application", + "description": "%settings.workspaceCommandListsEnabled.description%", + "markdownDescription": "%settings.workspaceCommandListsEnabled.description%" + }, "zoo-code.commandExecutionTimeout": { "type": "number", "default": 0, diff --git a/src/package.nls.ca.json b/src/package.nls.ca.json index d6b40efba3..77f5d530fe 100644 --- a/src/package.nls.ca.json +++ b/src/package.nls.ca.json @@ -50,5 +50,6 @@ "settings.debugProxy.tlsInsecure.description": "Accepta certificats auto-signats del proxy. **Requerit per a la inspecció MITM.** ⚠️ Insegur — utilitza-ho només per a debugging local.", "settings.workspace.rootResolution.description": "Com resol Zoo l'arrel de l'espai de treball en un espai de treball multi-arrel. L'arrel s'utilitza per localitzar `.roomodes`, `.roo/mcp.json`, `.roo/rules/` i altra configuració d'àmbit de projecte. Canviar aquest paràmetre només afecta les cerques futures; les tasques en execució conserven la seva arrel original.", "settings.workspace.rootResolution.activeEditor.description": "Utilitza la carpeta de l'espai de treball que conté l'editor actiu; recorre a la primera carpeta de l'espai de treball. (Per defecte — conserva el comportament heretat.)", - "settings.workspace.rootResolution.firstFolder.description": "Utilitza sempre la primera carpeta de l'espai de treball (workspaceFolders[0]). Determinista — independent del fitxer que tingui el focus en aquest moment." + "settings.workspace.rootResolution.firstFolder.description": "Utilitza sempre la primera carpeta de l'espai de treball (workspaceFolders[0]). Determinista — independent del fitxer que tingui el focus en aquest moment.", + "settings.workspaceCommandListsEnabled.description": "Activa les llistes de comandes aportades pels ajustos de l'espai de treball (els valors de `zoo-code.allowedCommands` i `zoo-code.deniedCommands` als fitxers d'ajustos de l'espai de treball). Mentre estigui desactivada, les llistes de comandes de l'espai de treball no tenen cap efecte. Quan estigui activada, les entrades de nivell d'usuari tenen preferència: una entrada de nivell d'usuari decideix qualsevol comanda que coincideixi, i les entrades de l'espai de treball només s'apliquen a les comandes amb què cap entrada de nivell d'usuari no coincideix. Aquest paràmetre només es pot canviar als [paràmetres d'usuari](https://code.visualstudio.com/docs/getstarted/settings)." } diff --git a/src/package.nls.de.json b/src/package.nls.de.json index a8bbadbf15..d74eed6998 100644 --- a/src/package.nls.de.json +++ b/src/package.nls.de.json @@ -50,5 +50,6 @@ "settings.debugProxy.tlsInsecure.description": "Akzeptiere selbstsignierte Zertifikate vom Proxy. **Erforderlich für MITM-Inspektion.** ⚠️ Unsicher – verwende das nur für lokales Debugging.", "settings.workspace.rootResolution.description": "Wie Zoo das Workspace-Stammverzeichnis in einem Multi-Root-Workspace auflöst. Das Stammverzeichnis wird verwendet, um `.roomodes`, `.roo/mcp.json`, `.roo/rules/` und andere projektbezogene Konfiguration zu finden. Eine Änderung dieser Einstellung wirkt sich nur auf zukünftige Suchen aus; laufende Aufgaben behalten ihr ursprüngliches Stammverzeichnis.", "settings.workspace.rootResolution.activeEditor.description": "Verwende den Workspace-Ordner, der den aktiven Editor enthält; greife auf den ersten Workspace-Ordner zurück. (Standard – behält das bisherige Verhalten bei.)", - "settings.workspace.rootResolution.firstFolder.description": "Verwende immer den ersten Workspace-Ordner (workspaceFolders[0]). Deterministisch – unabhängig davon, welche Datei gerade fokussiert ist." + "settings.workspace.rootResolution.firstFolder.description": "Verwende immer den ersten Workspace-Ordner (workspaceFolders[0]). Deterministisch – unabhängig davon, welche Datei gerade fokussiert ist.", + "settings.workspaceCommandListsEnabled.description": "Aktiviert die von Workspace-Einstellungen beigesteuerten Befehlslisten (die Werte von `zoo-code.allowedCommands` und `zoo-code.deniedCommands` in Workspace-Einstellungsdateien). Solange deaktiviert, haben Workspace-Befehlslisten keine Auswirkung. Wenn aktiviert, haben Einträge auf Benutzerebene Vorrang: Ein Eintrag auf Benutzerebene entscheidet über jeden Befehl, mit dem er übereinstimmt, und Workspace-Einträge gelten nur für Befehle, mit denen kein Eintrag auf Benutzerebene übereinstimmt. Diese Einstellung kann nur in den [Benutzereinstellungen](https://code.visualstudio.com/docs/getstarted/settings) geändert werden." } diff --git a/src/package.nls.es.json b/src/package.nls.es.json index 26d9f87e9d..e2762d26be 100644 --- a/src/package.nls.es.json +++ b/src/package.nls.es.json @@ -50,5 +50,6 @@ "settings.debugProxy.tlsInsecure.description": "Aceptar certificados autofirmados del proxy. **Necesario para la inspección MITM.** ⚠️ Inseguro: úsalo solo para depuración local.", "settings.workspace.rootResolution.description": "Cómo resuelve Zoo la raíz del espacio de trabajo en un espacio de trabajo multi-raíz. La raíz se usa para localizar `.roomodes`, `.roo/mcp.json`, `.roo/rules/` y otra configuración del ámbito del proyecto. Cambiar este ajuste solo afecta a las búsquedas futuras; las tareas en ejecución conservan su raíz original.", "settings.workspace.rootResolution.activeEditor.description": "Usa la carpeta del espacio de trabajo que contiene el editor activo; recurre a la primera carpeta del espacio de trabajo. (Predeterminado: conserva el comportamiento heredado.)", - "settings.workspace.rootResolution.firstFolder.description": "Usa siempre la primera carpeta del espacio de trabajo (workspaceFolders[0]). Determinista: independiente del archivo que tenga el foco en ese momento." + "settings.workspace.rootResolution.firstFolder.description": "Usa siempre la primera carpeta del espacio de trabajo (workspaceFolders[0]). Determinista: independiente del archivo que tenga el foco en ese momento.", + "settings.workspaceCommandListsEnabled.description": "Habilita las listas de comandos aportadas por la configuración del espacio de trabajo (los valores de `zoo-code.allowedCommands` y `zoo-code.deniedCommands` en los archivos de configuración del espacio de trabajo). Mientras esté deshabilitada, las listas de comandos del espacio de trabajo no tienen ningún efecto. Cuando esté habilitada, las entradas de nivel de usuario tienen prioridad: una entrada de nivel de usuario decide cualquier comando que coincida, y las entradas del espacio de trabajo solo se aplican a los comandos con los que ninguna entrada de nivel de usuario coincide. Este ajuste solo se puede cambiar en la [configuración de usuario](https://code.visualstudio.com/docs/getstarted/settings)." } diff --git a/src/package.nls.fr.json b/src/package.nls.fr.json index 364a8878c4..e3d28cfcd3 100644 --- a/src/package.nls.fr.json +++ b/src/package.nls.fr.json @@ -50,5 +50,6 @@ "settings.debugProxy.tlsInsecure.description": "Accepter les certificats auto-signés du proxy. **Requis pour l'inspection MITM.** ⚠️ Non sécurisé — à utiliser uniquement pour le debug local.", "settings.workspace.rootResolution.description": "Comment Zoo résout la racine de l'espace de travail dans un espace de travail multi-racine. La racine sert à localiser `.roomodes`, `.roo/mcp.json`, `.roo/rules/` et d'autres configurations propres au projet. Modifier ce paramètre n'affecte que les recherches futures ; les tâches en cours conservent leur racine d'origine.", "settings.workspace.rootResolution.activeEditor.description": "Utilise le dossier de l'espace de travail contenant l'éditeur actif ; revient au premier dossier de l'espace de travail. (Par défaut — conserve le comportement hérité.)", - "settings.workspace.rootResolution.firstFolder.description": "Utilise toujours le premier dossier de l'espace de travail (workspaceFolders[0]). Déterministe — indépendant du fichier actuellement ciblé." + "settings.workspace.rootResolution.firstFolder.description": "Utilise toujours le premier dossier de l'espace de travail (workspaceFolders[0]). Déterministe — indépendant du fichier actuellement ciblé.", + "settings.workspaceCommandListsEnabled.description": "Active les listes de commandes issues des paramètres de l'espace de travail (les valeurs de `zoo-code.allowedCommands` et `zoo-code.deniedCommands` dans les fichiers de paramètres de l'espace de travail). Lorsqu'elle est désactivée, les listes de commandes de l'espace de travail n'ont aucun effet. Lorsqu'elle est activée, les entrées de niveau utilisateur ont la priorité : une entrée de niveau utilisateur décide de toute commande à laquelle elle correspond, et les entrées de l'espace de travail ne s'appliquent qu'aux commandes pour lesquelles aucune entrée de niveau utilisateur ne correspond. Ce paramètre ne peut être modifié que dans les [paramètres utilisateur](https://code.visualstudio.com/docs/getstarted/settings)." } diff --git a/src/package.nls.hi.json b/src/package.nls.hi.json index db70bc8417..74ce2fd37a 100644 --- a/src/package.nls.hi.json +++ b/src/package.nls.hi.json @@ -50,5 +50,6 @@ "settings.debugProxy.tlsInsecure.description": "Proxy से आने वाले self-signed certificates accept करो। **MITM inspection के लिए ज़रूरी।** ⚠️ Insecure — सिर्फ local debugging के लिए इस्तेमाल करो।", "settings.workspace.rootResolution.description": "Multi-root workspace में Zoo workspace root को कैसे resolve करता है। Root का इस्तेमाल `.roomodes`, `.roo/mcp.json`, `.roo/rules/` और दूसरी project-scoped configuration ढूंढने के लिए होता है। इस setting को बदलने का असर सिर्फ आगे की lookups पर पड़ता है; चल रहे tasks अपना original root बनाए रखते हैं।", "settings.workspace.rootResolution.activeEditor.description": "उस workspace folder का इस्तेमाल करो जिसमें active editor है; पहले workspace folder पर fall back करो। (Default — legacy behavior बनाए रखता है।)", - "settings.workspace.rootResolution.firstFolder.description": "हमेशा पहले workspace folder (workspaceFolders[0]) का इस्तेमाल करो। Deterministic — इस बात से स्वतंत्र कि अभी कौन सी file focused है।" + "settings.workspace.rootResolution.firstFolder.description": "हमेशा पहले workspace folder (workspaceFolders[0]) का इस्तेमाल करो। Deterministic — इस बात से स्वतंत्र कि अभी कौन सी file focused है।", + "settings.workspaceCommandListsEnabled.description": "Workspace settings द्वारा दी गई command lists को सक्रिय करো (workspace settings files में `zoo-code.allowedCommands` और `zoo-code.deniedCommands` के मान)। जब तक इह निष्क्रिय है, workspace command lists का कौनो प्रभाव नाहीं होतै। सक्रिय करतै पर user-level entries कौ प्राथमिकत मिलतै है: हर user-level entry उन सब्ह commands कौ तय करतै है जिनसै वह मेल खातै है, अऊर workspace entries केवल उन commands पर लागू होतै हैं जिनसै कौनो user-level entry मेल नाहीं खातै। इस setting कौ केवल [user settings](https://code.visualstudio.com/docs/getstarted/settings) में बदल जा सकतै है।" } diff --git a/src/package.nls.id.json b/src/package.nls.id.json index b7e150ee53..2e5d084ae8 100644 --- a/src/package.nls.id.json +++ b/src/package.nls.id.json @@ -50,5 +50,6 @@ "settings.debugProxy.tlsInsecure.description": "Terima sertifikat self-signed dari proxy. **Diperlukan untuk inspeksi MITM.** ⚠️ Tidak aman — gunakan hanya untuk debugging lokal.", "settings.workspace.rootResolution.description": "Bagaimana Zoo menentukan root workspace di workspace multi-root. Root digunakan untuk menemukan `.roomodes`, `.roo/mcp.json`, `.roo/rules/`, dan konfigurasi lain yang bersifat project-scoped. Mengubah pengaturan ini hanya memengaruhi pencarian berikutnya; task yang sedang berjalan tetap memakai root aslinya.", "settings.workspace.rootResolution.activeEditor.description": "Gunakan folder workspace yang berisi editor aktif; kembali ke folder workspace pertama. (Default — mempertahankan perilaku lama.)", - "settings.workspace.rootResolution.firstFolder.description": "Selalu gunakan folder workspace pertama (workspaceFolders[0]). Deterministik — tidak bergantung pada file mana yang sedang difokuskan." + "settings.workspace.rootResolution.firstFolder.description": "Selalu gunakan folder workspace pertama (workspaceFolders[0]). Deterministik — tidak bergantung pada file mana yang sedang difokuskan.", + "settings.workspaceCommandListsEnabled.description": "Aktifkan daftar perintah yang disediakan oleh pengaturan workspace (nilai `zoo-code.allowedCommands` dan `zoo-code.deniedCommands` di file pengaturan workspace). Saat dinonaktifkan, daftar perintah workspace tidak memiliki efek apa pun. Saat diaktifkan, entri level pengguna lebih diutamakan: entri level pengguna menentukan perintah mana pun yang cocok dengannya, dan entri workspace hanya berlaku untuk perintah yang tidak cocok dengan entri level pengguna mana pun. Pengaturan ini hanya dapat diubah di [pengaturan pengguna](https://code.visualstudio.com/docs/getstarted/settings)." } diff --git a/src/package.nls.it.json b/src/package.nls.it.json index 6d50b96bc4..6ec6801fc4 100644 --- a/src/package.nls.it.json +++ b/src/package.nls.it.json @@ -50,5 +50,6 @@ "settings.debugProxy.tlsInsecure.description": "Accetta certificati autofirmati dal proxy. **Necessario per l'ispezione MITM.** ⚠️ Non sicuro — usalo solo per il debugging locale.", "settings.workspace.rootResolution.description": "Come Zoo determina la radice del workspace in un workspace multi-radice. La radice viene usata per individuare `.roomodes`, `.roo/mcp.json`, `.roo/rules/` e altra configurazione a livello di progetto. Modificare questa impostazione influisce solo sulle ricerche future; i task in esecuzione mantengono la loro radice originale.", "settings.workspace.rootResolution.activeEditor.description": "Usa la cartella del workspace che contiene l'editor attivo; ripiega sulla prima cartella del workspace. (Predefinito — mantiene il comportamento legacy.)", - "settings.workspace.rootResolution.firstFolder.description": "Usa sempre la prima cartella del workspace (workspaceFolders[0]). Deterministico — indipendente dal file attualmente attivo." + "settings.workspace.rootResolution.firstFolder.description": "Usa sempre la prima cartella del workspace (workspaceFolders[0]). Deterministico — indipendente dal file attualmente attivo.", + "settings.workspaceCommandListsEnabled.description": "Abilita gli elenchi di comandi forniti dalle impostazioni del workspace (i valori di `zoo-code.allowedCommands` e `zoo-code.deniedCommands` nei file delle impostazioni del workspace). Finché è disabilitata, gli elenchi di comandi del workspace non hanno alcun effetto. Quando è abilitata, le voci a livello utente hanno la precedenza: una voce a livello utente decide qualsiasi comando con cui corrisponde, e le voci del workspace si applicano solo ai comandi con cui nessuna voce a livello utente corrisponde. Questa impostazione può essere modificata solo nelle [impostazioni utente](https://code.visualstudio.com/docs/getstarted/settings)." } diff --git a/src/package.nls.ja.json b/src/package.nls.ja.json index 22f4f0dfce..8c48c277e0 100644 --- a/src/package.nls.ja.json +++ b/src/package.nls.ja.json @@ -50,5 +50,6 @@ "settings.debugProxy.tlsInsecure.description": "プロキシからの自己署名証明書を許可します。**MITM インスペクションに必須です。** ⚠️ 危険な設定なので、ローカルでのデバッグにだけ使用してください。", "settings.workspace.rootResolution.description": "マルチルートワークスペースで Zoo がワークスペースルートをどのように解決するか。ルートは `.roomodes`、`.roo/mcp.json`、`.roo/rules/` などのプロジェクト単位の設定を見つけるために使われます。この設定の変更は今後の検索にのみ影響し、実行中のタスクは元のルートを保持します。", "settings.workspace.rootResolution.activeEditor.description": "アクティブなエディタを含むワークスペースフォルダを使用し、最初のワークスペースフォルダにフォールバックします。(デフォルト — 従来の動作を維持します。)", - "settings.workspace.rootResolution.firstFolder.description": "常に最初のワークスペースフォルダ(workspaceFolders[0])を使用します。決定的で、現在フォーカスされているファイルに依存しません。" + "settings.workspace.rootResolution.firstFolder.description": "常に最初のワークスペースフォルダ(workspaceFolders[0])を使用します。決定的で、現在フォーカスされているファイルに依存しません。", + "settings.workspaceCommandListsEnabled.description": "ワークスペース設定が提供するコマンドリスト(ワークスペースの設定ファイル内の `zoo-code.allowedCommands` と `zoo-code.deniedCommands` の値)を有効にします。無効の間は、ワークスペースのコマンドリストは効果を持ちません。有効にすると、ユーザーレベルのエントリが優先されます。ユーザーレベルのエントリは、一致するコマンドをすべて決定し、ワークスペースのエントリはユーザーレベルのエントリが一致しないコマンドにのみ適用されます。この設定は[ユーザー設定](https://code.visualstudio.com/docs/getstarted/settings)でのみ変更できます。" } diff --git a/src/package.nls.json b/src/package.nls.json index d1f9afbc39..3d5a7caa33 100644 --- a/src/package.nls.json +++ b/src/package.nls.json @@ -50,5 +50,6 @@ "settings.debugProxy.tlsInsecure.description": "Accept self-signed certificates from the proxy. **Required for MITM inspection.** ⚠️ Insecure — only use for local debugging.", "settings.workspace.rootResolution.description": "How Zoo resolves the workspace root in a multi-root workspace. The root is used to locate `.roomodes`, `.roo/mcp.json`, `.roo/rules/`, and other project-scoped configuration. Changing this setting only affects future lookups; running tasks keep their original root.", "settings.workspace.rootResolution.activeEditor.description": "Use the workspace folder containing the active editor; fall back to the first workspace folder. (Default — preserves legacy behavior.)", - "settings.workspace.rootResolution.firstFolder.description": "Always use the first workspace folder (workspaceFolders[0]). Deterministic — independent of which file is currently focused." + "settings.workspace.rootResolution.firstFolder.description": "Always use the first workspace folder (workspaceFolders[0]). Deterministic — independent of which file is currently focused.", + "settings.workspaceCommandListsEnabled.description": "Enable command lists contributed by workspace settings (`zoo-code.allowedCommands` and `zoo-code.deniedCommands` values in workspace settings files). While disabled, workspace command lists have no effect. When enabled, user-level entries take precedence: a user-level entry decides any command it matches, and workspace entries only apply to commands no user-level entry matches. This setting can only be changed in [user settings](https://code.visualstudio.com/docs/getstarted/settings)." } diff --git a/src/package.nls.ko.json b/src/package.nls.ko.json index 48d8695a1d..7546304047 100644 --- a/src/package.nls.ko.json +++ b/src/package.nls.ko.json @@ -50,5 +50,6 @@ "settings.debugProxy.tlsInsecure.description": "프록시의 self-signed 인증서를 허용합니다. **MITM 검사에 필요합니다.** ⚠️ 안전하지 않으므로 로컬 디버깅에만 사용하세요.", "settings.workspace.rootResolution.description": "멀티 루트 워크스페이스에서 Zoo가 워크스페이스 루트를 어떻게 결정하는지 설정합니다. 루트는 `.roomodes`, `.roo/mcp.json`, `.roo/rules/` 및 기타 프로젝트 범위 구성을 찾는 데 사용됩니다. 이 설정을 변경해도 이후의 조회에만 영향을 주며, 실행 중인 작업은 원래 루트를 유지합니다.", "settings.workspace.rootResolution.activeEditor.description": "활성 편집기가 있는 워크스페이스 폴더를 사용하고, 첫 번째 워크스페이스 폴더로 폴백합니다. (기본값 — 기존 동작을 유지합니다.)", - "settings.workspace.rootResolution.firstFolder.description": "항상 첫 번째 워크스페이스 폴더(workspaceFolders[0])를 사용합니다. 결정적이며, 현재 포커스된 파일과 무관합니다." + "settings.workspace.rootResolution.firstFolder.description": "항상 첫 번째 워크스페이스 폴더(workspaceFolders[0])를 사용합니다. 결정적이며, 현재 포커스된 파일과 무관합니다.", + "settings.workspaceCommandListsEnabled.description": "워크스페이스 설정이 제공하는 명령 목록을 활성화합니다(워크스페이스 설정 파일의 `zoo-code.allowedCommands` 및 `zoo-code.deniedCommands` 값). 비활성화된 동안에는 워크스페이스 명령 목록이 효과가 없습니다. 활성화하면 사용자 수준 항목이 우선합니다: 사용자 수준 항목은 자신이 일치시키는 모든 명령을 결정하며, 워크스페이스 항목은 사용자 수준 항목이 일치시키지 않는 명령에만 적용됩니다. 이 설정은 [사용자 설정](https://code.visualstudio.com/docs/getstarted/settings)에서만 변경할 수 있습니다." } diff --git a/src/package.nls.nl.json b/src/package.nls.nl.json index 81113c4b5a..46f5eab9bd 100644 --- a/src/package.nls.nl.json +++ b/src/package.nls.nl.json @@ -50,5 +50,6 @@ "settings.debugProxy.tlsInsecure.description": "Accepteer zelfondertekende certificaten van de proxy. **Vereist voor MITM-inspectie.** ⚠️ Onveilig — gebruik dit alleen voor lokale debugging.", "settings.workspace.rootResolution.description": "Hoe Zoo de workspace-root bepaalt in een multi-root workspace. De root wordt gebruikt om `.roomodes`, `.roo/mcp.json`, `.roo/rules/` en andere project-scoped configuratie te vinden. Deze instelling wijzigen heeft alleen invloed op toekomstige zoekopdrachten; lopende taken behouden hun oorspronkelijke root.", "settings.workspace.rootResolution.activeEditor.description": "Gebruik de workspace-map die de actieve editor bevat; val terug op de eerste workspace-map. (Standaard — behoudt het oude gedrag.)", - "settings.workspace.rootResolution.firstFolder.description": "Gebruik altijd de eerste workspace-map (workspaceFolders[0]). Deterministisch — onafhankelijk van welk bestand op dit moment de focus heeft." + "settings.workspace.rootResolution.firstFolder.description": "Gebruik altijd de eerste workspace-map (workspaceFolders[0]). Deterministisch — onafhankelijk van welk bestand op dit moment de focus heeft.", + "settings.workspaceCommandListsEnabled.description": "Schakel commandolijsten in die worden bijgedragen door werkruimte-instellingen (de waarden van `zoo-code.allowedCommands` en `zoo-code.deniedCommands` in instellingenbestanden van de werkruimte). Terwijl uitgeschakeld hebben commandolijsten van de werkruimte geen effect. Wanneer ingeschakeld hebben items op gebruikersniveau voorrang: een item op gebruikersniveau beslist elk commando dat ermee overeenkomt, en werkruimte-items zijn alleen van toepassing op commando's waar geen item op gebruikersniveau mee overeenkomt. Deze instelling kan alleen worden gewijzigd in de [gebruikersinstellingen](https://code.visualstudio.com/docs/getstarted/settings)." } diff --git a/src/package.nls.pl.json b/src/package.nls.pl.json index 267ddff277..a6378085f1 100644 --- a/src/package.nls.pl.json +++ b/src/package.nls.pl.json @@ -50,5 +50,6 @@ "settings.debugProxy.tlsInsecure.description": "Akceptuj certyfikaty self-signed z proxy. **Wymagane do inspekcji MITM.** ⚠️ Niezabezpieczone — używaj tylko do lokalnego debugowania.", "settings.workspace.rootResolution.description": "Jak Zoo ustala katalog główny workspace w workspace z wieloma katalogami głównymi. Katalog główny służy do lokalizowania `.roomodes`, `.roo/mcp.json`, `.roo/rules/` i innej konfiguracji na poziomie projektu. Zmiana tego ustawienia wpływa tylko na przyszłe wyszukiwania; uruchomione zadania zachowują swój pierwotny katalog główny.", "settings.workspace.rootResolution.activeEditor.description": "Użyj folderu workspace zawierającego aktywny edytor; w razie potrzeby wróć do pierwszego folderu workspace. (Domyślnie — zachowuje dotychczasowe zachowanie.)", - "settings.workspace.rootResolution.firstFolder.description": "Zawsze używaj pierwszego folderu workspace (workspaceFolders[0]). Deterministyczne — niezależne od tego, który plik jest aktualnie aktywny." + "settings.workspace.rootResolution.firstFolder.description": "Zawsze używaj pierwszego folderu workspace (workspaceFolders[0]). Deterministyczne — niezależne od tego, który plik jest aktualnie aktywny.", + "settings.workspaceCommandListsEnabled.description": "Włącza listy poleceń dostarczane przez ustawienia workspace (wartości `zoo-code.allowedCommands` i `zoo-code.deniedCommands` w plikach ustawień workspace). Gdy jest wyłączona, listy poleceń workspace nie mają żadnego efektu. Gdy jest włączona, wpisy na poziomie użytkownika mają pierwszeństwo: wpis na poziomie użytkownika rozstrzyga każde polecenie, z którym się dopasuje, a wpisy workspace dotyczą tylko poleceń, z którymi nie dopasuje się żaden wpis na poziomie użytkownika. To ustawienie można zmienić tylko w [ustawieniach użytkownika](https://code.visualstudio.com/docs/getstarted/settings)." } diff --git a/src/package.nls.pt-BR.json b/src/package.nls.pt-BR.json index 853832b699..9824a9fd67 100644 --- a/src/package.nls.pt-BR.json +++ b/src/package.nls.pt-BR.json @@ -50,5 +50,6 @@ "settings.debugProxy.tlsInsecure.description": "Aceitar certificados self-signed do proxy. **Necessário para inspeção MITM.** ⚠️ Inseguro — use apenas para depuração local.", "settings.workspace.rootResolution.description": "Como o Zoo resolve a raiz do workspace em um workspace multi-raiz. A raiz é usada para localizar `.roomodes`, `.roo/mcp.json`, `.roo/rules/` e outras configurações no escopo do projeto. Alterar esta configuração afeta apenas as buscas futuras; as tarefas em execução mantêm sua raiz original.", "settings.workspace.rootResolution.activeEditor.description": "Usa a pasta do workspace que contém o editor ativo; recorre à primeira pasta do workspace. (Padrão — preserva o comportamento legado.)", - "settings.workspace.rootResolution.firstFolder.description": "Usa sempre a primeira pasta do workspace (workspaceFolders[0]). Determinístico — independente de qual arquivo está em foco no momento." + "settings.workspace.rootResolution.firstFolder.description": "Usa sempre a primeira pasta do workspace (workspaceFolders[0]). Determinístico — independente de qual arquivo está em foco no momento.", + "settings.workspaceCommandListsEnabled.description": "Ativa as listas de comandos fornecidas pelas configurações do workspace (os valores de `zoo-code.allowedCommands` e `zoo-code.deniedCommands` nos arquivos de configurações do workspace). Enquanto desativada, as listas de comandos do workspace não têm efeito. Quando ativada, as entradas em nível de usuário têm precedência: uma entrada em nível de usuário decide qualquer comando com o qual corresponda, e as entradas do workspace se aplicam apenas a comandos com os quais nenhuma entrada em nível de usuário corresponde. Essa configuração só pode ser alterada nas [configurações de usuário](https://code.visualstudio.com/docs/getstarted/settings)." } diff --git a/src/package.nls.ru.json b/src/package.nls.ru.json index d46ea14245..e3231a3bcf 100644 --- a/src/package.nls.ru.json +++ b/src/package.nls.ru.json @@ -50,5 +50,6 @@ "settings.debugProxy.tlsInsecure.description": "Принимать self-signed сертификаты от прокси. **Требуется для MITM-инспекции.** ⚠️ Небезопасно — используй только для локальной отладки.", "settings.workspace.rootResolution.description": "Как Zoo определяет корень рабочей области в рабочей области с несколькими корнями. Корень используется для поиска `.roomodes`, `.roo/mcp.json`, `.roo/rules/` и другой конфигурации уровня проекта. Изменение этой настройки влияет только на последующие поиски; выполняющиеся задачи сохраняют свой исходный корень.", "settings.workspace.rootResolution.activeEditor.description": "Использовать папку рабочей области, содержащую активный редактор; при необходимости вернуться к первой папке рабочей области. (По умолчанию — сохраняет прежнее поведение.)", - "settings.workspace.rootResolution.firstFolder.description": "Всегда использовать первую папку рабочей области (workspaceFolders[0]). Детерминированно — не зависит от того, какой файл сейчас в фокусе." + "settings.workspace.rootResolution.firstFolder.description": "Всегда использовать первую папку рабочей области (workspaceFolders[0]). Детерминированно — не зависит от того, какой файл сейчас в фокусе.", + "settings.workspaceCommandListsEnabled.description": "Включает списки команд, задаваемые в настройках рабочей области (значения `zoo-code.allowedCommands` и `zoo-code.deniedCommands` в файлах настроек рабочей области). Пока отключено, списки команд рабочей области ни на что не влияют. Когда включено, записи уровня пользователя имеют приоритет: запись уровня пользователя решает любую подошедшую ей команду, а записи рабочей области применяются только к командам, которые не совпали ни с одной записью уровня пользователя. Этот параметр можно изменить только в [настройках пользователя](https://code.visualstudio.com/docs/getstarted/settings)." } diff --git a/src/package.nls.tr.json b/src/package.nls.tr.json index cbc81d959f..25f1df2fc8 100644 --- a/src/package.nls.tr.json +++ b/src/package.nls.tr.json @@ -50,5 +50,6 @@ "settings.debugProxy.tlsInsecure.description": "Proxy'den gelen self-signed sertifikaları kabul et. **MITM incelemesi için gerekli.** ⚠️ Güvensiz — yalnızca lokal debugging için kullan.", "settings.workspace.rootResolution.description": "Zoo'nun çok köklü bir çalışma alanında çalışma alanı kökünü nasıl belirlediği. Kök; `.roomodes`, `.roo/mcp.json`, `.roo/rules/` ve diğer proje kapsamındaki yapılandırmayı bulmak için kullanılır. Bu ayarı değiştirmek yalnızca sonraki aramaları etkiler; çalışan görevler özgün köklerini korur.", "settings.workspace.rootResolution.activeEditor.description": "Aktif düzenleyiciyi içeren çalışma alanı klasörünü kullan; ilk çalışma alanı klasörüne geri dön. (Varsayılan — eski davranışı korur.)", - "settings.workspace.rootResolution.firstFolder.description": "Her zaman ilk çalışma alanı klasörünü (workspaceFolders[0]) kullan. Deterministik — o anda hangi dosyanın odakta olduğundan bağımsız." + "settings.workspace.rootResolution.firstFolder.description": "Her zaman ilk çalışma alanı klasörünü (workspaceFolders[0]) kullan. Deterministik — o anda hangi dosyanın odakta olduğundan bağımsız.", + "settings.workspaceCommandListsEnabled.description": "Çalışma alanı ayarlarının sağladığı komut listelerini etkinleştirir (çalışma alanı ayar dosyalarındaki `zoo-code.allowedCommands` ve `zoo-code.deniedCommands` değerleri). Devre dışıyken çalışma alanı komut listelerinin hiçbir etkisi olmaz. Etkinleştirildiğinde kullanıcı düzeyi girdiler önceliklidir: bir kullanıcı düzeyi girdi eşleştiği her komutu kararlaştırır ve çalışma alanı girdileri yalnızca hiçbir kullanıcı düzeyi girdinin eşleşmediği komutlara uygulanır. Bu ayar yalnızca [kullanıcı ayarlarında](https://code.visualstudio.com/docs/getstarted/settings) değiştirilebilir." } diff --git a/src/package.nls.vi.json b/src/package.nls.vi.json index 1ee4eabd70..f8913118c1 100644 --- a/src/package.nls.vi.json +++ b/src/package.nls.vi.json @@ -50,5 +50,6 @@ "settings.debugProxy.tlsInsecure.description": "Chấp nhận chứng chỉ self-signed từ proxy. **Bắt buộc cho việc kiểm tra MITM.** ⚠️ Không an toàn — chỉ dùng cho debug cục bộ.", "settings.workspace.rootResolution.description": "Cách Zoo xác định gốc workspace trong một workspace nhiều gốc. Gốc được dùng để tìm `.roomodes`, `.roo/mcp.json`, `.roo/rules/` và các cấu hình khác ở phạm vi dự án. Thay đổi cài đặt này chỉ ảnh hưởng đến các lần tra cứu về sau; các tác vụ đang chạy vẫn giữ gốc ban đầu của chúng.", "settings.workspace.rootResolution.activeEditor.description": "Dùng thư mục workspace chứa trình soạn thảo đang hoạt động; quay lại thư mục workspace đầu tiên. (Mặc định — giữ nguyên hành vi cũ.)", - "settings.workspace.rootResolution.firstFolder.description": "Luôn dùng thư mục workspace đầu tiên (workspaceFolders[0]). Mang tính xác định — không phụ thuộc vào tệp nào đang được focus." + "settings.workspace.rootResolution.firstFolder.description": "Luôn dùng thư mục workspace đầu tiên (workspaceFolders[0]). Mang tính xác định — không phụ thuộc vào tệp nào đang được focus.", + "settings.workspaceCommandListsEnabled.description": "Bật các danh sách lệnh do cài đặt workspace đóng góp (giá trị `zoo-code.allowedCommands` và `zoo-code.deniedCommands` trong các tệp cài đặt workspace). Khi bị tắt, các danh sách lệnh của workspace không có tác dụng. Khi được bật, các mục cấp người dùng được ưu tiên: một mục cấp người dùng quyết định bất kỳ lệnh nào mà nó khớp, và các mục của workspace chỉ áp dụng cho các lệnh không có mục cấp người dùng nào khớp. Cài đặt này chỉ có thể thay đổi trong [cài đặt người dùng](https://code.visualstudio.com/docs/getstarted/settings)." } diff --git a/src/package.nls.zh-CN.json b/src/package.nls.zh-CN.json index 85dafc3bc7..fe384c7efa 100644 --- a/src/package.nls.zh-CN.json +++ b/src/package.nls.zh-CN.json @@ -50,5 +50,6 @@ "settings.debugProxy.tlsInsecure.description": "接受来自代理的 self-signed 证书。**MITM 检查所必需。** ⚠️ 不安全——只在本地调试时使用。", "settings.workspace.rootResolution.description": "Zoo 在多根工作区中如何解析工作区根目录。根目录用于定位 `.roomodes`、`.roo/mcp.json`、`.roo/rules/` 以及其他项目范围的配置。更改此设置仅影响后续查找;正在运行的任务会保留其原始根目录。", "settings.workspace.rootResolution.activeEditor.description": "使用包含活动编辑器的工作区文件夹;回退到第一个工作区文件夹。(默认——保留旧行为。)", - "settings.workspace.rootResolution.firstFolder.description": "始终使用第一个工作区文件夹(workspaceFolders[0])。具有确定性——与当前聚焦的文件无关。" + "settings.workspace.rootResolution.firstFolder.description": "始终使用第一个工作区文件夹(workspaceFolders[0])。具有确定性——与当前聚焦的文件无关。", + "settings.workspaceCommandListsEnabled.description": "启用由工作区设置提供的命令列表(工作区设置文件中的 `zoo-code.allowedCommands` 和 `zoo-code.deniedCommands` 值)。禁用期间,工作区命令列表不产生任何影响。启用后,用户级条目具有优先权:用户级条目决定其匹配的任何命令,工作区条目仅适用于没有任何用户级条目匹配的命令。此设置只能在[用户设置](https://code.visualstudio.com/docs/getstarted/settings)中更改。" } diff --git a/src/package.nls.zh-TW.json b/src/package.nls.zh-TW.json index cda075e306..084e620258 100644 --- a/src/package.nls.zh-TW.json +++ b/src/package.nls.zh-TW.json @@ -50,5 +50,6 @@ "settings.debugProxy.tlsInsecure.description": "接受來自代理的 self-signed 憑證。**MITM 檢查所必需。** ⚠️ 不安全——只在本機偵錯時使用。", "settings.workspace.rootResolution.description": "Zoo 在多根工作區中如何解析工作區根目錄。根目錄用於定位 `.roomodes`、`.roo/mcp.json`、`.roo/rules/` 以及其他專案範圍的設定。變更此設定僅影響後續查找;正在執行的工作會保留其原始根目錄。", "settings.workspace.rootResolution.activeEditor.description": "使用包含使用中編輯器的工作區資料夾;回退到第一個工作區資料夾。(預設——保留舊行為。)", - "settings.workspace.rootResolution.firstFolder.description": "一律使用第一個工作區資料夾(workspaceFolders[0])。具決定性——與目前聚焦的檔案無關。" + "settings.workspace.rootResolution.firstFolder.description": "一律使用第一個工作區資料夾(workspaceFolders[0])。具決定性——與目前聚焦的檔案無關。", + "settings.workspaceCommandListsEnabled.description": "啟用由工作區設定所提供的工作區指令清單(工作區設定檔案中的 `zoo-code.allowedCommands` 與 `zoo-code.deniedCommands` 值)。停用時,工作區指令清單不會產生任何影響。啟用後,使用者層級的條目具有優先權:使用者層級的條目會決定其符合的任何指令,工作區條目僅適用於沒有任何使用者層級條目符合的指令。此設定只能在[使用者設定](https://code.visualstudio.com/docs/getstarted/settings)中變更。" } diff --git a/src/utils/__tests__/seedAllowedCommands.spec.ts b/src/utils/__tests__/seedAllowedCommands.spec.ts new file mode 100644 index 0000000000..50c1dd5fc6 --- /dev/null +++ b/src/utils/__tests__/seedAllowedCommands.spec.ts @@ -0,0 +1,319 @@ +// npx vitest run utils/__tests__/seedAllowedCommands.spec.ts + +import * as vscode from "vscode" + +import { readCommandListScopes } from "../../core/auto-approval/commandScope" +import { Package } from "../../shared/package" +import { makeUri, makeWorkspaceConfiguration } from "../../test-utils/vscode" +import { seedAllowedCommands } from "../seedAllowedCommands" + +// Scriptable stand-in for `vscode.workspace.workspaceFolders`: the module +// mock's getter reads through this object, so a test can change the open +// folders without re-mocking the module. +const workspaceState = vi.hoisted(() => ({ + workspaceFolders: undefined as { uri: unknown }[] | undefined, +})) + +vi.mock("vscode", () => ({ + workspace: { + get workspaceFolders() { + return workspaceState.workspaceFolders + }, + getConfiguration: vi.fn(), + }, +})) + +interface ConfigDouble { + inspect?: { + key: string + defaultValue?: string[] + globalValue?: string[] + workspaceValue?: string[] + workspaceFolderValue?: string[] + } + effective?: string[] + inspectThrows?: boolean +} + +function mockConfiguration({ inspect, effective = [], inspectThrows = false }: ConfigDouble = {}) { + // Real VS Code reports `workspaceFolderValue` only for a configuration + // looked up against a resource; an unscoped lookup reports no folder + // layer no matter what any folder's settings contain. Scripting that + // split per call is what lets these tests tell a scoped lookup apart + // from an unscoped one. + vi.mocked(vscode.workspace.getConfiguration).mockImplementation( + (_section?: string, scope?: vscode.ConfigurationScope | null) => { + const config = makeWorkspaceConfiguration() + // The shared double is scripted per call so the per-test fixture + // values need no type assertion at the construction site. + vi.mocked(config.get).mockReturnValue(effective) + vi.mocked(config.inspect).mockImplementation(() => { + if (inspectThrows) { + throw new Error("configuration access failed") + } + if (inspect === undefined) { + return undefined + } + // No resource to resolve against => no folder layer, matching + // the real contract for unscoped (and explicit-null) lookups. + return scope === undefined || scope === null ? { ...inspect, workspaceFolderValue: undefined } : inspect + }) + return config + }, + ) +} + +function mockMemento(existing: unknown = undefined) { + const store = new Map() + if (existing !== undefined) { + store.set("allowedCommands", existing) + } + + // Structurally complete Memento double (keys/get/update), so the call + // sites need no type assertion. + return { + keys: vi.fn((): readonly string[] => [...store.keys()]), + get: vi.fn().mockImplementation((key: string) => store.get(key)), + update: vi.fn().mockImplementation(async (key: string, value: unknown) => { + store.set(key, value) + }), + } +} + +describe("seedAllowedCommands", () => { + beforeEach(() => { + vi.clearAllMocks() + workspaceState.workspaceFolders = undefined + }) + + it("leaves global state unset when only a workspace-scoped value exists", async () => { + // A real VS Code get() folds the workspace override into the effective + // value, so this is the leak scenario: reading get() would seed + // ["docker"] (from .vscode/settings.json) into global state. The + // defaultValue mirrors real VS Code, where the declared default is + // always present: the workspace override replaced it deliberately, so + // re-granting it from global state is also forbidden. + mockConfiguration({ + inspect: { + key: "allowedCommands", + defaultValue: ["git log", "git diff", "git show"], + globalValue: undefined, + workspaceValue: ["docker"], + }, + effective: ["docker"], + }) + const globalState = mockMemento() + + await seedAllowedCommands(globalState) + + expect(globalState.update).not.toHaveBeenCalled() + expect(globalState.get("allowedCommands")).toBeUndefined() + }) + + it("leaves global state unset when only a workspace-folder-scoped value exists", async () => { + // A folder override is visible only to a lookup resolved against that + // folder's resource, so this fixture opens a folder and scripts the + // folder layer (which the resource-aware double reports only for the + // scoped call). An unscoped seed would read "no value anywhere" here + // and re-grant the default the folder removed — this test fails on + // that regression. + const folderA = makeUri("/mock/folder-a") + workspaceState.workspaceFolders = [{ uri: folderA }] + mockConfiguration({ + inspect: { + key: "allowedCommands", + defaultValue: ["git log", "git diff", "git show"], + globalValue: undefined, + workspaceFolderValue: ["docker"], + }, + }) + const globalState = mockMemento() + + await seedAllowedCommands(globalState) + + // The folder layer only reaches the seed through a resource-scoped + // lookup, so the seed must request one (same pin as the save path). + expect(vscode.workspace.getConfiguration).toHaveBeenCalledWith(Package.name, folderA) + expect(globalState.update).not.toHaveBeenCalled() + expect(globalState.get("allowedCommands")).toBeUndefined() + }) + + it("resolves the folder layer against the first workspace folder only", async () => { + // Reading any other folder's configuration would silently decide the + // seed from the wrong folder's settings file. + const folderA = makeUri("/mock/folder-a") + const folderB = makeUri("/mock/folder-b") + workspaceState.workspaceFolders = [{ uri: folderA }, { uri: folderB }] + mockConfiguration({ + inspect: { + key: "allowedCommands", + defaultValue: ["git log", "git diff", "git show"], + workspaceFolderValue: ["docker"], + }, + }) + const globalState = mockMemento() + + await seedAllowedCommands(globalState) + + expect(vscode.workspace.getConfiguration).toHaveBeenCalledWith(Package.name, folderA) + expect(vscode.workspace.getConfiguration).not.toHaveBeenCalledWith(Package.name, folderB) + expect(globalState.update).not.toHaveBeenCalled() + }) + + it("treats an explicitly empty workspace value as an override that suppresses the default", async () => { + // Presence is checked with `undefined`, not truthiness: a workspace that + // set the list to [] opted out of the default as deliberately as one + // that set ["docker"]. + mockConfiguration({ + inspect: { + key: "allowedCommands", + defaultValue: ["git log", "git diff", "git show"], + globalValue: undefined, + workspaceValue: [], + }, + }) + const globalState = mockMemento() + + await seedAllowedCommands(globalState) + + expect(globalState.update).not.toHaveBeenCalled() + expect(globalState.get("allowedCommands")).toBeUndefined() + }) + + it("seeds the declared default on a fresh install with no value set at any scope", async () => { + const defaultValue = ["git log", "git diff", "git show"] + mockConfiguration({ + inspect: { key: "allowedCommands", defaultValue }, + effective: defaultValue, + }) + const globalState = mockMemento() + + await seedAllowedCommands(globalState) + + expect(globalState.update).toHaveBeenCalledExactlyOnceWith("allowedCommands", defaultValue) + // Copy, like the global-value path: global state must not share a + // reference with the configuration object. + expect(globalState.update.mock.calls[0][1]).not.toBe(defaultValue) + }) + + it("seeds from the global-scope value when global state is empty", async () => { + mockConfiguration({ inspect: { key: "allowedCommands", globalValue: ["git"] }, effective: ["git"] }) + const globalState = mockMemento() + + await seedAllowedCommands(globalState) + + expect(globalState.update).toHaveBeenCalledExactlyOnceWith("allowedCommands", ["git"]) + expect(globalState.get("allowedCommands")).toEqual(["git"]) + }) + + it("does not overwrite global state that is already populated", async () => { + mockConfiguration({ inspect: { key: "allowedCommands", globalValue: ["git"] }, effective: ["git"] }) + const globalState = mockMemento(["npm"]) + + await seedAllowedCommands(globalState) + + expect(globalState.update).not.toHaveBeenCalled() + expect(globalState.get("allowedCommands")).toEqual(["npm"]) + }) + + it("leaves an explicitly empty global-state list untouched", async () => { + // The guard treats presence, not emptiness: saving [] is a deliberate + // choice to auto-approve nothing, and re-seeding the package default + // would silently re-grant the approvals the user just revoked. + mockConfiguration({ inspect: { key: "allowedCommands", defaultValue: ["git log", "git diff", "git show"] } }) + const globalState = mockMemento([]) + + await seedAllowedCommands(globalState) + + expect(globalState.update).not.toHaveBeenCalled() + expect(globalState.get("allowedCommands")).toEqual([]) + }) + + it("composes a later workspace override as a union with the seeded defaults", async () => { + // Full schedule: a fresh activation seeds the declared default, then a + // workspace override appears in a later activation. The seed + // early-returns because global state already holds a list, so the + // override is never persisted — and the enforcement read composes the + // two layers as a set union. Neither list replaces the other; the + // longest-prefix matcher decides which entry governs a given command. + const defaultValue = ["git log", "git diff", "git show"] + mockConfiguration({ + inspect: { key: "allowedCommands", defaultValue }, + effective: defaultValue, + }) + const globalState = mockMemento() + + await seedAllowedCommands(globalState) + expect(globalState.update).toHaveBeenCalledExactlyOnceWith("allowedCommands", defaultValue) + + // A later activation in a workspace whose settings file sets the list: + // the guard early-returns before any inspection, so no second write. + mockConfiguration({ + inspect: { key: "allowedCommands", defaultValue, workspaceValue: ["docker ps"] }, + effective: ["docker ps"], + }) + + await seedAllowedCommands(globalState) + expect(globalState.update).toHaveBeenCalledTimes(1) + expect(globalState.get("allowedCommands")).toEqual(defaultValue) + + // The enforcement view (ClineProvider.getState) keeps both layers and + // merges them: the override adds entries, it does not replace the + // seeded defaults, and vice versa. + const scopes = readCommandListScopes("allowedCommands", globalState.get("allowedCommands")) + expect(scopes.global).toEqual(defaultValue) + expect(scopes.workspace).toEqual(["docker ps"]) + expect(scopes.merged).toEqual([...defaultValue, "docker ps"]) + }) + + it("inspects the global value under the extension's own section key", async () => { + // The seed must read the extension's own section; asking VS Code about + // a different (or empty) section key would silently never find the + // user's global list. With no folder open there is no resource to + // scope against, so the explicit unscoped call is (section, undefined). + mockConfiguration({ inspect: { key: "allowedCommands", globalValue: ["git"] } }) + // Structurally complete Memento double (keys/get/update), so the call + // site needs no type assertion. + const globalState: vscode.Memento = { + keys: () => [], + get: vi.fn(), + update: vi.fn(), + } + + await seedAllowedCommands(globalState) + + expect(vscode.workspace.getConfiguration).toHaveBeenCalledWith(Package.name, undefined) + const config = vi.mocked(vscode.workspace.getConfiguration).mock.results[0].value + expect(config.inspect).toHaveBeenCalledWith("allowedCommands") + expect(globalState.update).toHaveBeenCalledExactlyOnceWith("allowedCommands", ["git"]) + }) + + it("leaves global state untouched when there is no inspect result at all", async () => { + mockConfiguration({ inspect: undefined, effective: ["git"] }) + const globalState = mockMemento() + + await seedAllowedCommands(globalState) + + expect(globalState.update).not.toHaveBeenCalled() + }) + + it("skips the seed without throwing when configuration access fails", async () => { + mockConfiguration({ inspectThrows: true }) + const globalState = mockMemento() + + await expect(seedAllowedCommands(globalState)).resolves.toBeUndefined() + + expect(globalState.update).not.toHaveBeenCalled() + }) + + it("does not share a reference with the inspected configuration value", async () => { + const globalValue = ["git"] + mockConfiguration({ inspect: { key: "allowedCommands", globalValue } }) + const globalState = mockMemento() + + await seedAllowedCommands(globalState) + + expect(globalState.update).toHaveBeenCalledWith("allowedCommands", expect.arrayContaining(["git"])) + expect(globalState.update.mock.calls[0][1]).not.toBe(globalValue) + }) +}) diff --git a/src/utils/seedAllowedCommands.ts b/src/utils/seedAllowedCommands.ts new file mode 100644 index 0000000000..1afa0bbfcb --- /dev/null +++ b/src/utils/seedAllowedCommands.ts @@ -0,0 +1,69 @@ +import * as vscode from "vscode" + +import { commandListConfiguration } from "../core/auto-approval/commandScope" + +/** + * Seeds the extension's global-state allowlist from the `allowedCommands` + * setting, but only when global state holds no list yet (first run). + * + * The seed reads scoped values via `inspect()` rather than the effective + * `get()` on purpose: `get()` folds in workspace-scoped overrides, so a + * project's `.vscode/settings.json` would otherwise be copied permanently + * into global state the first time the extension activates inside that + * project — a leak that survives closing the project. The lookup goes + * through `commandListConfiguration()`, the folder-scoped accessor shared + * with the command-list read and write paths: only a configuration resolved + * against a resource reports `workspaceFolderValue` at all, so the folder + * layer is visible here exactly where enforcement can see it. In a + * multi-root workspace only the first folder is in scope; with no folder + * open the configuration is unscoped and reports no folder value. + * + * Selection: the global-scope value wins. With nothing set at any scope (a + * fresh install) the declared package default is seeded, because the + * enforcement path reads only global state plus workspace scopes and never + * falls back to that default on its own. A workspace-scoped value is an + * explicit choice and suppresses the default: array settings replace across + * scopes, so seeding it would re-grant the auto-approvals the override + * removed. + * + * @param globalState - The extension global-state memento to seed. + */ +export async function seedAllowedCommands(globalState: vscode.Memento): Promise { + if (globalState.get("allowedCommands")) { + return + } + + // Only the configuration lookup itself is guarded: it can fail (for example + // before the workspace is ready), and skipping the seed then only defers it + // to a later activation. The `inspected` reads below stay outside the + // guarded region so a genuine lookup failure stays distinguishable from an + // absent value rather than being swallowed as one. + let inspected: + | { + globalValue?: string[] + defaultValue?: string[] + workspaceValue?: string[] + workspaceFolderValue?: string[] + } + | undefined + try { + inspected = commandListConfiguration().inspect("allowedCommands") + } catch { + return + } + + // The `undefined` checks, not truthiness: an explicitly empty array still + // counts as an override. + const seedValue = + inspected?.globalValue ?? + (inspected?.workspaceValue === undefined && inspected?.workspaceFolderValue === undefined + ? inspected?.defaultValue + : undefined) + + if (!Array.isArray(seedValue)) { + return + } + + // Copy so global state never shares a reference with the configuration object. + await globalState.update("allowedCommands", [...seedValue]) +}