diff --git a/packages/runtime/src/__tests__/builtin-tools.test.ts b/packages/runtime/src/__tests__/builtin-tools.test.ts index d12705ead2..166e5ba7f3 100644 --- a/packages/runtime/src/__tests__/builtin-tools.test.ts +++ b/packages/runtime/src/__tests__/builtin-tools.test.ts @@ -2100,6 +2100,83 @@ describe('builtin write tools path containment', () => { expect(await readFile(join(root, 'inside.txt'), 'utf8')).toBe('hello Maka'); }); + test('file tools stay usable when the session cwd is reached through a symlink', async () => { + // The session cwd itself sits under a symlink (macOS hands out `/var/...` + // tmpdirs whose realpath is `/private/var/...`, and workspace roots are often + // organised with symlinks). Containment must be decided in one path space, so + // absolute paths spelled through the link are inside — and escapes still are not. + const base = await realpath(await mkdtemp(join(tmpdir(), 'maka-symlink-cwd-'))); + const workspace = join(base, 'workspace'); + const outside = join(base, 'outside'); + await mkdir(join(workspace, 'src'), { recursive: true }); + await mkdir(outside, { recursive: true }); + await writeFile(join(workspace, 'src', 'inside.txt'), 'inside token\n', 'utf8'); + await writeFile(join(outside, 'secret.txt'), 'secret\n', 'utf8'); + await symlink(join(outside, 'secret.txt'), join(workspace, 'escape.txt')); + await symlink(outside, join(workspace, 'outside-link')); + const cwd = join(base, 'link-to-workspace'); + await symlink(workspace, cwd); + + const read = tool('Read'); + const write = tool('Write'); + const edit = tool('Edit'); + const glob = tool('Glob'); + const grep = tool('Grep'); + + expect(await runTool(read, { path: join(cwd, 'src', 'inside.txt') }, cwd)).toMatchObject({ + content: 'inside token\n', + }); + await runTool(write, { path: join(cwd, 'src', 'written.txt'), content: 'written\n' }, cwd); + expect(await readFile(join(workspace, 'src', 'written.txt'), 'utf8')).toBe('written\n'); + await runTool( + edit, + { path: join(cwd, 'src', 'inside.txt'), old_string: 'token', new_string: 'edited' }, + cwd, + ); + expect(await readFile(join(workspace, 'src', 'inside.txt'), 'utf8')).toBe('inside edited\n'); + expect(await runTool(glob, { pattern: '*.txt', cwd: join(cwd, 'src') }, cwd)).toMatchObject({ + files: ['inside.txt', 'written.txt'], + }); + const grepResult = await runTool(grep, { pattern: 'edited', path: join(cwd, 'src') }, cwd); + expect(JSON.stringify(grepResult).includes('inside.txt')).toBe(true); + + // Resolving into the canonical space must not turn "follow a symlink out of + // the workspace" into a legal path. + await expectRejects( + runTool(read, { path: join(outside, 'secret.txt') }, cwd), + /Read path must stay inside/, + ); + await expectRejects( + runTool(read, { path: join(cwd, 'escape.txt') }, cwd), + /Read path must stay inside/, + ); + await expectRejects( + runTool(write, { path: join(cwd, 'escape.txt'), content: 'x' }, cwd), + /Write path must stay inside/, + ); + await expectRejects( + runTool(write, { path: join(cwd, 'outside-link', 'new.txt'), content: 'x' }, cwd), + /Write path must stay inside/, + ); + // A link whose target does not exist yet cannot be realpath'd, but a write + // through it still lands outside the workspace, so it must be followed by + // hand rather than treated as a plain missing leaf. + await symlink(join(outside, 'not-yet.txt'), join(workspace, 'dangling-escape.txt')); + await expectRejects( + runTool(write, { path: join(cwd, 'dangling-escape.txt'), content: 'x' }, cwd), + /Write path must stay inside/, + ); + await expectRejects(access(join(outside, 'not-yet.txt')), /ENOENT|no such file/); + await expectRejects( + runTool(grep, { pattern: 'secret', path: join(cwd, 'outside-link') }, cwd), + /Grep path must stay inside/, + ); + await expectRejects( + runTool(glob, { pattern: '*.txt', cwd: join(cwd, 'outside-link') }, cwd), + /Glob cwd path must stay inside/, + ); + }); + test('concurrent Edits to the same file serialize — no lost update', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-edit-lock-')); const n = 20; @@ -2156,6 +2233,37 @@ describe('builtin write tools path containment', () => { expect(await readFile(join(root, 'data.txt'), 'utf8')).toBe(expected); }); + test('concurrent Edits through a symlinked cwd serialize on one key', async () => { + const base = await realpath(await mkdtemp(join(tmpdir(), 'maka-edit-symlink-'))); + const workspace = join(base, 'workspace'); + await mkdir(workspace, { recursive: true }); + const cwd = join(base, 'link-to-workspace'); + await symlink(workspace, cwd); + const n = 20; + const markers = Array.from({ length: n }, (_, i) => `marker-${String(i).padStart(2, '0')}`); + await writeFile(join(workspace, 'data.txt'), `${markers.join('\n')}\n`, 'utf8'); + const edit = tool('Edit'); + // The relative spelling resolves against the canonical cwd while the absolute + // one is spelled through the link. Unless the lock key canonicalises both, the + // two groups take different locks and clobber each other. + const results = await Promise.all( + markers.map((m, i) => + runTool( + edit, + { + path: i % 2 === 0 ? 'data.txt' : join(cwd, 'data.txt'), + old_string: m, + new_string: `done-${String(i).padStart(2, '0')}`, + }, + cwd, + ), + ), + ); + expect(results.every((r) => (r as { ok: boolean }).ok === true)).toBe(true); + const expected = `${Array.from({ length: n }, (_, i) => `done-${String(i).padStart(2, '0')}`).join('\n')}\n`; + expect(await readFile(join(workspace, 'data.txt'), 'utf8')).toBe(expected); + }); + test('Write then Edit on one file resolves inside the lock — the fresh file is found', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-write-edit-')); const write = tool('Write'); diff --git a/packages/runtime/src/__tests__/filesystem-worker.test.ts b/packages/runtime/src/__tests__/filesystem-worker.test.ts index 59ab46d65a..229992fe62 100644 --- a/packages/runtime/src/__tests__/filesystem-worker.test.ts +++ b/packages/runtime/src/__tests__/filesystem-worker.test.ts @@ -195,6 +195,30 @@ describe('filesystem worker operations', () => { await assert.rejects(readFile(outsidePath, 'utf8'), { code: 'ENOENT' }); }); + test('denies a write through a dangling symlink the boundary does not cover', async () => { + // The worker enforces its own boundary rather than trusting the caller to + // have canonicalised the path: a link inside the root whose target does not + // exist yet cannot be realpath'd, and a write through it lands on the + // target, outside the root, while the boundary names only the link. + const root = await temporaryDirectory('maka-worker-dangling-root-'); + const outside = await temporaryDirectory('maka-worker-dangling-outside-'); + const link = join(root, 'dangling.txt'); + const target = join(outside, 'not-yet.txt'); + await symlink(target, link); + + const response = await executeFilesystemWorkerRequest( + requestFor( + { kind: 'write', cwd: root, path: link, content: 'blocked' }, + { enforcementPath: target, access: 'write', scope: 'exact', targetType: 'missing' }, + link, + ), + ); + + assert.equal(response.ok, false); + if (!response.ok) assert.equal(response.error.code, 'path_denied'); + await assert.rejects(readFile(target, 'utf8'), { code: 'ENOENT' }); + }); + test('fails when an approved target changes type before execution', async () => { const root = await temporaryDirectory('maka-worker-type-'); const target = join(root, 'target'); diff --git a/packages/runtime/src/__tests__/path-containment.test.ts b/packages/runtime/src/__tests__/path-containment.test.ts index 167b3f024a..dd8cb96628 100644 --- a/packages/runtime/src/__tests__/path-containment.test.ts +++ b/packages/runtime/src/__tests__/path-containment.test.ts @@ -1,7 +1,15 @@ import { strict as assert } from 'node:assert'; -import { win32, posix } from 'node:path'; -import { describe, test } from 'node:test'; -import { isPathInside } from '../path-containment.js'; +import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, win32, posix } from 'node:path'; +import { afterEach, describe, test } from 'node:test'; +import { isPathInside, realpathAllowMissing } from '../path-containment.js'; + +const cleanup: string[] = []; + +afterEach(async () => { + await Promise.all(cleanup.splice(0).map((path) => rm(path, { recursive: true, force: true }))); +}); describe('isPathInside', () => { test('rejects cross-drive Windows targets (different drive is not inside root)', () => { @@ -34,3 +42,94 @@ describe('isPathInside', () => { assert.equal(isPathInside('C:\\repo', 'C:\\repo\\..rules\\AGENTS.md', win32), true); }); }); + +describe('realpathAllowMissing', () => { + test('canonicalises an existing path through a symlinked ancestor', async () => { + const { workspace, link } = await symlinkedWorkspace('maka-realpath-existing-'); + await writeFile(join(workspace, 'file.txt'), 'x', 'utf8'); + + assert.equal(await realpathAllowMissing(join(link, 'file.txt')), join(workspace, 'file.txt')); + }); + + test('appends missing trailing segments to the deepest existing ancestor', async () => { + const { workspace, link } = await symlinkedWorkspace('maka-realpath-missing-'); + + assert.equal( + await realpathAllowMissing(join(link, 'absent', 'nested', 'file.txt')), + join(workspace, 'absent', 'nested', 'file.txt'), + ); + }); + + test('follows a dangling symlink to its target, which is where a write would land', async () => { + // realpath fails ENOENT on a link whose target does not exist. Treating it + // as a plain missing leaf returns the link's own path, which reads as + // contained while a write through it escapes. + const { base, workspace, link } = await symlinkedWorkspace('maka-realpath-dangling-'); + const outside = join(base, 'outside'); + await mkdir(outside); + await symlink(join(outside, 'not-yet.txt'), join(workspace, 'dangling.txt')); + + assert.equal( + await realpathAllowMissing(join(link, 'dangling.txt')), + join(outside, 'not-yet.txt'), + ); + }); + + test('follows a chain of dangling links and keeps the segments below them', async () => { + const { base, workspace, link } = await symlinkedWorkspace('maka-realpath-chain-'); + const outside = join(base, 'outside'); + await mkdir(outside); + await symlink(join(outside, 'absent-dir'), join(workspace, 'hop-b')); + await symlink(join(workspace, 'hop-b'), join(workspace, 'hop-a')); + + assert.equal( + await realpathAllowMissing(join(link, 'hop-a', 'file.txt')), + join(outside, 'absent-dir', 'file.txt'), + ); + }); + + test('treats a non-directory ancestor as missing rather than throwing ENOTDIR', async () => { + const { workspace, link } = await symlinkedWorkspace('maka-realpath-notdir-'); + await writeFile(join(workspace, 'file.txt'), 'x', 'utf8'); + + assert.equal( + await realpathAllowMissing(join(link, 'file.txt', 'child')), + join(workspace, 'file.txt', 'child'), + ); + }); + + test('rejects a pathological dangling chain instead of walking it forever', async () => { + const { workspace } = await symlinkedWorkspace('maka-realpath-hops-'); + // Every link resolves to the next and only the last one dangles. The kernel + // caps its own symlink traversal well below this length, so it answers ELOOP + // before the helper's hop cap can fire; either way the walk must terminate + // by rejecting rather than by hopping forever. + const chain = 64; + await symlink(join(workspace, 'absent'), join(workspace, `hop-${chain}`)); + for (let index = chain; index > 0; index -= 1) { + await symlink(join(workspace, `hop-${index}`), join(workspace, `hop-${index - 1}`)); + } + + await assert.rejects(realpathAllowMissing(join(workspace, 'hop-0')), /ELOOP|too many/); + }); + + test('propagates a symlink cycle instead of walking forever', async () => { + const { workspace } = await symlinkedWorkspace('maka-realpath-cycle-'); + await symlink(join(workspace, 'b'), join(workspace, 'a')); + await symlink(join(workspace, 'a'), join(workspace, 'b')); + + await assert.rejects(realpathAllowMissing(join(workspace, 'a')), { code: 'ELOOP' }); + }); +}); + +async function symlinkedWorkspace( + prefix: string, +): Promise<{ base: string; workspace: string; link: string }> { + const base = await realpath(await mkdtemp(join(tmpdir(), prefix))); + cleanup.push(base); + const workspace = join(base, 'workspace'); + const link = join(base, 'link-to-workspace'); + await mkdir(workspace); + await symlink(workspace, link); + return { base, workspace, link }; +} diff --git a/packages/runtime/src/filesystem-worker/operations.ts b/packages/runtime/src/filesystem-worker/operations.ts index 4081e78da3..800f35336f 100644 --- a/packages/runtime/src/filesystem-worker/operations.ts +++ b/packages/runtime/src/filesystem-worker/operations.ts @@ -2,7 +2,7 @@ import { spawn } from 'node:child_process'; import { promises as fs } from 'node:fs'; import { glob as nodeGlob } from 'node:fs/promises'; import { dirname, isAbsolute, parse, resolve } from 'node:path'; -import { isPathInside } from '../path-containment.js'; +import { isPathInside, realpathAllowMissing } from '../path-containment.js'; import { sandboxBoundaryExpansionAllowsPath } from '@maka/core'; import { computeEditedSource } from '../edit-replace.js'; @@ -316,15 +316,21 @@ async function resolveWritableAllowed( } catch (error) { if (nodeErrorCode(error) !== 'ENOENT') throw error; } - const parent = await fs.realpath(dirname(candidate)); - assertAllowed(root, candidate, label, 'write', permission); - if (!isPathInside(root, parent) && !exactWriteCoversParent(permission, candidate, parent)) { + // The target does not exist, but it can still be a dangling symlink, and a + // write lands on what the link names rather than on the link. Authorise the + // followed path — the same one `assertTargetUnchanged` pins the request to — + // so the worker enforces its own boundary instead of trusting the caller to + // have canonicalised the path for it. + const followed = await realpathAllowMissing(candidate); + const parent = await fs.realpath(dirname(followed)); + assertAllowed(root, followed, label, 'write', permission); + if (!isPathInside(root, parent) && !exactWriteCoversParent(permission, followed, parent)) { throw operationError( 'path_denied', `${label} parent was not covered by the operation boundary.`, ); } - return candidate; + return followed; } async function resolveExistingAllowed( @@ -392,17 +398,6 @@ function assertContainedGlobPattern(pattern: string): void { } } -async function realpathAllowMissing(path: string): Promise { - try { - return await fs.realpath(path); - } catch (error) { - if (nodeErrorCode(error) !== 'ENOENT') throw error; - const parent = dirname(path); - if (parent === path) throw error; - return resolve(await realpathAllowMissing(parent), path.slice(parent.length + 1)); - } -} - async function targetTypeOf(path: string): Promise { try { const metadata = await fs.stat(path); diff --git a/packages/runtime/src/path-containment.ts b/packages/runtime/src/path-containment.ts index c278ad437a..616e159689 100644 --- a/packages/runtime/src/path-containment.ts +++ b/packages/runtime/src/path-containment.ts @@ -1,13 +1,23 @@ import { createHash } from 'node:crypto'; -import { lstat, mkdir, readFile, realpath, rename, unlink, writeFile } from 'node:fs/promises'; -import { isAbsolute, join, relative, sep } from 'node:path'; +import { + lstat, + mkdir, + readFile, + readlink, + realpath, + rename, + unlink, + writeFile, +} from 'node:fs/promises'; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; /** * Shared filesystem-containment and identifier guards. This is the single * authority for path-containment checks across the runtime, the desktop main * process, and headless: both the pure-Node runtime and the desktop main (which * already depends on `@maka/runtime`) reach it here without reverse - * dependencies. The leaf imports only `node:path`. + * dependencies. {@link isPathInside} itself is a pure `node:path` predicate; the + * canonicalisation and contained-I/O helpers around it touch the filesystem. * * {@link isPathInside} is separator-aware: it rejects only a real * parent-reference segment (`..` exactly, or `..${sep}`-prefixed), so a child @@ -58,6 +68,65 @@ export function toRelative(root: string, target: string): string { return rel === '' ? '.' : rel.split(sep).join('/'); } +/** + * Canonicalise `target` even when its leaf (or a run of trailing segments) does + * not exist yet: the deepest existing ancestor is realpath'd and the missing + * segments are appended. Containment can then be decided against a realpath'd + * root in a single path space, which is what {@link isPathInside} assumes — + * comparing a realpath'd root against a merely `resolve`d candidate rejects + * every legitimate absolute path whenever the root sits under a symlink (macOS + * `/var` → `/private/var`, symlinked workspace roots). + * + * Because symlinks are followed all the way to the leaf, this never legalises + * an escape: a link inside the root that points out of it resolves to its + * outside target and fails containment. That includes a dangling link, whose + * `realpath` fails ENOENT — the link is read and followed by hand, because a + * write through it lands on its target, not on the link. + * + * Throws the underlying error when a path component is unreadable rather than + * missing, so permission problems are not silently treated as containment, and + * when a symlink cycle keeps the walk from terminating. + */ +export async function realpathAllowMissing(target: string): Promise { + let cursor = resolve(target); + const missing: string[] = []; + // `realpath` reports a cycle as ELOOP, so each hop consumes one existing link + // and the walk terminates; the cap is a backstop against a pathological + // filesystem, not the cycle guard. + let hops = 0; + while (true) { + try { + const realExisting = await realpath(cursor); + return resolve(realExisting, ...missing.reverse()); + } catch (error) { + if (!isMissingPathError(error)) throw error; + const link = await readlink(cursor).catch(() => null); + if (link !== null) { + if (++hops > MAX_DANGLING_SYMLINK_HOPS) + throw new Error(`Path ${JSON.stringify(target)} traverses too many dangling symlinks.`); + cursor = resolve(dirname(cursor), link); + continue; + } + const parent = dirname(cursor); + if (parent === cursor) throw error; + missing.push(basename(cursor)); + cursor = parent; + } + } +} + +/** Dangling links followed by {@link realpathAllowMissing} before it gives up. */ +const MAX_DANGLING_SYMLINK_HOPS = 32; + +function isMissingPathError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error.code === 'ENOENT' || error.code === 'ENOTDIR') + ); +} + // ── Contained file I/O ──────────────────────────────────────────────────── /** diff --git a/packages/runtime/src/sandbox-boundary-path.ts b/packages/runtime/src/sandbox-boundary-path.ts index 9a1274df8d..ca32fa4720 100644 --- a/packages/runtime/src/sandbox-boundary-path.ts +++ b/packages/runtime/src/sandbox-boundary-path.ts @@ -1,5 +1,6 @@ import { promises as fs } from 'node:fs'; -import { dirname, resolve } from 'node:path'; +import { resolve } from 'node:path'; +import { realpathAllowMissing } from './path-containment.js'; import { MAX_SANDBOX_BOUNDARY_PATH_CHARS, validateSandboxBoundaryExpansion, @@ -73,23 +74,6 @@ export async function normalizeSandboxBoundaryExpansion( return normalized.expansion; } -async function realpathAllowMissing(target: string): Promise { - let cursor = target; - const missing: string[] = []; - while (true) { - try { - const realParent = await fs.realpath(cursor); - return resolve(realParent, ...missing.reverse()); - } catch (error) { - if (!isMissingPathError(error)) throw error; - const parent = dirname(cursor); - if (parent === cursor) throw error; - missing.push(cursor.slice(parent.length + (parent === '/' ? 0 : 1))); - cursor = parent; - } - } -} - async function targetTypeFor(path: string): Promise { try { const stat = await fs.stat(path); diff --git a/packages/runtime/src/workspace-executor.ts b/packages/runtime/src/workspace-executor.ts index 354f16e945..2ca6da6f12 100644 --- a/packages/runtime/src/workspace-executor.ts +++ b/packages/runtime/src/workspace-executor.ts @@ -1,8 +1,8 @@ import { promises as fs } from 'node:fs'; import { exec } from 'node:child_process'; import { glob as nodeGlob } from 'node:fs/promises'; -import { dirname, isAbsolute, resolve } from 'node:path'; -import { isPathInside } from './path-containment.js'; +import { isAbsolute, resolve } from 'node:path'; +import { isPathInside, realpathAllowMissing } from './path-containment.js'; import { promisify } from 'node:util'; import type { ToolExecutionFacts } from '@maka/core/permission'; import { runProcessWithBoundedTail, runShellWithBoundedTail } from './shell-exec.js'; @@ -249,11 +249,16 @@ export class LocalWorkspaceExecutor implements WorkspaceExecutor { } async resolveWritablePath(input: WorkspaceResolvePathInput): Promise { - return { path: await resolveWritableInsideCwd(input.cwd, input.path, input.label) }; + return { path: (await canonicalPathInsideCwd(input.cwd, input.path, input.label)).path }; } async writeLockKey(input: WorkspaceWriteLockKeyInput): Promise { - return { key: resolve(await fs.realpath(input.cwd), input.path) }; + // The resolvers' canonicalisation without their containment check, so every + // spelling of one file — relative, absolute, or through a symlink — takes + // the same lock. Escapes are rejected by the resolvers inside the lock, not + // here. Sharing the canonicalisation is what keeps the lock-key space and + // the resolved-path space from drifting apart. + return { key: (await canonicalPathUnderCwd(input.cwd, input.path)).path }; } async globFiles(input: WorkspaceGlobInput): Promise { @@ -294,27 +299,36 @@ function shellEscape(arg: string): string { return `'${arg.replaceAll("'", "'\\''")}'`; } -async function resolveWritableInsideCwd( +/** + * Canonical session cwd and the canonical path `inputPath` names under it, with + * no containment check — the single place that decides which path space every + * caller works in. Both the root and the candidate are realpath'd, the candidate + * through its deepest existing ancestor since the target may not exist yet. + * Comparing a realpath'd root against a merely resolved candidate rejected every + * legitimate absolute path whenever the session cwd sat under a symlink — macOS + * tmpdirs (`/var` → `/private/var`) and symlinked workspace roots. + */ +async function canonicalPathUnderCwd( cwd: string, inputPath: string, - label: string, -): Promise { +): Promise<{ root: string; path: string }> { const root = await fs.realpath(cwd); - const candidate = isAbsolute(inputPath) ? resolve(inputPath) : resolve(root, inputPath); - - if (!isPathInside(root, candidate)) { - throw new Error( - `${label} path must stay inside session cwd ${JSON.stringify(root)}; ` + - `received ${JSON.stringify(inputPath)}.`, - ); - } - - const parent = await fs.realpath(dirname(candidate)); - if (!isPathInside(root, parent)) { - throw new Error(`${label} path must stay inside session cwd`); - } + const requested = isAbsolute(inputPath) ? resolve(inputPath) : resolve(root, inputPath); + return { root, path: await realpathAllowMissing(requested) }; +} - return candidate; +/** + * The same canonical path, rejected unless it stays inside the session cwd. + * Following the symlinks does not weaken containment: a link inside the cwd that + * points out of it resolves to its outside target and is rejected. + */ +async function canonicalPathInsideCwd( + cwd: string, + inputPath: string, + label: string, +): Promise<{ root: string; path: string }> { + const { root, path } = await canonicalPathUnderCwd(cwd, inputPath); + return { root, path: assertInsideCwd(root, path, inputPath, label) }; } async function resolveExistingInsideCwd( @@ -322,20 +336,29 @@ async function resolveExistingInsideCwd( inputPath: string, label: string, ): Promise { - const root = await fs.realpath(cwd); - const candidate = isAbsolute(inputPath) ? resolve(inputPath) : resolve(root, inputPath); + const { root, path: candidate } = await canonicalPathInsideCwd(cwd, inputPath, label); + // The read/search callers depend on the target existing; surface that here + // rather than as a downstream open/spawn failure. + // + // Do not drop the second assertion: it closes the window between the two + // awaits, where a segment that was missing during canonicalisation can become + // a symlink out of the cwd before the realpath runs. It is deliberately + // defence-in-depth and no deterministic test can drive that race, so nothing + // will fail if it is removed. + return assertInsideCwd(root, await fs.realpath(candidate), inputPath, label); +} +function assertInsideCwd( + root: string, + candidate: string, + inputPath: string, + label: string, +): string { if (!isPathInside(root, candidate)) { throw new Error( `${label} path must stay inside session cwd ${JSON.stringify(root)}; ` + - `received ${JSON.stringify(inputPath)}.`, + `received ${JSON.stringify(inputPath)}, which resolves to ${JSON.stringify(candidate)}.`, ); } - - const target = await fs.realpath(candidate); - if (!isPathInside(root, target)) { - throw new Error(`${label} path must stay inside session cwd`); - } - - return target; + return candidate; }