From 38ef021541c4c6606226356a3733f45b84f61bc5 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Wed, 15 Jul 2026 21:08:49 +0800 Subject: [PATCH 1/3] fix(kap-server): fail closed on percent-encoded API paths in auth bypass The auth hook checked the raw, still percent-encoded request URL against its bypass policy, while find-my-way matches routes against the decoded path. A raw /%61pi/v1/... URL therefore skipped the bearer-token check yet still reached the /api/... handlers, allowing unauthenticated access to every API route. Decode the request path the same way the router does before matching, and fail closed when the path cannot be decoded. Add tests covering encoded /api/ paths, encoded meta documents, the encoded healthz probe, and unaffected non-API bypasses. --- .../fix-kap-server-auth-encoded-path.md | 5 ++ packages/kap-server/src/middleware/auth.ts | 24 +++++++++- packages/kap-server/test/rateLimit.test.ts | 48 +++++++++++++++++++ 3 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 .changeset/fix-kap-server-auth-encoded-path.md diff --git a/.changeset/fix-kap-server-auth-encoded-path.md b/.changeset/fix-kap-server-auth-encoded-path.md new file mode 100644 index 0000000000..c868271ff4 --- /dev/null +++ b/.changeset/fix-kap-server-auth-encoded-path.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix the web server bearer-token check being bypassed by percent-encoded API paths (e.g. `/%61pi/v1/…`), which allowed unauthenticated access to every API route. diff --git a/packages/kap-server/src/middleware/auth.ts b/packages/kap-server/src/middleware/auth.ts index f3a1d670d6..b79711242c 100644 --- a/packages/kap-server/src/middleware/auth.ts +++ b/packages/kap-server/src/middleware/auth.ts @@ -29,6 +29,24 @@ export interface AuthHookOptions { readonly validateCredential?: CredentialValidator; } +/** + * Decode the request path the same way the router does before matching. + * + * `req.url` is the raw, still percent-encoded URL, while find-my-way matches + * routes against the decoded path — so a raw `/%61pi/…` reaches the `/api/…` + * handlers. Checking the decoded path keeps the auth decision aligned with + * routing. Returns `null` when the path cannot be decoded, in which case the + * caller must fail closed. + */ +function decodeRequestPath(rawUrl: string): string | null { + const path = rawUrl.split('?', 1)[0] ?? rawUrl; + try { + return decodeURIComponent(path); + } catch { + return null; + } +} + /** * Default bypass policy — the security boundary. * @@ -47,7 +65,11 @@ function defaultIsBypassed(req: FastifyRequest): boolean { if (req.method === 'OPTIONS') { return true; } - const path = req.url.split('?', 1)[0] ?? req.url; + const path = decodeRequestPath(req.url); + if (path === null) { + // Fail closed: an undecodable path must never skip authentication. + return false; + } if (req.method === 'GET' && path === '/api/v1/healthz') { return true; } diff --git a/packages/kap-server/test/rateLimit.test.ts b/packages/kap-server/test/rateLimit.test.ts index bdb932bc12..c668fa70c8 100644 --- a/packages/kap-server/test/rateLimit.test.ts +++ b/packages/kap-server/test/rateLimit.test.ts @@ -97,6 +97,54 @@ describe('createAuthHook rate limiting', () => { }); }); +describe('createAuthHook bypass policy (URL encoding)', () => { + let app: FastifyInstance; + + beforeEach(async () => { + app = buildApp(undefined); + await app.ready(); + }); + + afterEach(async () => { + await app.close(); + }); + + it('requires a token for percent-encoded /api/ paths', async () => { + // The router matches these against /api/v1/sessions; the auth hook must + // see the same decoded path instead of bypassing on the raw URL. + for (const url of ['/%61pi/v1/sessions', '/%61%70%69/v1/sessions']) { + const res = await app.inject({ method: 'GET', url }); + expect(res.statusCode).toBe(401); + } + }); + + it('serves percent-encoded /api/ paths with a valid token', async () => { + const res = await app.inject({ + method: 'GET', + url: '/%61pi/v1/sessions', + headers: { authorization: `Bearer ${TOKEN}` }, + }); + expect(res.statusCode).toBe(200); + }); + + it('requires a token for percent-encoded meta documents', async () => { + const res = await app.inject({ method: 'GET', url: '/%6fpenapi.json' }); + expect(res.statusCode).toBe(401); + }); + + it('still bypasses non-API paths without a token', async () => { + // No static route is registered, so a bypassed request falls through to + // the router's 404 instead of being rejected with 401. + const res = await app.inject({ method: 'GET', url: '/index.html' }); + expect(res.statusCode).toBe(404); + }); + + it('still bypasses the healthz probe when percent-encoded', async () => { + const res = await app.inject({ method: 'GET', url: '/%61pi/v1/healthz' }); + expect(res.statusCode).toBe(404); + }); +}); + describe('createAuthFailureLimiter (unit)', () => { afterEach(() => { vi.useRealTimers(); From 6fea9c39c6ba6a2da5a0d35eb891d4011170a0f2 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Wed, 15 Jul 2026 21:18:37 +0800 Subject: [PATCH 2/3] fix(agent-core-v2): make session fs path confinement symlink-aware SessionFsService confined paths only lexically, so a symlink planted inside the workspace could steer read, list, stat, mkdir, resolvePath and resolveDownload to host files outside the session directory. - add IHostFileSystem.realpath (Node fs.realpath semantics) and implement it in the node-local HostFileSystem - resolveWithin now re-verifies each candidate through realpath of the longest existing prefix (so not-yet-created paths still work) and rejects paths escaping the canonicalized workspace roots, which are resolved once and cached - cover symlink escapes for every fs entry point plus kap-server read/download e2e; in-workspace symlink targets remain allowed --- .changeset/fix-sessionfs-symlink-escape.md | 5 + .../os/backends/node-local/hostFsService.ts | 20 ++- .../src/os/interface/hostFileSystem.ts | 7 +- .../src/session/sessionFs/fsService.ts | 101 ++++++++++++--- .../os/backends/node-local/tools/grep.test.ts | 1 + .../test/session/sessionFs/fsService.test.ts | 120 +++++++++++++++++- .../workspaceCommand/workspaceCommand.test.ts | 5 + .../test/tools/fixtures/fake-exec.ts | 1 + packages/kap-server/test/fs.test.ts | 22 +++- 9 files changed, 259 insertions(+), 23 deletions(-) create mode 100644 .changeset/fix-sessionfs-symlink-escape.md diff --git a/.changeset/fix-sessionfs-symlink-escape.md b/.changeset/fix-sessionfs-symlink-escape.md new file mode 100644 index 0000000000..b0995fbaf5 --- /dev/null +++ b/.changeset/fix-sessionfs-symlink-escape.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix the session filesystem API following symlinks that point outside the workspace, which allowed reading, listing, creating, and downloading host files beyond the session directory through a planted symlink. diff --git a/packages/agent-core-v2/src/os/backends/node-local/hostFsService.ts b/packages/agent-core-v2/src/os/backends/node-local/hostFsService.ts index f9d7fb11e9..8d911ce5d7 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/hostFsService.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/hostFsService.ts @@ -5,7 +5,17 @@ * Bound at App scope. */ -import { appendFile, lstat, open, readFile, readdir, mkdir, rm, writeFile } from 'node:fs/promises'; +import { + appendFile, + lstat, + open, + readFile, + readdir, + mkdir, + realpath as nodeRealpath, + rm, + writeFile, +} from 'node:fs/promises'; import { InstantiationType } from '#/_base/di/extensions'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; @@ -222,6 +232,14 @@ export class HostFileSystem implements IHostFileSystem { throw toHostFsError(error, { path, op: 'remove' }); } } + + async realpath(path: string): Promise { + try { + return await nodeRealpath(path); + } catch (error) { + throw toHostFsError(error, { path, op: 'realpath' }); + } + } } registerScopedService( diff --git a/packages/agent-core-v2/src/os/interface/hostFileSystem.ts b/packages/agent-core-v2/src/os/interface/hostFileSystem.ts index a6ce33be05..9f1eae9983 100644 --- a/packages/agent-core-v2/src/os/interface/hostFileSystem.ts +++ b/packages/agent-core-v2/src/os/interface/hostFileSystem.ts @@ -3,8 +3,10 @@ * * Defines the `IHostFileSystem` used by the program side (persistence, skill * loading, workspace registry) and the os file tools to read and write files on - * the real local disk, plus the stat/entry models. App-scoped — one shared - * instance. + * the real local disk, plus the stat/entry models. `realpath` canonicalizes a + * path by resolving every symlink component (Node `fs.realpath` semantics) and + * rejects with `os.fs.not_found` for a missing path; consumers use it to make + * lexical path confinement symlink-aware. App-scoped — one shared instance. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; @@ -46,6 +48,7 @@ export interface IHostFileSystem { readdir(path: string): Promise; mkdir(path: string, options?: { readonly recursive?: boolean }): Promise; remove(path: string): Promise; + realpath(path: string): Promise; } export const IHostFileSystem: ServiceIdentifier = diff --git a/packages/agent-core-v2/src/session/sessionFs/fsService.ts b/packages/agent-core-v2/src/session/sessionFs/fsService.ts index 8eecf00dc0..aa314b3d92 100644 --- a/packages/agent-core-v2/src/session/sessionFs/fsService.ts +++ b/packages/agent-core-v2/src/session/sessionFs/fsService.ts @@ -9,11 +9,13 @@ * `IGitService`; this service only confines paths and computes repo-relative * paths before calling it. * - * Path confinement is lexical (`ISessionWorkspaceContext.isWithin`); it does not - * follow symlinks, matching the rest of v2 (`_base/tools/policies/path-access.ts`). + * Path confinement applies the lexical `ISessionWorkspaceContext.isWithin` + * check first, then re-verifies the candidate through `IHostFileSystem.realpath` + * (resolving the longest existing prefix, so not-yet-created paths still work): + * a symlink inside the workspace must not steer fs actions to files outside it. */ -import { basename, extname, isAbsolute, join, relative, sep } from 'node:path'; +import { basename, dirname, extname, isAbsolute, join, relative, sep } from 'node:path'; import { ErrorCode, @@ -46,7 +48,7 @@ import ignore, { type Ignore } from 'ignore'; import { InstantiationType } from '#/_base/di/extensions'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; -import { ErrorCodes, Error2, unwrapErrorCause } from '#/errors'; +import { ErrorCodes, Error2, isError2, unwrapErrorCause } from '#/errors'; import { IGitService } from '#/app/git/git'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { IHostFileSystem, type HostDirEntry, type HostFileStat } from '#/os/interface/hostFileSystem'; @@ -97,7 +99,7 @@ export class SessionFsService implements ISessionFsService { } async list(req: FsListRequest): Promise { - const abs = this.resolveWithin(req.path); + const abs = await this.resolveWithin(req.path); const rel = this.toRel(abs); let topStat: HostFileStat; @@ -189,7 +191,7 @@ export class SessionFsService implements ISessionFsService { } async read(req: FsReadRequest): Promise { - const abs = this.resolveWithin(req.path); + const abs = await this.resolveWithin(req.path); const rel = this.toRel(abs); let st: HostFileStat; @@ -289,7 +291,7 @@ export class SessionFsService implements ISessionFsService { } async stat(req: FsStatRequest): Promise { - const abs = this.resolveWithin(req.path); + const abs = await this.resolveWithin(req.path); const rel = this.toRel(abs); let st: HostFileStat; try { @@ -302,10 +304,12 @@ export class SessionFsService implements ISessionFsService { } async statMany(req: FsStatManyRequest): Promise { - const resolved = req.paths.map((p) => { - const abs = this.resolveWithin(p); - return { raw: p, rel: this.toRel(abs), abs }; - }); + const resolved = await Promise.all( + req.paths.map(async (p) => { + const abs = await this.resolveWithin(p); + return { raw: p, rel: this.toRel(abs), abs }; + }), + ); const entries: Record = {}; await Promise.all( @@ -323,7 +327,7 @@ export class SessionFsService implements ISessionFsService { } async mkdir(req: FsMkdirRequest): Promise { - const abs = this.resolveWithin(req.path); + const abs = await this.resolveWithin(req.path); const rel = this.toRel(abs); try { await this.hostFs.mkdir(abs, { recursive: req.recursive }); @@ -346,7 +350,7 @@ export class SessionFsService implements ISessionFsService { } async resolvePath(relPath: string): Promise { - const abs = this.resolveWithin(relPath); + const abs = await this.resolveWithin(relPath); const rel = this.toRel(abs); let st: HostFileStat; try { @@ -358,7 +362,7 @@ export class SessionFsService implements ISessionFsService { } async resolveDownload(relPath: string): Promise { - const abs = this.resolveWithin(relPath); + const abs = await this.resolveWithin(relPath); const rel = this.toRel(abs); let st: HostFileStat; try { @@ -442,7 +446,7 @@ export class SessionFsService implements ISessionFsService { if (req.paths !== undefined && req.paths.length > 0) { filter = new Set(); for (const p of req.paths) { - filter.add(this.toRel(this.resolveWithin(p))); + filter.add(this.toRel(await this.resolveWithin(p))); } } @@ -451,7 +455,7 @@ export class SessionFsService implements ISessionFsService { async diff(req: FsDiffRequest): Promise { const cwd = this.workspace.workDir; - const abs = this.resolveWithin(req.path); + const abs = await this.resolveWithin(req.path); return this.git.diff(cwd, this.toRel(abs), abs); } @@ -669,7 +673,43 @@ export class SessionFsService implements ISessionFsService { return this.rgResolution; } - private resolveWithin(inputPath: string): string { + private realRootsCache: { readonly key: string; readonly roots: readonly string[] } | undefined; + + private async realRoots(): Promise { + const dirs = [this.workspace.workDir, ...this.workspace.additionalDirs]; + const key = dirs.join('\n'); + if (this.realRootsCache?.key === key) return this.realRootsCache.roots; + const roots: string[] = []; + for (const dir of dirs) { + try { + roots.push(await this.hostFs.realpath(dir)); + } catch { + roots.push(dir); + } + } + this.realRootsCache = { key, roots }; + return roots; + } + + private async realpathExistingPrefix(abs: string): Promise { + const tail: string[] = []; + let current = abs; + for (let i = 0; i < 256; i++) { + try { + const real = await this.hostFs.realpath(current); + return tail.length === 0 ? real : join(real, ...tail.reverse()); + } catch (err) { + if (!isMissingPathError(err)) throw err; + const parent = dirname(current); + if (parent === current) return abs; + tail.push(basename(current)); + current = parent; + } + } + return abs; + } + + private async resolveWithin(inputPath: string): Promise { if (inputPath === '' || inputPath === '/') { throw new Error2(ErrorCodes.FS_PATH_ESCAPES, `path "${inputPath}" rejected (empty)`, { details: { path: inputPath, reason: 'empty' }, @@ -692,6 +732,15 @@ export class SessionFsService implements ISessionFsService { details: { path: inputPath, reason: 'resolved_outside' }, }); } + const resolved = await this.realpathExistingPrefix(abs); + const roots = await this.realRoots(); + if (!roots.some((root) => isInsideOrEqual(resolved, root))) { + throw new Error2( + ErrorCodes.FS_PATH_ESCAPES, + `path "${inputPath}" escapes workspace through a symlink`, + { details: { path: inputPath, reason: 'symlink_outside' } }, + ); + } return abs; } @@ -905,6 +954,24 @@ function errnoCode(err: unknown): string | undefined { return undefined; } +function isMissingPathError(err: unknown): boolean { + if (isError2(err)) { + return ( + err.code === ErrorCodes.OS_FS_NOT_FOUND || err.code === ErrorCodes.OS_FS_NOT_DIRECTORY + ); + } + const code = errnoCode(err); + return code === 'ENOENT' || code === 'ENOTDIR'; +} + +function isInsideOrEqual(child: string, parent: string): boolean { + const rel = relative(parent, child); + if (rel === '') return true; + if (rel.startsWith('..')) return false; + if (isAbsolute(rel)) return false; + return true; +} + function mapFsError(err: unknown, inputPath: string): Error { const code = errnoCode(err); if (code === 'ENOENT' || code === 'ENOTDIR') { diff --git a/packages/agent-core-v2/test/os/backends/node-local/tools/grep.test.ts b/packages/agent-core-v2/test/os/backends/node-local/tools/grep.test.ts index e6a7c506c6..b5c69f2a99 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/tools/grep.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/tools/grep.test.ts @@ -142,6 +142,7 @@ function createTestFs(kaos: FakeKaos): IHostFileSystem { readdir: () => notImplemented('readdir'), mkdir: () => notImplemented('mkdir'), remove: () => notImplemented('remove'), + realpath: () => notImplemented('realpath'), }; } diff --git a/packages/agent-core-v2/test/session/sessionFs/fsService.test.ts b/packages/agent-core-v2/test/session/sessionFs/fsService.test.ts index 089cd27b27..7349ebc2ba 100644 --- a/packages/agent-core-v2/test/session/sessionFs/fsService.test.ts +++ b/packages/agent-core-v2/test/session/sessionFs/fsService.test.ts @@ -39,7 +39,11 @@ function stubWorkspace(): ISessionWorkspaceContext { }; } -function fakeFs(files: Record, symlinks: readonly string[] = []): IHostFileSystem { +function fakeFs( + files: Record, + symlinks: readonly string[] = [], + symlinkTargets: Record = {}, +): IHostFileSystem { const fileMap = new Map(); const dirSet = new Set([WORK_DIR]); const addAncestors = (rel: string): void => { @@ -57,6 +61,13 @@ function fakeFs(files: Record, symlinks: readonly string[] = []) symlinkSet.add(join(WORK_DIR, rel)); addAncestors(rel); } + const symlinkTargetMap = new Map(); + for (const [rel, target] of Object.entries(symlinkTargets)) { + const abs = join(WORK_DIR, rel); + symlinkTargetMap.set(abs, target); + symlinkSet.add(abs); + addAncestors(rel); + } const isDir = (p: string): boolean => p === WORK_DIR || dirSet.has(p); const enoent = (p: string): NodeJS.ErrnoException => { const err = new Error(`ENOENT: ${p}`) as NodeJS.ErrnoException; @@ -162,6 +173,29 @@ function fakeFs(files: Record, symlinks: readonly string[] = []) dirSet.add(p); }, remove: async () => {}, + realpath: async (p) => { + let current = p; + for (let i = 0; i < 40; i++) { + let longest: string | undefined; + for (const linkPath of symlinkTargetMap.keys()) { + if ( + (current === linkPath || current.startsWith(`${linkPath}/`)) && + (longest === undefined || linkPath.length > longest.length) + ) { + longest = linkPath; + } + } + if (longest === undefined) { + if (current === p) { + if (p === WORK_DIR || fileMap.has(p) || isDir(p) || symlinkSet.has(p)) return p; + throw enoent(p); + } + return current; + } + current = symlinkTargetMap.get(longest)! + current.slice(longest.length); + } + return current; + }, }; } @@ -291,11 +325,12 @@ function makeSession( git: IGitService = defaultGitStub(), symlinks: readonly string[] = [], runner?: ISessionProcessRunner, + symlinkTargets: Record = {}, ): ISessionFsService { host = createScopedTestHost(); const session = host.child(LifecycleScope.Session, 's1', [ stubPair(ISessionWorkspaceContext, stubWorkspace()), - stubPair(IHostFileSystem, fakeFs(files, symlinks)), + stubPair(IHostFileSystem, fakeFs(files, symlinks, symlinkTargets)), stubPair(ISessionProcessRunner, runner ?? fakeRunner(handler)), stubPair(ITelemetryService, telemetryStub(events)), stubPair(IGitService, git), @@ -711,3 +746,84 @@ describe('SessionFsService.resolveDownload', () => { await expect(fs.resolveDownload('src')).rejects.toMatchObject({ code: 'fs.is_directory' }); }); }); + +describe('SessionFsService symlink confinement', () => { + const escapeTargets = { docs: '/outside' }; + + function escapeSession(): ISessionFsService { + return makeSession( + { 'src/a.ts': '' }, + emptyHandler, + [], + defaultGitStub(), + [], + undefined, + escapeTargets, + ); + } + + it('rejects reads that escape through a symlinked directory', async () => { + const fs = escapeSession(); + await expect( + fs.read({ path: 'docs/secret.txt', offset: 0, length: 1024, encoding: 'utf-8' }), + ).rejects.toMatchObject({ code: 'fs.path_escapes' }); + }); + + it('rejects list through a symlinked directory', async () => { + const fs = escapeSession(); + await expect( + fs.list({ + path: 'docs', + depth: 1, + limit: 200, + show_hidden: false, + follow_gitignore: false, + sort: 'name_asc', + include_git_status: false, + }), + ).rejects.toMatchObject({ code: 'fs.path_escapes' }); + }); + + it('rejects stat, mkdir, resolvePath and resolveDownload through a symlinked directory', async () => { + const fs = escapeSession(); + await expect(fs.stat({ path: 'docs/secret.txt' })).rejects.toMatchObject({ + code: 'fs.path_escapes', + }); + await expect(fs.mkdir({ path: 'docs/newdir', recursive: true })).rejects.toMatchObject({ + code: 'fs.path_escapes', + }); + await expect(fs.resolvePath('docs/secret.txt')).rejects.toMatchObject({ + code: 'fs.path_escapes', + }); + await expect(fs.resolveDownload('docs/secret.txt')).rejects.toMatchObject({ + code: 'fs.path_escapes', + }); + }); + + it('rejects statMany when any path escapes through a symlinked directory', async () => { + const fs = escapeSession(); + await expect(fs.statMany({ paths: ['docs/secret.txt'] })).rejects.toMatchObject({ + code: 'fs.path_escapes', + }); + }); + + it('still allows a symlink whose target stays inside the workspace', async () => { + const fs = makeSession( + { 'real/a.txt': 'hi' }, + emptyHandler, + [], + defaultGitStub(), + [], + undefined, + { link: '/repo/real' }, + ); + const entry = await fs.stat({ path: 'link' }); + expect(entry.kind).toBe('symlink'); + }); + + it('still resolves ordinary in-workspace paths', async () => { + const fs = makeSession({ 'src/a.ts': 'content' }, emptyHandler); + const res = await fs.read({ path: 'src/a.ts', offset: 0, length: 1024, encoding: 'utf-8' }); + expect(res.content).toBe('content'); + }); +}); diff --git a/packages/agent-core-v2/test/session/workspaceCommand/workspaceCommand.test.ts b/packages/agent-core-v2/test/session/workspaceCommand/workspaceCommand.test.ts index fe1c7c3eeb..5354a54e33 100644 --- a/packages/agent-core-v2/test/session/workspaceCommand/workspaceCommand.test.ts +++ b/packages/agent-core-v2/test/session/workspaceCommand/workspaceCommand.test.ts @@ -133,6 +133,11 @@ class MemoryHostFs implements IHostFileSystem { this.files.delete(path); this.dirs.delete(path); } + + async realpath(path: string): Promise { + if (this.files.has(path) || this.dirs.has(path)) return path; + throw enoent(path); + } } function enoent(path: string): NodeJS.ErrnoException { diff --git a/packages/agent-core-v2/test/tools/fixtures/fake-exec.ts b/packages/agent-core-v2/test/tools/fixtures/fake-exec.ts index 6a85e62f86..f8c952f05c 100644 --- a/packages/agent-core-v2/test/tools/fixtures/fake-exec.ts +++ b/packages/agent-core-v2/test/tools/fixtures/fake-exec.ts @@ -29,6 +29,7 @@ export function createFakeHostFs(overrides: Partial = {}): IHos readdir: () => notImplemented('FakeHostFs.readdir'), mkdir: () => notImplemented('FakeHostFs.mkdir'), remove: () => notImplemented('FakeHostFs.remove'), + realpath: () => notImplemented('FakeHostFs.realpath'), }; return { ...fs, ...overrides }; } diff --git a/packages/kap-server/test/fs.test.ts b/packages/kap-server/test/fs.test.ts index 25764d5b2e..478b96280d 100644 --- a/packages/kap-server/test/fs.test.ts +++ b/packages/kap-server/test/fs.test.ts @@ -1,4 +1,4 @@ -import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { chmod, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -225,6 +225,26 @@ describe('server-v2 /api/v1/sessions/{sid}/fs:*', () => { expect(body.code).toBe(ErrorCode.FS_PATH_ESCAPES_SESSION); }); + it('rejects reads and downloads that escape the workspace through a symlink', async () => { + const outside = await mkdtemp(join(tmpdir(), 'kimi-server-v2-fs-outside-')); + try { + await writeFile(join(outside, 'secret.txt'), 'top-secret'); + await symlink(outside, join(work!, 'docs'), 'dir'); + const id = await createSession(); + + const body = await postFs(id, 'read', { path: 'docs/secret.txt' }); + expect(body.code).toBe(ErrorCode.FS_PATH_ESCAPES_SESSION); + + const res = await fetch(`${base}/api/v1/sessions/${id}/fs/docs/secret.txt:download`, { + headers: authHeaders(server as RunningServer), + } as never); + const downloadBody = (await res.json()) as Envelope; + expect(downloadBody.code).toBe(ErrorCode.FS_PATH_ESCAPES_SESSION); + } finally { + await rm(outside, { recursive: true, force: true }); + } + }); + it('GET fs/{path}:download streams the file and honors If-None-Match', async () => { await writeFile(join(work!, 'a.txt'), 'download-me'); const id = await createSession(); From 7477fde61c73aaed55b8496754f8fe7e7253473c Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Wed, 15 Jul 2026 21:25:00 +0800 Subject: [PATCH 3/3] fix(agent-core-v2): accept workspace roots given through a symlink WorkspaceRegistryService.createOrTouch probes the root with the lstat-based IHostFileSystem.stat, so a root given as a symlink to a directory reported isDirectory=false and session creation was rejected with fs.path_not_found ("not a directory"). Re-check a non-directory root through IHostFileSystem.realpath before failing, while keeping the workspace identity lexical (v1 deliberately never realpaths the root either). Covered by a unit test for a symlink-form root and a kap-server e2e that creates a session with a symlinked cwd and reads a file through it. --- .changeset/fix-symlinked-workspace-root.md | 5 ++++ .../workspaceRegistryService.ts | 16 ++++++++++--- .../workspaceRegistryService.test.ts | 10 ++++++++ packages/kap-server/test/fs.test.ts | 23 +++++++++++++++++++ 4 files changed, 51 insertions(+), 3 deletions(-) create mode 100644 .changeset/fix-symlinked-workspace-root.md diff --git a/.changeset/fix-symlinked-workspace-root.md b/.changeset/fix-symlinked-workspace-root.md new file mode 100644 index 0000000000..52820e1033 --- /dev/null +++ b/.changeset/fix-symlinked-workspace-root.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix sessions failing to be created when the workspace directory is given through a symlink, which the v2 engine rejected as "not a directory". diff --git a/packages/agent-core-v2/src/app/workspaceRegistry/workspaceRegistryService.ts b/packages/agent-core-v2/src/app/workspaceRegistry/workspaceRegistryService.ts index 16e1547697..75277c2920 100644 --- a/packages/agent-core-v2/src/app/workspaceRegistry/workspaceRegistryService.ts +++ b/packages/agent-core-v2/src/app/workspaceRegistry/workspaceRegistryService.ts @@ -31,9 +31,12 @@ * `createOrTouch` is the single choke point every workspace/session creation * funnels through, so it owns the root-existence contract: the root must be * an existing directory on the host filesystem, otherwise it throws - * `fs.path_not_found` (mirrors v1's `WorkspaceRootNotFoundError`). The rebuild - * and merge paths bypass the check on purpose — they catalog where sessions - * *were*, not where new ones may open. Bound at App scope. + * `fs.path_not_found` (mirrors v1's `WorkspaceRootNotFoundError`). The + * directory probe follows symlinks (`IHostFileSystem.stat` is lstat-based, so + * a symlink-form root is re-checked through `realpath`), while the workspace + * identity stays lexical — v1 deliberately never realpaths the root either. + * The rebuild and merge paths bypass the check on purpose — they catalog + * where sessions *were*, not where new ones may open. Bound at App scope. */ import { basename, isAbsolute } from 'pathe'; @@ -101,6 +104,13 @@ export class WorkspaceRegistryService implements IWorkspaceRegistry { } throw error; } + if (!stat.isDirectory) { + try { + stat = await this.hostFs.stat(await this.hostFs.realpath(root)); + } catch { + // Fall through to the not-a-directory error below. + } + } if (!stat.isDirectory) { throw new Error2(ErrorCodes.FS_PATH_NOT_FOUND, `workspace root ${root} is not a directory`); } diff --git a/packages/agent-core-v2/test/app/workspaceRegistry/workspaceRegistryService.test.ts b/packages/agent-core-v2/test/app/workspaceRegistry/workspaceRegistryService.test.ts index 1c9f8abbc3..3b242dfe7a 100644 --- a/packages/agent-core-v2/test/app/workspaceRegistry/workspaceRegistryService.test.ts +++ b/packages/agent-core-v2/test/app/workspaceRegistry/workspaceRegistryService.test.ts @@ -382,6 +382,16 @@ describe('WorkspaceRegistryService (file-backed)', () => { expect(await build().list()).toEqual([]); }); + it('accepts createOrTouch when the root is given through a symlink', async () => { + const real = join(homeDir, 'real-root'); + await fsp.mkdir(real, { recursive: true }); + const link = join(homeDir, 'link-root'); + await fsp.symlink(real, link, 'dir'); + const ws = await build().createOrTouch(link); + expect(ws.root).toBe(link); + expect(ws.id).toBe(encodeWorkDirKey(link)); + }); + it('rejects createOrTouch when a parent of the root is not a directory', async () => { const file = join(homeDir, 'a-file.txt'); await fsp.writeFile(file, 'hi', 'utf8'); diff --git a/packages/kap-server/test/fs.test.ts b/packages/kap-server/test/fs.test.ts index 478b96280d..ed42db8928 100644 --- a/packages/kap-server/test/fs.test.ts +++ b/packages/kap-server/test/fs.test.ts @@ -245,6 +245,29 @@ describe('server-v2 /api/v1/sessions/{sid}/fs:*', () => { } }); + it('serves fs actions when the session cwd itself goes through a symlink', async () => { + const link = join(tmpdir(), `kimi-server-v2-fs-cwd-link-${process.pid}`); + await symlink(work!, link, 'dir'); + try { + const res = await fetch(`${base}/api/v1/sessions`, { + method: 'POST', + headers: authHeaders(server as RunningServer, { 'content-type': 'application/json' }), + body: JSON.stringify({ metadata: { cwd: link } }), + } as never); + const body = (await res.json()) as Envelope<{ id: string }>; + expect(body.code).toBe(0); + + await writeFile(join(work!, 'via-link.txt'), 'through-link'); + const read = await postFs<{ content: string }>(body.data.id, 'read', { + path: 'via-link.txt', + }); + expect(read.code).toBe(0); + expect(read.data.content).toBe('through-link'); + } finally { + await rm(link, { force: true }); + } + }); + it('GET fs/{path}:download streams the file and honors If-None-Match', async () => { await writeFile(join(work!, 'a.txt'), 'download-me'); const id = await createSession();