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(