From 103234d34e3b14a3c3aed28460edf4d512b8d560 Mon Sep 17 00:00:00 2001 From: Laxman Reddy Aileni Date: Thu, 16 Jul 2026 00:47:17 +0000 Subject: [PATCH 1/4] fix: canonicalize symlinks (including dangling links) in workspace boundary check The workspace-boundary acceptance check (requiresPathAcceptance) canonicalized paths with fs.realpath and, for paths that do not exist yet, fell back to realpath(parent) + basename. That fallback returned the in-workspace link name for a symlink whose target does not exist yet (a "dangling" symlink), so such a path was treated as in-workspace and a create through it (e.g. fsWrite create) could land outside the workspace without prompting for approval. Add resolveSymlinkAwarePath, which follows a symlink at the leaf even when its target does not exist, resolves symlinked ancestor directories and symlink chains (with a cycle guard), and re-appends not-yet-created segments so the result reflects where a read/write would actually land. Also canonicalize workspace folders before the containment comparison so a workspace located under a symlinked directory (e.g. macOS /tmp -> /private/tmp) does not produce false prompts. Add OS-agnostic real-filesystem tests covering dangling leaf symlinks, symlinked ancestors, symlink chains, the existing-target regression, and the symlinked-workspace-root case. Also add hardening TODOs to grepSearch and lspApplyWorkspaceEdit (both currently not enabled) to route their boundary checks through the same symlink-aware helper before they are enabled. --- .../agenticChat/tools/grepSearch.ts | 6 + .../tools/lspApplyWorkspaceEdit.ts | 6 + .../agenticChat/tools/symlinkBoundary.test.ts | 158 ++++++++++++++++++ .../agenticChat/tools/toolShared.ts | 112 ++++++++++--- 4 files changed, 259 insertions(+), 23 deletions(-) create mode 100644 server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/symlinkBoundary.test.ts diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/grepSearch.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/grepSearch.ts index 9ae48ccae3..6a8cbf3b3b 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/grepSearch.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/grepSearch.ts @@ -79,6 +79,12 @@ export class GrepSearch { await closeWriter(writer) } + // TODO (workspace-boundary hardening): this acceptance check performs a + // string-only, non-symlink-aware workspace containment test on the raw + // search path. For parity with the file tools, route it through the + // symlink-aware requiresPathAcceptance helper (as fsRead/fsWrite/ + // listDirectory do) before this tool is enabled. Note this tool is not + // currently registered as an active tool (see toolServer.ts). public async requiresAcceptance(params: GrepSearchParams): Promise { const path = this.getSearchDirectory(params.path) return { requiresAcceptance: !workspaceUtils.isInWorkspace(getWorkspaceFolderPaths(this.workspace), path) } diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/lspApplyWorkspaceEdit.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/lspApplyWorkspaceEdit.ts index 3cc4040535..870d37ecc6 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/lspApplyWorkspaceEdit.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/lspApplyWorkspaceEdit.ts @@ -28,6 +28,12 @@ export class LspApplyWorkspaceEdit { this.lsp = features.lsp } + // TODO (workspace-boundary hardening): this tool applies edits without a + // workspace-boundary or acceptance check. For parity with the file tools, + // add symlink-aware boundary validation and approval gating that mirror the + // fs* write tools (resolve each edited URI via requiresPathAcceptance) + // before this tool is enabled. Note this tool is not currently registered + // in any runtime (LspToolsServer is not enabled). public async invoke(params: LspApplyWorkspaceEditParams): Promise { try { if (!params.edit || !params.edit.changes) { diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/symlinkBoundary.test.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/symlinkBoundary.test.ts new file mode 100644 index 0000000000..b5d9fee5af --- /dev/null +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/symlinkBoundary.test.ts @@ -0,0 +1,158 @@ +import * as assert from 'assert' +import * as fs from 'fs' +import * as os from 'os' +import * as path from 'path' +import { URI } from 'vscode-uri' +import { Features } from '@aws/language-server-runtimes/server-interface/server' +import { resolveSymlinkAwarePath, requiresPathAcceptance } from './toolShared' + +/** + * Real-filesystem tests for the symlink-aware workspace-boundary guard used by + * the file-writing/reading tools (fsWrite, fsRead, fsReplace, listDirectory, + * fileSearch) via requiresPathAcceptance. These exercise symlink resolution + * against the actual filesystem, since the guard's whole purpose is to reason + * about where a path physically lands, so they intentionally do not stub path + * utilities. + * + * The tests are OS-agnostic: paths are built with `path.join`, temp roots are + * canonicalized up front (macOS exposes the temp dir under a /var -> /private + * symlink), and any test that needs a symlink skips itself where the platform + * or user cannot create one (e.g. Windows without the privilege). + */ +describe('workspace boundary symlink handling', () => { + let root: string + let ws: string + let outside: string + + const noopLogging = { + info: () => {}, + warn: () => {}, + error: () => {}, + log: () => {}, + debug: () => {}, + } as unknown as Features['logging'] + + const makeWorkspace = (folder: string): Features['workspace'] => + ({ + getAllWorkspaceFolders: () => [{ uri: URI.file(folder).toString(), name: 'ws' }], + }) as unknown as Features['workspace'] + + /** Create a symlink, returning false if the platform/user cannot. */ + const trySymlink = (target: string, linkPath: string): boolean => { + try { + fs.symlinkSync(target, linkPath) + return true + } catch { + return false + } + } + + beforeEach(() => { + // Canonicalize the temp root so ws/outside are clean, real siblings. + const realTmp = fs.realpathSync(os.tmpdir()) + root = fs.mkdtempSync(path.join(realTmp, 'wsb-')) + ws = path.join(root, 'workspace') + outside = path.join(root, 'outside') + fs.mkdirSync(ws) + fs.mkdirSync(outside) + }) + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }) + }) + + describe('resolveSymlinkAwarePath', () => { + it('resolves a dangling leaf symlink to its (outside) target', async function () { + const target = path.join(outside, 'authorized_keys') // does not exist yet + const link = path.join(ws, 'setup-notes.md') + if (!trySymlink(target, link)) { + return this.skip() + } + const resolved = await resolveSymlinkAwarePath(link) + assert.strictEqual(resolved, target) + }) + + it('resolves a nonexistent leaf under a symlinked ancestor to the real (outside) location', async function () { + const linkDir = path.join(ws, 'sub') // -> outside + if (!trySymlink(outside, linkDir)) { + return this.skip() + } + const resolved = await resolveSymlinkAwarePath(path.join(linkDir, 'newfile.txt')) + assert.strictEqual(resolved, path.join(outside, 'newfile.txt')) + }) + + it('leaves a plain not-yet-created in-workspace path inside the workspace', async () => { + const resolved = await resolveSymlinkAwarePath(path.join(ws, 'brand-new.txt')) + assert.strictEqual(resolved, path.join(ws, 'brand-new.txt')) + }) + + it('canonicalizes an existing regular file', async () => { + const file = path.join(ws, 'exists.txt') + fs.writeFileSync(file, 'x') + const resolved = await resolveSymlinkAwarePath(file) + assert.strictEqual(resolved, fs.realpathSync(file)) + }) + + it('follows a chain of symlinks to the final (outside) target', async function () { + const finalTarget = path.join(outside, 'chained.txt') + const mid = path.join(ws, 'mid') + const head = path.join(ws, 'head') + if (!trySymlink(finalTarget, mid) || !trySymlink(mid, head)) { + return this.skip() + } + const resolved = await resolveSymlinkAwarePath(head) + assert.strictEqual(resolved, finalTarget) + }) + }) + + describe('requiresPathAcceptance (write/read guard)', () => { + it('requires acceptance for a dangling in-workspace symlink that points outside', async function () { + const target = path.join(outside, 'authorized_keys') // dangling: parent exists, file does not + const link = path.join(ws, 'setup-notes.md') + if (!trySymlink(target, link)) { + return this.skip() + } + const result = await requiresPathAcceptance(link, 'fsWrite', makeWorkspace(ws), noopLogging) + assert.strictEqual(result.requiresAcceptance, true) + }) + + it('does not require acceptance for a normal new in-workspace file', async () => { + const result = await requiresPathAcceptance( + path.join(ws, 'notes.md'), + 'fsWrite', + makeWorkspace(ws), + noopLogging + ) + assert.strictEqual(result.requiresAcceptance, false) + }) + + it('still requires acceptance for a symlink to an existing outside file (regression)', async function () { + const target = path.join(outside, 'existing.txt') + fs.writeFileSync(target, 'secret') + const link = path.join(ws, 'link.txt') + if (!trySymlink(target, link)) { + return this.skip() + } + const result = await requiresPathAcceptance(link, 'fsWrite', makeWorkspace(ws), noopLogging) + assert.strictEqual(result.requiresAcceptance, true) + }) + + it('does not raise a false prompt when the workspace itself lives under a symlinked directory', async function () { + // Simulate macOS-style /tmp -> /private/tmp: workspace reached via a symlink. + const realWs = path.join(root, 'realws') + fs.mkdirSync(realWs) + const symWs = path.join(root, 'linkws') + if (!trySymlink(realWs, symWs)) { + return this.skip() + } + // A new file created through the symlinked workspace path is genuinely in-workspace. + const result = await requiresPathAcceptance( + path.join(symWs, 'inside.txt'), + 'fsWrite', + makeWorkspace(symWs), + noopLogging + ) + assert.strictEqual(result.requiresAcceptance, false) + }) + }) +}) diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/toolShared.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/toolShared.ts index 5bee9dacf2..0776a90819 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/toolShared.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/toolShared.ts @@ -5,6 +5,82 @@ import * as fs from 'fs' import * as path from 'path' import { CommandCategory } from './executeBash' +/** + * Resolve a path to its canonical on-disk location in a symlink-aware way, + * including when the final path segment is a symlink whose target does not + * yet exist (a "dangling" symlink), or when an ancestor directory is a + * symlink. + * + * Unlike fs.realpath, this does not throw for paths that do not exist yet. + * It follows a symlink at the leaf (even a dangling one), resolves the + * longest existing ancestor through the filesystem, then re-appends any + * remaining not-yet-created segments. The returned path therefore reflects + * where a subsequent read or write would actually land, so a workspace + * boundary check cannot be fooled by an in-workspace link name whose target + * points outside the workspace. A `visited` set bounds symlink-cycle + * traversal. + */ +export async function resolveSymlinkAwarePath(inputPath: string): Promise { + let current = path.resolve(inputPath) + const visited = new Set() + + // eslint-disable-next-line no-constant-condition + while (true) { + let stats: fs.Stats + try { + stats = await fs.promises.lstat(current) + } catch { + // `current` does not exist even as a symlink: resolve its existing + // ancestor chain (which may traverse symlinked directories) and + // re-append the final, not-yet-created segment. + const parent = path.dirname(current) + if (parent === current) { + return current + } + const resolvedParent = await resolveSymlinkAwarePath(parent) + return path.join(resolvedParent, path.basename(current)) + } + + if (stats.isSymbolicLink()) { + if (visited.has(current)) { + // Cyclic symlink: stop resolving and return what we have. + return current + } + visited.add(current) + const linkTarget = await fs.promises.readlink(current) + current = path.resolve(path.dirname(current), linkTarget) + continue + } + + // `current` exists and is not a symlink: canonicalize it, which also + // resolves any symlinked ancestor directories. + try { + return await fs.promises.realpath(current) + } catch { + return current + } + } +} + +/** + * Canonicalize workspace folder paths through the filesystem so boundary + * comparisons stay accurate even when a workspace lives under a symlinked + * directory (for example, macOS exposes temp directories under /var and /tmp + * which are symlinks into /private). Falls back to a lexical resolve for any + * folder that cannot be resolved. + */ +export async function canonicalizeWorkspaceFolders(workspaceFolderPaths: string[]): Promise { + return Promise.all( + workspaceFolderPaths.map(async folder => { + try { + return await fs.promises.realpath(folder) + } catch { + return path.resolve(folder) + } + }) + ) +} + interface Output { kind: Kind content: Content @@ -126,28 +202,15 @@ export async function requiresPathAcceptance( approvedPaths?: Map> ): Promise { try { - // Canonicalize the path through the filesystem so symlinks are resolved - // before the workspace-boundary check. path.resolve is string-only and - // would treat a symlink as in-workspace based on its name alone, even - // when its target lies outside the workspace. For paths that don't - // exist yet (e.g., a new file the agent is about to create), fall back - // to realpath of the parent directory joined with the basename. - let canonicalPath: string - try { - canonicalPath = await fs.promises.realpath(inputPath) - } catch (err: any) { - if (err && err.code === 'ENOENT') { - const parent = path.dirname(inputPath) - try { - const realParent = await fs.promises.realpath(parent) - canonicalPath = path.join(realParent, path.basename(inputPath)) - } catch { - canonicalPath = path.resolve(inputPath) - } - } else { - canonicalPath = path.resolve(inputPath) - } - } + // Canonicalize the path in a symlink-aware way before the + // workspace-boundary check. This resolves symlinks at every segment, + // including a symlink at the leaf whose target does not exist yet + // (a "dangling" symlink). A string-only resolve, or an fs.realpath + // that silently falls back to the literal link name when the target + // is missing, would treat such a link as in-workspace based on its + // name alone even though a write or read through it would land + // outside the workspace. + const canonicalPath = await resolveSymlinkAwarePath(inputPath) // Then check if the path is already approved for this specific tool if (isPathApproved(canonicalPath, toolName, approvedPaths)) { @@ -166,7 +229,10 @@ export async function requiresPathAcceptance( // This is the primary security check — files genuinely inside the workspace // are trusted regardless of their filename (e.g. "PasswordService.java", // "credentials/auth.ts", or paths under a "/dev/" folder). - const isInWs = workspaceUtils.isInWorkspace(workspaceFolders, canonicalPath) + // Workspace folders are canonicalized too so the comparison holds even + // when the workspace itself lives under a symlinked directory. + const canonicalWorkspaceFolders = await canonicalizeWorkspaceFolders(workspaceFolders) + const isInWs = workspaceUtils.isInWorkspace(canonicalWorkspaceFolders, canonicalPath) if (isInWs) { return { requiresAcceptance: false } } From 25f6be7d62a25d7dfbd47a71ef01732594bdd4b6 Mon Sep 17 00:00:00 2001 From: Laxman Reddy Aileni Date: Thu, 16 Jul 2026 21:22:08 +0000 Subject: [PATCH 2/4] test: make symlink path assertions robust to Windows 8.3 short paths The Windows CI runner's os.tmpdir() returns an 8.3 short path (e.g. C:\Users\RUNNER~1\...), while fs.realpath inside resolveSymlinkAwarePath expands it to the long form (C:\Users\runneradmin\...). The expected values were built with path.join (string-only), so they kept the short name and mismatched the realpath-resolved actual values. Re-read the workspace and outside directories through fs.realpathSync after creating them so both sides of the assertions are canonical on all platforms. --- .../agenticChat/tools/symlinkBoundary.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/symlinkBoundary.test.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/symlinkBoundary.test.ts index b5d9fee5af..07d3995a8d 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/symlinkBoundary.test.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/symlinkBoundary.test.ts @@ -55,6 +55,12 @@ describe('workspace boundary symlink handling', () => { outside = path.join(root, 'outside') fs.mkdirSync(ws) fs.mkdirSync(outside) + // Re-read through realpath so these match what resolveSymlinkAwarePath + // returns internally. On Windows this also resolves 8.3 short names + // (e.g. RUNNER~1 -> runneradmin), keeping exact path-string assertions + // stable across platforms. + ws = fs.realpathSync(ws) + outside = fs.realpathSync(outside) }) afterEach(() => { From a787e1249ccc44a2a665c94ca1ab8d5623056cdf Mon Sep 17 00:00:00 2001 From: Laxman Reddy Aileni Date: Thu, 16 Jul 2026 23:31:13 +0000 Subject: [PATCH 3/4] test: normalize both sides of symlink path assertions via fs.promises.realpath The previous attempt canonicalized the test's expected paths with fs.realpathSync, but on Windows fs.realpathSync does not expand 8.3 short names (e.g. RUNNER~1) while fs.promises.realpath (used by the production resolveSymlinkAwarePath) does (runneradmin), so the assertions still mismatched by short-vs-long form. Add a `canon` helper that normalizes a path through fs.promises.realpath (the same call the production guard uses), resolving the deepest existing ancestor for not-yet-created paths, and apply it to BOTH the actual and expected values in every path-equality assertion. This makes the assertions independent of which 8.3/long form the platform surfaces. The production code is unchanged. --- .../agenticChat/tools/symlinkBoundary.test.ts | 42 ++++++++++++------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/symlinkBoundary.test.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/symlinkBoundary.test.ts index 07d3995a8d..d07cf7e89c 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/symlinkBoundary.test.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/symlinkBoundary.test.ts @@ -14,10 +14,13 @@ import { resolveSymlinkAwarePath, requiresPathAcceptance } from './toolShared' * about where a path physically lands, so they intentionally do not stub path * utilities. * - * The tests are OS-agnostic: paths are built with `path.join`, temp roots are - * canonicalized up front (macOS exposes the temp dir under a /var -> /private - * symlink), and any test that needs a symlink skips itself where the platform - * or user cannot create one (e.g. Windows without the privilege). + * The tests are OS-agnostic: paths are built with `path.join`, and both sides + * of every path assertion are normalized through the same fs.promises.realpath + * the production code uses (see `canon`). This matters on Windows, where the + * temp dir can surface as an 8.3 short name (e.g. RUNNER~1) that fs.realpath + * expands to the long name (runneradmin); normalizing both sides identically + * keeps the assertions stable. Any test that needs a symlink skips itself where + * the platform or user cannot create one (e.g. Windows without the privilege). */ describe('workspace boundary symlink handling', () => { let root: string @@ -37,6 +40,20 @@ describe('workspace boundary symlink handling', () => { getAllWorkspaceFolders: () => [{ uri: URI.file(folder).toString(), name: 'ws' }], }) as unknown as Features['workspace'] + /** + * Canonicalize a path the same way the production guard does + * (fs.promises.realpath), resolving the deepest existing ancestor for paths + * that do not exist yet. Used on BOTH sides of path assertions so a + * Windows 8.3 short name never causes a spurious mismatch. + */ + const canon = async (p: string): Promise => { + try { + return await fs.promises.realpath(p) + } catch { + return path.join(await fs.promises.realpath(path.dirname(p)), path.basename(p)) + } + } + /** Create a symlink, returning false if the platform/user cannot. */ const trySymlink = (target: string, linkPath: string): boolean => { try { @@ -48,19 +65,12 @@ describe('workspace boundary symlink handling', () => { } beforeEach(() => { - // Canonicalize the temp root so ws/outside are clean, real siblings. const realTmp = fs.realpathSync(os.tmpdir()) root = fs.mkdtempSync(path.join(realTmp, 'wsb-')) ws = path.join(root, 'workspace') outside = path.join(root, 'outside') fs.mkdirSync(ws) fs.mkdirSync(outside) - // Re-read through realpath so these match what resolveSymlinkAwarePath - // returns internally. On Windows this also resolves 8.3 short names - // (e.g. RUNNER~1 -> runneradmin), keeping exact path-string assertions - // stable across platforms. - ws = fs.realpathSync(ws) - outside = fs.realpathSync(outside) }) afterEach(() => { @@ -75,7 +85,7 @@ describe('workspace boundary symlink handling', () => { return this.skip() } const resolved = await resolveSymlinkAwarePath(link) - assert.strictEqual(resolved, target) + assert.strictEqual(await canon(resolved), await canon(target)) }) it('resolves a nonexistent leaf under a symlinked ancestor to the real (outside) location', async function () { @@ -84,19 +94,19 @@ describe('workspace boundary symlink handling', () => { return this.skip() } const resolved = await resolveSymlinkAwarePath(path.join(linkDir, 'newfile.txt')) - assert.strictEqual(resolved, path.join(outside, 'newfile.txt')) + assert.strictEqual(await canon(resolved), await canon(path.join(outside, 'newfile.txt'))) }) it('leaves a plain not-yet-created in-workspace path inside the workspace', async () => { const resolved = await resolveSymlinkAwarePath(path.join(ws, 'brand-new.txt')) - assert.strictEqual(resolved, path.join(ws, 'brand-new.txt')) + assert.strictEqual(await canon(resolved), await canon(path.join(ws, 'brand-new.txt'))) }) it('canonicalizes an existing regular file', async () => { const file = path.join(ws, 'exists.txt') fs.writeFileSync(file, 'x') const resolved = await resolveSymlinkAwarePath(file) - assert.strictEqual(resolved, fs.realpathSync(file)) + assert.strictEqual(await canon(resolved), await canon(file)) }) it('follows a chain of symlinks to the final (outside) target', async function () { @@ -107,7 +117,7 @@ describe('workspace boundary symlink handling', () => { return this.skip() } const resolved = await resolveSymlinkAwarePath(head) - assert.strictEqual(resolved, finalTarget) + assert.strictEqual(await canon(resolved), await canon(finalTarget)) }) }) From c1e09b9a0fdff9fb7884dbe63f3777cb0437a1a7 Mon Sep 17 00:00:00 2001 From: Laxman Reddy Aileni Date: Fri, 17 Jul 2026 00:53:56 +0000 Subject: [PATCH 4/4] test: de-brittle isInWorkspace call-arg assertions for cross-platform Two existing requiresPathAcceptance tests asserted that isInWorkspace was called with the raw workspace-folder strings and path.resolve(filePath). The symlink-aware canonicalization now passes filesystem-canonicalized folders and a symlink-resolved path, which on Windows differ from the raw POSIX-style test fixtures (path.resolve adds a drive letter), so the exact-arg match failed on the Windows runner while passing on Linux. Replace the exact calledWith(...) checks with platform-agnostic assertions: isInWorkspace was consulted, and with an absolute resolved path. The exact canonical form is covered by the real-filesystem tests in symlinkBoundary.test.ts. The behavioral assertions (requiresAcceptance true/false) are unchanged. --- .../agenticChat/tools/toolShared.test.ts | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/toolShared.test.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/toolShared.test.ts index dfea83d502..92476196e1 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/toolShared.test.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/toolShared.test.ts @@ -258,13 +258,12 @@ describe('toolShared', () => { ) assert.strictEqual(result.requiresAcceptance, false) - // requiresPathAcceptance canonicalizes the path with path.resolve() before - // passing to isInWorkspace, so verify with the resolved path - const expectedPath = path.resolve(filePath) - assert.strictEqual( - isInWorkspaceStub.calledWith(['/workspace/folder1', '/workspace/folder2'], expectedPath), - true - ) + // requiresPathAcceptance canonicalizes the path (symlink-aware) before + // passing it to isInWorkspace. The exact canonical form is platform + // specific (and is covered by symlinkBoundary.test.ts), so here just + // verify isInWorkspace was consulted with an absolute, resolved path. + assert.strictEqual(isInWorkspaceStub.called, true) + assert.strictEqual(path.isAbsolute(isInWorkspaceStub.firstCall.args[1]), true) }) it('should return requiresAcceptance=true if path is not in workspace', async () => { @@ -284,11 +283,8 @@ describe('toolShared', () => { ) assert.strictEqual(result.requiresAcceptance, true) - const expectedPath = path.resolve(filePath) - assert.strictEqual( - isInWorkspaceStub.calledWith(['/workspace/folder1', '/workspace/folder2'], expectedPath), - true - ) + assert.strictEqual(isInWorkspaceStub.called, true) + assert.strictEqual(path.isAbsolute(isInWorkspaceStub.firstCall.args[1]), true) }) it('should return requiresAcceptance=true if an error occurs', async () => {