Skip to content

Commit c9697f9

Browse files
authored
Merge pull request #485 from bborn/task/1944-non-git-followup
Harden non-git project support: security, DRY, tests
2 parents 2eee176 + 1097f34 commit c9697f9

File tree

10 files changed

+198
-52
lines changed

10 files changed

+198
-52
lines changed

internal/config/config_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package config
2+
3+
import (
4+
"path/filepath"
5+
"testing"
6+
7+
"github.com/bborn/workflow/internal/db"
8+
)
9+
10+
func TestProjectUsesWorktrees(t *testing.T) {
11+
tmpDir := t.TempDir()
12+
dbPath := filepath.Join(tmpDir, "test.db")
13+
14+
database, err := db.Open(dbPath)
15+
if err != nil {
16+
t.Fatalf("failed to open database: %v", err)
17+
}
18+
defer database.Close()
19+
20+
cfg := New(database)
21+
22+
t.Run("empty project name defaults to true", func(t *testing.T) {
23+
if !cfg.ProjectUsesWorktrees("") {
24+
t.Error("empty project name should default to true")
25+
}
26+
})
27+
28+
t.Run("unknown project defaults to true", func(t *testing.T) {
29+
if !cfg.ProjectUsesWorktrees("nonexistent-project") {
30+
t.Error("unknown project should default to true")
31+
}
32+
})
33+
34+
t.Run("worktree-enabled project returns true", func(t *testing.T) {
35+
if err := database.CreateProject(&db.Project{
36+
Name: "git-proj", Path: filepath.Join(tmpDir, "git"), UseWorktrees: true,
37+
}); err != nil {
38+
t.Fatalf("failed to create project: %v", err)
39+
}
40+
if !cfg.ProjectUsesWorktrees("git-proj") {
41+
t.Error("worktree-enabled project should return true")
42+
}
43+
})
44+
45+
t.Run("worktree-disabled project returns false", func(t *testing.T) {
46+
if err := database.CreateProject(&db.Project{
47+
Name: "no-git-proj", Path: filepath.Join(tmpDir, "nogit"), UseWorktrees: false,
48+
}); err != nil {
49+
t.Fatalf("failed to create project: %v", err)
50+
}
51+
if cfg.ProjectUsesWorktrees("no-git-proj") {
52+
t.Error("worktree-disabled project should return false")
53+
}
54+
})
55+
}

internal/db/sqlite_test.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,4 +272,20 @@ func TestProjectUseWorktrees(t *testing.T) {
272272
if !p.UsesWorktrees() {
273273
t.Error("no-git-project should use worktrees after update")
274274
}
275+
276+
// Test alias-based lookup preserves UseWorktrees
277+
aliasProject := &Project{Name: "alias-proj", Path: filepath.Join(tmpDir, "alias"), Aliases: "ap,aliased", UseWorktrees: false}
278+
if err := db.CreateProject(aliasProject); err != nil {
279+
t.Fatalf("failed to create alias project: %v", err)
280+
}
281+
p, err = db.GetProjectByName("ap")
282+
if err != nil {
283+
t.Fatalf("failed to get project by alias: %v", err)
284+
}
285+
if p == nil {
286+
t.Fatal("expected project from alias lookup, got nil")
287+
}
288+
if p.UsesWorktrees() {
289+
t.Error("alias-proj looked up by alias should NOT use worktrees")
290+
}
275291
}

internal/db/tasks.go

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1096,17 +1096,21 @@ func (p *Project) GetAction(trigger string) *ProjectAction {
10961096
return nil
10971097
}
10981098

1099+
// boolToInt converts a bool to an int for SQLite storage (1=true, 0=false).
1100+
func boolToInt(b bool) int {
1101+
if b {
1102+
return 1
1103+
}
1104+
return 0
1105+
}
1106+
10991107
// CreateProject creates a new project.
11001108
func (db *DB) CreateProject(p *Project) error {
11011109
actionsJSON, _ := json.Marshal(p.Actions)
1102-
useWorktrees := 1
1103-
if !p.UseWorktrees {
1104-
useWorktrees = 0
1105-
}
11061110
result, err := db.Exec(`
11071111
INSERT INTO projects (name, path, aliases, instructions, actions, color, claude_config_dir, use_worktrees)
11081112
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1109-
`, p.Name, p.Path, p.Aliases, p.Instructions, string(actionsJSON), p.Color, p.ClaudeConfigDir, useWorktrees)
1113+
`, p.Name, p.Path, p.Aliases, p.Instructions, string(actionsJSON), p.Color, p.ClaudeConfigDir, boolToInt(p.UseWorktrees))
11101114
if err != nil {
11111115
return fmt.Errorf("insert project: %w", err)
11121116
}
@@ -1118,14 +1122,10 @@ func (db *DB) CreateProject(p *Project) error {
11181122
// UpdateProject updates a project.
11191123
func (db *DB) UpdateProject(p *Project) error {
11201124
actionsJSON, _ := json.Marshal(p.Actions)
1121-
useWorktrees := 1
1122-
if !p.UseWorktrees {
1123-
useWorktrees = 0
1124-
}
11251125
_, err := db.Exec(`
11261126
UPDATE projects SET name = ?, path = ?, aliases = ?, instructions = ?, actions = ?, color = ?, claude_config_dir = ?, use_worktrees = ?
11271127
WHERE id = ?
1128-
`, p.Name, p.Path, p.Aliases, p.Instructions, string(actionsJSON), p.Color, p.ClaudeConfigDir, useWorktrees, p.ID)
1128+
`, p.Name, p.Path, p.Aliases, p.Instructions, string(actionsJSON), p.Color, p.ClaudeConfigDir, boolToInt(p.UseWorktrees), p.ID)
11291129
if err != nil {
11301130
return fmt.Errorf("update project: %w", err)
11311131
}

internal/executor/codex_executor.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ func (c *CodexExecutor) runCodex(ctx context.Context, task *db.Task, workDir, pr
160160
task.ID, sessionID, task.Port, task.WorktreePath, envPrefix, dangerousFlag, resumeFlag, promptFile.Name())
161161

162162
// Create new window in task-daemon session
163-
actualSession, tmuxErr := createTmuxWindow(daemonSession, windowName, workDir, script)
163+
actualSession, tmuxErr := createTmuxWindow(daemonSession, windowName, workDir, script, c.executor.getProjectDir(task.Project))
164164
if tmuxErr != nil {
165165
c.logger.Error("tmux new-window failed", "error", tmuxErr, "session", daemonSession)
166166
c.executor.logLine(task.ID, "error", fmt.Sprintf("Failed to create tmux window: %s", tmuxErr.Error()))

internal/executor/executor.go

Lines changed: 33 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
51515148
func 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

internal/executor/executor_test.go

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -912,27 +912,42 @@ func TestIsValidWorkDir(t *testing.T) {
912912
t.Fatal(err)
913913
}
914914

915-
t.Run("worktree path is valid", func(t *testing.T) {
916-
if !isValidWorkDir(taskDir) {
917-
t.Errorf("isValidWorkDir(%q) should return true for worktree directory", taskDir)
915+
t.Run("worktree path is valid regardless of allowedProjectDir", func(t *testing.T) {
916+
if !isValidWorkDir(taskDir, "") {
917+
t.Errorf("isValidWorkDir(%q, \"\") should return true for worktree directory", taskDir)
918+
}
919+
if !isValidWorkDir(taskDir, "/some/other/path") {
920+
t.Errorf("isValidWorkDir(%q, other) should return true for worktree directory", taskDir)
921+
}
922+
})
923+
924+
t.Run("project directory is valid when it matches allowedProjectDir", func(t *testing.T) {
925+
if !isValidWorkDir(tmpDir, tmpDir) {
926+
t.Errorf("isValidWorkDir(%q, %q) should return true when paths match", tmpDir, tmpDir)
927+
}
928+
})
929+
930+
t.Run("project directory is rejected when allowedProjectDir differs", func(t *testing.T) {
931+
if isValidWorkDir(tmpDir, "/some/other/path") {
932+
t.Errorf("isValidWorkDir(%q, other) should return false when paths don't match", tmpDir)
918933
}
919934
})
920935

921-
t.Run("project directory is valid for non-worktree projects", func(t *testing.T) {
922-
// For non-worktree projects, the project directory itself is the work dir
923-
if !isValidWorkDir(tmpDir) {
924-
t.Errorf("isValidWorkDir(%q) should return true for existing project directory", tmpDir)
936+
t.Run("arbitrary directory is rejected without allowedProjectDir", func(t *testing.T) {
937+
if isValidWorkDir(tmpDir, "") {
938+
t.Errorf("isValidWorkDir(%q, \"\") should return false for non-worktree path with empty allowed", tmpDir)
925939
}
926940
})
927941

928942
t.Run("empty path is not valid", func(t *testing.T) {
929-
if isValidWorkDir("") {
930-
t.Error("isValidWorkDir('') should return false")
943+
if isValidWorkDir("", tmpDir) {
944+
t.Error("isValidWorkDir('', ...) should return false")
931945
}
932946
})
933947

934-
t.Run("non-existent path is not valid", func(t *testing.T) {
935-
if isValidWorkDir("/nonexistent/path/xyz") {
948+
t.Run("non-existent path is not valid even if allowed", func(t *testing.T) {
949+
nonExistent := "/nonexistent/path/xyz"
950+
if isValidWorkDir(nonExistent, nonExistent) {
936951
t.Error("isValidWorkDir should return false for non-existent path")
937952
}
938953
})

internal/executor/gemini_executor.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ func (g *GeminiExecutor) runGemini(ctx context.Context, task *db.Task, workDir,
125125
script := fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q %sgemini %s%s-i "$(cat %q)"`,
126126
task.ID, sessionID, task.Port, task.WorktreePath, envPrefix, dangerousFlag, resumeFlag, promptFile.Name())
127127

128-
actualSession, tmuxErr := createTmuxWindow(daemonSession, windowName, workDir, script)
128+
actualSession, tmuxErr := createTmuxWindow(daemonSession, windowName, workDir, script, g.executor.getProjectDir(task.Project))
129129
if tmuxErr != nil {
130130
g.logger.Error("tmux new-window failed", "error", tmuxErr, "session", daemonSession)
131131
g.executor.logLine(task.ID, "error", fmt.Sprintf("Failed to create tmux window: %s", tmuxErr.Error()))

internal/executor/openclaw_executor.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ func (o *OpenClawExecutor) runOpenClaw(ctx context.Context, task *db.Task, workD
140140
script := fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q %sopenclaw tui --session %s %s--message "$(cat %q)"`,
141141
task.ID, worktreeSessionID, task.Port, task.WorktreePath, envPrefix, sessionKey, thinkingFlag, promptFile.Name())
142142

143-
actualSession, tmuxErr := createTmuxWindow(daemonSession, windowName, workDir, script)
143+
actualSession, tmuxErr := createTmuxWindow(daemonSession, windowName, workDir, script, o.executor.getProjectDir(task.Project))
144144
if tmuxErr != nil {
145145
o.logger.Error("tmux new-window failed", "error", tmuxErr, "session", daemonSession)
146146
o.executor.logLine(task.ID, "error", fmt.Sprintf("Failed to create tmux window: %s", tmuxErr.Error()))

internal/executor/opencode_executor.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ func (o *OpenCodeExecutor) runOpenCode(ctx context.Context, task *db.Task, workD
133133
workDir, task.ID, worktreeSessionID, task.Port, task.WorktreePath, envPrefix, promptFile.Name(), promptFile.Name())
134134
}
135135

136-
actualSession, tmuxErr := createTmuxWindow(daemonSession, windowName, workDir, script)
136+
actualSession, tmuxErr := createTmuxWindow(daemonSession, windowName, workDir, script, o.executor.getProjectDir(task.Project))
137137
if tmuxErr != nil {
138138
o.logger.Error("tmux new-window failed", "error", tmuxErr, "session", daemonSession)
139139
o.executor.logLine(task.ID, "error", fmt.Sprintf("Failed to create tmux window: %s", tmuxErr.Error()))

0 commit comments

Comments
 (0)