Skip to content

Commit 2f89dd9

Browse files
committed
Support absolute file tool paths
1 parent 43d0008 commit 2f89dd9

11 files changed

Lines changed: 346 additions & 144 deletions

File tree

common/src/templates/initial-agents-dir/types/tools.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -353,7 +353,7 @@ export interface SpawnAgentsParams {
353353
* Replace strings in a file with new strings.
354354
*/
355355
export interface StrReplaceParams {
356-
/** The path to the file to edit. */
356+
/** The path to the file to edit, either relative to the project root or an absolute path inside the project. */
357357
path: string
358358
/** Array of replacements to make. */
359359
replacements: {
@@ -411,7 +411,7 @@ export interface WebSearchParams {
411411
* Create or edit a file with the given content.
412412
*/
413413
export interface WriteFileParams {
414-
/** Path to the file relative to the **project root** */
414+
/** Path to the file, either relative to the project root or an absolute path inside the project. */
415415
path: string
416416
/** What the change is intended to do in only one sentence. */
417417
instructions: string

common/src/tools/params/tool/str-replace.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@ const inputSchema = z
2828
path: z
2929
.string()
3030
.min(1, 'Path cannot be empty')
31-
.describe(`The path to the file to edit.`),
31+
.describe(
32+
`The path to the file to edit, either relative to the project root or an absolute path inside the project.`,
33+
),
3234
replacements: z
3335
.preprocess(
3436
coerceToArray,

common/src/tools/params/tool/write-file.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ const inputSchema = z
1212
path: z
1313
.string()
1414
.min(1, 'Path cannot be empty')
15-
.describe(`Path to the file relative to the **project root**`),
15+
.describe(
16+
`Path to the file, either relative to the project root or an absolute path inside the project.`,
17+
),
1618
instructions: z
1719
.string()
1820
.describe('What the change is intended to do in only one sentence.'),

sdk/src/__tests__/change-file.test.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,37 @@ describe('changeFile', () => {
3636
)
3737
})
3838

39+
test('accepts absolute paths inside the project for string replacements', async () => {
40+
const fs = createMockFs({
41+
files: {
42+
'/repo/src/file.ts': 'const value = 1\n',
43+
},
44+
})
45+
46+
const result = await changeFile({
47+
parameters: {
48+
type: 'patch',
49+
path: '/repo/src/file.ts',
50+
content: '@@ -1,1 +1,1 @@\n-const value = 1\n+const value = 2\n',
51+
},
52+
cwd: '/repo',
53+
fs,
54+
})
55+
56+
expect(result).toEqual([
57+
{
58+
type: 'json',
59+
value: {
60+
file: 'src/file.ts',
61+
message: 'String replace applied successfully.',
62+
},
63+
},
64+
])
65+
expect(await fs.readFile('/repo/src/file.ts', 'utf-8')).toBe(
66+
'const value = 2\n',
67+
)
68+
})
69+
3970
test('returns a simple success message for new file writes', async () => {
4071
const fs = createMockFs()
4172

@@ -63,6 +94,58 @@ describe('changeFile', () => {
6394
)
6495
})
6596

97+
test('accepts absolute paths inside the project for file writes', async () => {
98+
const fs = createMockFs()
99+
100+
const result = await changeFile({
101+
parameters: {
102+
type: 'file',
103+
path: '/repo/src/file.ts',
104+
content: 'const value = 1\n',
105+
},
106+
cwd: '/repo',
107+
fs,
108+
})
109+
110+
expect(result).toEqual([
111+
{
112+
type: 'json',
113+
value: {
114+
file: 'src/file.ts',
115+
message: 'Created file successfully.',
116+
},
117+
},
118+
])
119+
expect(await fs.readFile('/repo/src/file.ts', 'utf-8')).toBe(
120+
'const value = 1\n',
121+
)
122+
})
123+
124+
test('accepts paths whose file names start with two dots inside the project', async () => {
125+
const fs = createMockFs()
126+
127+
const result = await changeFile({
128+
parameters: {
129+
type: 'file',
130+
path: '/repo/..config',
131+
content: 'value = true\n',
132+
},
133+
cwd: '/repo',
134+
fs,
135+
})
136+
137+
expect(result).toEqual([
138+
{
139+
type: 'json',
140+
value: {
141+
file: '..config',
142+
message: 'Created file successfully.',
143+
},
144+
},
145+
])
146+
expect(await fs.readFile('/repo/..config', 'utf-8')).toBe('value = true\n')
147+
})
148+
66149
test('returns a simple success message for overwritten file writes', async () => {
67150
const fs = createMockFs({
68151
files: {
@@ -93,4 +176,20 @@ describe('changeFile', () => {
93176
'const value = 2\n',
94177
)
95178
})
179+
180+
test('rejects absolute paths outside the project', async () => {
181+
const fs = createMockFs()
182+
183+
await expect(
184+
changeFile({
185+
parameters: {
186+
type: 'file',
187+
path: '/outside/file.ts',
188+
content: 'const value = 1\n',
189+
},
190+
cwd: '/repo',
191+
fs,
192+
}),
193+
).rejects.toThrow('file path is outside the project directory')
194+
})
96195
})
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { describe, expect, test } from 'bun:test'
2+
3+
import { resolveFilePathWithinProject } from '../tools/path-utils'
4+
5+
describe('resolveFilePathWithinProject', () => {
6+
test('normalizes relative paths to full and project-relative paths', () => {
7+
expect(resolveFilePathWithinProject('/repo', 'src/file.ts')).toEqual({
8+
fullPath: '/repo/src/file.ts',
9+
relativePath: 'src/file.ts',
10+
})
11+
})
12+
13+
test('normalizes absolute paths inside the project', () => {
14+
expect(resolveFilePathWithinProject('/repo', '/repo/src/file.ts')).toEqual({
15+
fullPath: '/repo/src/file.ts',
16+
relativePath: 'src/file.ts',
17+
})
18+
})
19+
20+
test('allows file names that start with two dots inside the project', () => {
21+
expect(resolveFilePathWithinProject('/repo', '/repo/..config')).toEqual({
22+
fullPath: '/repo/..config',
23+
relativePath: '..config',
24+
})
25+
})
26+
27+
test('rejects paths outside the project', () => {
28+
expect(resolveFilePathWithinProject('/repo', '../outside.ts')).toBeNull()
29+
expect(resolveFilePathWithinProject('/repo', '/outside.ts')).toBeNull()
30+
expect(
31+
resolveFilePathWithinProject('/repo', '/repo-sibling/file.ts'),
32+
).toBeNull()
33+
})
34+
})

sdk/src/__tests__/read-files.test.ts

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,11 @@ import {
1111
spyOn,
1212
} from 'bun:test'
1313

14-
1514
import { getFiles } from '../tools/read-files'
1615

1716
import type { CodebuffFileSystem } from '@codebuff/common/types/filesystem'
1817
import type { PathLike } from 'node:fs'
1918

20-
2119
// Helper to create a mock filesystem
2220
function createMockFs(config: {
2321
files?: Record<string, { content: string; size?: number }>
@@ -75,9 +73,10 @@ describe('getFiles', () => {
7573

7674
beforeEach(() => {
7775
// Default: no files are ignored
78-
isFileIgnoredSpy = spyOn(projectFileTree, 'isFileIgnored').mockResolvedValue(
79-
false,
80-
)
76+
isFileIgnoredSpy = spyOn(
77+
projectFileTree,
78+
'isFileIgnored',
79+
).mockResolvedValue(false)
8180
})
8281

8382
afterEach(() => {
@@ -320,9 +319,7 @@ describe('getFiles', () => {
320319

321320
test('should handle mix of ignored and non-ignored files', async () => {
322321
// First call returns false (not ignored), second returns true (ignored)
323-
isFileIgnoredSpy
324-
.mockResolvedValueOnce(false)
325-
.mockResolvedValueOnce(true)
322+
isFileIgnoredSpy.mockResolvedValueOnce(false).mockResolvedValueOnce(true)
326323

327324
const mockFs = createMockFs({
328325
files: {
@@ -393,7 +390,10 @@ describe('getFiles', () => {
393390
const mockFs = createMockFs({
394391
files: {},
395392
errors: {
396-
'/project/broken.ts': { code: 'EACCES', message: 'Permission denied' },
393+
'/project/broken.ts': {
394+
code: 'EACCES',
395+
message: 'Permission denied',
396+
},
397397
},
398398
})
399399

@@ -423,6 +423,24 @@ describe('getFiles', () => {
423423

424424
expect(result['src/index.ts']).toBe('content')
425425
})
426+
427+
test('should reject absolute paths in sibling directories with matching prefixes', async () => {
428+
const mockFs = createMockFs({
429+
files: {
430+
'/project-other/src/index.ts': { content: 'outside' },
431+
},
432+
})
433+
434+
const result = await getFiles({
435+
filePaths: ['/project-other/src/index.ts'],
436+
cwd: '/project',
437+
fs: mockFs,
438+
})
439+
440+
expect(result['/project-other/src/index.ts']).toBe(
441+
FILE_READ_STATUS.OUTSIDE_PROJECT,
442+
)
443+
})
426444
})
427445

428446
describe('fileFilter option', () => {

0 commit comments

Comments
 (0)