Skip to content
Closed
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
1 change: 1 addition & 0 deletions packages/cli/src/config/policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export async function createPolicyEngineConfig(
disableAlwaysAllow:
settings.security?.disableAlwaysAllow ||
settings.admin?.secureModeEnabled,
trustReadOnlyHintInPlanMode: settings.general?.plan?.trustReadOnlyHint,
};

return createCorePolicyEngineConfig(
Expand Down
10 changes: 10 additions & 0 deletions packages/cli/src/config/settingsSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,16 @@ const SETTINGS_SCHEMA = {
'Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pro for the planning phase and Flash for the implementation phase.',
showInDialog: true,
},
trustReadOnlyHint: {
type: 'boolean',
label: 'Trust MCP readOnlyHint in Plan Mode',
category: 'General',
requiresRestart: true,
default: false,
description:
'When true, MCP tools declared with `readOnlyHint: true` are allowed silently in Plan Mode instead of prompting on every call. Disabled by default — Plan Mode follows secure-by-default and prompts for tools whose read-only nature is asserted only by external metadata. Enable only for MCP servers you fully trust.',
showInDialog: true,
},
},
},
retryFetchErrors: {
Expand Down
45 changes: 45 additions & 0 deletions packages/core/src/policy/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -779,6 +779,51 @@ modes = ["plan"]

feedbackSpy.mockRestore();
});

describe('trustReadOnlyHintInPlanMode setting', () => {
it('should NOT inject the trust-readOnlyHint rule when setting is unset', async () => {
const config = await createPolicyEngineConfig(
{},
ApprovalMode.PLAN,
MOCK_DEFAULT_DIR,
);
const trustRule = config.rules?.find(
(r) => r.source === 'Settings (general.plan.trustReadOnlyHint)',
);
expect(trustRule).toBeUndefined();
});

it('should NOT inject the rule when setting is explicitly false', async () => {
const config = await createPolicyEngineConfig(
{ trustReadOnlyHintInPlanMode: false },
ApprovalMode.PLAN,
MOCK_DEFAULT_DIR,
);
const trustRule = config.rules?.find(
(r) => r.source === 'Settings (general.plan.trustReadOnlyHint)',
);
expect(trustRule).toBeUndefined();
});

it('should inject a high-priority ALLOW rule when setting is true', async () => {
const config = await createPolicyEngineConfig(
{ trustReadOnlyHintInPlanMode: true },
ApprovalMode.PLAN,
MOCK_DEFAULT_DIR,
);
const trustRule = config.rules?.find(
(r) => r.source === 'Settings (general.plan.trustReadOnlyHint)',
);
expect(trustRule).toBeDefined();
expect(trustRule!.decision).toBe(PolicyDecision.ALLOW);
expect(trustRule!.modes).toEqual([ApprovalMode.PLAN]);
expect(trustRule!.toolAnnotations).toEqual({ readOnlyHint: true });
expect(trustRule!.mcpName).toBe('*');
// Priority 4.5 — user tier, above the 1.05 ASK_USER rule from plan.toml,
// below MCP_EXCLUDED_PRIORITY (4.9) so admin/security blocks still win.
expect(trustRule!.priority).toBeCloseTo(4.5, 5);
});
});
});

describe('getPolicyDirectories', () => {
Expand Down
24 changes: 24 additions & 0 deletions packages/core/src/policy/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export const ADMIN_POLICY_TIER = 5;
// Specific priority offsets and derived priorities for dynamic/settings rules.

export const MCP_EXCLUDED_PRIORITY = USER_POLICY_TIER + 0.9;
export const TRUST_READ_ONLY_HINT_PRIORITY = USER_POLICY_TIER + 0.5;
export const EXCLUDE_TOOLS_FLAG_PRIORITY = USER_POLICY_TIER + 0.4;
export const CONFIRMATION_REQUIRED_PRIORITY = USER_POLICY_TIER + 0.35;
export const ALLOWED_TOOLS_FLAG_PRIORITY = USER_POLICY_TIER + 0.3;
Expand Down Expand Up @@ -436,6 +437,29 @@ export async function createPolicyEngineConfig(
}
}

// Opt-in: trust MCP-declared `readOnlyHint = true` in Plan Mode.
// Off by default — Plan Mode follows secure-by-default and prompts on
// every MCP read so the user can verify the call. When the user enables
// `general.plan.trustReadOnlyHint`, this dynamic rule (priority
// TRUST_READ_ONLY_HINT_PRIORITY = 4.5, comfortably above plan.toml's
// priority-1.05 ASK_USER rule and below MCP_EXCLUDED_PRIORITY)
// overrides ASK_USER → ALLOW for the readOnlyHint annotation.
//
// Built-in read tools (read_file, glob, grep_search, list_directory,
// ...) already run silently in Plan Mode via the explicit allow-list
// in read-only.toml and are not affected by this setting.
if (settings.trustReadOnlyHintInPlanMode) {
rules.push({
toolName: '*',
mcpName: '*',
toolAnnotations: { readOnlyHint: true },
decision: PolicyDecision.ALLOW,
priority: TRUST_READ_ONLY_HINT_PRIORITY,
modes: [ApprovalMode.PLAN],
source: 'Settings (general.plan.trustReadOnlyHint)',
});
}

const nonPlanModes = [
ApprovalMode.DEFAULT,
ApprovalMode.AUTO_EDIT,
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/policy/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,10 @@ export interface PolicySettings {
adminPolicyPaths?: string[];
workspacePoliciesDir?: string;
disableAlwaysAllow?: boolean;
// When true, MCP tools annotated `readOnlyHint = true` are allowed silently
// in Plan Mode (overrides plan.toml's default ASK_USER). Maps to
// `general.plan.trustReadOnlyHint` in user settings.
trustReadOnlyHintInPlanMode?: boolean;
}

export interface CheckResult {
Expand Down