Skip to content

Commit 5df63d7

Browse files
authored
Add pytest suite for 6 critical plugin scripts (241 tests) (#40)
Security guards, workspace scope enforcement, readonly bash guard, and agent redirect logic had zero test coverage. This adds pytest tests for the pure functions in each script, covering all regex patterns, edge cases, bypass vectors, and false positive checks. Co-authored-by: AnExiledDev <AnExiledDev@users.noreply.github.com>
1 parent 5cf554c commit 5df63d7

11 files changed

+1508
-2
lines changed

.devcontainer/CHANGELOG.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,18 @@
22

33
## [Unreleased]
44

5+
### Added
6+
7+
#### Testing
8+
- **Plugin test suite** — 241 pytest tests covering 6 critical plugin scripts that previously had zero tests:
9+
- `block-dangerous.py` (46 tests) — all 22 dangerous command patterns with positive/negative/edge cases
10+
- `guard-workspace-scope.py` (40 tests) — blacklist, scope, allowlist, bash enforcement layers, primary command extraction
11+
- `guard-protected.py` (55 tests) — all protected file patterns (secrets, locks, keys, credentials, auth dirs)
12+
- `guard-protected-bash.py` (24 tests) — write target extraction and protected path integration
13+
- `guard-readonly-bash.py` (63 tests) — general-readonly and git-readonly modes, bypass prevention
14+
- `redirect-builtin-agents.py` (13 tests) — redirect mapping, passthrough, output structure
15+
- Added `test:plugins` and `test:all` npm scripts for running plugin tests
16+
517
### Fixed
618

719
#### Dangerous Command Blocker
@@ -10,8 +22,6 @@
1022
- **Block remote branch deletion**`git push origin --delete` and colon-refspec deletion (`git push origin :branch`) now blocked; deleting remote branches closes associated PRs
1123
- **Fixed README** — error handling was documented as "fails open" but code actually fails closed; corrected to match behavior
1224

13-
### Added
14-
1525
#### Documentation
1626
- **DevContainer CLI guide** — dedicated Getting Started page for terminal-only workflows without VS Code
1727
- **v2 Migration Guide** — path changes, automatic migration, manual steps, breaking changes, and troubleshooting

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
},
99
"scripts": {
1010
"test": "node test.js",
11+
"test:plugins": "pytest tests/ -v",
12+
"test:all": "npm test && pytest tests/ -v",
1113
"prepublishOnly": "npm test",
1214
"docs:dev": "npm run dev --prefix docs",
1315
"docs:build": "npm run build --prefix docs",

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)