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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions packages/types/src/global-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,17 @@

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

Check warning on line 66 in packages/types/src/global-settings.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

packages/types/src/global-settings.ts:66: Survived BooleanLiteral mutant (replacement: true). See the job summary for the complete list and resolution guidance.

/**
* Whether commands that are not explicitly auto-approved are automatically
* denied (with a structured reason sent to the model) instead of prompting the
Expand Down Expand Up @@ -180,6 +191,14 @@
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(),
Expand Down
24 changes: 21 additions & 3 deletions packages/types/src/vscode-extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,7 @@ export type ExtensionState = Pick<
| "followupAutoApproveTimeoutMs"
| "allowedCommands"
| "deniedCommands"
| "workspaceCommandListsEnabled"
| "allowedMaxRequests"
| "allowedMaxCost"
| "ttsEnabled"
Expand Down Expand Up @@ -387,6 +388,18 @@ export type ExtensionState = Pick<
toolRequirements?: Record<string, boolean> // Map of tool names to their requirements (e.g. {"apply_diff": true})

cwd?: string // Current working directory

/**
* The user-scope command lists as persisted in extension global state,
* before any workspace-scoped composition. Auto-approval composes the
* pair it enforces from these raw layers plus the folder the task runs
* in, because the merged `allowedCommands`/`deniedCommands` pair follows
* the window's first folder and would judge a task by another root's
* lists. Not user settings: runtime state, written by the provider's
* state bake, never by the settings dialog.
*/
globalAllowedCommands?: string[]
globalDeniedCommands?: string[]
telemetrySetting: TelemetrySetting
telemetryKey?: string
machineId?: string
Expand Down Expand Up @@ -666,9 +679,7 @@ export interface WebviewMessage {
| "getDismissedUpsells"
| "openMarkdownPreview"
| "updateSettings"
| "allowedCommands"
| "getTaskWithAggregatedCosts"
| "deniedCommands"
| "openDebugApiHistory"
| "openDebugUiHistory"
| "downloadErrorDiagnostics"
Expand Down Expand Up @@ -726,7 +737,6 @@ export interface WebviewMessage {
stepIndex?: number
isLaunchAction?: boolean
forceShow?: boolean
commands?: string[]
audioType?: AudioType
serverName?: string
toolName?: string
Expand Down Expand Up @@ -815,6 +825,14 @@ export interface WebviewMessage {
codebaseIndexOpenRouterApiKey?: string
}
updatedSettings?: RooCodeSettings
/**
* The command lists as the settings dialog last displayed them, sent alongside the
* save that rewrites them. The settings file can gain entries while the dialog is
* open, and without this baseline the save path cannot tell those additions apart
* from list entries the user deleted: both look like "in the file, absent from the
* submitted list". Senders that do not display the lists omit it.
*/
commandListBaseline?: { allowedCommands?: string[]; deniedCommands?: string[] }
/** Task configuration applied via `createTask()`. */
taskConfiguration?: RooCodeSettings
// Worktree properties
Expand Down
79 changes: 78 additions & 1 deletion src/__tests__/extension.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -228,15 +229,17 @@ vi.mock("../api/providers/fetchers/modelCache", () => ({

describe("extension.ts", () => {
let mockContext: vscode.ExtensionContext
let globalStateValues: Map<string, unknown>
let settingsUpdatedHandler: ((data: Record<string, never>) => void | Promise<void>) | undefined

beforeEach(() => {
vi.clearAllMocks()

globalStateValues = new Map<string, unknown>()
mockContext = {
extensionPath: "/test/path",
globalState: {
get: vi.fn().mockReturnValue(undefined),
get: vi.fn((key: string) => globalStateValues.get(key)),
update: vi.fn(),
},
subscriptions: [],
Expand Down Expand Up @@ -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()
Expand Down
Loading
Loading