fix(permissions): resolve relative worktree edit paths (#1930)

This commit is contained in:
JATMN
2026-07-10 16:35:57 +08:00
committed by GitHub
parent 5918e330b2
commit 4f971a1316
2 changed files with 69 additions and 8 deletions
+55 -2
View File
@@ -1,18 +1,24 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { mkdtemp, mkdir, rm } from 'fs/promises'
import { execFileSync } from 'child_process'
import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { z } from 'zod/v4'
import type { ToolPermissionContext } from '../../types/permissions.js'
import {
getOriginalCwd,
getCwdState,
getProjectRoot,
setCwdState,
setOriginalCwd,
setProjectRoot,
} from '../../bootstrap/state.js'
import { getAutoMemPath } from '../../memdir/paths.js'
import { createToolFixture } from '../../test/toolFixtures.js'
import { checkWritePermissionForTool } from './filesystem.js'
import {
checkWritePermissionForTool,
getResolvedWorkingDirPaths,
} from './filesystem.js'
import { resetSafetyLevelCache } from './safetyLevel.js'
import { resetSafetyLevelForTest } from '../../test/safetyLevelTestHelpers.js'
@@ -225,3 +231,50 @@ describe('OpenClaude commit message temp file permissions', () => {
expect(result.decisionReason).toMatchObject({ type: 'safetyCheck' })
})
})
describe('nested Git worktree write permissions', () => {
test('allows relative writes in acceptEdits mode when the session uses a nested worktree', async () => {
const originalCwd = getOriginalCwd()
const originalCwdState = getCwdState()
const repository = await mkdtemp(join(tmpdir(), 'openclaude-worktree-perms-'))
const worktree = join(repository, 'a', 'b', 'worktrees', 'feature-branch')
try {
execFileSync('git', ['init', repository])
execFileSync('git', ['-C', repository, 'config', 'user.email', 'test@example.com'])
execFileSync('git', ['-C', repository, 'config', 'user.name', 'OpenClaude Test'])
await writeFile(join(repository, 'seed.txt'), 'seed\n')
execFileSync('git', ['-C', repository, 'add', 'seed.txt'])
execFileSync('git', ['-C', repository, 'commit', '-m', 'seed'])
await mkdir(join(repository, 'a', 'b', 'worktrees'), { recursive: true })
execFileSync('git', [
'-C',
repository,
'worktree',
'add',
'-b',
'feature-branch',
worktree,
])
// Scoped sessions update application CWD state without changing the
// shared process CWD. Relative tool paths must still target this worktree.
setOriginalCwd(worktree)
setCwdState(worktree)
getResolvedWorkingDirPaths.cache.clear?.()
const result = checkWritePermissionForTool(
writeTool,
{ file_path: 'src/new-file.ts' },
permissionContext('acceptEdits'),
)
expect(result.behavior).toBe('allow')
} finally {
setOriginalCwd(originalCwd)
setCwdState(originalCwdState)
getResolvedWorkingDirPaths.cache.clear?.()
await rm(repository, { recursive: true, force: true })
}
})
})
+14 -6
View File
@@ -1119,7 +1119,11 @@ export function checkReadPermissionForTool(
message: `${PRODUCT_DISPLAY_NAME} requested permissions to use ${tool.name}, but you haven't granted it yet.`,
}
}
const path = tool.getPath(input)
// Tool inputs may be relative to the session CWD, which can differ from the
// process CWD for bridged or scoped sessions. Resolve before gathering
// symlink variants so every permission check uses the same session-relative
// absolute path.
const path = expandPath(tool.getPath(input))
// Get paths to check (includes both original and resolved symlinks).
// Computed once here and threaded through checkWritePermissionForTool →
@@ -1278,10 +1282,10 @@ export function checkReadPermissionForTool(
* Permission result for write permission for the specified tool & tool input.
*
* @param precomputedPathsToCheck - Optional cached result of
* `getPathsForPermissionCheck(tool.getPath(input))`. Callers MUST derive this
* from the same `tool` and `input` in the same synchronous frame — `path` is
* re-derived internally for error messages and internal-path checks, so a
* stale value would silently check deny rules for the wrong path.
* `getPathsForPermissionCheck(expandPath(tool.getPath(input)))`. Callers MUST
* derive this from the same `tool` and `input` in the same synchronous frame
* — `path` is re-derived internally for error messages and internal-path
* checks, so a stale value would silently check deny rules for the wrong path.
*/
export function checkWritePermissionForTool<Input extends AnyObject>(
tool: Tool<Input>,
@@ -1295,7 +1299,11 @@ export function checkWritePermissionForTool<Input extends AnyObject>(
message: `${PRODUCT_DISPLAY_NAME} requested permissions to use ${tool.name}, but you haven't granted it yet.`,
}
}
const path = tool.getPath(input)
// Tool inputs may be relative to the session CWD, which can differ from the
// process CWD for bridged or scoped sessions. Resolve before gathering
// symlink variants so every permission check uses the same session-relative
// absolute path.
const path = expandPath(tool.getPath(input))
// 1. Check for deny rules - check both the original path and resolved symlink path
const pathsToCheck =