diff --git a/docker/scripts/bash-guard-hook.sh b/docker/scripts/bash-guard-hook.sh index eca8ea6b..6b634a64 100755 --- a/docker/scripts/bash-guard-hook.sh +++ b/docker/scripts/bash-guard-hook.sh @@ -321,4 +321,21 @@ if echo "$low" | grep -qE '(^|[[:space:];&|])rm[[:space:]]+[^|;&]*-[[:alpha:]]*[ fi fi +# --- package-environment mutations targeting /app (ALL providers) ------------- +# /app holds the orchestrator code and the MCP-gateway venv (/app/.venv). An +# agent that `uv sync` / `pip install`s into /app rebuilds that venv and breaks +# its OWN gateway tools (every roboco-flow / -do / -git verb) — stranding the +# agent and getting its task reaped. Agents manage dependencies in their +# workspace clone under /data/workspaces, never in /app. Two-step: a +# package-mutation verb AND a target that resolves to /app's environment +# (cd /app, --project/--directory /app, /app/.venv, UV_PROJECT_ENVIRONMENT=/app). +# Reads of /app (cat/ls/grep) and workspace installs are untouched. Deliberately +# NOT gated by ROBOCO_GUARD_SKIP_GIT, so it fires for every provider — the Claude +# PreToolUse hook AND the grok exfil hook (and future provider hooks). +if echo "$low" | grep -qE '(^|[[:space:];&|])(uv[[:space:]]+(sync|lock|add|remove)|uv[[:space:]]+pip[[:space:]]+(install|uninstall)|pip3?[[:space:]]+(install|uninstall))' && \ + echo "$low" | grep -qE '(/app/\.venv|--project[[:space:]=]+"?/app([^a-z]|$)|--directory[[:space:]=]+"?/app([^a-z]|$)|uv_project_environment="?/app([^a-z]|$)|(^|[[:space:];&|])cd[[:space:]]+"?/app([^a-z]|$))'; then + echo "Denied: installing or syncing packages into /app rebuilds the orchestrator / MCP-gateway venv (/app/.venv) and breaks your own gateway tools. Manage dependencies in your workspace clone under /data/workspaces, never /app. If /app's environment looks broken, report it via your blocked / escalation verb — don't try to repair it." >&2 + exit 2 +fi + exit 0 diff --git a/tests/unit/scripts/test_bash_guard.py b/tests/unit/scripts/test_bash_guard.py index 4a92661b..3322dd8d 100644 --- a/tests/unit/scripts/test_bash_guard.py +++ b/tests/unit/scripts/test_bash_guard.py @@ -381,3 +381,71 @@ def test_allows_pytest_even_if_suite_uses_requests() -> None: """The command string is just the runner — no http-client token and no internal host literal — so it must pass.""" assert _run("uv run python -m pytest tests/unit/ -q") == _ALLOWED + + +# --------------------------------------------------------------------------- +# Package-environment mutations targeting /app (the orchestrator + MCP-gateway +# venv). Must be blocked for EVERY provider so an agent can't corrupt its own +# gateway by `uv sync` / `pip install`ing into /app. +# --------------------------------------------------------------------------- + + +def test_blocks_uv_sync_cd_app() -> None: + assert _run("cd /app && uv sync") == _DENIED + + +def test_blocks_pip_install_cd_app() -> None: + assert _run("cd /app && pip install httpcore httpx") == _DENIED + + +def test_blocks_uv_sync_project_app() -> None: + assert _run("uv sync --project /app") == _DENIED + + +def test_blocks_uv_pip_install_app_venv() -> None: + assert _run("uv pip install --python /app/.venv/bin/python httpx") == _DENIED + + +def test_blocks_uv_project_environment_app() -> None: + assert _run("UV_PROJECT_ENVIRONMENT=/app/.venv uv sync --no-dev") == _DENIED + + +def test_blocks_app_mutation_even_in_grok_mode() -> None: + """Grok runs the hook with ROBOCO_GUARD_SKIP_GIT=1; the /app rule is NOT a + git rule, so it must STILL fire — every provider is protected.""" + payload = json.dumps( + {"tool_name": "Bash", "tool_input": {"command": "cd /app && uv sync"}} + ) + result = subprocess.run( + [str(GUARD)], + input=payload, + capture_output=True, + text=True, + check=False, + env={**os.environ, "ROBOCO_GUARD_SKIP_GIT": "1"}, + ) + assert result.returncode == _DENIED + + +def test_allows_uv_sync_in_workspace() -> None: + """Legit dependency sync in the agent's own workspace clone must pass.""" + assert ( + _run("cd /data/workspaces/roboco/backend/be-dev-1 && uv sync --extra dev") + == _ALLOWED + ) + + +def test_allows_pip_install_in_workspace() -> None: + assert _run("pip install -r requirements.txt") == _ALLOWED + + +def test_allows_reading_files_under_app() -> None: + """Reads of /app (not env mutations) are untouched.""" + assert _run("cat /app/pyproject.toml") == _ALLOWED + assert _run("ls -la /app/.venv/bin") == _ALLOWED + + +def test_allows_uv_sync_for_app_named_workspace_project() -> None: + """A workspace path that merely contains 'app' (e.g. .../myapp/...) must not + trip the rule — the boundary requires /app to be its own path segment.""" + assert _run("cd /data/workspaces/myapp/backend/be-dev-1 && uv sync") == _ALLOWED