mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 10:14:19 -05:00
fix(permissions): resolve relative worktree edit paths (#1930)
This commit is contained in:
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user