Skip to content

Commit 2f0736e

Browse files
committed
Fix scope guard blocking project root from subdirectory CWDs
resolve_scope_root() now walks up from CWD looking for .git to find the repository root, preventing false positives when working in subdirectories like cli/, src/, or tests/. Safety ceiling at /workspaces prevents scope from escaping the workspace boundary.
1 parent 1dad59f commit 2f0736e

5 files changed

Lines changed: 124 additions & 7 deletions

File tree

.devcontainer/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# CodeForge Devcontainer Changelog
22

3+
## v2.0.3 — 2026-03-03
4+
5+
### Workspace Scope Guard
6+
7+
- Fix scope guard blocking project root access from subdirectory CWDs — now detects git repository root and uses it as scope boundary
8+
39
## v2.0.2 — 2026-03-02
410

511
### Security

.devcontainer/plugins/devs-marketplace/plugins/workspace-scope-guard/README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,17 @@ This means:
4141

4242
Example: if CWD is `/workspaces/projects/MyApp/.claude/worktrees/agent-abc123`, the scope root becomes `/workspaces/projects/MyApp/`. All paths under that root are permitted.
4343

44+
### Git Root Detection
45+
46+
When CWD is a subdirectory of a git repository, the guard automatically expands scope to the **repository root** (the directory containing `.git`). This prevents false positives when working in subdirectories like `src/`, `cli/`, or `tests/`.
47+
48+
The walk stops at `/workspaces` or the filesystem root as a safety ceiling — scope never expands beyond the workspace boundary.
49+
50+
Priority order:
51+
1. **Worktree detection**`.claude/worktrees/` in CWD → project root
52+
2. **Git root detection** — walk up to `.git` → repository root
53+
3. **Fallback** — CWD unchanged (non-git directories)
54+
4455
### Path Resolution
4556

4657
Both CWD and target paths are resolved via `os.path.realpath()` before comparison. This prevents false positives when paths involve symlinks or bind mounts.

.devcontainer/plugins/devs-marketplace/plugins/workspace-scope-guard/scripts/guard-workspace-scope.py

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
Permanently blacklists /workspaces/.devcontainer/ — no exceptions, no bypass.
77
Bash enforcement via two-layer detection: write target extraction + workspace path scan.
88
Worktree-aware: detects .claude/worktrees/ in CWD and expands scope to project root.
9+
Git-root-aware: walks up from CWD to find .git, expanding scope to repository root.
910
Fails closed on any error.
1011
1112
Exit code 2 blocks the operation with an error message.
@@ -156,15 +157,30 @@ def is_allowlisted(resolved_path: str) -> bool:
156157
def resolve_scope_root(cwd: str) -> str:
157158
"""Resolve CWD to the effective scope root.
158159
159-
When CWD is inside a .claude/worktrees/<id> directory, the scope root
160-
is the project root (the parent of .claude/worktrees/). This allows
161-
sibling worktrees and the main project directory to remain in-scope.
162-
163-
Returns cwd unchanged when not in a worktree.
160+
Priority:
161+
1. Worktree detection: if CWD is inside .claude/worktrees/<id>, scope root
162+
is the project root (parent of .claude/worktrees/).
163+
2. Git root detection: walk up from CWD looking for .git directory/file.
164+
Stops at / or /workspaces to prevent scope from escaping the workspace.
165+
3. Fallback: CWD unchanged (non-git directories).
164166
"""
167+
# 1. Worktree detection
165168
idx = cwd.find(_WORKTREE_SEGMENT)
166169
if idx != -1:
167170
return cwd[:idx]
171+
172+
# 2. Git root detection — walk up looking for .git
173+
current = cwd
174+
while True:
175+
if os.path.exists(os.path.join(current, ".git")):
176+
return current
177+
parent = os.path.dirname(current)
178+
# Safety ceiling: stop at filesystem root or /workspaces
179+
if parent == current or current == "/workspaces":
180+
break
181+
current = parent
182+
183+
# 3. Fallback — no git root found
168184
return cwd
169185

170186

.devcontainer/plugins/devs-marketplace/plugins/workspace-scope-guard/scripts/inject-workspace-cwd.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
66
Worktree-aware: when CWD is inside .claude/worktrees/, injects the project
77
root as the scope boundary instead of the worktree-specific path.
8+
Git-root-aware: walks up from CWD to find .git, expanding scope to repository root.
89
910
Fires on: SessionStart, UserPromptSubmit, PreToolUse, SubagentStart
1011
Always exits 0 (advisory, never blocking).
@@ -19,10 +20,32 @@
1920

2021

2122
def resolve_scope_root(cwd: str) -> str:
22-
"""Resolve CWD to project root when inside a worktree."""
23+
"""Resolve CWD to the effective scope root.
24+
25+
Priority:
26+
1. Worktree detection: if CWD is inside .claude/worktrees/<id>, scope root
27+
is the project root (parent of .claude/worktrees/).
28+
2. Git root detection: walk up from CWD looking for .git directory/file.
29+
Stops at / or /workspaces to prevent scope from escaping the workspace.
30+
3. Fallback: CWD unchanged (non-git directories).
31+
"""
32+
# 1. Worktree detection
2333
idx = cwd.find(_WORKTREE_SEGMENT)
2434
if idx != -1:
2535
return cwd[:idx]
36+
37+
# 2. Git root detection — walk up looking for .git
38+
current = cwd
39+
while True:
40+
if os.path.exists(os.path.join(current, ".git")):
41+
return current
42+
parent = os.path.dirname(current)
43+
# Safety ceiling: stop at filesystem root or /workspaces
44+
if parent == current or current == "/workspaces":
45+
break
46+
current = parent
47+
48+
# 3. Fallback — no git root found
2649
return cwd
2750

2851

tests/plugins/test_guard_workspace_scope.py

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,73 @@
11
"""Tests for workspace scope guard plugin.
22
33
Covers: is_blacklisted, is_in_scope, is_allowlisted, get_target_path,
4-
extract_primary_command, extract_write_targets, check_bash_scope.
4+
extract_primary_command, extract_write_targets, check_bash_scope,
5+
resolve_scope_root.
56
"""
67

8+
import os
79
from unittest.mock import patch
810

911
import pytest
1012

1113
from tests.conftest import guard_workspace_scope
1214

1315

16+
# ---------------------------------------------------------------------------
17+
# resolve_scope_root
18+
# ---------------------------------------------------------------------------
19+
class TestResolveScopeRoot:
20+
@pytest.mark.parametrize(
21+
"cwd, git_at, expected",
22+
[
23+
(
24+
"/workspaces/projects/MyApp/src/components",
25+
"/workspaces/projects/MyApp",
26+
"/workspaces/projects/MyApp",
27+
),
28+
(
29+
"/workspaces/projects/MyApp/src/deeply/nested",
30+
"/workspaces/projects/MyApp",
31+
"/workspaces/projects/MyApp",
32+
),
33+
(
34+
"/workspaces/projects/MyApp",
35+
"/workspaces/projects/MyApp",
36+
"/workspaces/projects/MyApp",
37+
),
38+
(
39+
"/workspaces/projects/MyApp/src",
40+
None,
41+
"/workspaces/projects/MyApp/src",
42+
),
43+
(
44+
"/workspaces/projects/MyApp/.claude/worktrees/abc/src",
45+
"/workspaces/projects/MyApp",
46+
"/workspaces/projects/MyApp",
47+
),
48+
],
49+
ids=[
50+
"subdirectory_finds_git_root",
51+
"deeply_nested_finds_git_root",
52+
"already_at_git_root",
53+
"no_git_fallback_to_cwd",
54+
"worktree_takes_priority",
55+
],
56+
)
57+
def test_resolve_scope_root(self, cwd, git_at, expected):
58+
original_exists = os.path.exists
59+
60+
def mock_exists(path):
61+
if git_at and path == os.path.join(git_at, ".git"):
62+
return True
63+
if path.endswith("/.git"):
64+
return False
65+
return original_exists(path)
66+
67+
with patch("os.path.exists", side_effect=mock_exists):
68+
assert guard_workspace_scope.resolve_scope_root(cwd) == expected
69+
70+
1471
# ---------------------------------------------------------------------------
1572
# is_blacklisted
1673
# ---------------------------------------------------------------------------
@@ -210,13 +267,17 @@ def test_blocked(self, command, cwd):
210267
("echo x > /workspaces/other/file", "/workspaces"),
211268
("echo x > /tmp/scratch", "/workspaces/proj"),
212269
("", "/workspaces/proj"),
270+
("ls /workspaces/proj/other-dir", "/workspaces/proj"),
271+
("cat /workspaces/proj/README.md", "/workspaces/proj"),
213272
],
214273
ids=[
215274
"write_inside_scope",
216275
"no_paths",
217276
"cwd_is_workspaces_bypass",
218277
"allowlisted_tmp",
219278
"empty_command",
279+
"sibling_dir_in_scope",
280+
"project_root_file_in_scope",
220281
],
221282
)
222283
def test_allowed(self, command, cwd):

0 commit comments

Comments
 (0)