Skip to content

Commit 4d8a675

Browse files
committed
Merge remote-tracking branch 'origin/fix/scope-guard-false-positives' into staging
# Conflicts: # container/.devcontainer/CHANGELOG.md # container/.devcontainer/plugins/devs-marketplace/plugins/workspace-scope-guard/scripts/guard-workspace-scope.py
2 parents ba2cb48 + cab5728 commit 4d8a675

File tree

5 files changed

+64
-129
lines changed

5 files changed

+64
-129
lines changed

container/.devcontainer/CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,20 @@
2222
### Skills
2323
- Added `agent-browser` skill to skill-engine plugin — guides headless browser automation with CLI reference, workflow patterns, and authentication
2424

25+
### Scope Guard
26+
27+
- Fix false positives blocking writes to system paths (`/dev/null`, `/usr/`, `/etc/`, `$HOME/`) — scope guard now only enforces isolation between workspace projects
28+
- Remove complex system-command exemption logic (no longer needed)
29+
30+
### Dangerous Command Blocker
31+
32+
- Remove system directory write redirect blocks (`> /usr/`, `> /etc/`, `> /bin/`, `> /sbin/`) — caused false positives on text content in command arguments (e.g. PR body text containing paths); write location enforcement is the scope guard's responsibility
33+
34+
### CLI Integration
35+
36+
- Add codeforge-cli devcontainer feature — installs the CodeForge CLI (`codeforge` command) globally via npm
37+
- Remove dead `codeforge` alias from setup-aliases.sh (was pointing to obsolete `setup.js`)
38+
2539
### Windows Compatibility
2640

2741
- Fix `claude-code-native` install failure on Windows/macOS Docker Desktop — installer now falls back to `HOME` override when `su` is unavailable

container/.devcontainer/plugins/devs-marketplace/plugins/dangerous-command-blocker/scripts/block-dangerous.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,6 @@
5555
r"\bgit\s+push\s+--force\s+(origin\s+)?(main|master)\b",
5656
"Blocked: force push to main/master destroys history",
5757
),
58-
# System directory modification
59-
(r">\s*/usr/", "Blocked: writing to /usr system directory"),
60-
(r">\s*/etc/", "Blocked: writing to /etc system directory"),
61-
(r">\s*/bin/", "Blocked: writing to /bin system directory"),
62-
(r">\s*/sbin/", "Blocked: writing to /sbin system directory"),
6358
# Disk formatting
6459
(r"\bmkfs\.\w+", "Blocked: disk formatting command"),
6560
(r"\bdd\s+.*of=/dev/", "Blocked: dd writing to device"),

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

Lines changed: 24 additions & 90 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
@@ -326,47 +284,23 @@ def check_bash_scope(command: str, cwd: str) -> None:
326284
return
327285

328286
# --- Layer 1: Write target scope check ---
329-
if write_targets:
330-
primary_cmd = extract_primary_command(command)
331-
is_system_cmd = primary_cmd in SYSTEM_COMMANDS
332-
333-
resolved_targets = [
334-
(t, os.path.realpath(t.strip("'\""))) for t in write_targets
335-
]
336-
337-
# System command exemption: skip Layer 1 ONLY if ALL targets are system paths
338-
skip_layer1 = False
339-
if is_system_cmd:
340-
skip_layer1 = all(
341-
any(r.startswith(sp) for sp in SYSTEM_PATH_PREFIXES)
342-
for _, r in resolved_targets
287+
for target in write_targets:
288+
resolved = os.path.realpath(target.strip("'\""))
289+
if is_outside_workspace(resolved):
290+
continue # Not under /workspaces/ — not this guard's jurisdiction
291+
if not is_in_scope(resolved, cwd):
292+
detail = f" (resolved: {resolved})" if resolved != target else ""
293+
print(
294+
f"Blocked: Bash command writes to '{target}'{detail} which is "
295+
f"outside the working directory ({cwd}).",
296+
file=sys.stderr,
343297
)
344-
# Override: if ANY target is under /workspaces/ outside cwd → NOT exempt
345-
if skip_layer1:
346-
for _, resolved in resolved_targets:
347-
if resolved.startswith("/workspaces/") and not is_in_scope(
348-
resolved, cwd
349-
):
350-
skip_layer1 = False
351-
break
352-
353-
if not skip_layer1:
354-
for target, resolved in resolved_targets:
355-
if any(resolved.startswith(sp) for sp in SYSTEM_PATH_PREFIXES):
356-
continue
357-
if not is_in_scope(resolved, cwd) and not is_allowlisted(resolved):
358-
detail = f" (resolved: {resolved})" if resolved != target else ""
359-
print(
360-
f"Blocked: Bash command writes to '{target}'{detail} which is "
361-
f"outside the working directory ({cwd}).",
362-
file=sys.stderr,
363-
)
364-
sys.exit(2)
298+
sys.exit(2)
365299

366300
# --- Layer 2: Workspace path scan (ALWAYS runs, never exempt) ---
367301
for path_str in workspace_paths:
368302
resolved = os.path.realpath(path_str)
369-
if not is_in_scope(resolved, cwd) and not is_allowlisted(resolved):
303+
if not is_in_scope(resolved, cwd):
370304
detail = f" (resolved: {resolved})" if resolved != path_str else ""
371305
print(
372306
f"Blocked: Bash command references '{path_str}'{detail} which is "
@@ -430,8 +364,8 @@ def main():
430364
if is_in_scope(resolved, scope_root):
431365
sys.exit(0)
432366

433-
# Allowlist check
434-
if is_allowlisted(resolved):
367+
# Outside workspace — not this guard's jurisdiction
368+
if is_outside_workspace(resolved):
435369
sys.exit(0)
436370

437371
# Out of scope — BLOCK for ALL tools

container/tests/plugins/test_block_dangerous.py

Lines changed: 4 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -118,26 +118,7 @@ def test_bare_force_push(self, cmd: str) -> None:
118118

119119

120120
# ---------------------------------------------------------------------------
121-
# 5. System directory writes
122-
# ---------------------------------------------------------------------------
123-
124-
125-
class TestSystemDirectoryWrites:
126-
@pytest.mark.parametrize(
127-
"cmd,dir_name",
128-
[
129-
("> /usr/foo", "/usr"),
130-
("> /etc/foo", "/etc"),
131-
("> /bin/foo", "/bin"),
132-
("> /sbin/foo", "/sbin"),
133-
],
134-
)
135-
def test_redirect_to_system_dir(self, cmd: str, dir_name: str) -> None:
136-
assert_blocked(cmd, substr=dir_name)
137-
138-
139-
# ---------------------------------------------------------------------------
140-
# 6. Disk operations
121+
# 5. Disk operations
141122
# ---------------------------------------------------------------------------
142123

143124

@@ -229,6 +210,9 @@ class TestSafeCommands:
229210
"cat /etc/hosts",
230211
"echo hello",
231212
"git status",
213+
"echo '> /usr/local/bin/foo' | gh pr create --body-file -",
214+
"echo x > /usr/local/bin/tool",
215+
"echo x > /etc/myconfig",
232216
],
233217
)
234218
def test_safe_commands_allowed(self, cmd: str) -> None:

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)