fix(fs): align symlinked parent traversal

This commit is contained in:
Tianyi Cui
2026-07-21 20:49:14 +08:00
parent 4ab7633571
commit d9eef06051
7 changed files with 73 additions and 17 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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 } : {},

View File

@@ -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)