fix(fs): align symlinked parent traversal
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { mkdir, mkdtemp, readFile, rm, symlink } from 'node:fs/promises'
|
||||
import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { basename, join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
@@ -192,4 +192,38 @@ describe('one-context multi-project sandbox', () => {
|
||||
await expectMissing(join(lexicalRoot, 'bash-escaped.txt'))
|
||||
await expectMissing(join(lexicalRoot, 'fs-escaped.txt'))
|
||||
})
|
||||
|
||||
it.skipIf(!processSandboxUsable)('resolves parent traversal from a symlinked session root consistently', async () => {
|
||||
const active = ctx as Context
|
||||
const lexicalRoot = await projectDir('lexical-parent')
|
||||
const physicalRoot = await projectDir('physical-parent')
|
||||
const physicalChild = join(physicalRoot, 'child')
|
||||
await mkdir(physicalChild)
|
||||
const link = join(lexicalRoot, 'link')
|
||||
await symlink(physicalChild, link, process.platform === 'win32' ? 'junction' : 'dir')
|
||||
await writeFile(join(lexicalRoot, 'shared.txt'), 'from-lexical-parent')
|
||||
await writeFile(join(physicalRoot, 'shared.txt'), 'from-physical-parent')
|
||||
const handle = await active.agents.create({
|
||||
sessionId: SessionId('symlink-root-parent-path-session'),
|
||||
meta: { cwd: link },
|
||||
})
|
||||
|
||||
const [bashRead, fsRead] = await Promise.all([
|
||||
active.tools.execute({
|
||||
callId: CallId('bash-symlink-parent-read'), name: 'bash', agent: handle.agent,
|
||||
arguments: { command: 'cat ../shared.txt', description: 'Read through the physical parent' },
|
||||
}),
|
||||
active.tools.execute({
|
||||
callId: CallId('fs-symlink-parent-read'), name: 'read', agent: handle.agent,
|
||||
arguments: { file_path: '../shared.txt' },
|
||||
}),
|
||||
])
|
||||
|
||||
expect(bashRead.isError).toBe(false)
|
||||
expect(fsRead.isError).toBe(false)
|
||||
expect(resultText(bashRead)).toContain('from-physical-parent')
|
||||
expect(resultText(fsRead)).toContain('from-physical-parent')
|
||||
expect(resultText(bashRead)).not.toContain('from-lexical-parent')
|
||||
expect(resultText(fsRead)).not.toContain('from-lexical-parent')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -95,7 +95,7 @@ export function applyEditTool(ctx: Context, sandbox: FsSandboxSurface): void {
|
||||
// Resolve the per-call sandbox policy (approved mode > session override
|
||||
// > backend default, plus the session cwd root) BEFORE anything executes.
|
||||
const sandboxPolicy = await sandbox.resolvePolicy('edit', args, exec)
|
||||
const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec, sandboxPolicy?.workspaceRoot))
|
||||
const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec, input.filePath, sandboxPolicy?.workspaceRoot))
|
||||
// Single-slot decision: the policy plugin returns { version: vObserved } or
|
||||
// throws FS_NOT_OBSERVED; the bare default is undefined (unconditional edit).
|
||||
// No stat — the bare default never manufactures a version basis.
|
||||
|
||||
@@ -88,7 +88,7 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void {
|
||||
isConcurrencySafe: () => true,
|
||||
async execute(args, exec): Promise<ContentBlock[]> {
|
||||
const input = parseReadArgs(args, caps.limit)
|
||||
const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec))
|
||||
const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec, input.filePath))
|
||||
|
||||
// One stat: type check + size routing + the version recorded as observed.
|
||||
// A concurrent write can only make a later guarded mutation fail stale and require reread.
|
||||
|
||||
@@ -16,22 +16,29 @@ const PARENT_PATH_SEGMENT = /(?:^|[\\/])\.\.(?:[\\/]|$)/
|
||||
/**
|
||||
* The session workspace cwd for this call, or `undefined` when none applies.
|
||||
* @param exec - the tool-execution context; only its optional `agent` is read.
|
||||
* @param requestedPath - the path the provider will resolve; parent traversal
|
||||
* makes a symlinked cwd's filesystem identity observable.
|
||||
* @returns the calling agent's session cwd, or undefined for a non-agent caller (the backend then applies its own default).
|
||||
*/
|
||||
export function sessionCwd(exec: ToolExecution): string | undefined {
|
||||
export function sessionCwd(exec: ToolExecution, requestedPath: string): string | undefined {
|
||||
const cwd = exec.agent?.session.header.cwd
|
||||
if (cwd === undefined || !PARENT_PATH_SEGMENT.test(cwd)) return cwd
|
||||
if (cwd === undefined || (!PARENT_PATH_SEGMENT.test(cwd) && !PARENT_PATH_SEGMENT.test(requestedPath))) return cwd
|
||||
return canonicalPath(cwd)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolution options shared by all model-facing filesystem tools.
|
||||
* @param exec - the tool-execution context supplying session cwd and cancellation.
|
||||
* @param requestedPath - the path the provider will resolve.
|
||||
* @param policyWorkspaceRoot - resolved per-call root, when a mutation carries sandbox policy.
|
||||
* @returns provider resolution options for the current tool call.
|
||||
*/
|
||||
export function sessionResolveOptions(exec: ToolExecution, policyWorkspaceRoot?: string): { cwd?: string; signal?: AbortSignal } {
|
||||
const cwd = policyWorkspaceRoot ?? sessionCwd(exec)
|
||||
export function sessionResolveOptions(
|
||||
exec: ToolExecution,
|
||||
requestedPath: string,
|
||||
policyWorkspaceRoot?: string,
|
||||
): { cwd?: string; signal?: AbortSignal } {
|
||||
const cwd = policyWorkspaceRoot ?? sessionCwd(exec, requestedPath)
|
||||
return {
|
||||
...cwd !== undefined ? { cwd } : {},
|
||||
...exec.signal !== undefined ? { signal: exec.signal } : {},
|
||||
|
||||
@@ -80,7 +80,7 @@ export function applyWriteTool(ctx: Context, sandbox: FsSandboxSurface): void {
|
||||
// > backend default, plus the session cwd root) BEFORE anything executes;
|
||||
// an escalating call throws its distinct text on any non-grant.
|
||||
const sandboxPolicy = await sandbox.resolvePolicy('write', args, exec)
|
||||
const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec, sandboxPolicy?.workspaceRoot))
|
||||
const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec, input.filePath, sandboxPolicy?.workspaceRoot))
|
||||
// Single-slot decision: the policy plugin produces createIfAbsent/
|
||||
// replaceIfVersion; the bare default is undefined (unconditional). No stat.
|
||||
const intent = await ctx.waterfall('fs/write-intent', target, exec, () => undefined)
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { realpathSync } from 'node:fs'
|
||||
import { sep } from 'node:path'
|
||||
import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, sep } from 'node:path'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
@@ -113,12 +114,24 @@ describe('session cwd resolution', () => {
|
||||
? {}
|
||||
: { agent: { session: { header: { cwd } } } }
|
||||
|
||||
it('retains ordinary spelling but resolves parent segments before lexical use', () => {
|
||||
it('retains ordinary spelling but resolves the cwd before parent traversal', () => {
|
||||
const cwd = process.cwd()
|
||||
const throughParent = `${cwd}${sep}..`
|
||||
expect(sessionCwd(execution() as never)).toBeUndefined()
|
||||
expect(sessionCwd(execution(cwd) as never)).toBe(cwd)
|
||||
expect(sessionCwd(execution(throughParent) as never)).toBe(realpathSync.native(throughParent))
|
||||
expect(sessionCwd(execution() as never, 'file.txt')).toBeUndefined()
|
||||
expect(sessionCwd(execution(cwd) as never, 'file.txt')).toBe(cwd)
|
||||
expect(sessionCwd(execution(throughParent) as never, 'file.txt')).toBe(realpathSync.native(throughParent))
|
||||
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-tool-fs-session-cwd-'))
|
||||
const physical = join(root, 'physical')
|
||||
const link = join(root, 'link')
|
||||
try {
|
||||
mkdirSync(physical)
|
||||
symlinkSync(physical, link, process.platform === 'win32' ? 'junction' : 'dir')
|
||||
expect(sessionCwd(execution(link) as never, 'child.txt')).toBe(link)
|
||||
expect(sessionCwd(execution(link) as never, `..${sep}parent.txt`)).toBe(realpathSync.native(link))
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user