diff --git a/docker/scripts/bash-guard-hook.sh b/docker/scripts/bash-guard-hook.sh index c744dc38..c2ab7a65 100755 --- a/docker/scripts/bash-guard-hook.sh +++ b/docker/scripts/bash-guard-hook.sh @@ -72,6 +72,23 @@ if echo "$low" | grep -qE '(^|[[:space:];&|])(curl|wget|http|https)[[:space:]][^ exit 2 fi +# --- internal API calls ------------------------------------------------------- +# Agents must reach the orchestrator through their MCP manifest verbs, never +# raw HTTP. This blocks `curl http://roboco-orchestrator:8000/...`, +# `wget http://localhost:8000/...`, `http http://127.0.0.1/...` (HTTPie), +# scheme-less forms like `curl roboco-orchestrator:8000/...`, and the +# protocol-relative `//host/path` form. +# KNOWN GAPS (out of scope here): +# - Variable expansion: `URL=http://orchestrator/x; curl $URL` — the guard +# sees `curl $URL`, not the expanded URL, so this slips through. The +# X-Agent-Role check (task 4) is the second gate. +# - Interpreter one-liners: `python -c "import urllib.request; ..."` — too +# deeply hidden to regex. Mitigated by the manifest-bound MCP surface. +if echo "$low" | grep -qE '(^|[[:space:];&|])(curl|wget|http|https|httpie)[[:space:]][^|]*((http|https):)?//?(roboco-[a-z0-9_-]+|localhost|127\.0\.0\.1|0\.0\.0\.0)[:/]'; then + echo "Denied: internal API calls bypass the gateway. Use the MCP verbs (roboco-flow / roboco-do / roboco-git-readonly / roboco-optimal / roboco-docs) — they route through the orchestrator with the right auth and tracing." >&2 + exit 2 +fi + if echo "$low" | grep -qE '(^|[[:space:];&|])(env|printenv)([[:space:]]|$)' && ! echo "$low" | grep -qE '(^|[[:space:];&|])env[[:space:]]+-i'; then # allow `env VAR=val cmd` style prefixes (`env ` followed by `NAME=`) if ! echo "$low" | grep -qE '(^|[[:space:];&|])env[[:space:]]+[a-z_][a-z0-9_]*='; then diff --git a/tests/unit/scripts/__init__.py b/tests/unit/scripts/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/scripts/test_bash_guard.py b/tests/unit/scripts/test_bash_guard.py new file mode 100644 index 00000000..3421a975 --- /dev/null +++ b/tests/unit/scripts/test_bash_guard.py @@ -0,0 +1,52 @@ +"""bash-guard-hook.sh denies curl/wget against orchestrator/localhost. + +The PreToolUse hook reads Claude Code's event JSON from stdin in the form +``{"tool_name": "Bash", "tool_input": {"command": "..."}}`` and exits with +code 2 to deny, 0 to allow. Tests wrap each command in that envelope. +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +# tests/unit/scripts/test_bash_guard.py +# parents[0] = scripts +# parents[1] = unit +# parents[2] = tests +# parents[3] = +GUARD = Path(__file__).parents[3] / "docker" / "scripts" / "bash-guard-hook.sh" + +_DENIED = 2 +_ALLOWED = 0 + + +def _run(cmd: str) -> int: + payload = json.dumps({"tool_name": "Bash", "tool_input": {"command": cmd}}) + result = subprocess.run( + [str(GUARD)], + input=payload, + capture_output=True, + text=True, + ) + return result.returncode + + +def test_blocks_internal_curl_to_orchestrator() -> None: + assert ( + _run("curl http://roboco-orchestrator:8000/api/v2/flow/main_pm/delegate") + == _DENIED + ) + + +def test_blocks_internal_curl_to_localhost() -> None: + assert _run("curl http://localhost:8000/api/v2/flow/dev/i_am_done") == _DENIED + + +def test_blocks_internal_curl_to_127() -> None: + assert _run("curl http://127.0.0.1:8000/api/health") == _DENIED + + +def test_allows_external_curl_to_documentation() -> None: + assert _run("curl https://docs.python.org/3/") == _ALLOWED