Skip to content

Commit 7d88eb1

Browse files
committed
test(sdk): cover apply_patch target resolution
Exercise the nested apply_patch target check in project-only mode: in-project paths dispatch, out-of-project and symlink-escape paths are rejected, and a malformed target with no path fails closed. The write override is explicitly trusted so the test isolates target confinement from host trust.
1 parent 355bc39 commit 7d88eb1

1 file changed

Lines changed: 74 additions & 16 deletions

File tree

Lines changed: 74 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
11
import { createMockFs } from '@codebuff/common/testing/mocks/filesystem'
22
import { describe, expect, mock, test } from 'bun:test'
3+
import { mkdtempSync, mkdirSync, symlinkSync, rmSync } from 'fs'
4+
import { realpath as fsRealpath } from 'fs/promises'
5+
import { tmpdir } from 'os'
6+
import { join } from 'path'
37

48
import { createExecutionPolicy } from '../execution-policy'
59
import { handleToolCall } from '../../run'
610

7-
async function dispatchApplyPatch(path: string) {
11+
async function dispatchApplyPatch(
12+
operation: unknown,
13+
options: { projectRoot: string; cwd: string; canonicalize?: (p: string) => Promise<string> },
14+
) {
815
const writeOverride = mock(async () => [
916
{ type: 'json' as const, value: { message: 'patch dispatched' } },
1017
])
@@ -14,39 +21,90 @@ async function dispatchApplyPatch(path: string) {
1421
requestId: crypto.randomUUID(),
1522
userInputId: 'test-input',
1623
toolName: 'apply_patch',
17-
input: {
18-
operation: {
19-
type: 'create_file',
20-
path,
21-
diff: '@@\n+content\n',
22-
},
23-
},
24+
input: { operation },
2425
timeout: undefined,
2526
},
2627
overrides: { write_file: writeOverride },
2728
customToolDefinitions: {},
28-
cwd: '/project',
29+
cwd: options.cwd,
2930
fs: createMockFs(),
3031
policy: createExecutionPolicy({
31-
projectRoot: '/project',
32+
projectRoot: options.projectRoot,
3233
projectOnly: true,
34+
allowExternalTools: true,
35+
...(options.canonicalize ? { canonicalize: options.canonicalize } : {}),
3336
}),
3437
})
3538
return { result, writeOverride }
3639
}
3740

3841
describe('apply_patch project-only target resolution', () => {
42+
const withProject = async (
43+
body: (paths: { base: string; root: string; outside: string }) => Promise<void>,
44+
) => {
45+
const base = mkdtempSync(join(tmpdir(), 'apply-patch-test-'))
46+
const root = join(base, 'project')
47+
const outside = join(base, 'outside')
48+
mkdirSync(root)
49+
mkdirSync(outside)
50+
try {
51+
await body({ base, root, outside })
52+
} finally {
53+
rmSync(base, { recursive: true, force: true })
54+
}
55+
}
56+
3957
test('dispatches a patch targeting a file inside the project', async () => {
40-
const { result, writeOverride } = await dispatchApplyPatch('src/new.ts')
58+
await withProject(async ({ root }) => {
59+
const { result, writeOverride } = await dispatchApplyPatch(
60+
{ type: 'create_file', path: 'src/new.ts', diff: '@@\n+content\n' },
61+
{ projectRoot: root, cwd: root, canonicalize: fsRealpath },
62+
)
4163

42-
expect(writeOverride).toHaveBeenCalledTimes(1)
43-
expect(JSON.stringify(result.output)).toContain('patch dispatched')
64+
expect(writeOverride).toHaveBeenCalledTimes(1)
65+
expect(JSON.stringify(result.output)).toContain('patch dispatched')
66+
})
4467
})
4568

4669
test('rejects a patch targeting a file outside the project', async () => {
47-
const { result, writeOverride } = await dispatchApplyPatch('../outside.txt')
70+
await withProject(async ({ root }) => {
71+
const { result, writeOverride } = await dispatchApplyPatch(
72+
{ type: 'create_file', path: '../outside.txt', diff: '@@\n+content\n' },
73+
{ projectRoot: root, cwd: root, canonicalize: fsRealpath },
74+
)
75+
76+
expect(writeOverride).not.toHaveBeenCalled()
77+
expect(JSON.stringify(result.output)).toContain('outside project root')
78+
})
79+
})
80+
81+
test('rejects a patch whose in-project path is a symlink to outside the project', async () => {
82+
await withProject(async ({ root, outside }) => {
83+
symlinkSync(outside, join(root, 'link'))
84+
const { result, writeOverride } = await dispatchApplyPatch(
85+
{ type: 'create_file', path: 'link/evil.txt', diff: '@@\n+content\n' },
86+
{ projectRoot: root, cwd: root, canonicalize: fsRealpath },
87+
)
88+
89+
expect(writeOverride).not.toHaveBeenCalled()
90+
expect(JSON.stringify(result.output)).toContain('outside project root')
91+
})
92+
})
93+
94+
test('rejects a malformed patch with no target path', async () => {
95+
await withProject(async ({ root }) => {
96+
for (const operation of [{ type: 'create_file' }, undefined, { path: '' }]) {
97+
const { result, writeOverride } = await dispatchApplyPatch(operation, {
98+
projectRoot: root,
99+
cwd: root,
100+
canonicalize: fsRealpath,
101+
})
48102

49-
expect(writeOverride).not.toHaveBeenCalled()
50-
expect(JSON.stringify(result.output)).toContain('outside project root')
103+
expect(writeOverride).not.toHaveBeenCalled()
104+
expect(JSON.stringify(result.output)).toContain(
105+
'apply_patch target could not be verified',
106+
)
107+
}
108+
})
51109
})
52110
})

0 commit comments

Comments
 (0)