@@ -1933,12 +1933,10 @@ func ensureTmuxDaemon() (string, error) {
19331933
19341934// createTmuxWindow creates a new tmux window in the daemon session with retry logic.
19351935// If the session doesn't exist, it will re-create it and retry once.
1936- // SECURITY: workDir must be within a .task-worktrees directory to prevent Claude from
1937- // accidentally writing to the main project directory.
1938- func createTmuxWindow (daemonSession , windowName , workDir , script string ) (string , error ) {
1939- // SECURITY: Validate that workDir is within a .task-worktrees directory,
1940- // OR is a valid project directory (for non-worktree projects).
1941- if ! isValidWorkDir (workDir ) {
1936+ // SECURITY: workDir must be within a .task-worktrees directory, or match allowedProjectDir
1937+ // for non-worktree projects. Pass empty allowedProjectDir to require worktree paths only.
1938+ func createTmuxWindow (daemonSession , windowName , workDir , script , allowedProjectDir string ) (string , error ) {
1939+ if ! isValidWorkDir (workDir , allowedProjectDir ) {
19421940 return "" , fmt .Errorf ("security: refusing to create tmux window with invalid workDir: %s" , workDir )
19431941 }
19441942
@@ -2224,7 +2222,7 @@ func (e *Executor) runClaude(ctx context.Context, task *db.Task, workDir, prompt
22242222 }
22252223
22262224 // Create new window in task-daemon session (with retry logic for race conditions)
2227- actualSession , tmuxErr := createTmuxWindow (daemonSession , windowName , workDir , script )
2225+ actualSession , tmuxErr := createTmuxWindow (daemonSession , windowName , workDir , script , e . getProjectDir ( task . Project ) )
22282226 if tmuxErr != nil {
22292227 e .logger .Error ("tmux new-window failed" , "error" , tmuxErr , "session" , daemonSession )
22302228 e .logLine (task .ID , "error" , fmt .Sprintf ("Failed to create tmux window: %s" , tmuxErr .Error ()))
@@ -2380,7 +2378,7 @@ func (e *Executor) runClaudeResume(ctx context.Context, task *db.Task, workDir,
23802378 task .ID , taskSessionID , task .Port , task .WorktreePath , envPrefix , dangerousFlag , systemPromptFlag , claudeSessionID , feedbackFile .Name ())
23812379
23822380 // Create new window in task-daemon session (with retry logic for race conditions)
2383- actualSession , tmuxErr := createTmuxWindow (daemonSession , windowName , workDir , script )
2381+ actualSession , tmuxErr := createTmuxWindow (daemonSession , windowName , workDir , script , e . getProjectDir ( task . Project ) )
23842382 if tmuxErr != nil {
23852383 e .logger .Error ("tmux new-window failed" , "error" , tmuxErr , "session" , daemonSession )
23862384 e .logLine (task .ID , "error" , fmt .Sprintf ("Failed to create tmux window: %s" , tmuxErr .Error ()))
@@ -2537,7 +2535,7 @@ func (e *Executor) resumeClaudeDangerous(task *db.Task, workDir string) bool {
25372535 taskID , taskSessionID , task .Port , task .WorktreePath , envPrefix , claudeSessionID )
25382536
25392537 // Create new window in task-daemon session (with retry logic for race conditions)
2540- actualSession , tmuxErr := createTmuxWindow (daemonSession , windowName , workDir , script )
2538+ actualSession , tmuxErr := createTmuxWindow (daemonSession , windowName , workDir , script , e . getProjectDir ( task . Project ) )
25412539 if tmuxErr != nil {
25422540 e .logger .Warn ("tmux failed to create window" , "error" , tmuxErr , "session" , daemonSession )
25432541 if cleanupHooks != nil {
@@ -2702,7 +2700,7 @@ func (e *Executor) resumeClaudeSafe(task *db.Task, workDir string) bool {
27022700 taskID , taskSessionID , task .Port , task .WorktreePath , envPrefix , claudeSessionID )
27032701
27042702 // Create new window in task-daemon session (with retry logic for race conditions)
2705- actualSession , tmuxErr := createTmuxWindow (daemonSession , windowName , workDir , script )
2703+ actualSession , tmuxErr := createTmuxWindow (daemonSession , windowName , workDir , script , e . getProjectDir ( task . Project ) )
27062704 if tmuxErr != nil {
27072705 e .logger .Warn ("tmux failed to create window" , "error" , tmuxErr , "session" , daemonSession )
27082706 if cleanupHooks != nil {
@@ -2819,7 +2817,7 @@ func (e *Executor) resumeCodexWithMode(task *db.Task, workDir string, dangerousM
28192817 script := fmt .Sprintf (`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q %scodex %s--resume %s` ,
28202818 taskID , taskSessionID , task .Port , task .WorktreePath , envPrefix , dangerousFlag , sessionID )
28212819
2822- actualSession , tmuxErr := createTmuxWindow (daemonSession , windowName , workDir , script )
2820+ actualSession , tmuxErr := createTmuxWindow (daemonSession , windowName , workDir , script , e . getProjectDir ( task . Project ) )
28232821 if tmuxErr != nil {
28242822 e .logger .Warn ("tmux failed to create window" , "error" , tmuxErr , "session" , daemonSession )
28252823 return false
@@ -2927,7 +2925,7 @@ func (e *Executor) resumeGeminiWithMode(task *db.Task, workDir string, dangerous
29272925 script := fmt .Sprintf (`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q %sgemini %s--resume %s` ,
29282926 taskID , taskSessionID , task .Port , task .WorktreePath , envPrefix , dangerousFlag , sessionID )
29292927
2930- actualSession , tmuxErr := createTmuxWindow (daemonSession , windowName , workDir , script )
2928+ actualSession , tmuxErr := createTmuxWindow (daemonSession , windowName , workDir , script , e . getProjectDir ( task . Project ) )
29312929 if tmuxErr != nil {
29322930 e .logger .Warn ("tmux failed to create window" , "error" , tmuxErr , "session" , daemonSession )
29332931 return false
@@ -4882,7 +4880,7 @@ func (e *Executor) runPi(ctx context.Context, task *db.Task, workDir, prompt str
48824880 }
48834881
48844882 // Create new window in task-daemon session (with retry logic for race conditions)
4885- actualSession , tmuxErr := createTmuxWindow (daemonSession , windowName , workDir , script )
4883+ actualSession , tmuxErr := createTmuxWindow (daemonSession , windowName , workDir , script , e . getProjectDir ( task . Project ) )
48864884 if tmuxErr != nil {
48874885 e .logger .Error ("tmux new-window failed" , "error" , tmuxErr , "session" , daemonSession )
48884886 e .logLine (task .ID , "error" , fmt .Sprintf ("Failed to create tmux window: %s" , tmuxErr .Error ()))
@@ -5004,7 +5002,7 @@ func (e *Executor) runPiResume(ctx context.Context, task *db.Task, workDir, prom
50045002 task .ID , taskSessionID , task .Port , task .WorktreePath , sessionPath , systemPromptFlag , feedbackFile .Name ())
50055003
50065004 // Create new window in task-daemon session (with retry logic for race conditions)
5007- actualSession , tmuxErr := createTmuxWindow (daemonSession , windowName , workDir , script )
5005+ actualSession , tmuxErr := createTmuxWindow (daemonSession , windowName , workDir , script , e . getProjectDir ( task . Project ) )
50085006 if tmuxErr != nil {
50095007 e .logger .Error ("tmux new-window failed" , "error" , tmuxErr , "session" , daemonSession )
50105008 e .logLine (task .ID , "error" , fmt .Sprintf ("Failed to create tmux window: %s" , tmuxErr .Error ()))
@@ -5147,45 +5145,53 @@ func (e *Executor) KillPiProcess(taskID int64) bool {
51475145// isValidWorktreePath validates that a working directory is within a .task-worktrees directory.
51485146// This prevents Claude from accidentally writing to the main project directory.
51495147// Returns true if the path is valid for task execution.
5150- // isValidWorktreePath validates that a working directory is within a .task-worktrees directory.
51515148func isValidWorktreePath (workDir string ) bool {
5152- // Empty path is never valid
51535149 if workDir == "" {
51545150 return false
51555151 }
51565152
5157- // Resolve symlinks and clean the path
51585153 absPath , err := filepath .Abs (workDir )
51595154 if err != nil {
51605155 return false
51615156 }
51625157
5163- // Evaluate any symlinks in the path
51645158 resolvedPath , err := filepath .EvalSymlinks (absPath )
51655159 if err != nil {
5166- // Path might not exist yet, use the absolute path
51675160 resolvedPath = absPath
51685161 }
51695162
5170- // Check that the path contains .task-worktrees
51715163 // Valid paths look like: /path/to/project/.task-worktrees/123-task-slug
51725164 return strings .Contains (resolvedPath , string (filepath .Separator )+ ".task-worktrees" + string (filepath .Separator ))
51735165}
51745166
51755167// isValidWorkDir validates that a working directory is either within a .task-worktrees directory
5176- // (for git worktree projects) or is an existing directory (for non-worktree projects).
5177- func isValidWorkDir (workDir string ) bool {
5178- // First check the traditional worktree path
5168+ // (for git worktree projects) or matches a specific allowed project directory (for non-worktree
5169+ // projects). The allowedProjectDir parameter restricts which non-worktree paths are accepted,
5170+ // preventing arbitrary directory access.
5171+ func isValidWorkDir (workDir string , allowedProjectDir string ) bool {
51795172 if isValidWorktreePath (workDir ) {
51805173 return true
51815174 }
51825175
5183- // For non-worktree projects, the workDir is the project directory itself.
5184- // Validate it exists and is a directory.
5185- if workDir == "" {
5176+ // For non-worktree projects, only accept the exact configured project directory.
5177+ if workDir == "" || allowedProjectDir == "" {
5178+ return false
5179+ }
5180+
5181+ absWork , err := filepath .Abs (workDir )
5182+ if err != nil {
5183+ return false
5184+ }
5185+ absAllowed , err := filepath .Abs (allowedProjectDir )
5186+ if err != nil {
5187+ return false
5188+ }
5189+
5190+ // Must match the allowed path AND exist as a directory
5191+ if absWork != absAllowed {
51865192 return false
51875193 }
5188- info , err := os .Stat (workDir )
5194+ info , err := os .Stat (absWork )
51895195 return err == nil && info .IsDir ()
51905196}
51915197
0 commit comments