Skip to content

Commit b9e10f0

Browse files
committed
Merge branch 'test-gaps' into staging
# Conflicts: # container/.devcontainer/CHANGELOG.md # container/package.json
2 parents 5af7ca7 + e5b18f6 commit b9e10f0

File tree

11 files changed

+1518
-1
lines changed

11 files changed

+1518
-1
lines changed

container/.devcontainer/CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,16 @@
99
- Command renamed: `claude-dashboard``codeforge-dashboard`
1010
- Removed persistence symlink hook (dashboard DB now lives on bind mount at `~/.codeforge/data/`)
1111

12+
### Testing
13+
- **Plugin test suite** — 241 pytest tests covering 6 critical plugin scripts that previously had zero tests:
14+
- `block-dangerous.py` (46 tests) — all 22 dangerous command patterns with positive/negative/edge cases
15+
- `guard-workspace-scope.py` (40 tests) — blacklist, scope, allowlist, bash enforcement layers, primary command extraction
16+
- `guard-protected.py` (55 tests) — all protected file patterns (secrets, locks, keys, credentials, auth dirs)
17+
- `guard-protected-bash.py` (24 tests) — write target extraction and protected path integration
18+
- `guard-readonly-bash.py` (63 tests) — general-readonly and git-readonly modes, bypass prevention
19+
- `redirect-builtin-agents.py` (13 tests) — redirect mapping, passthrough, output structure
20+
- Added `test:plugins` and `test:all` npm scripts for running plugin tests
21+
1222
### Skills
1323
- Added `agent-browser` skill to skill-engine plugin — guides headless browser automation with CLI reference, workflow patterns, and authentication
1424

@@ -17,6 +27,16 @@
1727
- Fix `claude-code-native` install failure on Windows/macOS Docker Desktop — installer now falls back to `HOME` override when `su` is unavailable
1828
- Remove `preflight.sh` runtime check — redundant with Docker's own error reporting and caused failures on Windows
1929

30+
### Documentation
31+
- **DevContainer CLI guide** — dedicated Getting Started page for terminal-only workflows without VS Code
32+
- **v2 Migration Guide** — path changes, automatic migration, manual steps, breaking changes, and troubleshooting
33+
- Documented 4 previously undocumented agents in agents.md: implementer, investigator, tester, documenter
34+
- Added missing git-workflow and prompt-snippets to configuration.md enabledPlugins example
35+
- Added CONFIG_SOURCE_DIR deprecation note in environment variables reference
36+
- Added cc-orc orchestrator command to first-session launch commands table
37+
- Tabbed client-specific instructions on the installation page
38+
- Dedicated port forwarding reference page covering VS Code auto-detect, devcontainer-bridge, and SSH tunneling
39+
2040
### Configuration
2141

2242
- Add `autoMemoryDirectory` setting — auto-memory now stored in project-local `.claude/memory/` instead of deep inside `~/.claude/projects/`, making it visible and version-controllable

container/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@
1010
"test": "node test.js",
1111
"test:plugins": "pytest tests/ -v",
1212
"test:all": "npm test && pytest tests/ -v",
13-
"prepublishOnly": "npm run test:all"
13+
"prepublishOnly": "npm run test:all",
14+
"docs:dev": "npm run dev --prefix docs",
15+
"docs:build": "npm run build --prefix docs",
16+
"docs:preview": "npm run preview --prefix docs"
1417
},
1518
"keywords": [
1619
"devcontainer",

tests/__init__.py

Whitespace-only changes.

tests/conftest.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"""Conftest for plugin tests.
2+
3+
Loads plugin scripts by absolute path since they don't have package structure.
4+
Each module is loaded once and cached by importlib.
5+
"""
6+
7+
import importlib.util
8+
from pathlib import Path
9+
10+
# Root of the plugin scripts
11+
PLUGINS_ROOT = (
12+
Path(__file__).resolve().parent.parent
13+
/ ".devcontainer"
14+
/ "plugins"
15+
/ "devs-marketplace"
16+
/ "plugins"
17+
)
18+
19+
20+
def _load_script(plugin_name: str, script_name: str):
21+
"""Load a plugin script as a Python module.
22+
23+
Args:
24+
plugin_name: Plugin directory name (e.g. "dangerous-command-blocker")
25+
script_name: Script filename (e.g. "block-dangerous.py")
26+
27+
Returns:
28+
The loaded module.
29+
"""
30+
script_path = PLUGINS_ROOT / plugin_name / "scripts" / script_name
31+
if not script_path.exists():
32+
raise FileNotFoundError(f"Plugin script not found: {script_path}")
33+
34+
# Convert filename to valid module name
35+
module_name = script_name.replace("-", "_").replace(".py", "")
36+
spec = importlib.util.spec_from_file_location(module_name, script_path)
37+
module = importlib.util.module_from_spec(spec)
38+
39+
spec.loader.exec_module(module)
40+
41+
return module
42+
43+
44+
# Pre-load all tested plugin modules
45+
block_dangerous = _load_script("dangerous-command-blocker", "block-dangerous.py")
46+
guard_workspace_scope = _load_script(
47+
"workspace-scope-guard", "guard-workspace-scope.py"
48+
)
49+
guard_protected = _load_script("protected-files-guard", "guard-protected.py")
50+
guard_protected_bash = _load_script("protected-files-guard", "guard-protected-bash.py")
51+
guard_readonly_bash = _load_script("agent-system", "guard-readonly-bash.py")
52+
redirect_builtin_agents = _load_script("agent-system", "redirect-builtin-agents.py")

tests/plugins/__init__.py

Whitespace-only changes.
Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
"""Tests for the dangerous-command-blocker plugin.
2+
3+
Verifies that check_command() correctly identifies dangerous shell commands
4+
and allows safe commands through without false positives.
5+
"""
6+
7+
import pytest
8+
9+
from tests.conftest import block_dangerous
10+
11+
12+
# ---------------------------------------------------------------------------
13+
# Helpers
14+
# ---------------------------------------------------------------------------
15+
16+
17+
def assert_blocked(command: str, *, substr: str | None = None) -> None:
18+
"""Assert the command is blocked, optionally checking the message."""
19+
is_dangerous, message = block_dangerous.check_command(command)
20+
assert is_dangerous is True, f"Expected blocked: {command!r}"
21+
assert message, f"Blocked command should have a message: {command!r}"
22+
if substr:
23+
assert substr.lower() in message.lower(), (
24+
f"Expected {substr!r} in message {message!r}"
25+
)
26+
27+
28+
def assert_allowed(command: str) -> None:
29+
"""Assert the command is allowed (not dangerous)."""
30+
is_dangerous, message = block_dangerous.check_command(command)
31+
assert is_dangerous is False, f"Expected allowed: {command!r} (got: {message})"
32+
assert message == "", f"Allowed command should have empty message: {command!r}"
33+
34+
35+
# ---------------------------------------------------------------------------
36+
# 1. Destructive rm patterns
37+
# ---------------------------------------------------------------------------
38+
39+
40+
class TestDestructiveRm:
41+
@pytest.mark.parametrize(
42+
"cmd",
43+
[
44+
"rm -rf /",
45+
"rm -rf ~",
46+
"rm -rf ../",
47+
"rm -fr /",
48+
"rm -rfi /",
49+
],
50+
)
51+
def test_rm_rf_dangerous_paths(self, cmd: str) -> None:
52+
assert_blocked(cmd, substr="rm")
53+
54+
55+
# ---------------------------------------------------------------------------
56+
# 2. sudo rm
57+
# ---------------------------------------------------------------------------
58+
59+
60+
class TestSudoRm:
61+
@pytest.mark.parametrize(
62+
"cmd",
63+
[
64+
"sudo rm file.txt",
65+
"sudo rm -rf /var",
66+
"sudo rm -r dir",
67+
],
68+
)
69+
def test_sudo_rm_blocked(self, cmd: str) -> None:
70+
assert_blocked(cmd, substr="sudo rm")
71+
72+
73+
# ---------------------------------------------------------------------------
74+
# 3. chmod 777
75+
# ---------------------------------------------------------------------------
76+
77+
78+
class TestChmod777:
79+
@pytest.mark.parametrize(
80+
"cmd",
81+
[
82+
"chmod 777 file.txt",
83+
"chmod -R 777 /var/www",
84+
"chmod 777 .",
85+
],
86+
)
87+
def test_chmod_777_blocked(self, cmd: str) -> None:
88+
assert_blocked(cmd, substr="chmod 777")
89+
90+
91+
# ---------------------------------------------------------------------------
92+
# 4. Force push to main/master
93+
# ---------------------------------------------------------------------------
94+
95+
96+
class TestForcePush:
97+
@pytest.mark.parametrize(
98+
"cmd",
99+
[
100+
"git push --force origin main",
101+
"git push -f origin master",
102+
"git push --force origin master",
103+
"git push -f origin main",
104+
],
105+
)
106+
def test_force_push_to_main_master(self, cmd: str) -> None:
107+
assert_blocked(cmd, substr="force push")
108+
109+
@pytest.mark.parametrize(
110+
"cmd",
111+
[
112+
"git push -f",
113+
"git push --force",
114+
],
115+
)
116+
def test_bare_force_push(self, cmd: str) -> None:
117+
assert_blocked(cmd, substr="force push")
118+
119+
120+
# ---------------------------------------------------------------------------
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
141+
# ---------------------------------------------------------------------------
142+
143+
144+
class TestDiskOperations:
145+
def test_mkfs(self) -> None:
146+
assert_blocked("mkfs.ext4 /dev/sda1", substr="disk formatting")
147+
148+
def test_dd_to_device(self) -> None:
149+
assert_blocked("dd if=/dev/zero of=/dev/sda bs=1M", substr="dd")
150+
151+
152+
# ---------------------------------------------------------------------------
153+
# 7. Git history destruction
154+
# ---------------------------------------------------------------------------
155+
156+
157+
class TestGitHistoryDestruction:
158+
def test_git_reset_hard_origin_main(self) -> None:
159+
assert_blocked("git reset --hard origin/main", substr="hard reset")
160+
161+
def test_git_reset_hard_origin_master(self) -> None:
162+
assert_blocked("git reset --hard origin/master", substr="hard reset")
163+
164+
@pytest.mark.parametrize(
165+
"cmd",
166+
[
167+
"git clean -f",
168+
"git clean -fd",
169+
"git clean -fdx",
170+
],
171+
)
172+
def test_git_clean_blocked(self, cmd: str) -> None:
173+
assert_blocked(cmd, substr="git clean")
174+
175+
176+
# ---------------------------------------------------------------------------
177+
# 8. Docker dangerous operations
178+
# ---------------------------------------------------------------------------
179+
180+
181+
class TestDockerDangerous:
182+
def test_docker_run_privileged(self) -> None:
183+
assert_blocked("docker run --privileged ubuntu", substr="privileged")
184+
185+
def test_docker_run_mount_root(self) -> None:
186+
assert_blocked("docker run -v /:/host ubuntu", substr="root filesystem")
187+
188+
@pytest.mark.parametrize(
189+
"cmd",
190+
[
191+
"docker stop my-container",
192+
"docker rm my-container",
193+
"docker kill my-container",
194+
"docker rmi my-image",
195+
],
196+
)
197+
def test_docker_destructive_ops(self, cmd: str) -> None:
198+
assert_blocked(cmd, substr="docker operation")
199+
200+
201+
# ---------------------------------------------------------------------------
202+
# 9. Find delete
203+
# ---------------------------------------------------------------------------
204+
205+
206+
class TestFindDelete:
207+
def test_find_exec_rm(self) -> None:
208+
assert_blocked("find . -exec rm {} \\;", substr="find")
209+
210+
def test_find_delete(self) -> None:
211+
assert_blocked("find /tmp -name '*.log' -delete", substr="find")
212+
213+
214+
# ---------------------------------------------------------------------------
215+
# 10. Safe commands (false positive checks)
216+
# ---------------------------------------------------------------------------
217+
218+
219+
class TestSafeCommands:
220+
@pytest.mark.parametrize(
221+
"cmd",
222+
[
223+
"rm file.txt",
224+
"git push origin feature-branch",
225+
"chmod 644 file",
226+
"docker ps",
227+
"docker logs container",
228+
"ls /usr/bin",
229+
"cat /etc/hosts",
230+
"echo hello",
231+
"git status",
232+
],
233+
)
234+
def test_safe_commands_allowed(self, cmd: str) -> None:
235+
assert_allowed(cmd)
236+
237+
238+
# ---------------------------------------------------------------------------
239+
# 10b. Force push with lease (intentionally blocked)
240+
# ---------------------------------------------------------------------------
241+
242+
243+
class TestForceWithLease:
244+
def test_force_with_lease_blocked(self) -> None:
245+
"""--force-with-lease is intentionally blocked alongside all force
246+
push variants to prevent agents from using it as a workaround."""
247+
assert_blocked(
248+
"git push --force-with-lease origin feature",
249+
substr="force push",
250+
)
251+
252+
253+
# ---------------------------------------------------------------------------
254+
# 11. Remote branch deletion
255+
# ---------------------------------------------------------------------------
256+
257+
258+
class TestRemoteBranchDeletion:
259+
@pytest.mark.parametrize(
260+
"cmd",
261+
[
262+
"git push origin --delete feature-branch",
263+
"git push --delete feature-branch",
264+
],
265+
)
266+
def test_push_delete_blocked(self, cmd: str) -> None:
267+
assert_blocked(cmd, substr="deleting remote branches")
268+
269+
def test_colon_refspec_blocked(self) -> None:
270+
assert_blocked(
271+
"git push origin :feature-branch",
272+
substr="colon-refspec",
273+
)

0 commit comments

Comments
 (0)