Skip to content

Commit a70105c

Browse files
committed
Fix scope guard false positives on system paths
Scope guard now only enforces isolation between workspace projects. Paths outside the workspace (e.g. /dev/null, /usr/, /etc/) are not this guard's jurisdiction — other guards handle system-level security. Removes the complex system-command exemption logic that was insufficient and fragile.
1 parent c763769 commit a70105c

File tree

3 files changed

+58
-102
lines changed

3 files changed

+58
-102
lines changed

container/.devcontainer/CHANGELOG.md

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

3+
## Unreleased
4+
5+
### Scope Guard
6+
7+
- Fix false positives blocking writes to system paths (`/dev/null`, `/usr/`, `/etc/`, `$HOME/`) — scope guard now only enforces isolation between workspace projects
8+
- Remove complex system-command exemption logic (no longer needed)
9+
10+
### CLI Integration
11+
12+
- Add codeforge-cli devcontainer feature — installs the CodeForge CLI (`codeforge` command) globally via npm
13+
- Remove dead `codeforge` alias from setup-aliases.sh (was pointing to obsolete `setup.js`)
14+
315
## v2.0.3 — 2026-03-03
416

517
### Workspace Scope Guard

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

Lines changed: 24 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -22,20 +22,13 @@
2222
# ---------------------------------------------------------------------------
2323
# BLACKLIST — checked FIRST, overrides everything.
2424
# Nothing touches these paths. Ever. No exceptions.
25-
# Checked before allowlist, before scope check, before cwd bypass.
25+
# Checked before scope check, before cwd bypass.
2626
# ---------------------------------------------------------------------------
2727
BLACKLISTED_PREFIXES = [
2828
"/workspaces/.devcontainer/",
2929
"/workspaces/.devcontainer", # exact match (no trailing slash)
3030
]
3131

32-
# Paths always allowed regardless of working directory
33-
_home = os.environ.get("HOME", "/home/vscode")
34-
ALLOWED_PREFIXES = [
35-
f"{_home}/.claude/", # Claude config, plans, rules
36-
"/tmp/", # System scratch
37-
]
38-
3932
WRITE_TOOLS = {"Write", "Edit", "NotebookEdit"}
4033
READ_TOOLS = {"Read", "Glob", "Grep"}
4134
ALL_FILE_TOOLS = WRITE_TOOLS | READ_TOOLS
@@ -85,47 +78,6 @@
8578
# ---------------------------------------------------------------------------
8679
WORKSPACE_PATH_RE = re.compile(r'/workspaces/[^\s;|&>)<\'"]+')
8780

88-
# ---------------------------------------------------------------------------
89-
# System command exemption (Layer 1 only)
90-
# ---------------------------------------------------------------------------
91-
SYSTEM_COMMANDS = frozenset(
92-
{
93-
"git",
94-
"pip",
95-
"pip3",
96-
"npm",
97-
"npx",
98-
"yarn",
99-
"pnpm",
100-
"apt-get",
101-
"apt",
102-
"cargo",
103-
"go",
104-
"docker",
105-
"make",
106-
"cmake",
107-
"node",
108-
"python3",
109-
"python",
110-
"ruby",
111-
"gem",
112-
"bundle",
113-
}
114-
)
115-
116-
SYSTEM_PATH_PREFIXES = (
117-
"/usr/",
118-
"/bin/",
119-
"/sbin/",
120-
"/lib/",
121-
"/opt/",
122-
"/proc/",
123-
"/sys/",
124-
"/dev/",
125-
"/var/",
126-
"/etc/",
127-
)
128-
12981

13082
# ---------------------------------------------------------------------------
13183
# Core check functions
@@ -145,9 +97,15 @@ def is_in_scope(resolved_path: str, cwd: str) -> bool:
14597
return resolved_path == cwd or resolved_path.startswith(cwd_prefix)
14698

14799

148-
def is_allowlisted(resolved_path: str) -> bool:
149-
"""Check if resolved_path falls under an allowed prefix."""
150-
return any(resolved_path.startswith(prefix) for prefix in ALLOWED_PREFIXES)
100+
def is_outside_workspace(resolved_path: str) -> bool:
101+
"""Check if resolved_path is outside /workspaces/.
102+
103+
Paths outside the workspace are not this guard's jurisdiction —
104+
system paths (/dev/, /usr/, /tmp/, $HOME/) are handled by other guards.
105+
"""
106+
return not (
107+
resolved_path == "/workspaces" or resolved_path.startswith("/workspaces/")
108+
)
151109

152110

153111
# Worktree path segment used to detect worktree CWDs
@@ -295,45 +253,23 @@ def check_bash_scope(command: str, cwd: str) -> None:
295253
return
296254

297255
# --- Layer 1: Write target scope check ---
298-
if write_targets:
299-
primary_cmd = extract_primary_command(command)
300-
is_system_cmd = primary_cmd in SYSTEM_COMMANDS
301-
302-
resolved_targets = [
303-
(t, os.path.realpath(t.strip("'\""))) for t in write_targets
304-
]
305-
306-
# System command exemption: skip Layer 1 ONLY if ALL targets are system paths
307-
skip_layer1 = False
308-
if is_system_cmd:
309-
skip_layer1 = all(
310-
any(r.startswith(sp) for sp in SYSTEM_PATH_PREFIXES)
311-
for _, r in resolved_targets
256+
for target in write_targets:
257+
resolved = os.path.realpath(target.strip("'\""))
258+
if is_outside_workspace(resolved):
259+
continue # Not under /workspaces/ — not this guard's jurisdiction
260+
if not is_in_scope(resolved, cwd):
261+
detail = f" (resolved: {resolved})" if resolved != target else ""
262+
print(
263+
f"Blocked: Bash command writes to '{target}'{detail} which is "
264+
f"outside the working directory ({cwd}).",
265+
file=sys.stderr,
312266
)
313-
# Override: if ANY target is under /workspaces/ outside cwd → NOT exempt
314-
if skip_layer1:
315-
for _, resolved in resolved_targets:
316-
if resolved.startswith("/workspaces/") and not is_in_scope(
317-
resolved, cwd
318-
):
319-
skip_layer1 = False
320-
break
321-
322-
if not skip_layer1:
323-
for target, resolved in resolved_targets:
324-
if not is_in_scope(resolved, cwd) and not is_allowlisted(resolved):
325-
detail = f" (resolved: {resolved})" if resolved != target else ""
326-
print(
327-
f"Blocked: Bash command writes to '{target}'{detail} which is "
328-
f"outside the working directory ({cwd}).",
329-
file=sys.stderr,
330-
)
331-
sys.exit(2)
267+
sys.exit(2)
332268

333269
# --- Layer 2: Workspace path scan (ALWAYS runs, never exempt) ---
334270
for path_str in workspace_paths:
335271
resolved = os.path.realpath(path_str)
336-
if not is_in_scope(resolved, cwd) and not is_allowlisted(resolved):
272+
if not is_in_scope(resolved, cwd):
337273
detail = f" (resolved: {resolved})" if resolved != path_str else ""
338274
print(
339275
f"Blocked: Bash command references '{path_str}'{detail} which is "
@@ -398,8 +334,8 @@ def main():
398334
if is_in_scope(resolved, scope_root):
399335
sys.exit(0)
400336

401-
# Allowlist check
402-
if is_allowlisted(resolved):
337+
# Outside workspace — not this guard's jurisdiction
338+
if is_outside_workspace(resolved):
403339
sys.exit(0)
404340

405341
# Out of scope — BLOCK for ALL tools

container/tests/plugins/test_guard_workspace_scope.py

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Tests for workspace scope guard plugin.
22
3-
Covers: is_blacklisted, is_in_scope, is_allowlisted, get_target_path,
3+
Covers: is_blacklisted, is_in_scope, is_outside_workspace, get_target_path,
44
extract_primary_command, extract_write_targets, check_bash_scope,
55
resolve_scope_root.
66
"""
@@ -117,26 +117,32 @@ def test_in_scope(self, resolved_path, cwd, expected):
117117

118118

119119
# ---------------------------------------------------------------------------
120-
# is_allowlisted
120+
# is_outside_workspace
121121
# ---------------------------------------------------------------------------
122-
class TestIsAllowlisted:
122+
class TestIsOutsideWorkspace:
123123
@pytest.mark.parametrize(
124124
"path, expected",
125125
[
126-
(f"{guard_workspace_scope._home}/.claude/rules/foo.md", True),
127-
("/tmp/scratch.txt", True),
126+
("/dev/null", True),
127+
("/usr/lib/node_modules/foo", True),
128+
("/home/vscode/.config/foo", True),
129+
("/tmp/scratch", True),
128130
("/workspaces/proj/file", False),
129-
(f"{guard_workspace_scope._home}/.ssh/id_rsa", False),
131+
("/workspaces", False),
132+
("/workspaces/.devcontainer/foo", False),
130133
],
131134
ids=[
132-
"claude_config_dir",
133-
"tmp_file",
134-
"project_file",
135-
"ssh_key",
135+
"dev_null",
136+
"usr_lib",
137+
"home_config",
138+
"tmp_scratch",
139+
"workspace_project_file",
140+
"workspaces_root_exact",
141+
"devcontainer_under_workspace",
136142
],
137143
)
138-
def test_allowlisted(self, path, expected):
139-
assert guard_workspace_scope.is_allowlisted(path) is expected
144+
def test_outside_workspace(self, path, expected):
145+
assert guard_workspace_scope.is_outside_workspace(path) is expected
140146

141147

142148
# ---------------------------------------------------------------------------
@@ -265,7 +271,8 @@ def test_blocked(self, command, cwd):
265271
("echo x > /workspaces/proj/out.txt", "/workspaces/proj"),
266272
("echo hello", "/workspaces/proj"),
267273
("echo x > /workspaces/other/file", "/workspaces"),
268-
("echo x > /tmp/scratch", "/workspaces/proj"),
274+
("command 2>/dev/null", "/workspaces/proj"),
275+
("echo x > /usr/local/bin/foo", "/workspaces/proj"),
269276
("", "/workspaces/proj"),
270277
("ls /workspaces/proj/other-dir", "/workspaces/proj"),
271278
("cat /workspaces/proj/README.md", "/workspaces/proj"),
@@ -274,7 +281,8 @@ def test_blocked(self, command, cwd):
274281
"write_inside_scope",
275282
"no_paths",
276283
"cwd_is_workspaces_bypass",
277-
"allowlisted_tmp",
284+
"redirect_to_dev_null",
285+
"write_to_system_path",
278286
"empty_command",
279287
"sibling_dir_in_scope",
280288
"project_root_file_in_scope",

0 commit comments

Comments
 (0)