feat(security): add disableAlwaysAllow setting to disable auto-approvals (#21941)

This commit is contained in:
Gal Zahavi
2026-03-13 16:02:09 -07:00
committed by GitHub
parent b0d151bd65
commit b49fc8122d
20 changed files with 352 additions and 63 deletions
+1
View File
@@ -127,6 +127,7 @@ they appear in the UI.
| ------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------- | | ------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------- |
| Tool Sandboxing | `security.toolSandboxing` | Experimental tool-level sandboxing (implementation in progress). | `false` | | Tool Sandboxing | `security.toolSandboxing` | Experimental tool-level sandboxing (implementation in progress). | `false` |
| Disable YOLO Mode | `security.disableYoloMode` | Disable YOLO mode, even if enabled by a flag. | `false` | | Disable YOLO Mode | `security.disableYoloMode` | Disable YOLO mode, even if enabled by a flag. | `false` |
| Disable Always Allow | `security.disableAlwaysAllow` | Disable "Always allow" options in tool confirmation dialogs. | `false` |
| Allow Permanent Tool Approval | `security.enablePermanentToolApproval` | Enable the "Allow for all future sessions" option in tool confirmation dialogs. | `false` | | Allow Permanent Tool Approval | `security.enablePermanentToolApproval` | Enable the "Allow for all future sessions" option in tool confirmation dialogs. | `false` |
| Auto-add to Policy by Default | `security.autoAddToPolicyByDefault` | When enabled, the "Allow for all future sessions" option becomes the default choice for low-risk tools in trusted workspaces. | `false` | | Auto-add to Policy by Default | `security.autoAddToPolicyByDefault` | When enabled, the "Allow for all future sessions" option becomes the default choice for low-risk tools in trusted workspaces. | `false` |
| Blocks extensions from Git | `security.blockGitExtensions` | Blocks installing and loading extensions from Git. | `false` | | Blocks extensions from Git | `security.blockGitExtensions` | Blocks installing and loading extensions from Git. | `false` |
+8 -1
View File
@@ -901,6 +901,12 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `false` - **Default:** `false`
- **Requires restart:** Yes - **Requires restart:** Yes
- **`security.disableAlwaysAllow`** (boolean):
- **Description:** Disable "Always allow" options in tool confirmation
dialogs.
- **Default:** `false`
- **Requires restart:** Yes
- **`security.enablePermanentToolApproval`** (boolean): - **`security.enablePermanentToolApproval`** (boolean):
- **Description:** Enable the "Allow for all future sessions" option in tool - **Description:** Enable the "Allow for all future sessions" option in tool
confirmation dialogs. confirmation dialogs.
@@ -1191,7 +1197,8 @@ their corresponding top-level category object in your `settings.json` file.
#### `admin` #### `admin`
- **`admin.secureModeEnabled`** (boolean): - **`admin.secureModeEnabled`** (boolean):
- **Description:** If true, disallows yolo mode from being used. - **Description:** If true, disallows YOLO mode and "Always allow" options
from being used.
- **Default:** `false` - **Default:** `false`
- **`admin.extensions.enabled`** (boolean): - **`admin.extensions.enabled`** (boolean):
+57
View File
@@ -176,6 +176,7 @@ describe('GeminiAgent', () => {
getGemini31LaunchedSync: vi.fn().mockReturnValue(false), getGemini31LaunchedSync: vi.fn().mockReturnValue(false),
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false), getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
getCheckpointingEnabled: vi.fn().mockReturnValue(false), getCheckpointingEnabled: vi.fn().mockReturnValue(false),
getDisableAlwaysAllow: vi.fn().mockReturnValue(false),
} as unknown as Mocked<Awaited<ReturnType<typeof loadCliConfig>>>; } as unknown as Mocked<Awaited<ReturnType<typeof loadCliConfig>>>;
mockSettings = { mockSettings = {
merged: { merged: {
@@ -654,6 +655,7 @@ describe('Session', () => {
getCheckpointingEnabled: vi.fn().mockReturnValue(false), getCheckpointingEnabled: vi.fn().mockReturnValue(false),
getGitService: vi.fn().mockResolvedValue({} as GitService), getGitService: vi.fn().mockResolvedValue({} as GitService),
waitForMcpInit: vi.fn(), waitForMcpInit: vi.fn(),
getDisableAlwaysAllow: vi.fn().mockReturnValue(false),
} as unknown as Mocked<Config>; } as unknown as Mocked<Config>;
mockConnection = { mockConnection = {
sessionUpdate: vi.fn(), sessionUpdate: vi.fn(),
@@ -947,6 +949,61 @@ describe('Session', () => {
); );
}); });
it('should exclude always allow options when disableAlwaysAllow is true', async () => {
mockConfig.getDisableAlwaysAllow = vi.fn().mockReturnValue(true);
const confirmationDetails = {
type: 'info',
onConfirm: vi.fn(),
};
mockTool.build.mockReturnValue({
getDescription: () => 'Test Tool',
toolLocations: () => [],
shouldConfirmExecute: vi.fn().mockResolvedValue(confirmationDetails),
execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }),
});
mockConnection.requestPermission.mockResolvedValue({
outcome: {
outcome: 'selected',
optionId: ToolConfirmationOutcome.ProceedOnce,
},
});
const stream1 = createMockStream([
{
type: StreamEventType.CHUNK,
value: {
functionCalls: [{ name: 'test_tool', args: {} }],
},
},
]);
const stream2 = createMockStream([
{
type: StreamEventType.CHUNK,
value: { candidates: [] },
},
]);
mockChat.sendMessageStream
.mockResolvedValueOnce(stream1)
.mockResolvedValueOnce(stream2);
await session.prompt({
sessionId: 'session-1',
prompt: [{ type: 'text', text: 'Call tool' }],
});
expect(mockConnection.requestPermission).toHaveBeenCalledWith(
expect.objectContaining({
options: expect.not.arrayContaining([
expect.objectContaining({
optionId: ToolConfirmationOutcome.ProceedAlways,
}),
]),
}),
);
});
it('should use filePath for ACP diff content in permission request', async () => { it('should use filePath for ACP diff content in permission request', async () => {
const confirmationDetails = { const confirmationDetails = {
type: 'edit', type: 'edit',
+54 -38
View File
@@ -908,7 +908,7 @@ export class Session {
const params: acp.RequestPermissionRequest = { const params: acp.RequestPermissionRequest = {
sessionId: this.id, sessionId: this.id,
options: toPermissionOptions(confirmationDetails), options: toPermissionOptions(confirmationDetails, this.config),
toolCall: { toolCall: {
toolCallId: callId, toolCallId: callId,
status: 'pending', status: 'pending',
@@ -1457,60 +1457,76 @@ const basicPermissionOptions = [
function toPermissionOptions( function toPermissionOptions(
confirmation: ToolCallConfirmationDetails, confirmation: ToolCallConfirmationDetails,
config: Config,
): acp.PermissionOption[] { ): acp.PermissionOption[] {
switch (confirmation.type) { const disableAlwaysAllow = config.getDisableAlwaysAllow();
case 'edit': const options: acp.PermissionOption[] = [];
return [
{ if (!disableAlwaysAllow) {
switch (confirmation.type) {
case 'edit':
options.push({
optionId: ToolConfirmationOutcome.ProceedAlways, optionId: ToolConfirmationOutcome.ProceedAlways,
name: 'Allow All Edits', name: 'Allow All Edits',
kind: 'allow_always', kind: 'allow_always',
}, });
...basicPermissionOptions, break;
]; case 'exec':
case 'exec': options.push({
return [
{
optionId: ToolConfirmationOutcome.ProceedAlways, optionId: ToolConfirmationOutcome.ProceedAlways,
name: `Always Allow ${confirmation.rootCommand}`, name: `Always Allow ${confirmation.rootCommand}`,
kind: 'allow_always', kind: 'allow_always',
}, });
...basicPermissionOptions, break;
]; case 'mcp':
case 'mcp': options.push(
return [ {
{ optionId: ToolConfirmationOutcome.ProceedAlwaysServer,
optionId: ToolConfirmationOutcome.ProceedAlwaysServer, name: `Always Allow ${confirmation.serverName}`,
name: `Always Allow ${confirmation.serverName}`, kind: 'allow_always',
kind: 'allow_always', },
}, {
{ optionId: ToolConfirmationOutcome.ProceedAlwaysTool,
optionId: ToolConfirmationOutcome.ProceedAlwaysTool, name: `Always Allow ${confirmation.toolName}`,
name: `Always Allow ${confirmation.toolName}`, kind: 'allow_always',
kind: 'allow_always', },
}, );
...basicPermissionOptions, break;
]; case 'info':
case 'info': options.push({
return [
{
optionId: ToolConfirmationOutcome.ProceedAlways, optionId: ToolConfirmationOutcome.ProceedAlways,
name: `Always Allow`, name: `Always Allow`,
kind: 'allow_always', kind: 'allow_always',
}, });
...basicPermissionOptions, break;
]; case 'ask_user':
case 'exit_plan_mode':
// askuser and exit_plan_mode don't need "always allow" options
break;
default:
// No "always allow" options for other types
break;
}
}
options.push(...basicPermissionOptions);
// Exhaustive check
switch (confirmation.type) {
case 'edit':
case 'exec':
case 'mcp':
case 'info':
case 'ask_user': case 'ask_user':
// askuser doesn't need "always allow" options since it's asking questions
return [...basicPermissionOptions];
case 'exit_plan_mode': case 'exit_plan_mode':
// exit_plan_mode doesn't need "always allow" options since it's a plan approval flow break;
return [...basicPermissionOptions];
default: { default: {
const unreachable: never = confirmation; const unreachable: never = confirmation;
throw new Error(`Unexpected: ${unreachable}`); throw new Error(`Unexpected: ${unreachable}`);
} }
} }
return options;
} }
/** /**
+3
View File
@@ -785,6 +785,9 @@ export async function loadCliConfig(
approvalMode, approvalMode,
disableYoloMode: disableYoloMode:
settings.security?.disableYoloMode || settings.admin?.secureModeEnabled, settings.security?.disableYoloMode || settings.admin?.secureModeEnabled,
disableAlwaysAllow:
settings.security?.disableAlwaysAllow ||
settings.admin?.secureModeEnabled,
showMemoryUsage: settings.ui?.showMemoryUsage || false, showMemoryUsage: settings.ui?.showMemoryUsage || false,
accessibility: { accessibility: {
...settings.ui?.accessibility, ...settings.ui?.accessibility,
+3
View File
@@ -63,6 +63,9 @@ export async function createPolicyEngineConfig(
policyPaths: settings.policyPaths, policyPaths: settings.policyPaths,
adminPolicyPaths: settings.adminPolicyPaths, adminPolicyPaths: settings.adminPolicyPaths,
workspacePoliciesDir, workspacePoliciesDir,
disableAlwaysAllow:
settings.security?.disableAlwaysAllow ||
settings.admin?.secureModeEnabled,
}; };
return createCorePolicyEngineConfig(policySettings, approvalMode); return createCorePolicyEngineConfig(policySettings, approvalMode);
+4
View File
@@ -524,16 +524,19 @@ describe('Settings Loading and Merging', () => {
const userSettingsContent = { const userSettingsContent = {
security: { security: {
disableYoloMode: false, disableYoloMode: false,
disableAlwaysAllow: false,
}, },
}; };
const workspaceSettingsContent = { const workspaceSettingsContent = {
security: { security: {
disableYoloMode: false, // This should be ignored disableYoloMode: false, // This should be ignored
disableAlwaysAllow: false, // This should be ignored
}, },
}; };
const systemSettingsContent = { const systemSettingsContent = {
security: { security: {
disableYoloMode: true, disableYoloMode: true,
disableAlwaysAllow: true,
}, },
}; };
@@ -551,6 +554,7 @@ describe('Settings Loading and Merging', () => {
const settings = loadSettings(MOCK_WORKSPACE_DIR); const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.security?.disableYoloMode).toBe(true); // System setting should be used expect(settings.merged.security?.disableYoloMode).toBe(true); // System setting should be used
expect(settings.merged.security?.disableAlwaysAllow).toBe(true); // System setting should be used
}); });
it.each([ it.each([
+12 -1
View File
@@ -1541,6 +1541,16 @@ const SETTINGS_SCHEMA = {
description: 'Disable YOLO mode, even if enabled by a flag.', description: 'Disable YOLO mode, even if enabled by a flag.',
showInDialog: true, showInDialog: true,
}, },
disableAlwaysAllow: {
type: 'boolean',
label: 'Disable Always Allow',
category: 'Security',
requiresRestart: true,
default: false,
description:
'Disable "Always allow" options in tool confirmation dialogs.',
showInDialog: true,
},
enablePermanentToolApproval: { enablePermanentToolApproval: {
type: 'boolean', type: 'boolean',
label: 'Allow Permanent Tool Approval', label: 'Allow Permanent Tool Approval',
@@ -2267,7 +2277,8 @@ const SETTINGS_SCHEMA = {
category: 'Admin', category: 'Admin',
requiresRestart: false, requiresRestart: false,
default: false, default: false,
description: 'If true, disallows yolo mode from being used.', description:
'If true, disallows YOLO mode and "Always allow" options from being used.',
showInDialog: false, showInDialog: false,
mergeStrategy: MergeStrategy.REPLACE, mergeStrategy: MergeStrategy.REPLACE,
}, },
@@ -122,6 +122,7 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
getBannerTextNoCapacityIssues: vi.fn().mockResolvedValue(''), getBannerTextNoCapacityIssues: vi.fn().mockResolvedValue(''),
getBannerTextCapacityIssues: vi.fn().mockResolvedValue(''), getBannerTextCapacityIssues: vi.fn().mockResolvedValue(''),
isInteractiveShellEnabled: vi.fn().mockReturnValue(false), isInteractiveShellEnabled: vi.fn().mockReturnValue(false),
getDisableAlwaysAllow: vi.fn().mockReturnValue(false),
isSkillsSupportEnabled: vi.fn().mockReturnValue(false), isSkillsSupportEnabled: vi.fn().mockReturnValue(false),
reloadSkills: vi.fn().mockResolvedValue(undefined), reloadSkills: vi.fn().mockResolvedValue(undefined),
reloadAgents: vi.fn().mockResolvedValue(undefined), reloadAgents: vi.fn().mockResolvedValue(undefined),
@@ -42,6 +42,7 @@ describe('ToolConfirmationQueue', () => {
const mockConfig = { const mockConfig = {
isTrustedFolder: () => true, isTrustedFolder: () => true,
getIdeMode: () => false, getIdeMode: () => false,
getDisableAlwaysAllow: () => false,
getModel: () => 'gemini-pro', getModel: () => 'gemini-pro',
getDebugMode: () => false, getDebugMode: () => false,
getTargetDir: () => '/mock/target/dir', getTargetDir: () => '/mock/target/dir',
@@ -21,6 +21,7 @@ describe('ToolConfirmationMessage Redirection', () => {
const mockConfig = { const mockConfig = {
isTrustedFolder: () => true, isTrustedFolder: () => true,
getIdeMode: () => false, getIdeMode: () => false,
getDisableAlwaysAllow: () => false,
} as unknown as Config; } as unknown as Config;
it('should display redirection warning and tip for redirected commands', async () => { it('should display redirection warning and tip for redirected commands', async () => {
@@ -37,6 +37,7 @@ describe('ToolConfirmationMessage', () => {
const mockConfig = { const mockConfig = {
isTrustedFolder: () => true, isTrustedFolder: () => true,
getIdeMode: () => false, getIdeMode: () => false,
getDisableAlwaysAllow: () => false,
} as unknown as Config; } as unknown as Config;
it('should not display urls if prompt and url are the same', async () => { it('should not display urls if prompt and url are the same', async () => {
@@ -331,8 +332,8 @@ describe('ToolConfirmationMessage', () => {
const mockConfig = { const mockConfig = {
isTrustedFolder: () => true, isTrustedFolder: () => true,
getIdeMode: () => false, getIdeMode: () => false,
getDisableAlwaysAllow: () => false,
} as unknown as Config; } as unknown as Config;
const { lastFrame, waitUntilReady, unmount } = renderWithProviders( const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ToolConfirmationMessage <ToolConfirmationMessage
callId="test-call-id" callId="test-call-id"
@@ -353,6 +354,7 @@ describe('ToolConfirmationMessage', () => {
const mockConfig = { const mockConfig = {
isTrustedFolder: () => false, isTrustedFolder: () => false,
getIdeMode: () => false, getIdeMode: () => false,
getDisableAlwaysAllow: () => false,
} as unknown as Config; } as unknown as Config;
const { lastFrame, waitUntilReady, unmount } = renderWithProviders( const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
@@ -388,8 +390,8 @@ describe('ToolConfirmationMessage', () => {
const mockConfig = { const mockConfig = {
isTrustedFolder: () => true, isTrustedFolder: () => true,
getIdeMode: () => false, getIdeMode: () => false,
getDisableAlwaysAllow: () => false,
} as unknown as Config; } as unknown as Config;
const { lastFrame, waitUntilReady, unmount } = renderWithProviders( const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ToolConfirmationMessage <ToolConfirmationMessage
callId="test-call-id" callId="test-call-id"
@@ -415,8 +417,8 @@ describe('ToolConfirmationMessage', () => {
const mockConfig = { const mockConfig = {
isTrustedFolder: () => true, isTrustedFolder: () => true,
getIdeMode: () => false, getIdeMode: () => false,
getDisableAlwaysAllow: () => false,
} as unknown as Config; } as unknown as Config;
const { lastFrame, waitUntilReady, unmount } = renderWithProviders( const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ToolConfirmationMessage <ToolConfirmationMessage
callId="test-call-id" callId="test-call-id"
@@ -457,8 +459,8 @@ describe('ToolConfirmationMessage', () => {
const mockConfig = { const mockConfig = {
isTrustedFolder: () => true, isTrustedFolder: () => true,
getIdeMode: () => false, getIdeMode: () => false,
getDisableAlwaysAllow: () => false,
} as unknown as Config; } as unknown as Config;
vi.mocked(useToolActions).mockReturnValue({ vi.mocked(useToolActions).mockReturnValue({
confirm: vi.fn(), confirm: vi.fn(),
cancel: vi.fn(), cancel: vi.fn(),
@@ -485,8 +487,8 @@ describe('ToolConfirmationMessage', () => {
const mockConfig = { const mockConfig = {
isTrustedFolder: () => true, isTrustedFolder: () => true,
getIdeMode: () => true, getIdeMode: () => true,
getDisableAlwaysAllow: () => false,
} as unknown as Config; } as unknown as Config;
vi.mocked(useToolActions).mockReturnValue({ vi.mocked(useToolActions).mockReturnValue({
confirm: vi.fn(), confirm: vi.fn(),
cancel: vi.fn(), cancel: vi.fn(),
@@ -513,8 +515,8 @@ describe('ToolConfirmationMessage', () => {
const mockConfig = { const mockConfig = {
isTrustedFolder: () => true, isTrustedFolder: () => true,
getIdeMode: () => true, getIdeMode: () => true,
getDisableAlwaysAllow: () => false,
} as unknown as Config; } as unknown as Config;
vi.mocked(useToolActions).mockReturnValue({ vi.mocked(useToolActions).mockReturnValue({
confirm: vi.fn(), confirm: vi.fn(),
cancel: vi.fn(), cancel: vi.fn(),
@@ -86,12 +86,14 @@ export const ToolConfirmationMessage: React.FC<
const settings = useSettings(); const settings = useSettings();
const allowPermanentApproval = const allowPermanentApproval =
settings.merged.security.enablePermanentToolApproval; settings.merged.security.enablePermanentToolApproval &&
!config.getDisableAlwaysAllow();
const handlesOwnUI = const handlesOwnUI =
confirmationDetails.type === 'ask_user' || confirmationDetails.type === 'ask_user' ||
confirmationDetails.type === 'exit_plan_mode'; confirmationDetails.type === 'exit_plan_mode';
const isTrustedFolder = config.isTrustedFolder(); const isTrustedFolder =
config.isTrustedFolder() && !config.getDisableAlwaysAllow();
const handleConfirm = useCallback( const handleConfirm = useCallback(
(outcome: ToolConfirmationOutcome, payload?: ToolConfirmationPayload) => { (outcome: ToolConfirmationOutcome, payload?: ToolConfirmationPayload) => {
+8
View File
@@ -606,6 +606,7 @@ export interface ConfigParameters {
recordResponses?: string; recordResponses?: string;
ptyInfo?: string; ptyInfo?: string;
disableYoloMode?: boolean; disableYoloMode?: boolean;
disableAlwaysAllow?: boolean;
rawOutput?: boolean; rawOutput?: boolean;
acceptRawOutputRisk?: boolean; acceptRawOutputRisk?: boolean;
modelConfigServiceConfig?: ModelConfigServiceConfig; modelConfigServiceConfig?: ModelConfigServiceConfig;
@@ -805,6 +806,7 @@ export class Config implements McpContext, AgentLoopContext {
readonly fakeResponses?: string; readonly fakeResponses?: string;
readonly recordResponses?: string; readonly recordResponses?: string;
private readonly disableYoloMode: boolean; private readonly disableYoloMode: boolean;
private readonly disableAlwaysAllow: boolean;
private readonly rawOutput: boolean; private readonly rawOutput: boolean;
private readonly acceptRawOutputRisk: boolean; private readonly acceptRawOutputRisk: boolean;
private pendingIncludeDirectories: string[]; private pendingIncludeDirectories: string[];
@@ -1045,11 +1047,13 @@ export class Config implements McpContext, AgentLoopContext {
this.policyUpdateConfirmationRequest = this.policyUpdateConfirmationRequest =
params.policyUpdateConfirmationRequest; params.policyUpdateConfirmationRequest;
this.disableAlwaysAllow = params.disableAlwaysAllow ?? false;
this.policyEngine = new PolicyEngine( this.policyEngine = new PolicyEngine(
{ {
...params.policyEngineConfig, ...params.policyEngineConfig,
approvalMode: approvalMode:
params.approvalMode ?? params.policyEngineConfig?.approvalMode, params.approvalMode ?? params.policyEngineConfig?.approvalMode,
disableAlwaysAllow: this.disableAlwaysAllow,
}, },
checkerRunner, checkerRunner,
); );
@@ -2203,6 +2207,10 @@ export class Config implements McpContext, AgentLoopContext {
return this.disableYoloMode || !this.isTrustedFolder(); return this.disableYoloMode || !this.isTrustedFolder();
} }
getDisableAlwaysAllow(): boolean {
return this.disableAlwaysAllow;
}
getRawOutput(): boolean { getRawOutput(): boolean {
return this.rawOutput; return this.rawOutput;
} }
+2 -13
View File
@@ -16,6 +16,7 @@ import {
type PolicyRule, type PolicyRule,
type PolicySettings, type PolicySettings,
type SafetyCheckerRule, type SafetyCheckerRule,
ALWAYS_ALLOW_PRIORITY_OFFSET,
} from './types.js'; } from './types.js';
import type { PolicyEngine } from './policy-engine.js'; import type { PolicyEngine } from './policy-engine.js';
import { loadPoliciesFromToml, type PolicyFileError } from './toml-loader.js'; import { loadPoliciesFromToml, type PolicyFileError } from './toml-loader.js';
@@ -66,19 +67,6 @@ export const WORKSPACE_POLICY_TIER = 3;
export const USER_POLICY_TIER = 4; export const USER_POLICY_TIER = 4;
export const ADMIN_POLICY_TIER = 5; export const ADMIN_POLICY_TIER = 5;
/**
* The fractional priority of "Always allow" rules (e.g., 950/1000).
* Higher fraction within a tier wins.
*/
export const ALWAYS_ALLOW_PRIORITY_FRACTION = 950;
/**
* The fractional priority offset for "Always allow" rules (e.g., 0.95).
* This ensures consistency between in-memory rules and persisted rules.
*/
export const ALWAYS_ALLOW_PRIORITY_OFFSET =
ALWAYS_ALLOW_PRIORITY_FRACTION / 1000;
// Specific priority offsets and derived priorities for dynamic/settings rules. // Specific priority offsets and derived priorities for dynamic/settings rules.
export const MCP_EXCLUDED_PRIORITY = USER_POLICY_TIER + 0.9; export const MCP_EXCLUDED_PRIORITY = USER_POLICY_TIER + 0.9;
@@ -535,6 +523,7 @@ export async function createPolicyEngineConfig(
checkers, checkers,
defaultDecision: PolicyDecision.ASK_USER, defaultDecision: PolicyDecision.ASK_USER,
approvalMode, approvalMode,
disableAlwaysAllow: settings.disableAlwaysAllow,
}; };
} }
@@ -14,6 +14,7 @@ import {
InProcessCheckerType, InProcessCheckerType,
ApprovalMode, ApprovalMode,
PRIORITY_SUBAGENT_TOOL, PRIORITY_SUBAGENT_TOOL,
ALWAYS_ALLOW_PRIORITY_FRACTION,
} from './types.js'; } from './types.js';
import type { FunctionCall } from '@google/genai'; import type { FunctionCall } from '@google/genai';
import { SafetyCheckDecision } from '../safety/protocol.js'; import { SafetyCheckDecision } from '../safety/protocol.js';
@@ -3229,4 +3230,116 @@ describe('PolicyEngine', () => {
expect(hookCheckers[1].priority).toBe(5); expect(hookCheckers[1].priority).toBe(5);
}); });
}); });
describe('disableAlwaysAllow', () => {
it('should ignore "Always Allow" rules when disableAlwaysAllow is true', async () => {
const alwaysAllowRule: PolicyRule = {
toolName: 'test-tool',
decision: PolicyDecision.ALLOW,
priority: 3 + ALWAYS_ALLOW_PRIORITY_FRACTION / 1000, // 3.95
source: 'Dynamic (Confirmed)',
};
const engine = new PolicyEngine({
rules: [alwaysAllowRule],
disableAlwaysAllow: true,
defaultDecision: PolicyDecision.ASK_USER,
});
const result = await engine.check(
{ name: 'test-tool', args: {} },
undefined,
);
expect(result.decision).toBe(PolicyDecision.ASK_USER);
});
it('should respect "Always Allow" rules when disableAlwaysAllow is false', async () => {
const alwaysAllowRule: PolicyRule = {
toolName: 'test-tool',
decision: PolicyDecision.ALLOW,
priority: 3 + ALWAYS_ALLOW_PRIORITY_FRACTION / 1000, // 3.95
source: 'Dynamic (Confirmed)',
};
const engine = new PolicyEngine({
rules: [alwaysAllowRule],
disableAlwaysAllow: false,
defaultDecision: PolicyDecision.ASK_USER,
});
const result = await engine.check(
{ name: 'test-tool', args: {} },
undefined,
);
expect(result.decision).toBe(PolicyDecision.ALLOW);
});
it('should NOT ignore other rules when disableAlwaysAllow is true', async () => {
const normalRule: PolicyRule = {
toolName: 'test-tool',
decision: PolicyDecision.ALLOW,
priority: 1.5, // Not a .950 fraction
source: 'Normal Rule',
};
const engine = new PolicyEngine({
rules: [normalRule],
disableAlwaysAllow: true,
defaultDecision: PolicyDecision.ASK_USER,
});
const result = await engine.check(
{ name: 'test-tool', args: {} },
undefined,
);
expect(result.decision).toBe(PolicyDecision.ALLOW);
});
});
describe('getExcludedTools with disableAlwaysAllow', () => {
it('should exclude tool if an Always Allow rule says ALLOW but disableAlwaysAllow is true (falling back to DENY)', async () => {
// To prove the ALWAYS_ALLOW rule is ignored, we set the default decision to DENY.
// If the rule was honored, the decision would be ALLOW (tool not excluded).
// Since it's ignored, it falls back to the default DENY (tool is excluded).
// In the real app, it usually falls back to ASK_USER, but ASK_USER also doesn't
// exclude the tool, so we use DENY here purely to make the test observable.
const alwaysAllowRule: PolicyRule = {
toolName: 'test-tool',
decision: PolicyDecision.ALLOW,
priority: 3 + ALWAYS_ALLOW_PRIORITY_FRACTION / 1000,
};
const engine = new PolicyEngine({
rules: [alwaysAllowRule],
disableAlwaysAllow: true,
defaultDecision: PolicyDecision.DENY,
});
const excluded = engine.getExcludedTools(
undefined,
new Set(['test-tool']),
);
expect(excluded.has('test-tool')).toBe(true);
});
it('should NOT exclude tool if ALWAYS_ALLOW is enabled and rule says ALLOW', async () => {
const alwaysAllowRule: PolicyRule = {
toolName: 'test-tool',
decision: PolicyDecision.ALLOW,
priority: 3 + ALWAYS_ALLOW_PRIORITY_FRACTION / 1000,
};
const engine = new PolicyEngine({
rules: [alwaysAllowRule],
disableAlwaysAllow: false,
defaultDecision: PolicyDecision.DENY,
});
const excluded = engine.getExcludedTools(
undefined,
new Set(['test-tool']),
);
expect(excluded.has('test-tool')).toBe(false);
});
});
}); });
+18
View File
@@ -13,6 +13,7 @@ import {
type HookCheckerRule, type HookCheckerRule,
ApprovalMode, ApprovalMode,
type CheckResult, type CheckResult,
ALWAYS_ALLOW_PRIORITY_FRACTION,
} from './types.js'; } from './types.js';
import { stableStringify } from './stable-stringify.js'; import { stableStringify } from './stable-stringify.js';
import { debugLogger } from '../utils/debugLogger.js'; import { debugLogger } from '../utils/debugLogger.js';
@@ -154,6 +155,7 @@ export class PolicyEngine {
private hookCheckers: HookCheckerRule[]; private hookCheckers: HookCheckerRule[];
private readonly defaultDecision: PolicyDecision; private readonly defaultDecision: PolicyDecision;
private readonly nonInteractive: boolean; private readonly nonInteractive: boolean;
private readonly disableAlwaysAllow: boolean;
private readonly checkerRunner?: CheckerRunner; private readonly checkerRunner?: CheckerRunner;
private approvalMode: ApprovalMode; private approvalMode: ApprovalMode;
@@ -169,6 +171,7 @@ export class PolicyEngine {
); );
this.defaultDecision = config.defaultDecision ?? PolicyDecision.ASK_USER; this.defaultDecision = config.defaultDecision ?? PolicyDecision.ASK_USER;
this.nonInteractive = config.nonInteractive ?? false; this.nonInteractive = config.nonInteractive ?? false;
this.disableAlwaysAllow = config.disableAlwaysAllow ?? false;
this.checkerRunner = checkerRunner; this.checkerRunner = checkerRunner;
this.approvalMode = config.approvalMode ?? ApprovalMode.DEFAULT; this.approvalMode = config.approvalMode ?? ApprovalMode.DEFAULT;
} }
@@ -187,6 +190,13 @@ export class PolicyEngine {
return this.approvalMode; return this.approvalMode;
} }
private isAlwaysAllowRule(rule: PolicyRule): boolean {
return (
rule.priority !== undefined &&
Math.round((rule.priority % 1) * 1000) === ALWAYS_ALLOW_PRIORITY_FRACTION
);
}
private shouldDowngradeForRedirection( private shouldDowngradeForRedirection(
command: string, command: string,
allowRedirection?: boolean, allowRedirection?: boolean,
@@ -422,6 +432,10 @@ export class PolicyEngine {
} }
for (const rule of this.rules) { for (const rule of this.rules) {
if (this.disableAlwaysAllow && this.isAlwaysAllowRule(rule)) {
continue;
}
const match = toolCallsToTry.some((tc) => const match = toolCallsToTry.some((tc) =>
ruleMatches( ruleMatches(
rule, rule,
@@ -684,6 +698,10 @@ export class PolicyEngine {
// Evaluate rules in priority order (they are already sorted in constructor) // Evaluate rules in priority order (they are already sorted in constructor)
for (const rule of this.rules) { for (const rule of this.rules) {
if (this.disableAlwaysAllow && this.isAlwaysAllowRule(rule)) {
continue;
}
// Create a copy of the rule without argsPattern to see if it targets the tool // Create a copy of the rule without argsPattern to see if it targets the tool
// regardless of the runtime arguments it might receive. // regardless of the runtime arguments it might receive.
const ruleWithoutArgs: PolicyRule = { ...rule, argsPattern: undefined }; const ruleWithoutArgs: PolicyRule = { ...rule, argsPattern: undefined };
+19
View File
@@ -285,6 +285,11 @@ export interface PolicyEngineConfig {
*/ */
nonInteractive?: boolean; nonInteractive?: boolean;
/**
* Whether to ignore "Always Allow" rules.
*/
disableAlwaysAllow?: boolean;
/** /**
* Whether to allow hooks to execute. * Whether to allow hooks to execute.
* When false, all hooks are denied. * When false, all hooks are denied.
@@ -314,6 +319,7 @@ export interface PolicySettings {
// Admin provided policies that will supplement the ADMIN level policies // Admin provided policies that will supplement the ADMIN level policies
adminPolicyPaths?: string[]; adminPolicyPaths?: string[];
workspacePoliciesDir?: string; workspacePoliciesDir?: string;
disableAlwaysAllow?: boolean;
} }
export interface CheckResult { export interface CheckResult {
@@ -326,3 +332,16 @@ export interface CheckResult {
* Effective priority matching Tier 1 (Default) read-only tools. * Effective priority matching Tier 1 (Default) read-only tools.
*/ */
export const PRIORITY_SUBAGENT_TOOL = 1.05; export const PRIORITY_SUBAGENT_TOOL = 1.05;
/**
* The fractional priority of "Always allow" rules (e.g., 950/1000).
* Higher fraction within a tier wins.
*/
export const ALWAYS_ALLOW_PRIORITY_FRACTION = 950;
/**
* The fractional priority offset for "Always allow" rules (e.g., 0.95).
* This ensures consistency between in-memory rules and persisted rules.
*/
export const ALWAYS_ALLOW_PRIORITY_OFFSET =
ALWAYS_ALLOW_PRIORITY_FRACTION / 1000;
@@ -102,6 +102,32 @@ describe('policy.ts', () => {
); );
}); });
it('should respect disableAlwaysAllow from config', async () => {
const mockPolicyEngine = {
check: vi.fn().mockResolvedValue({ decision: PolicyDecision.ALLOW }),
} as unknown as Mocked<PolicyEngine>;
const mockConfig = {
getPolicyEngine: vi.fn().mockReturnValue(mockPolicyEngine),
getDisableAlwaysAllow: vi.fn().mockReturnValue(true),
} as unknown as Mocked<Config>;
(mockConfig as unknown as { config: Config }).config =
mockConfig as Config;
const toolCall = {
request: { name: 'test-tool', args: {} },
tool: { name: 'test-tool' },
} as ValidatingToolCall;
// Note: checkPolicy calls config.getPolicyEngine().check()
// The PolicyEngine itself is already configured with disableAlwaysAllow
// when created in Config. Here we are just verifying that checkPolicy
// doesn't somehow bypass it.
await checkPolicy(toolCall, mockConfig);
expect(mockPolicyEngine.check).toHaveBeenCalled();
});
it('should throw if ASK_USER is returned in non-interactive mode', async () => { it('should throw if ASK_USER is returned in non-interactive mode', async () => {
const mockPolicyEngine = { const mockPolicyEngine = {
check: vi.fn().mockResolvedValue({ decision: PolicyDecision.ASK_USER }), check: vi.fn().mockResolvedValue({ decision: PolicyDecision.ASK_USER }),
+9 -2
View File
@@ -1495,6 +1495,13 @@
"default": false, "default": false,
"type": "boolean" "type": "boolean"
}, },
"disableAlwaysAllow": {
"title": "Disable Always Allow",
"description": "Disable \"Always allow\" options in tool confirmation dialogs.",
"markdownDescription": "Disable \"Always allow\" options in tool confirmation dialogs.\n\n- Category: `Security`\n- Requires restart: `yes`\n- Default: `false`",
"default": false,
"type": "boolean"
},
"enablePermanentToolApproval": { "enablePermanentToolApproval": {
"title": "Allow Permanent Tool Approval", "title": "Allow Permanent Tool Approval",
"description": "Enable the \"Allow for all future sessions\" option in tool confirmation dialogs.", "description": "Enable the \"Allow for all future sessions\" option in tool confirmation dialogs.",
@@ -2027,8 +2034,8 @@
"properties": { "properties": {
"secureModeEnabled": { "secureModeEnabled": {
"title": "Secure Mode Enabled", "title": "Secure Mode Enabled",
"description": "If true, disallows yolo mode from being used.", "description": "If true, disallows YOLO mode and \"Always allow\" options from being used.",
"markdownDescription": "If true, disallows yolo mode from being used.\n\n- Category: `Admin`\n- Requires restart: `no`\n- Default: `false`", "markdownDescription": "If true, disallows YOLO mode and \"Always allow\" options from being used.\n\n- Category: `Admin`\n- Requires restart: `no`\n- Default: `false`",
"default": false, "default": false,
"type": "boolean" "type": "boolean"
}, },