@@ -26,6 +26,7 @@ import {
2626} from "../helpers/imageHelpers"
2727import { extractTextFromFile , addLineNumbers , getSupportedBinaryFormats } from "../../../integrations/misc/extract-text"
2828import { readWithIndentation , readWithSlice } from "../../../integrations/misc/indentation-reader"
29+ import { isPathOutsideWorkspace } from "../../../utils/pathUtils"
2930
3031// ─── Mocks ────────────────────────────────────────────────────────────────────
3132
@@ -60,6 +61,12 @@ vi.mock("../../../integrations/misc/indentation-reader", () => ({
6061 readWithSlice : vi . fn ( ) ,
6162} ) )
6263
64+ // Spy on the workspace-boundary check so we can assert symlink resolution is
65+ // honored for reads (#169 / #241). Default to "inside workspace" (false).
66+ vi . mock ( "../../../utils/pathUtils" , ( ) => ( {
67+ isPathOutsideWorkspace : vi . fn ( ( ) => false ) ,
68+ } ) )
69+
6370vi . mock ( "../helpers/imageHelpers" , ( ) => ( {
6471 DEFAULT_MAX_IMAGE_FILE_SIZE_MB : 5 ,
6572 DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB : 20 ,
@@ -132,10 +139,17 @@ interface MockTaskOptions {
132139 rooIgnoreAllowed ?: boolean
133140 maxImageFileSize ?: number
134141 maxTotalImageSize ?: number
142+ allowSymlinksOutsideWorkspace ?: boolean
135143}
136144
137145function createMockTask ( options : MockTaskOptions = { } ) {
138- const { supportsImages = false , rooIgnoreAllowed = true , maxImageFileSize = 5 , maxTotalImageSize = 20 } = options
146+ const {
147+ supportsImages = false ,
148+ rooIgnoreAllowed = true ,
149+ maxImageFileSize = 5 ,
150+ maxTotalImageSize = 20 ,
151+ allowSymlinksOutsideWorkspace = false ,
152+ } = options
139153
140154 return {
141155 cwd : "/test/workspace" ,
@@ -162,6 +176,7 @@ function createMockTask(options: MockTaskOptions = {}) {
162176 getState : vi . fn ( ) . mockResolvedValue ( {
163177 maxImageFileSize,
164178 maxTotalImageSize,
179+ allowSymlinksOutsideWorkspace,
165180 } ) ,
166181 } ) ,
167182 } ,
@@ -195,6 +210,57 @@ describe("ReadFileTool", () => {
195210 } )
196211 } )
197212
213+ describe ( "workspace boundary (symlink resolution, #169 / #241)" , ( ) => {
214+ it ( "routes the read boundary check through symlink resolution with the setting disabled" , async ( ) => {
215+ vi . mocked ( isPathOutsideWorkspace ) . mockReturnValue ( false )
216+ const mockTask = createMockTask ( { allowSymlinksOutsideWorkspace : false } )
217+ const callbacks = createMockCallbacks ( )
218+
219+ await readFileTool . execute ( { path : "test.txt" } , mockTask as any , callbacks )
220+
221+ // The boundary check must be invoked with the resolved option, not bypassed.
222+ expect ( isPathOutsideWorkspace ) . toHaveBeenCalledWith (
223+ expect . any ( String ) ,
224+ expect . objectContaining ( { allowSymlinksOutsideWorkspace : false } ) ,
225+ )
226+
227+ // The approval payload must reflect the boundary decision.
228+ const toolAsk = mockTask . ask . mock . calls . find ( ( [ type ] : [ string ] ) => type === "tool" )
229+ expect ( toolAsk ) . toBeDefined ( )
230+ expect ( JSON . parse ( toolAsk ! [ 1 ] as string ) ) . toEqual (
231+ expect . objectContaining ( { tool : "readFile" , isOutsideWorkspace : false } ) ,
232+ )
233+ } )
234+
235+ it ( "forwards allowSymlinksOutsideWorkspace=true from provider state to the boundary check" , async ( ) => {
236+ vi . mocked ( isPathOutsideWorkspace ) . mockReturnValue ( false )
237+ const mockTask = createMockTask ( { allowSymlinksOutsideWorkspace : true } )
238+ const callbacks = createMockCallbacks ( )
239+
240+ await readFileTool . execute ( { path : "test.txt" } , mockTask as any , callbacks )
241+
242+ expect ( isPathOutsideWorkspace ) . toHaveBeenCalledWith (
243+ expect . any ( String ) ,
244+ expect . objectContaining ( { allowSymlinksOutsideWorkspace : true } ) ,
245+ )
246+ } )
247+
248+ it ( "surfaces a symlink resolving outside the workspace as isOutsideWorkspace=true" , async ( ) => {
249+ // Simulate a symlink whose real target is outside the workspace.
250+ vi . mocked ( isPathOutsideWorkspace ) . mockReturnValue ( true )
251+ const mockTask = createMockTask ( { allowSymlinksOutsideWorkspace : false } )
252+ const callbacks = createMockCallbacks ( )
253+
254+ await readFileTool . execute ( { path : "link-to-outside.txt" } , mockTask as any , callbacks )
255+
256+ const toolAsk = mockTask . ask . mock . calls . find ( ( [ type ] : [ string ] ) => type === "tool" )
257+ expect ( toolAsk ) . toBeDefined ( )
258+ expect ( JSON . parse ( toolAsk ! [ 1 ] as string ) ) . toEqual (
259+ expect . objectContaining ( { tool : "readFile" , isOutsideWorkspace : true } ) ,
260+ )
261+ } )
262+ } )
263+
198264 describe ( "input validation" , ( ) => {
199265 it ( "should return error when path is missing" , async ( ) => {
200266 const mockTask = createMockTask ( )
0 commit comments