Skip to content

Commit e0a2179

Browse files
committed
🤖 Block redundant path prefixes in file_* tools to save tokens
Add validation to reject absolute paths that contain the workspace directory prefix, encouraging agents to use relative paths instead. This helps save tokens by avoiding redundant path prefixes in tool calls. Changes: - Add validateNoRedundantPrefix() to fileCommon.ts to detect absolute paths containing cwd - Integrate validation into all file_* tools (file_read, file_edit_insert, file_edit_operation) - Update all tool tests to use relative paths instead of absolute paths - Add comprehensive test coverage for redundant prefix detection The validation provides helpful error messages suggesting the relative path equivalent: "Redundant path prefix detected. The path '/workspace/project/src/file.ts' contains the workspace directory. Please use relative paths to save tokens: 'src/file.ts'" _Generated with `cmux`_
1 parent 45f49be commit e0a2179

File tree

8 files changed

+203
-40
lines changed

8 files changed

+203
-40
lines changed

src/services/tools/fileCommon.test.ts

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { describe, it, expect } from "bun:test";
22
import type { FileStat } from "@/runtime/Runtime";
3-
import { validatePathInCwd, validateFileSize, MAX_FILE_SIZE } from "./fileCommon";
3+
import {
4+
validatePathInCwd,
5+
validateFileSize,
6+
validateNoRedundantPrefix,
7+
MAX_FILE_SIZE,
8+
} from "./fileCommon";
49
import { createRuntime } from "@/runtime/runtimeFactory";
510

611
describe("fileCommon", () => {
@@ -131,4 +136,63 @@ describe("fileCommon", () => {
131136
expect(result).not.toBeNull();
132137
});
133138
});
139+
140+
describe("validateNoRedundantPrefix", () => {
141+
const cwd = "/workspace/project";
142+
const runtime = createRuntime({ type: "local", srcBaseDir: cwd });
143+
144+
it("should allow relative paths", () => {
145+
expect(validateNoRedundantPrefix("src/file.ts", cwd, runtime)).toBeNull();
146+
expect(validateNoRedundantPrefix("./src/file.ts", cwd, runtime)).toBeNull();
147+
expect(validateNoRedundantPrefix("file.ts", cwd, runtime)).toBeNull();
148+
});
149+
150+
it("should reject absolute paths that contain the cwd prefix", () => {
151+
const result = validateNoRedundantPrefix("/workspace/project/src/file.ts", cwd, runtime);
152+
expect(result).not.toBeNull();
153+
expect(result?.error).toContain("Redundant path prefix detected");
154+
expect(result?.error).toContain("Please use relative paths to save tokens");
155+
expect(result?.error).toContain("src/file.ts"); // Should suggest the relative path
156+
});
157+
158+
it("should reject absolute paths at the cwd root", () => {
159+
const result = validateNoRedundantPrefix("/workspace/project/file.ts", cwd, runtime);
160+
expect(result).not.toBeNull();
161+
expect(result?.error).toContain("Redundant path prefix detected");
162+
expect(result?.error).toContain("file.ts"); // Should suggest the relative path
163+
});
164+
165+
it("should allow absolute paths outside cwd (they will be caught by validatePathInCwd)", () => {
166+
// This validation only catches redundant prefixes, not paths outside cwd
167+
expect(validateNoRedundantPrefix("/etc/passwd", cwd, runtime)).toBeNull();
168+
expect(validateNoRedundantPrefix("/home/user/file.ts", cwd, runtime)).toBeNull();
169+
});
170+
171+
it("should handle paths with ..", () => {
172+
// Relative paths with .. are fine for this check
173+
expect(validateNoRedundantPrefix("../outside.ts", cwd, runtime)).toBeNull();
174+
expect(validateNoRedundantPrefix("src/../../outside.ts", cwd, runtime)).toBeNull();
175+
});
176+
177+
it("should work with cwd that has trailing slash", () => {
178+
const cwdWithSlash = "/workspace/project/";
179+
const result = validateNoRedundantPrefix(
180+
"/workspace/project/src/file.ts",
181+
cwdWithSlash,
182+
runtime
183+
);
184+
expect(result).not.toBeNull();
185+
expect(result?.error).toContain("src/file.ts");
186+
});
187+
188+
it("should handle nested paths correctly", () => {
189+
const result = validateNoRedundantPrefix(
190+
"/workspace/project/src/components/Button/index.ts",
191+
cwd,
192+
runtime
193+
);
194+
expect(result).not.toBeNull();
195+
expect(result?.error).toContain("src/components/Button/index.ts");
196+
});
197+
});
134198
});

src/services/tools/fileCommon.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,46 @@ export function validateFileSize(stats: FileStat): { error: string } | null {
4848
return null;
4949
}
5050

51+
/**
52+
* Validates that a file path doesn't contain redundant workspace prefix.
53+
* Returns an error object if the path contains the cwd prefix, null if valid.
54+
* This helps save tokens by encouraging relative paths.
55+
*
56+
* @param filePath - The file path to validate
57+
* @param cwd - The working directory
58+
* @param runtime - The runtime (skip check for SSH since paths are remote)
59+
* @returns Error object if redundant prefix found, null if valid
60+
*/
61+
export function validateNoRedundantPrefix(
62+
filePath: string,
63+
cwd: string,
64+
runtime: Runtime
65+
): { error: string } | null {
66+
// Skip for SSH runtimes - remote paths don't apply
67+
if (runtime instanceof SSHRuntime) {
68+
return null;
69+
}
70+
71+
// Check if the path contains the cwd as a prefix (indicating redundancy)
72+
// This catches cases like "/workspace/project/src/file.ts" when cwd is "/workspace/project"
73+
const normalizedPath = path.normalize(filePath);
74+
const normalizedCwd = path.normalize(cwd);
75+
76+
// Only check absolute paths - relative paths are fine
77+
if (path.isAbsolute(normalizedPath)) {
78+
// Check if the absolute path starts with the cwd
79+
if (normalizedPath.startsWith(normalizedCwd)) {
80+
// Calculate what the relative path would be
81+
const relativePath = path.relative(normalizedCwd, normalizedPath);
82+
return {
83+
error: `Redundant path prefix detected. The path '${filePath}' contains the workspace directory. Please use relative paths to save tokens: '${relativePath}'`,
84+
};
85+
}
86+
}
87+
88+
return null;
89+
}
90+
5191
/**
5292
* Validates that a file path is within the allowed working directory.
5393
* Returns an error object if the path is outside cwd, null if valid.

src/services/tools/file_edit_insert.test.ts

Lines changed: 14 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ describe("file_edit_insert tool", () => {
5555
using testEnv = createTestFileEditInsertTool({ cwd: testDir });
5656
const tool = testEnv.tool;
5757
const args: FileEditInsertToolArgs = {
58-
file_path: testFilePath,
58+
file_path: "test.txt", // Use relative path
5959
line_offset: 0,
6060
content: "INSERTED",
6161
};
@@ -78,7 +78,7 @@ describe("file_edit_insert tool", () => {
7878
using testEnv = createTestFileEditInsertTool({ cwd: testDir });
7979
const tool = testEnv.tool;
8080
const args: FileEditInsertToolArgs = {
81-
file_path: testFilePath,
81+
file_path: "test.txt", // Use relative path
8282
line_offset: 1,
8383
content: "INSERTED",
8484
};
@@ -101,7 +101,7 @@ describe("file_edit_insert tool", () => {
101101
using testEnv = createTestFileEditInsertTool({ cwd: testDir });
102102
const tool = testEnv.tool;
103103
const args: FileEditInsertToolArgs = {
104-
file_path: testFilePath,
104+
file_path: "test.txt", // Use relative path
105105
line_offset: 2,
106106
content: "INSERTED",
107107
};
@@ -124,7 +124,7 @@ describe("file_edit_insert tool", () => {
124124
using testEnv = createTestFileEditInsertTool({ cwd: testDir });
125125
const tool = testEnv.tool;
126126
const args: FileEditInsertToolArgs = {
127-
file_path: testFilePath,
127+
file_path: "test.txt", // Use relative path
128128
line_offset: 3,
129129
content: "INSERTED",
130130
};
@@ -147,7 +147,7 @@ describe("file_edit_insert tool", () => {
147147
using testEnv = createTestFileEditInsertTool({ cwd: testDir });
148148
const tool = testEnv.tool;
149149
const args: FileEditInsertToolArgs = {
150-
file_path: testFilePath,
150+
file_path: "test.txt", // Use relative path
151151
line_offset: 1,
152152
content: "INSERTED1\nINSERTED2",
153153
};
@@ -169,7 +169,7 @@ describe("file_edit_insert tool", () => {
169169
using testEnv = createTestFileEditInsertTool({ cwd: testDir });
170170
const tool = testEnv.tool;
171171
const args: FileEditInsertToolArgs = {
172-
file_path: testFilePath,
172+
file_path: "test.txt", // Use relative path
173173
line_offset: 0,
174174
content: "INSERTED",
175175
};
@@ -186,12 +186,10 @@ describe("file_edit_insert tool", () => {
186186

187187
it("should fail when file does not exist and create is not set", async () => {
188188
// Setup
189-
const nonExistentPath = path.join(testDir, "nonexistent.txt");
190-
191189
using testEnv = createTestFileEditInsertTool({ cwd: testDir });
192190
const tool = testEnv.tool;
193191
const args: FileEditInsertToolArgs = {
194-
file_path: nonExistentPath,
192+
file_path: "nonexistent.txt", // Use relative path
195193
line_offset: 0,
196194
content: "INSERTED",
197195
};
@@ -209,15 +207,13 @@ describe("file_edit_insert tool", () => {
209207

210208
it("should create file when create is true and file does not exist", async () => {
211209
// Setup
212-
const nonExistentPath = path.join(testDir, "newfile.txt");
213-
214210
const tool = createFileEditInsertTool({
215211
cwd: testDir,
216212
runtime: createRuntime({ type: "local", srcBaseDir: "/tmp" }),
217213
runtimeTempDir: "/tmp",
218214
});
219215
const args: FileEditInsertToolArgs = {
220-
file_path: nonExistentPath,
216+
file_path: "newfile.txt", // Use relative path
221217
line_offset: 0,
222218
content: "INSERTED",
223219
create: true,
@@ -229,21 +225,19 @@ describe("file_edit_insert tool", () => {
229225
// Assert
230226
expect(result.success).toBe(true);
231227

232-
const fileContent = await fs.readFile(nonExistentPath, "utf-8");
228+
const fileContent = await fs.readFile(path.join(testDir, "newfile.txt"), "utf-8");
233229
expect(fileContent).toBe("INSERTED\n");
234230
});
235231

236232
it("should create parent directories when create is true", async () => {
237233
// Setup
238-
const nestedPath = path.join(testDir, "nested", "dir", "newfile.txt");
239-
240234
const tool = createFileEditInsertTool({
241235
cwd: testDir,
242236
runtime: createRuntime({ type: "local", srcBaseDir: "/tmp" }),
243237
runtimeTempDir: "/tmp",
244238
});
245239
const args: FileEditInsertToolArgs = {
246-
file_path: nestedPath,
240+
file_path: "nested/dir/newfile.txt", // Use relative path
247241
line_offset: 0,
248242
content: "INSERTED",
249243
create: true,
@@ -255,7 +249,7 @@ describe("file_edit_insert tool", () => {
255249
// Assert
256250
expect(result.success).toBe(true);
257251

258-
const fileContent = await fs.readFile(nestedPath, "utf-8");
252+
const fileContent = await fs.readFile(path.join(testDir, "nested/dir/newfile.txt"), "utf-8");
259253
expect(fileContent).toBe("INSERTED\n");
260254
});
261255

@@ -270,7 +264,7 @@ describe("file_edit_insert tool", () => {
270264
runtimeTempDir: "/tmp",
271265
});
272266
const args: FileEditInsertToolArgs = {
273-
file_path: testFilePath,
267+
file_path: "test.txt", // Use relative path
274268
line_offset: 1,
275269
content: "INSERTED",
276270
create: true,
@@ -294,7 +288,7 @@ describe("file_edit_insert tool", () => {
294288
using testEnv = createTestFileEditInsertTool({ cwd: testDir });
295289
const tool = testEnv.tool;
296290
const args: FileEditInsertToolArgs = {
297-
file_path: testFilePath,
291+
file_path: "test.txt", // Use relative path
298292
line_offset: -1,
299293
content: "INSERTED",
300294
};
@@ -317,7 +311,7 @@ describe("file_edit_insert tool", () => {
317311
using testEnv = createTestFileEditInsertTool({ cwd: testDir });
318312
const tool = testEnv.tool;
319313
const args: FileEditInsertToolArgs = {
320-
file_path: testFilePath,
314+
file_path: "test.txt", // Use relative path
321315
line_offset: 10, // File only has 2 lines
322316
content: "INSERTED",
323317
};

src/services/tools/file_edit_insert.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { tool } from "ai";
22
import type { FileEditInsertToolResult } from "@/types/tools";
33
import type { ToolConfiguration, ToolFactory } from "@/utils/tools/tools";
44
import { TOOL_DEFINITIONS } from "@/utils/tools/toolDefinitions";
5-
import { validatePathInCwd } from "./fileCommon";
5+
import { validatePathInCwd, validateNoRedundantPrefix } from "./fileCommon";
66
import { WRITE_DENIED_PREFIX } from "@/types/tools";
77
import { executeFileEditOperation } from "./file_edit_operation";
88
import { RuntimeError } from "@/runtime/Runtime";
@@ -25,6 +25,19 @@ export const createFileEditInsertTool: ToolFactory = (config: ToolConfiguration)
2525
create,
2626
}): Promise<FileEditInsertToolResult> => {
2727
try {
28+
// Validate no redundant path prefix (must come first to catch absolute paths)
29+
const redundantPrefixValidation = validateNoRedundantPrefix(
30+
file_path,
31+
config.cwd,
32+
config.runtime
33+
);
34+
if (redundantPrefixValidation) {
35+
return {
36+
success: false,
37+
error: redundantPrefixValidation.error,
38+
};
39+
}
40+
2841
const pathValidation = validatePathInCwd(file_path, config.cwd, config.runtime);
2942
if (pathValidation) {
3043
return {

src/services/tools/file_edit_operation.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import type { FileEditDiffSuccessBase, FileEditErrorResult } from "@/types/tools";
22
import { WRITE_DENIED_PREFIX } from "@/types/tools";
33
import type { ToolConfiguration } from "@/utils/tools/tools";
4-
import { generateDiff, validateFileSize, validatePathInCwd } from "./fileCommon";
4+
import {
5+
generateDiff,
6+
validateFileSize,
7+
validatePathInCwd,
8+
validateNoRedundantPrefix,
9+
} from "./fileCommon";
510
import { RuntimeError } from "@/runtime/Runtime";
611
import { readFileString, writeFileString } from "@/utils/runtime/helpers";
712

@@ -36,6 +41,19 @@ export async function executeFileEditOperation<TMetadata>({
3641
FileEditErrorResult | (FileEditDiffSuccessBase & TMetadata)
3742
> {
3843
try {
44+
// Validate no redundant path prefix (must come first to catch absolute paths)
45+
const redundantPrefixValidation = validateNoRedundantPrefix(
46+
filePath,
47+
config.cwd,
48+
config.runtime
49+
);
50+
if (redundantPrefixValidation) {
51+
return {
52+
success: false,
53+
error: `${WRITE_DENIED_PREFIX} ${redundantPrefixValidation.error}`,
54+
};
55+
}
56+
3957
const pathValidation = validatePathInCwd(filePath, config.cwd, config.runtime);
4058
if (pathValidation) {
4159
return {

src/services/tools/file_edit_replace.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ describe("file_edit_replace_string tool", () => {
6464
});
6565

6666
const payload: FileEditReplaceStringToolArgs = {
67-
file_path: testFilePath,
67+
file_path: "test.txt", // Use relative path
6868
old_string: "Hello world",
6969
new_string: "Hello universe",
7070
};
@@ -102,7 +102,7 @@ describe("file_edit_replace_lines tool", () => {
102102
});
103103

104104
const payload: FileEditReplaceLinesToolArgs = {
105-
file_path: testFilePath,
105+
file_path: "test.txt", // Use relative path
106106
start_line: 2,
107107
end_line: 3,
108108
new_lines: ["LINE2", "LINE3"],

0 commit comments

Comments
 (0)