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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions docs/cli/plan-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,9 +180,16 @@ As described in the
rule that does not explicitly specify `modes` is considered "always active" and
will apply to Plan Mode as well.

If you want a rule to apply to other modes but _not_ to Plan Mode, you must
explicitly specify the target modes. For example, to allow `npm test` in default
and Auto-Edit modes but not in Plan Mode:
To maintain the integrity of Plan Mode as a safe research environment,
persistent tool approvals are context-aware. Approvals granted in modes like
Default or Auto-Edit do not apply to Plan Mode, ensuring that tools trusted for
implementation don't automatically execute while you're researching. However,
approvals granted while in Plan Mode are treated as intentional choices for
global trust and apply to all modes.

If you want to manually restrict a rule to other modes but _not_ to Plan Mode,
you must explicitly specify the target modes. For example, to allow `npm test`
in default and Auto-Edit modes but not in Plan Mode:

```toml
[[rule]]
Expand Down
21 changes: 20 additions & 1 deletion docs/reference/policy-engine.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,24 @@ modes specified, it is always active.
[Customizing Plan Mode Policies](../cli/plan-mode.md#customizing-policies).
- `yolo`: A mode where all tools are auto-approved (use with extreme caution).

To maintain the integrity of Plan Mode as a safe research environment,
persistent tool approvals are context-aware. When you select **"Allow for all
future sessions"**, the policy engine explicitly includes the current mode and
all more permissive modes in the hierarchy (`plan` < `default` < `autoEdit` <
`yolo`).

- **Approvals in `plan` mode**: These represent an intentional choice to trust a
tool globally. The resulting rule explicitly includes all modes (`plan`,
`default`, `autoEdit`, and `yolo`).
- **Approvals in other modes**: These only apply to the current mode and those
more permissive. For example:
- An approval granted in **`default`** mode applies to `default`, `autoEdit`,
and `yolo`.
- An approval granted in **`autoEdit`** mode applies to `autoEdit` and `yolo`.
- An approval granted in **`yolo`** mode applies only to `yolo`. This ensures
that trust flows correctly to more permissive environments while maintaining
the safety of more restricted modes like `plan`.

## Rule matching

When a tool call is made, the engine checks it against all active rules,
Expand Down Expand Up @@ -304,7 +322,8 @@ priority = 10
denyMessage = "Deletion is permanent"

# (Optional) An array of approval modes where this rule is active.
modes = ["autoEdit"]
# If omitted or empty, the rule applies to all modes.
modes = ["default", "autoEdit", "yolo"]

# (Optional) A boolean to restrict the rule to interactive (true) or
# non-interactive (false) environments.
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/confirmation-bus/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/

import { type FunctionCall } from '@google/genai';
import { type ApprovalMode } from '../policy/types.js';
import type {
ToolConfirmationOutcome,
ToolConfirmationPayload,
Expand Down Expand Up @@ -150,6 +151,7 @@ export interface UpdatePolicy {
commandPrefix?: string | string[];
mcpName?: string;
allowRedirection?: boolean;
modes?: ApprovalMode[];
}

export interface ToolPolicyRejection {
Expand Down
102 changes: 77 additions & 25 deletions packages/core/src/policy/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -533,7 +533,6 @@ export async function createPolicyEngineConfig(
disableAlwaysAllow: settings.disableAlwaysAllow,
};
}

interface TomlRule {
toolName?: string;
mcpName?: string;
Expand All @@ -542,10 +541,64 @@ interface TomlRule {
commandPrefix?: string | string[];
argsPattern?: string;
allowRedirection?: boolean;
modes?: ApprovalMode[];
// Index signature to satisfy Record type if needed for toml.stringify
[key: string]: unknown;
}

/**
* Finds a rule in the rule array that matches the given criteria.
*/
function findMatchingRule(
rules: TomlRule[],
criteria: {
toolName: string;
mcpName?: string;
commandPrefix?: string | string[];
argsPattern?: string;
},
): TomlRule | undefined {
return rules.find(
(r) =>
r.toolName === criteria.toolName &&
r.mcpName === criteria.mcpName &&
JSON.stringify(r.commandPrefix) ===
JSON.stringify(criteria.commandPrefix) &&
r.argsPattern === criteria.argsPattern,
);
}

/**
* Creates a new TOML rule object from the given tool name and message.
*/
function createTomlRule(toolName: string, message: UpdatePolicy): TomlRule {
const rule: TomlRule = {
decision: 'allow',
priority: getAlwaysAllowPriorityFraction(),
toolName,
};

if (message.mcpName) {
rule.mcpName = message.mcpName;
}

if (message.commandPrefix) {
rule.commandPrefix = message.commandPrefix;
} else if (message.argsPattern) {
rule.argsPattern = message.argsPattern;
}

if (message.allowRedirection !== undefined) {
rule.allowRedirection = message.allowRedirection;
}

if (message.modes) {
rule.modes = message.modes;
}

return rule;
}

export function createPolicyUpdater(
policyEngine: PolicyEngine,
messageBus: MessageBus,
Expand Down Expand Up @@ -585,6 +638,7 @@ export function createPolicyUpdater(
priority,
argsPattern: new RegExp(pattern),
mcpName: message.mcpName,
modes: message.modes,
source: 'Dynamic (Confirmed)',
allowRedirection: message.allowRedirection,
});
Expand Down Expand Up @@ -622,6 +676,7 @@ export function createPolicyUpdater(
priority,
argsPattern,
mcpName: message.mcpName,
modes: message.modes,
source: 'Dynamic (Confirmed)',
allowRedirection: message.allowRedirection,
});
Expand Down Expand Up @@ -662,39 +717,36 @@ export function createPolicyUpdater(
existingData.rule = [];
}

// Create new rule object
const newRule: TomlRule = {
decision: 'allow',
priority: getAlwaysAllowPriorityFraction(),
};

// Normalize tool name for MCP
let normalizedToolName = toolName;
if (message.mcpName) {
newRule.mcpName = message.mcpName;

const expectedPrefix = `${MCP_TOOL_PREFIX}${message.mcpName}_`;
if (toolName.startsWith(expectedPrefix)) {
newRule.toolName = toolName.slice(expectedPrefix.length);
} else {
newRule.toolName = toolName;
normalizedToolName = toolName.slice(expectedPrefix.length);
}
} else {
newRule.toolName = toolName;
}

if (message.commandPrefix) {
newRule.commandPrefix = message.commandPrefix;
} else if (message.argsPattern) {
// message.argsPattern was already validated above
newRule.argsPattern = message.argsPattern;
}
// Look for an existing rule to update
const existingRule = findMatchingRule(existingData.rule, {
toolName: normalizedToolName,
mcpName: message.mcpName,
commandPrefix: message.commandPrefix,
argsPattern: message.argsPattern,
});

if (message.allowRedirection !== undefined) {
newRule.allowRedirection = message.allowRedirection;
if (existingRule) {
if (message.allowRedirection !== undefined) {
existingRule.allowRedirection = message.allowRedirection;
}
if (message.modes) {
existingRule.modes = message.modes;
}
} else {
existingData.rule.push(
createTomlRule(normalizedToolName, message),
);
}

// Add to rules
existingData.rule.push(newRule);

// Serialize back to TOML
// @iarna/toml stringify might not produce beautiful output but it handles escaping correctly
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
Expand Down
53 changes: 53 additions & 0 deletions packages/core/src/policy/persistence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,4 +242,57 @@ decision = "deny"
const content = memfs.readFileSync(policyFile, 'utf-8') as string;
expect(content).toContain('toolName = "test_tool"');
});

it('should include modes if provided', async () => {
createPolicyUpdater(policyEngine, messageBus, mockStorage);

const policyFile = '/mock/user/.gemini/policies/auto-saved.toml';
vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile);

await messageBus.publish({
type: MessageBusType.UPDATE_POLICY,
toolName: 'test_tool',
persist: true,
modes: [ApprovalMode.DEFAULT, ApprovalMode.YOLO],
});

await vi.advanceTimersByTimeAsync(100);

const content = memfs.readFileSync(policyFile, 'utf-8') as string;
expect(content).toContain('modes = [ "default", "yolo" ]');
});

it('should update existing rule modes instead of appending redundant rule', async () => {
createPolicyUpdater(policyEngine, messageBus, mockStorage);

const policyFile = '/mock/user/.gemini/policies/auto-saved.toml';
vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile);

const existingContent = `
[[rule]]
decision = "allow"
priority = 950
toolName = "test_tool"
modes = [ "autoEdit", "yolo" ]
`;
const dir = path.dirname(policyFile);
memfs.mkdirSync(dir, { recursive: true });
memfs.writeFileSync(policyFile, existingContent);

// Now grant in DEFAULT mode, which should include [default, autoEdit, yolo]
await messageBus.publish({
type: MessageBusType.UPDATE_POLICY,
toolName: 'test_tool',
persist: true,
modes: [ApprovalMode.DEFAULT, ApprovalMode.AUTO_EDIT, ApprovalMode.YOLO],
});

await vi.advanceTimersByTimeAsync(100);

const content = memfs.readFileSync(policyFile, 'utf-8') as string;
// Should NOT have two [[rule]] entries for test_tool
const ruleCount = (content.match(/\[\[rule\]\]/g) || []).length;
expect(ruleCount).toBe(1);
expect(content).toContain('modes = [ "default", "autoEdit", "yolo" ]');
});
});
12 changes: 12 additions & 0 deletions packages/core/src/policy/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,18 @@ export enum ApprovalMode {
PLAN = 'plan',
}

/**
* The order of permissiveness for approval modes.
* Tools allowed in a less permissive mode should also be allowed
* in more permissive modes.
*/
export const MODES_BY_PERMISSIVENESS = [
ApprovalMode.PLAN,
ApprovalMode.DEFAULT,
ApprovalMode.AUTO_EDIT,
ApprovalMode.YOLO,
];

/**
* Configuration for the built-in allowed-path checker.
*/
Expand Down
Loading
Loading