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
+58 -33
View File
@@ -20,11 +20,14 @@ import {
type ToolLocation,
type ToolResult,
type ToolResultDisplay,
type PolicyUpdateOptions,
} from './tools.js';
import { buildFilePathArgsPattern } from '../policy/utils.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { ToolErrorType } from './tool-error.js';
import { makeRelative, shortenPath } from '../utils/paths.js';
import { isNodeError } from '../utils/errors.js';
import { correctPath } from '../utils/pathCorrector.js';
import type { Config } from '../config/config.js';
import { ApprovalMode } from '../policy/types.js';
import { CoreToolCallStatus } from '../scheduler/types.js';
@@ -44,7 +47,6 @@ import {
logEditCorrectionEvent,
} from '../telemetry/loggers.js';
import { correctPath } from '../utils/pathCorrector.js';
import {
EDIT_TOOL_NAME,
READ_FILE_TOOL_NAME,
@@ -442,6 +444,8 @@ class EditToolInvocation
extends BaseToolInvocation<EditToolParams, ToolResult>
implements ToolInvocation<EditToolParams, ToolResult>
{
private readonly resolvedPath: string;
constructor(
private readonly config: Config,
params: EditToolParams,
@@ -450,10 +454,31 @@ class EditToolInvocation
displayName?: string,
) {
super(params, messageBus, toolName, displayName);
if (!path.isAbsolute(this.params.file_path)) {
const result = correctPath(this.params.file_path, this.config);
if (result.success) {
this.resolvedPath = result.correctedPath;
} else {
this.resolvedPath = path.resolve(
this.config.getTargetDir(),
this.params.file_path,
);
}
} else {
this.resolvedPath = this.params.file_path;
}
}
override toolLocations(): ToolLocation[] {
return [{ path: this.params.file_path }];
return [{ path: this.resolvedPath }];
}
override getPolicyUpdateOptions(
_outcome: ToolConfirmationOutcome,
): PolicyUpdateOptions | undefined {
return {
argsPattern: buildFilePathArgsPattern(this.params.file_path),
};
}
private async attemptSelfCorrection(
@@ -471,7 +496,7 @@ class EditToolInvocation
const initialContentHash = hashContent(currentContent);
const onDiskContent = await this.config
.getFileSystemService()
.readTextFile(params.file_path);
.readTextFile(this.resolvedPath);
const onDiskContentHash = hashContent(onDiskContent.replace(/\r\n/g, '\n'));
if (initialContentHash !== onDiskContentHash) {
@@ -582,7 +607,7 @@ class EditToolInvocation
try {
currentContent = await this.config
.getFileSystemService()
.readTextFile(params.file_path);
.readTextFile(this.resolvedPath);
originalLineEnding = detectLineEnding(currentContent);
currentContent = currentContent.replace(/\r\n/g, '\n');
fileExists = true;
@@ -615,7 +640,7 @@ class EditToolInvocation
isNewFile: false,
error: {
display: `File not found. Cannot apply edit. Use an empty old_string to create a new file.`,
raw: `File not found: ${params.file_path}`,
raw: `File not found: ${this.resolvedPath}`,
type: ToolErrorType.FILE_NOT_FOUND,
},
originalLineEnding,
@@ -630,7 +655,7 @@ class EditToolInvocation
isNewFile: false,
error: {
display: `Failed to read content of file.`,
raw: `Failed to read content of existing file: ${params.file_path}`,
raw: `Failed to read content of existing file: ${this.resolvedPath}`,
type: ToolErrorType.READ_CONTENT_FAILURE,
},
originalLineEnding,
@@ -645,7 +670,7 @@ class EditToolInvocation
isNewFile: false,
error: {
display: `Failed to edit. Attempted to create a file that already exists.`,
raw: `File already exists, cannot create: ${params.file_path}`,
raw: `File already exists, cannot create: ${this.resolvedPath}`,
type: ToolErrorType.ATTEMPT_TO_CREATE_EXISTING_FILE,
},
originalLineEnding,
@@ -727,7 +752,7 @@ class EditToolInvocation
return false;
}
const fileName = path.basename(this.params.file_path);
const fileName = path.basename(this.resolvedPath);
const fileDiff = Diff.createPatch(
fileName,
editData.currentContent ?? '',
@@ -739,14 +764,14 @@ class EditToolInvocation
const ideClient = await IdeClient.getInstance();
const ideConfirmation =
this.config.getIdeMode() && ideClient.isDiffingEnabled()
? ideClient.openDiff(this.params.file_path, editData.newContent)
? ideClient.openDiff(this.resolvedPath, editData.newContent)
: undefined;
const confirmationDetails: ToolEditConfirmationDetails = {
type: 'edit',
title: `Confirm Edit: ${shortenPath(makeRelative(this.params.file_path, this.config.getTargetDir()))}`,
title: `Confirm Edit: ${shortenPath(makeRelative(this.resolvedPath, this.config.getTargetDir()))}`,
fileName,
filePath: this.params.file_path,
filePath: this.resolvedPath,
fileDiff,
originalContent: editData.currentContent,
newContent: editData.newContent,
@@ -771,7 +796,7 @@ class EditToolInvocation
getDescription(): string {
const relativePath = makeRelative(
this.params.file_path,
this.resolvedPath,
this.config.getTargetDir(),
);
if (this.params.old_string === '') {
@@ -797,11 +822,7 @@ class EditToolInvocation
* @returns Result of the edit operation
*/
async execute(signal: AbortSignal): Promise<ToolResult> {
const resolvedPath = path.resolve(
this.config.getTargetDir(),
this.params.file_path,
);
const validationError = this.config.validatePathAccess(resolvedPath);
const validationError = this.config.validatePathAccess(this.resolvedPath);
if (validationError) {
return {
llmContent: validationError,
@@ -843,7 +864,7 @@ class EditToolInvocation
}
try {
await this.ensureParentDirectoriesExistAsync(this.params.file_path);
await this.ensureParentDirectoriesExistAsync(this.resolvedPath);
let finalContent = editData.newContent;
// Restore original line endings if they were CRLF, or use OS default for new files
@@ -856,15 +877,15 @@ class EditToolInvocation
}
await this.config
.getFileSystemService()
.writeTextFile(this.params.file_path, finalContent);
.writeTextFile(this.resolvedPath, finalContent);
let displayResult: ToolResultDisplay;
if (editData.isNewFile) {
displayResult = `Created ${shortenPath(makeRelative(this.params.file_path, this.config.getTargetDir()))}`;
displayResult = `Created ${shortenPath(makeRelative(this.resolvedPath, this.config.getTargetDir()))}`;
} else {
// Generate diff for display, even though core logic doesn't technically need it
// The CLI wrapper will use this part of the ToolResult
const fileName = path.basename(this.params.file_path);
const fileName = path.basename(this.resolvedPath);
const fileDiff = Diff.createPatch(
fileName,
editData.currentContent ?? '', // Should not be null here if not isNewFile
@@ -883,7 +904,7 @@ class EditToolInvocation
displayResult = {
fileDiff,
fileName,
filePath: this.params.file_path,
filePath: this.resolvedPath,
originalContent: editData.currentContent,
newContent: editData.newContent,
diffStat,
@@ -893,8 +914,8 @@ class EditToolInvocation
const llmSuccessMessageParts = [
editData.isNewFile
? `Created new file: ${this.params.file_path} with provided content.`
: `Successfully modified file: ${this.params.file_path} (${editData.occurrences} replacements).`,
? `Created new file: ${this.resolvedPath} with provided content.`
: `Successfully modified file: ${this.resolvedPath} (${editData.occurrences} replacements).`,
];
// Return a diff of the file before and after the write so that the agent
@@ -985,16 +1006,20 @@ export class EditTool
return "The 'file_path' parameter must be non-empty.";
}
let filePath = params.file_path;
if (!path.isAbsolute(filePath)) {
// Attempt to auto-correct to an absolute path
const result = correctPath(filePath, this.config);
if (!result.success) {
return result.error;
let resolvedPath: string;
if (!path.isAbsolute(params.file_path)) {
const result = correctPath(params.file_path, this.config);
if (result.success) {
resolvedPath = result.correctedPath;
} else {
resolvedPath = path.resolve(
this.config.getTargetDir(),
params.file_path,
);
}
filePath = result.correctedPath;
} else {
resolvedPath = params.file_path;
}
params.file_path = filePath;
const newPlaceholders = detectOmissionPlaceholders(params.new_string);
if (newPlaceholders.length > 0) {
@@ -1009,7 +1034,7 @@ export class EditTool
}
}
return this.config.validatePathAccess(params.file_path);
return this.config.validatePathAccess(resolvedPath);
}
protected createInvocation(
+11
View File
@@ -14,12 +14,15 @@ import {
Kind,
type ToolInvocation,
type ToolResult,
type PolicyUpdateOptions,
type ToolConfirmationOutcome,
} from './tools.js';
import { shortenPath, makeRelative } from '../utils/paths.js';
import { type Config } from '../config/config.js';
import { DEFAULT_FILE_FILTERING_OPTIONS } from '../config/constants.js';
import { ToolErrorType } from './tool-error.js';
import { GLOB_TOOL_NAME, GLOB_DISPLAY_NAME } from './tool-names.js';
import { buildPatternArgsPattern } from '../policy/utils.js';
import { getErrorMessage } from '../utils/errors.js';
import { debugLogger } from '../utils/debugLogger.js';
import { GLOB_DEFINITION } from './definitions/coreTools.js';
@@ -118,6 +121,14 @@ class GlobToolInvocation extends BaseToolInvocation<
return description;
}
override getPolicyUpdateOptions(
_outcome: ToolConfirmationOutcome,
): PolicyUpdateOptions | undefined {
return {
argsPattern: buildPatternArgsPattern(this.params.pattern),
};
}
async execute(signal: AbortSignal): Promise<ToolResult> {
try {
const workspaceContext = this.config.getWorkspaceContext();
+11
View File
@@ -21,6 +21,8 @@ import {
Kind,
type ToolInvocation,
type ToolResult,
type PolicyUpdateOptions,
type ToolConfirmationOutcome,
} from './tools.js';
import { makeRelative, shortenPath } from '../utils/paths.js';
import { getErrorMessage, isNodeError } from '../utils/errors.js';
@@ -29,6 +31,7 @@ import type { Config } from '../config/config.js';
import type { FileExclusions } from '../utils/ignorePatterns.js';
import { ToolErrorType } from './tool-error.js';
import { GREP_TOOL_NAME } from './tool-names.js';
import { buildPatternArgsPattern } from '../policy/utils.js';
import { debugLogger } from '../utils/debugLogger.js';
import { GREP_DEFINITION } from './definitions/coreTools.js';
import { resolveToolDeclaration } from './definitions/resolver.js';
@@ -285,6 +288,14 @@ class GrepToolInvocation extends BaseToolInvocation<
}
}
override getPolicyUpdateOptions(
_outcome: ToolConfirmationOutcome,
): PolicyUpdateOptions | undefined {
return {
argsPattern: buildPatternArgsPattern(this.params.pattern),
};
}
/**
* Checks if a command is available in the system's PATH.
* @param {string} command The command name (e.g., 'git', 'grep').
+11
View File
@@ -13,12 +13,15 @@ import {
Kind,
type ToolInvocation,
type ToolResult,
type PolicyUpdateOptions,
type ToolConfirmationOutcome,
} from './tools.js';
import { makeRelative, shortenPath } from '../utils/paths.js';
import type { Config } from '../config/config.js';
import { DEFAULT_FILE_FILTERING_OPTIONS } from '../config/constants.js';
import { ToolErrorType } from './tool-error.js';
import { LS_TOOL_NAME } from './tool-names.js';
import { buildFilePathArgsPattern } from '../policy/utils.js';
import { debugLogger } from '../utils/debugLogger.js';
import { LS_DEFINITION } from './definitions/coreTools.js';
import { resolveToolDeclaration } from './definitions/resolver.js';
@@ -123,6 +126,14 @@ class LSToolInvocation extends BaseToolInvocation<LSToolParams, ToolResult> {
return shortenPath(relativePath);
}
override getPolicyUpdateOptions(
_outcome: ToolConfirmationOutcome,
): PolicyUpdateOptions | undefined {
return {
argsPattern: buildFilePathArgsPattern(this.params.dir_path),
};
}
// Helper for consistent error formatting
private errorResult(
llmContent: string,
+1 -1
View File
@@ -184,7 +184,7 @@ export class DiscoveredMCPToolInvocation extends BaseToolInvocation<
);
}
protected override getPolicyUpdateOptions(
override getPolicyUpdateOptions(
_outcome: ToolConfirmationOutcome,
): PolicyUpdateOptions | undefined {
return { mcpName: this.serverName };
+11
View File
@@ -14,8 +14,11 @@ import {
type ToolInvocation,
type ToolLocation,
type ToolResult,
type PolicyUpdateOptions,
type ToolConfirmationOutcome,
} from './tools.js';
import { ToolErrorType } from './tool-error.js';
import { buildFilePathArgsPattern } from '../policy/utils.js';
import type { PartUnion } from '@google/genai';
import {
@@ -88,6 +91,14 @@ class ReadFileToolInvocation extends BaseToolInvocation<
];
}
override getPolicyUpdateOptions(
_outcome: ToolConfirmationOutcome,
): PolicyUpdateOptions | undefined {
return {
argsPattern: buildFilePathArgsPattern(this.params.file_path),
};
}
async execute(): Promise<ToolResult> {
const validationError = this.config.validatePathAccess(
this.resolvedPath,
@@ -11,11 +11,14 @@ import {
Kind,
type ToolInvocation,
type ToolResult,
type PolicyUpdateOptions,
type ToolConfirmationOutcome,
} from './tools.js';
import { getErrorMessage } from '../utils/errors.js';
import * as fsPromises from 'node:fs/promises';
import * as path from 'node:path';
import { glob, escape } from 'glob';
import { buildPatternArgsPattern } from '../policy/utils.js';
import {
detectFileType,
processSingleFileContent,
@@ -155,6 +158,16 @@ ${finalExclusionPatternsForDescription
)}".`;
}
override getPolicyUpdateOptions(
_outcome: ToolConfirmationOutcome,
): PolicyUpdateOptions | undefined {
// We join the include patterns to match the JSON stringified arguments.
// buildPatternArgsPattern handles JSON stringification.
return {
argsPattern: buildPatternArgsPattern(JSON.stringify(this.params.include)),
};
}
async execute(signal: AbortSignal): Promise<ToolResult> {
const { include, exclude = [], useDefaultExcludes = true } = this.params;
+1 -1
View File
@@ -90,7 +90,7 @@ export class ShellToolInvocation extends BaseToolInvocation<
return description;
}
protected override getPolicyUpdateOptions(
override getPolicyUpdateOptions(
outcome: ToolConfirmationOutcome,
): PolicyUpdateOptions | undefined {
if (
+23 -6
View File
@@ -154,12 +154,22 @@ export const LS_TOOL_NAME_LEGACY = 'list_directory'; // Just to be safe if anyth
export const EDIT_TOOL_NAMES = new Set([EDIT_TOOL_NAME, WRITE_FILE_TOOL_NAME]);
// Tool Display Names
export const WRITE_FILE_DISPLAY_NAME = 'WriteFile';
export const EDIT_DISPLAY_NAME = 'Edit';
export const ASK_USER_DISPLAY_NAME = 'Ask User';
export const READ_FILE_DISPLAY_NAME = 'ReadFile';
export const GLOB_DISPLAY_NAME = 'FindFiles';
/**
* Tools that can access local files or remote resources and should be
* treated with extra caution when updating policies.
*/
export const SENSITIVE_TOOLS = new Set([
GLOB_TOOL_NAME,
GREP_TOOL_NAME,
READ_MANY_FILES_TOOL_NAME,
WEB_FETCH_TOOL_NAME,
READ_FILE_TOOL_NAME,
LS_TOOL_NAME,
WRITE_FILE_TOOL_NAME,
EDIT_TOOL_NAME,
SHELL_TOOL_NAME,
]);
export const TRACKER_CREATE_TASK_TOOL_NAME = 'tracker_create_task';
export const TRACKER_UPDATE_TASK_TOOL_NAME = 'tracker_update_task';
export const TRACKER_GET_TASK_TOOL_NAME = 'tracker_get_task';
@@ -167,6 +177,13 @@ export const TRACKER_LIST_TASKS_TOOL_NAME = 'tracker_list_tasks';
export const TRACKER_ADD_DEPENDENCY_TOOL_NAME = 'tracker_add_dependency';
export const TRACKER_VISUALIZE_TOOL_NAME = 'tracker_visualize';
// Tool Display Names
export const WRITE_FILE_DISPLAY_NAME = 'WriteFile';
export const EDIT_DISPLAY_NAME = 'Edit';
export const ASK_USER_DISPLAY_NAME = 'Ask User';
export const READ_FILE_DISPLAY_NAME = 'ReadFile';
export const GLOB_DISPLAY_NAME = 'FindFiles';
/**
* Mapping of legacy tool names to their current names.
* This ensures backward compatibility for user-defined policies, skills, and hooks.
+10 -1
View File
@@ -68,12 +68,21 @@ export interface ToolInvocation<
updateOutput?: (output: ToolLiveOutput) => void,
shellExecutionConfig?: ShellExecutionConfig,
): Promise<TResult>;
/**
* Returns tool-specific options for policy updates.
* This is used by the scheduler to narrow policy rules when a tool is approved.
*/
getPolicyUpdateOptions?(
outcome: ToolConfirmationOutcome,
): PolicyUpdateOptions | undefined;
}
/**
* Options for policy updates that can be customized by tool invocations.
*/
export interface PolicyUpdateOptions {
argsPattern?: string;
commandPrefix?: string | string[];
mcpName?: string;
}
@@ -130,7 +139,7 @@ export abstract class BaseToolInvocation<
* Subclasses can override this to provide additional options like
* commandPrefix (for shell) or mcpName (for MCP tools).
*/
protected getPolicyUpdateOptions(
getPolicyUpdateOptions(
_outcome: ToolConfirmationOutcome,
): PolicyUpdateOptions | undefined {
return undefined;
+18
View File
@@ -12,7 +12,9 @@ import {
type ToolInvocation,
type ToolResult,
type ToolConfirmationOutcome,
type PolicyUpdateOptions,
} from './tools.js';
import { buildPatternArgsPattern } from '../policy/utils.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { ToolErrorType } from './tool-error.js';
import { getErrorMessage } from '../utils/errors.js';
@@ -291,6 +293,22 @@ ${textContent}
return `Processing URLs and instructions from prompt: "${displayPrompt}"`;
}
override getPolicyUpdateOptions(
_outcome: ToolConfirmationOutcome,
): PolicyUpdateOptions | undefined {
if (this.params.url) {
return {
argsPattern: buildPatternArgsPattern(this.params.url),
};
}
if (this.params.prompt) {
return {
argsPattern: buildPatternArgsPattern(this.params.prompt),
};
}
return undefined;
}
protected override async getConfirmationDetails(
_abortSignal: AbortSignal,
): Promise<ToolCallConfirmationDetails | false> {
+10
View File
@@ -24,7 +24,9 @@ import {
type ToolLocation,
type ToolResult,
type ToolConfirmationOutcome,
type PolicyUpdateOptions,
} from './tools.js';
import { buildFilePathArgsPattern } from '../policy/utils.js';
import { ToolErrorType } from './tool-error.js';
import { makeRelative, shortenPath } from '../utils/paths.js';
import { getErrorMessage, isNodeError } from '../utils/errors.js';
@@ -164,6 +166,14 @@ class WriteFileToolInvocation extends BaseToolInvocation<
return [{ path: this.resolvedPath }];
}
override getPolicyUpdateOptions(
_outcome: ToolConfirmationOutcome,
): PolicyUpdateOptions | undefined {
return {
argsPattern: buildFilePathArgsPattern(this.params.file_path),
};
}
override getDescription(): string {
const relativePath = makeRelative(
this.resolvedPath,