feat(policy): support auto-add to policy by default and scoped persistence (#20361)

This commit is contained in:
Spencer
2026-03-10 13:01:41 -04:00
committed by GitHub
parent 49ea9b0457
commit a220874281
31 changed files with 929 additions and 498 deletions
+99 -1
View File
@@ -16,8 +16,12 @@ import {
import { checkPolicy, updatePolicy, getPolicyDenialError } from './policy.js';
import type { Config } from '../config/config.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { MessageBusType } from '../confirmation-bus/types.js';
import {
MessageBusType,
type SerializableConfirmationDetails,
} from '../confirmation-bus/types.js';
import { ApprovalMode, PolicyDecision } from '../policy/types.js';
import { escapeRegex } from '../policy/utils.js';
import {
ToolConfirmationOutcome,
type AnyDeclarativeTool,
@@ -219,6 +223,8 @@ describe('policy.ts', () => {
it('should handle standard policy updates with persistence', async () => {
const mockConfig = {
isTrustedFolder: vi.fn().mockReturnValue(false),
getWorkspacePoliciesDir: vi.fn().mockReturnValue(undefined),
setApprovalMode: vi.fn(),
} as unknown as Mocked<Config>;
@@ -453,6 +459,8 @@ describe('policy.ts', () => {
it('should handle MCP ProceedAlwaysAndSave (persist: true)', async () => {
const mockConfig = {
isTrustedFolder: vi.fn().mockReturnValue(false),
getWorkspacePoliciesDir: vi.fn().mockReturnValue(undefined),
setApprovalMode: vi.fn(),
} as unknown as Mocked<Config>;
@@ -487,6 +495,96 @@ describe('policy.ts', () => {
}),
);
});
it('should determine persistScope: workspace in trusted folders', async () => {
const mockConfig = {
isTrustedFolder: vi.fn().mockReturnValue(true),
getWorkspacePoliciesDir: vi
.fn()
.mockReturnValue('/mock/project/policies'),
setApprovalMode: vi.fn(),
} as unknown as Mocked<Config>;
const mockMessageBus = {
publish: vi.fn(),
} as unknown as Mocked<MessageBus>;
const tool = { name: 'test-tool' } as AnyDeclarativeTool;
await updatePolicy(
tool,
ToolConfirmationOutcome.ProceedAlwaysAndSave,
undefined,
{ config: mockConfig, messageBus: mockMessageBus },
);
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
persistScope: 'workspace',
}),
);
});
it('should determine persistScope: user in untrusted folders', async () => {
const mockConfig = {
isTrustedFolder: vi.fn().mockReturnValue(false),
getWorkspacePoliciesDir: vi
.fn()
.mockReturnValue('/mock/project/policies'),
setApprovalMode: vi.fn(),
} as unknown as Mocked<Config>;
const mockMessageBus = {
publish: vi.fn(),
} as unknown as Mocked<MessageBus>;
const tool = { name: 'test-tool' } as AnyDeclarativeTool;
await updatePolicy(
tool,
ToolConfirmationOutcome.ProceedAlwaysAndSave,
undefined,
{ config: mockConfig, messageBus: mockMessageBus },
);
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
persistScope: 'user',
}),
);
});
it('should narrow edit tools with argsPattern', async () => {
const mockConfig = {
isTrustedFolder: vi.fn().mockReturnValue(false),
getWorkspacePoliciesDir: vi.fn().mockReturnValue(undefined),
getTargetDir: vi.fn().mockReturnValue('/mock/dir'),
setApprovalMode: vi.fn(),
} as unknown as Mocked<Config>;
const mockMessageBus = {
publish: vi.fn(),
} as unknown as Mocked<MessageBus>;
const tool = { name: 'write_file' } as AnyDeclarativeTool;
const details: SerializableConfirmationDetails = {
type: 'edit',
title: 'Edit',
filePath: 'src/foo.ts',
fileName: 'foo.ts',
fileDiff: '--- foo.ts\n+++ foo.ts\n@@ -1 +1 @@\n-old\n+new',
originalContent: 'old',
newContent: 'new',
};
await updatePolicy(
tool,
ToolConfirmationOutcome.ProceedAlwaysAndSave,
details,
{ config: mockConfig, messageBus: mockMessageBus },
);
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
toolName: 'write_file',
argsPattern: escapeRegex('"file_path":"src/foo.ts"'),
}),
);
});
});
describe('getPolicyDenialError', () => {
+40 -3
View File
@@ -20,8 +20,11 @@ import {
import {
ToolConfirmationOutcome,
type AnyDeclarativeTool,
type AnyToolInvocation,
type PolicyUpdateOptions,
} from '../tools/tools.js';
import { buildFilePathArgsPattern } from '../policy/utils.js';
import { makeRelative } from '../utils/paths.js';
import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
import { EDIT_TOOL_NAMES } from '../tools/tool-names.js';
import type { ValidatingToolCall } from './types.js';
@@ -94,7 +97,11 @@ export async function updatePolicy(
tool: AnyDeclarativeTool,
outcome: ToolConfirmationOutcome,
confirmationDetails: SerializableConfirmationDetails | undefined,
deps: { config: Config; messageBus: MessageBus },
deps: {
config: Config;
messageBus: MessageBus;
toolInvocation?: AnyToolInvocation;
},
): Promise<void> {
// Mode Transitions (AUTO_EDIT)
if (isAutoEditTransition(tool, outcome)) {
@@ -102,6 +109,20 @@ export async function updatePolicy(
return;
}
// Determine persist scope if we are persisting.
let persistScope: 'workspace' | 'user' | undefined;
if (outcome === ToolConfirmationOutcome.ProceedAlwaysAndSave) {
// If folder is trusted and workspace policies are enabled, we prefer workspace scope.
if (
deps.config.isTrustedFolder() &&
deps.config.getWorkspacePoliciesDir() !== undefined
) {
persistScope = 'workspace';
} else {
persistScope = 'user';
}
}
// Specialized Tools (MCP)
if (confirmationDetails?.type === 'mcp') {
await handleMcpPolicyUpdate(
@@ -109,6 +130,7 @@ export async function updatePolicy(
outcome,
confirmationDetails,
deps.messageBus,
persistScope,
);
return;
}
@@ -119,6 +141,9 @@ export async function updatePolicy(
outcome,
confirmationDetails,
deps.messageBus,
persistScope,
deps.toolInvocation,
deps.config,
);
}
@@ -148,21 +173,31 @@ async function handleStandardPolicyUpdate(
outcome: ToolConfirmationOutcome,
confirmationDetails: SerializableConfirmationDetails | undefined,
messageBus: MessageBus,
persistScope?: 'workspace' | 'user',
toolInvocation?: AnyToolInvocation,
config?: Config,
): Promise<void> {
if (
outcome === ToolConfirmationOutcome.ProceedAlways ||
outcome === ToolConfirmationOutcome.ProceedAlwaysAndSave
) {
const options: PolicyUpdateOptions = {};
const options: PolicyUpdateOptions =
toolInvocation?.getPolicyUpdateOptions?.(outcome) || {};
if (confirmationDetails?.type === 'exec') {
if (!options.commandPrefix && confirmationDetails?.type === 'exec') {
options.commandPrefix = confirmationDetails.rootCommands;
} else if (!options.argsPattern && confirmationDetails?.type === 'edit') {
const filePath = config
? makeRelative(confirmationDetails.filePath, config.getTargetDir())
: confirmationDetails.filePath;
options.argsPattern = buildFilePathArgsPattern(filePath);
}
await messageBus.publish({
type: MessageBusType.UPDATE_POLICY,
toolName: tool.name,
persist: outcome === ToolConfirmationOutcome.ProceedAlwaysAndSave,
persistScope,
...options,
});
}
@@ -180,6 +215,7 @@ async function handleMcpPolicyUpdate(
{ type: 'mcp' }
>,
messageBus: MessageBus,
persistScope?: 'workspace' | 'user',
): Promise<void> {
const isMcpAlways =
outcome === ToolConfirmationOutcome.ProceedAlways ||
@@ -204,5 +240,6 @@ async function handleMcpPolicyUpdate(
toolName,
mcpName: confirmationDetails.serverName,
persist,
persistScope,
});
}
+1
View File
@@ -608,6 +608,7 @@ export class Scheduler {
await updatePolicy(toolCall.tool, outcome, lastDetails, {
config: this.config,
messageBus: this.messageBus,
toolInvocation: toolCall.invocation,
});
}