Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions .devcontainer/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@

## [Unreleased]

### Added

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

### Fixed

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

### Added

#### Documentation
- **DevContainer CLI guide** — dedicated Getting Started page for terminal-only workflows without VS Code
- **v2 Migration Guide** — path changes, automatic migration, manual steps, breaking changes, and troubleshooting
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
},
"scripts": {
"test": "node test.js",
"test:plugins": "pytest tests/ -v",
"test:all": "npm test && pytest tests/ -v",
"prepublishOnly": "npm test",
"docs:dev": "npm run dev --prefix docs",
"docs:build": "npm run build --prefix docs",
Expand Down
Empty file added tests/__init__.py
Empty file.
52 changes: 52 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""Conftest for plugin tests.

Loads plugin scripts by absolute path since they don't have package structure.
Each module is loaded once and cached by importlib.
"""

import importlib.util
from pathlib import Path

# Root of the plugin scripts
PLUGINS_ROOT = (
Path(__file__).resolve().parent.parent
/ ".devcontainer"
/ "plugins"
/ "devs-marketplace"
/ "plugins"
)


def _load_script(plugin_name: str, script_name: str):
"""Load a plugin script as a Python module.

Args:
plugin_name: Plugin directory name (e.g. "dangerous-command-blocker")
script_name: Script filename (e.g. "block-dangerous.py")

Returns:
The loaded module.
"""
script_path = PLUGINS_ROOT / plugin_name / "scripts" / script_name
if not script_path.exists():
raise FileNotFoundError(f"Plugin script not found: {script_path}")

# Convert filename to valid module name
module_name = script_name.replace("-", "_").replace(".py", "")
spec = importlib.util.spec_from_file_location(module_name, script_path)
module = importlib.util.module_from_spec(spec)

spec.loader.exec_module(module)

return module


# Pre-load all tested plugin modules
block_dangerous = _load_script("dangerous-command-blocker", "block-dangerous.py")
guard_workspace_scope = _load_script(
"workspace-scope-guard", "guard-workspace-scope.py"
)
guard_protected = _load_script("protected-files-guard", "guard-protected.py")
guard_protected_bash = _load_script("protected-files-guard", "guard-protected-bash.py")
guard_readonly_bash = _load_script("agent-system", "guard-readonly-bash.py")
redirect_builtin_agents = _load_script("agent-system", "redirect-builtin-agents.py")
Empty file added tests/plugins/__init__.py
Empty file.
273 changes: 273 additions & 0 deletions tests/plugins/test_block_dangerous.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,273 @@
"""Tests for the dangerous-command-blocker plugin.

Verifies that check_command() correctly identifies dangerous shell commands
and allows safe commands through without false positives.
"""

import pytest

from tests.conftest import block_dangerous


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def assert_blocked(command: str, *, substr: str | None = None) -> None:
"""Assert the command is blocked, optionally checking the message."""
is_dangerous, message = block_dangerous.check_command(command)
assert is_dangerous is True, f"Expected blocked: {command!r}"
assert message, f"Blocked command should have a message: {command!r}"
if substr:
assert substr.lower() in message.lower(), (
f"Expected {substr!r} in message {message!r}"
)


def assert_allowed(command: str) -> None:
"""Assert the command is allowed (not dangerous)."""
is_dangerous, message = block_dangerous.check_command(command)
assert is_dangerous is False, f"Expected allowed: {command!r} (got: {message})"
assert message == "", f"Allowed command should have empty message: {command!r}"


# ---------------------------------------------------------------------------
# 1. Destructive rm patterns
# ---------------------------------------------------------------------------


class TestDestructiveRm:
@pytest.mark.parametrize(
"cmd",
[
"rm -rf /",
"rm -rf ~",
"rm -rf ../",
"rm -fr /",
"rm -rfi /",
],
)
def test_rm_rf_dangerous_paths(self, cmd: str) -> None:
assert_blocked(cmd, substr="rm")


# ---------------------------------------------------------------------------
# 2. sudo rm
# ---------------------------------------------------------------------------


class TestSudoRm:
@pytest.mark.parametrize(
"cmd",
[
"sudo rm file.txt",
"sudo rm -rf /var",
"sudo rm -r dir",
],
)
def test_sudo_rm_blocked(self, cmd: str) -> None:
assert_blocked(cmd, substr="sudo rm")


# ---------------------------------------------------------------------------
# 3. chmod 777
# ---------------------------------------------------------------------------


class TestChmod777:
@pytest.mark.parametrize(
"cmd",
[
"chmod 777 file.txt",
"chmod -R 777 /var/www",
"chmod 777 .",
],
)
def test_chmod_777_blocked(self, cmd: str) -> None:
assert_blocked(cmd, substr="chmod 777")


# ---------------------------------------------------------------------------
# 4. Force push to main/master
# ---------------------------------------------------------------------------


class TestForcePush:
@pytest.mark.parametrize(
"cmd",
[
"git push --force origin main",
"git push -f origin master",
"git push --force origin master",
"git push -f origin main",
],
)
def test_force_push_to_main_master(self, cmd: str) -> None:
assert_blocked(cmd, substr="force push")

@pytest.mark.parametrize(
"cmd",
[
"git push -f",
"git push --force",
],
)
def test_bare_force_push(self, cmd: str) -> None:
assert_blocked(cmd, substr="force push")


# ---------------------------------------------------------------------------
# 5. System directory writes
# ---------------------------------------------------------------------------


class TestSystemDirectoryWrites:
@pytest.mark.parametrize(
"cmd,dir_name",
[
("> /usr/foo", "/usr"),
("> /etc/foo", "/etc"),
("> /bin/foo", "/bin"),
("> /sbin/foo", "/sbin"),
],
)
def test_redirect_to_system_dir(self, cmd: str, dir_name: str) -> None:
assert_blocked(cmd, substr=dir_name)


# ---------------------------------------------------------------------------
# 6. Disk operations
# ---------------------------------------------------------------------------


class TestDiskOperations:
def test_mkfs(self) -> None:
assert_blocked("mkfs.ext4 /dev/sda1", substr="disk formatting")

def test_dd_to_device(self) -> None:
assert_blocked("dd if=/dev/zero of=/dev/sda bs=1M", substr="dd")


# ---------------------------------------------------------------------------
# 7. Git history destruction
# ---------------------------------------------------------------------------


class TestGitHistoryDestruction:
def test_git_reset_hard_origin_main(self) -> None:
assert_blocked("git reset --hard origin/main", substr="hard reset")

def test_git_reset_hard_origin_master(self) -> None:
assert_blocked("git reset --hard origin/master", substr="hard reset")

@pytest.mark.parametrize(
"cmd",
[
"git clean -f",
"git clean -fd",
"git clean -fdx",
],
)
def test_git_clean_blocked(self, cmd: str) -> None:
assert_blocked(cmd, substr="git clean")


# ---------------------------------------------------------------------------
# 8. Docker dangerous operations
# ---------------------------------------------------------------------------


class TestDockerDangerous:
def test_docker_run_privileged(self) -> None:
assert_blocked("docker run --privileged ubuntu", substr="privileged")

def test_docker_run_mount_root(self) -> None:
assert_blocked("docker run -v /:/host ubuntu", substr="root filesystem")

@pytest.mark.parametrize(
"cmd",
[
"docker stop my-container",
"docker rm my-container",
"docker kill my-container",
"docker rmi my-image",
],
)
def test_docker_destructive_ops(self, cmd: str) -> None:
assert_blocked(cmd, substr="docker operation")


# ---------------------------------------------------------------------------
# 9. Find delete
# ---------------------------------------------------------------------------


class TestFindDelete:
def test_find_exec_rm(self) -> None:
assert_blocked("find . -exec rm {} \\;", substr="find")

def test_find_delete(self) -> None:
assert_blocked("find /tmp -name '*.log' -delete", substr="find")


# ---------------------------------------------------------------------------
# 10. Safe commands (false positive checks)
# ---------------------------------------------------------------------------


class TestSafeCommands:
@pytest.mark.parametrize(
"cmd",
[
"rm file.txt",
"git push origin feature-branch",
"chmod 644 file",
"docker ps",
"docker logs container",
"ls /usr/bin",
"cat /etc/hosts",
"echo hello",
"git status",
],
)
def test_safe_commands_allowed(self, cmd: str) -> None:
assert_allowed(cmd)


# ---------------------------------------------------------------------------
# 10b. Force push with lease (intentionally blocked)
# ---------------------------------------------------------------------------


class TestForceWithLease:
def test_force_with_lease_blocked(self) -> None:
"""--force-with-lease is intentionally blocked alongside all force
push variants to prevent agents from using it as a workaround."""
assert_blocked(
"git push --force-with-lease origin feature",
substr="force push",
)


# ---------------------------------------------------------------------------
# 11. Remote branch deletion
# ---------------------------------------------------------------------------


class TestRemoteBranchDeletion:
@pytest.mark.parametrize(
"cmd",
[
"git push origin --delete feature-branch",
"git push --delete feature-branch",
],
)
def test_push_delete_blocked(self, cmd: str) -> None:
assert_blocked(cmd, substr="deleting remote branches")

def test_colon_refspec_blocked(self) -> None:
assert_blocked(
"git push origin :feature-branch",
substr="colon-refspec",
)
Loading