Skip to content

Commit 24810f9

Browse files
fix(patch): cherry-pick 64c928f to release/v0.37.0-preview.0-pr-23257 to patch version v0.37.0-preview.0 and create version 0.37.0-preview.1 (#24561)
Co-authored-by: Jerop Kipruto <jerop@google.com>
1 parent c0f8f3c commit 24810f9

8 files changed

Lines changed: 295 additions & 29 deletions

File tree

docs/cli/plan-mode.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -180,9 +180,16 @@ As described in the
180180
rule that does not explicitly specify `modes` is considered "always active" and
181181
will apply to Plan Mode as well.
182182

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

187194
```toml
188195
[[rule]]

docs/reference/policy-engine.md

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,24 @@ modes specified, it is always active.
171171
[Customizing Plan Mode Policies](../cli/plan-mode.md#customizing-policies).
172172
- `yolo`: A mode where all tools are auto-approved (use with extreme caution).
173173
174+
To maintain the integrity of Plan Mode as a safe research environment,
175+
persistent tool approvals are context-aware. When you select **"Allow for all
176+
future sessions"**, the policy engine explicitly includes the current mode and
177+
all more permissive modes in the hierarchy (`plan` < `default` < `autoEdit` <
178+
`yolo`).
179+
180+
- **Approvals in `plan` mode**: These represent an intentional choice to trust a
181+
tool globally. The resulting rule explicitly includes all modes (`plan`,
182+
`default`, `autoEdit`, and `yolo`).
183+
- **Approvals in other modes**: These only apply to the current mode and those
184+
more permissive. For example:
185+
- An approval granted in **`default`** mode applies to `default`, `autoEdit`,
186+
and `yolo`.
187+
- An approval granted in **`autoEdit`** mode applies to `autoEdit` and `yolo`.
188+
- An approval granted in **`yolo`** mode applies only to `yolo`. This ensures
189+
that trust flows correctly to more permissive environments while maintaining
190+
the safety of more restricted modes like `plan`.
191+
174192
## Rule matching
175193
176194
When a tool call is made, the engine checks it against all active rules,
@@ -304,7 +322,8 @@ priority = 10
304322
denyMessage = "Deletion is permanent"
305323

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

309328
# (Optional) A boolean to restrict the rule to interactive (true) or
310329
# non-interactive (false) environments.

packages/core/src/confirmation-bus/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
*/
66

77
import { type FunctionCall } from '@google/genai';
8+
import { type ApprovalMode } from '../policy/types.js';
89
import type {
910
ToolConfirmationOutcome,
1011
ToolConfirmationPayload,
@@ -150,6 +151,7 @@ export interface UpdatePolicy {
150151
commandPrefix?: string | string[];
151152
mcpName?: string;
152153
allowRedirection?: boolean;
154+
modes?: ApprovalMode[];
153155
}
154156

155157
export interface ToolPolicyRejection {

packages/core/src/policy/config.ts

Lines changed: 77 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -533,7 +533,6 @@ export async function createPolicyEngineConfig(
533533
disableAlwaysAllow: settings.disableAlwaysAllow,
534534
};
535535
}
536-
537536
interface TomlRule {
538537
toolName?: string;
539538
mcpName?: string;
@@ -542,10 +541,64 @@ interface TomlRule {
542541
commandPrefix?: string | string[];
543542
argsPattern?: string;
544543
allowRedirection?: boolean;
544+
modes?: ApprovalMode[];
545545
// Index signature to satisfy Record type if needed for toml.stringify
546546
[key: string]: unknown;
547547
}
548548

549+
/**
550+
* Finds a rule in the rule array that matches the given criteria.
551+
*/
552+
function findMatchingRule(
553+
rules: TomlRule[],
554+
criteria: {
555+
toolName: string;
556+
mcpName?: string;
557+
commandPrefix?: string | string[];
558+
argsPattern?: string;
559+
},
560+
): TomlRule | undefined {
561+
return rules.find(
562+
(r) =>
563+
r.toolName === criteria.toolName &&
564+
r.mcpName === criteria.mcpName &&
565+
JSON.stringify(r.commandPrefix) ===
566+
JSON.stringify(criteria.commandPrefix) &&
567+
r.argsPattern === criteria.argsPattern,
568+
);
569+
}
570+
571+
/**
572+
* Creates a new TOML rule object from the given tool name and message.
573+
*/
574+
function createTomlRule(toolName: string, message: UpdatePolicy): TomlRule {
575+
const rule: TomlRule = {
576+
decision: 'allow',
577+
priority: getAlwaysAllowPriorityFraction(),
578+
toolName,
579+
};
580+
581+
if (message.mcpName) {
582+
rule.mcpName = message.mcpName;
583+
}
584+
585+
if (message.commandPrefix) {
586+
rule.commandPrefix = message.commandPrefix;
587+
} else if (message.argsPattern) {
588+
rule.argsPattern = message.argsPattern;
589+
}
590+
591+
if (message.allowRedirection !== undefined) {
592+
rule.allowRedirection = message.allowRedirection;
593+
}
594+
595+
if (message.modes) {
596+
rule.modes = message.modes;
597+
}
598+
599+
return rule;
600+
}
601+
549602
export function createPolicyUpdater(
550603
policyEngine: PolicyEngine,
551604
messageBus: MessageBus,
@@ -585,6 +638,7 @@ export function createPolicyUpdater(
585638
priority,
586639
argsPattern: new RegExp(pattern),
587640
mcpName: message.mcpName,
641+
modes: message.modes,
588642
source: 'Dynamic (Confirmed)',
589643
allowRedirection: message.allowRedirection,
590644
});
@@ -622,6 +676,7 @@ export function createPolicyUpdater(
622676
priority,
623677
argsPattern,
624678
mcpName: message.mcpName,
679+
modes: message.modes,
625680
source: 'Dynamic (Confirmed)',
626681
allowRedirection: message.allowRedirection,
627682
});
@@ -662,39 +717,36 @@ export function createPolicyUpdater(
662717
existingData.rule = [];
663718
}
664719

665-
// Create new rule object
666-
const newRule: TomlRule = {
667-
decision: 'allow',
668-
priority: getAlwaysAllowPriorityFraction(),
669-
};
670-
720+
// Normalize tool name for MCP
721+
let normalizedToolName = toolName;
671722
if (message.mcpName) {
672-
newRule.mcpName = message.mcpName;
673-
674723
const expectedPrefix = `${MCP_TOOL_PREFIX}${message.mcpName}_`;
675724
if (toolName.startsWith(expectedPrefix)) {
676-
newRule.toolName = toolName.slice(expectedPrefix.length);
677-
} else {
678-
newRule.toolName = toolName;
725+
normalizedToolName = toolName.slice(expectedPrefix.length);
679726
}
680-
} else {
681-
newRule.toolName = toolName;
682727
}
683728

684-
if (message.commandPrefix) {
685-
newRule.commandPrefix = message.commandPrefix;
686-
} else if (message.argsPattern) {
687-
// message.argsPattern was already validated above
688-
newRule.argsPattern = message.argsPattern;
689-
}
729+
// Look for an existing rule to update
730+
const existingRule = findMatchingRule(existingData.rule, {
731+
toolName: normalizedToolName,
732+
mcpName: message.mcpName,
733+
commandPrefix: message.commandPrefix,
734+
argsPattern: message.argsPattern,
735+
});
690736

691-
if (message.allowRedirection !== undefined) {
692-
newRule.allowRedirection = message.allowRedirection;
737+
if (existingRule) {
738+
if (message.allowRedirection !== undefined) {
739+
existingRule.allowRedirection = message.allowRedirection;
740+
}
741+
if (message.modes) {
742+
existingRule.modes = message.modes;
743+
}
744+
} else {
745+
existingData.rule.push(
746+
createTomlRule(normalizedToolName, message),
747+
);
693748
}
694749

695-
// Add to rules
696-
existingData.rule.push(newRule);
697-
698750
// Serialize back to TOML
699751
// @iarna/toml stringify might not produce beautiful output but it handles escaping correctly
700752
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion

packages/core/src/policy/persistence.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,4 +242,57 @@ decision = "deny"
242242
const content = memfs.readFileSync(policyFile, 'utf-8') as string;
243243
expect(content).toContain('toolName = "test_tool"');
244244
});
245+
246+
it('should include modes if provided', async () => {
247+
createPolicyUpdater(policyEngine, messageBus, mockStorage);
248+
249+
const policyFile = '/mock/user/.gemini/policies/auto-saved.toml';
250+
vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile);
251+
252+
await messageBus.publish({
253+
type: MessageBusType.UPDATE_POLICY,
254+
toolName: 'test_tool',
255+
persist: true,
256+
modes: [ApprovalMode.DEFAULT, ApprovalMode.YOLO],
257+
});
258+
259+
await vi.advanceTimersByTimeAsync(100);
260+
261+
const content = memfs.readFileSync(policyFile, 'utf-8') as string;
262+
expect(content).toContain('modes = [ "default", "yolo" ]');
263+
});
264+
265+
it('should update existing rule modes instead of appending redundant rule', async () => {
266+
createPolicyUpdater(policyEngine, messageBus, mockStorage);
267+
268+
const policyFile = '/mock/user/.gemini/policies/auto-saved.toml';
269+
vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile);
270+
271+
const existingContent = `
272+
[[rule]]
273+
decision = "allow"
274+
priority = 950
275+
toolName = "test_tool"
276+
modes = [ "autoEdit", "yolo" ]
277+
`;
278+
const dir = path.dirname(policyFile);
279+
memfs.mkdirSync(dir, { recursive: true });
280+
memfs.writeFileSync(policyFile, existingContent);
281+
282+
// Now grant in DEFAULT mode, which should include [default, autoEdit, yolo]
283+
await messageBus.publish({
284+
type: MessageBusType.UPDATE_POLICY,
285+
toolName: 'test_tool',
286+
persist: true,
287+
modes: [ApprovalMode.DEFAULT, ApprovalMode.AUTO_EDIT, ApprovalMode.YOLO],
288+
});
289+
290+
await vi.advanceTimersByTimeAsync(100);
291+
292+
const content = memfs.readFileSync(policyFile, 'utf-8') as string;
293+
// Should NOT have two [[rule]] entries for test_tool
294+
const ruleCount = (content.match(/\[\[rule\]\]/g) || []).length;
295+
expect(ruleCount).toBe(1);
296+
expect(content).toContain('modes = [ "default", "autoEdit", "yolo" ]');
297+
});
245298
});

packages/core/src/policy/types.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,18 @@ export enum ApprovalMode {
5252
PLAN = 'plan',
5353
}
5454

55+
/**
56+
* The order of permissiveness for approval modes.
57+
* Tools allowed in a less permissive mode should also be allowed
58+
* in more permissive modes.
59+
*/
60+
export const MODES_BY_PERMISSIVENESS = [
61+
ApprovalMode.PLAN,
62+
ApprovalMode.DEFAULT,
63+
ApprovalMode.AUTO_EDIT,
64+
ApprovalMode.YOLO,
65+
];
66+
5567
/**
5668
* Configuration for the built-in allowed-path checker.
5769
*/

0 commit comments

Comments
 (0)