Files
roboco/docker/scripts/tests/bash-guard-tests.sh
T
Renn F 7be10057d0 [bug] agent image: stop baking VIRTUAL_ENV=/app/.venv (silences uv run warning)
The agent-base image baked VIRTUAL_ENV=/app/.venv globally (since 8e201901,
conventional venv-baking, no targeted rationale), so every bare uv run in a
workspace clone warned 'VIRTUAL_ENV=/app/.venv does not match project .venv
and will be ignored' on every gate run. uv used the workspace .venv correctly
(the warning was noise), but it flooded every agent transcript.

The load-bearing MCP/SDK pin is UV_PROJECT_ENVIRONMENT=/app/.venv + --no-sync
(added in #179, the actual startup-stall fix), NOT VIRTUAL_ENV — so removing
VIRTUAL_ENV does not regress #179. The gateway tools stay on PATH.

Verified by building the image (roboco-agent-base:venv-verify) and smoke-
testing in a real container: (1) VIRTUAL_ENV is empty, python resolves to
/app/.venv/bin/python via PATH; (2) bare uv run from /app imports a roboco
module with no warning; (3) the MCP-launch path — cwd with its OWN .venv —
UV_PROJECT_ENVIRONMENT=/app/.venv uv run --no-sync resolves to /app/.venv/
bin/python, overriding the cwd .venv (the exact MCP-server invariant), no
warning; (4) grep for 'does not match|will be ignored' across all output is
empty. bash-guard-tests.sh 64/0, ruff clean, test_workspace_uv_resolves_clone_venv 3/3.

Ships on next agent-image rebuild + redeploy.
2026-06-30 09:58:36 +02:00

169 lines
7.3 KiB
Bash
Executable File

#!/usr/bin/env bash
# Test harness for docker/scripts/bash-guard-hook.sh.
#
# Feeds each command to the hook (via the same JSON stdin contract Claude
# Code uses) and asserts the expected exit code.
#
# Run:
# bash docker/scripts/tests/bash-guard-tests.sh
#
# Exit 0 on full pass, 1 on any failure.
set -u
HOOK="$(cd "$(dirname "$0")/.." && pwd)/bash-guard-hook.sh"
if [[ ! -x "$HOOK" ]]; then
# chmod may not have been applied in the dev checkout — run via bash.
HOOK="bash $HOOK"
fi
PASS=0
FAIL=0
FAILS=()
# run_case <label> <expected_exit> <command>
run_case() {
local label="$1"
local expected="$2"
local cmd="$3"
local json
# shellcheck disable=SC2016
json=$(python3 -c 'import json, sys; print(json.dumps({"tool_name":"Bash","tool_input":{"command":sys.argv[1]}}))' "$cmd")
local actual
echo "$json" | $HOOK >/dev/null 2>&1
actual=$?
if [[ "$actual" == "$expected" ]]; then
PASS=$((PASS + 1))
else
FAIL=$((FAIL + 1))
FAILS+=("[$label] expected $expected, got $actual | cmd: $cmd")
fi
}
# ---------- DENY cases (exit 2) ----------
# Git network/auth ops already covered by original hook.
run_case "deny git fetch" 2 "git fetch origin"
run_case "deny compound git push" 2 "cd /workspace && git push origin main"
run_case "deny git clone" 2 "git clone https://github.com/foo/bar"
# Credential file references.
run_case "deny cat .git/config" 2 "cat .git/config"
run_case "deny cat netrc" 2 "cat ~/.netrc"
run_case "deny ls .ssh" 2 "ls ~/.ssh/"
run_case "deny grep token gitconf" 2 "grep token .git/config"
# /proc env/cmdline exfil.
run_case "deny /proc/self/environ" 2 "cat /proc/self/environ"
run_case "deny /proc/1/environ" 2 "cat /proc/1/environ"
run_case "deny redirect /proc env" 2 'read -r v < /proc/self/environ && echo "$v"'
# env / printenv / set / declare / compgen / export dumps.
run_case "deny bare env" 2 "env"
run_case "deny bare printenv" 2 "printenv"
run_case "deny bare set" 2 "set"
run_case "deny set piped" 2 "set | grep TOKEN"
run_case "deny declare -x" 2 "declare -x"
run_case "deny export -p" 2 "export -p"
run_case "deny compgen -v" 2 "compgen -v"
run_case "deny compgen -e" 2 "compgen -e"
run_case "deny typeset -p" 2 "typeset -p"
# Sourcing credential-bearing files.
run_case "deny source .env" 2 "source .env"
run_case "deny dot-source /etc/env" 2 ". /etc/environment"
run_case "deny source /proc env" 2 "source /proc/self/environ"
# Encoding tools on credential files.
run_case "deny base64 .env" 2 "base64 .env"
run_case "deny xxd netrc" 2 "xxd ~/.netrc"
run_case "deny strings .git/config" 2 "strings .git/config"
run_case "deny od -c gitconfig" 2 "od -c .git/config"
# Interpreter one-liners against cred paths.
run_case "deny python open .env" 2 "python3 -c 'print(open(\".env\").read())'"
run_case "deny perl read netrc" 2 "perl -e 'open(F,\".netrc\"); print <F>'"
run_case "deny node fs netrc" 2 "node -e 'console.log(require(\"fs\").readFileSync(\".netrc\",\"utf8\"))'"
# GitHub HTTP.
run_case "deny curl github" 2 "curl https://github.com/foo"
run_case "deny wget api.github" 2 "wget https://api.github.com/repos/foo"
# rm on system paths.
run_case "deny rm -rf /app" 2 "rm -rf /app/roboco"
run_case "deny rm -rf /etc" 2 "rm -rf /etc"
# ---------- /app venv protection (exit 2) ----------
# /app/.venv is the image-baked MCP-gateway venv. Retargeting uv onto it
# rebuilds + bricks every gateway tool (be-dev-1 root cause, 2026-06-29).
# The package-mutation block (uv sync/pip install + /app target) and the
# uv-run block (uv run --active, or uv run with an explicit /app target).
run_case "deny uv sync --project /app" 2 "uv sync --project /app"
run_case "deny uv pip install /app venv" 2 "uv pip install --python /app/.venv/bin/python foo"
run_case "deny cd /app && uv sync" 2 "cd /app && uv sync"
# uv run --active: not the contract (bare `uv run` uses the workspace .venv).
# VIRTUAL_ENV is no longer image-baked, so --active has no active env and
# errors; the guard still denies it with a clear remediation message.
run_case "deny uv run --active" 2 "uv run --active pytest"
run_case "deny uv run --active ruff" 2 "uv run --active ruff check ."
run_case "deny env venv /app uv run active" 2 "VIRTUAL_ENV=/app/.venv uv run --active pytest"
# uv run with an explicit /app target (same brick, literal /app in the command).
run_case "deny uv run --project /app" 2 "uv run --project /app pytest"
run_case "deny uv run --directory /app" 2 "uv run --directory /app pytest"
run_case "deny cd /app && uv run" 2 "cd /app && uv run pytest"
run_case "deny UV_PROJECT_ENV=/app uv run" 2 "UV_PROJECT_ENVIRONMENT=/app/.venv uv run pytest"
# ---------- ALLOW cases (exit 0) — must NOT be denied ----------
run_case "allow set -e" 0 "set -e"
run_case "allow set -euo pipefail" 0 "set -euo pipefail"
run_case "allow set -o pipefail" 0 "set -o pipefail"
run_case "allow env VAR=val cmd" 0 "env FOO=bar uv run pytest"
run_case "allow env -i cmd" 0 "env -i HOME=/tmp ls /tmp"
run_case "allow ls" 0 "ls -la /workspace"
run_case "allow uv run ruff" 0 "uv run ruff check ."
run_case "allow uv run pytest" 0 "uv run pytest -q"
run_case "allow uv run --no-sync" 0 "uv run --no-sync pytest -q"
run_case "allow uv run --with dep" 0 "uv run --with httpx python -c 'pass'"
run_case "allow uv run in workspace" 0 "cd /data/workspaces/proj && uv run pytest"
run_case "allow pnpm typecheck" 0 "pnpm typecheck"
run_case "allow rm in workspace" 0 "rm -rf /workspace/tmp"
run_case "allow declare -a arr" 0 "declare -a arr=(a b c)"
run_case "allow cat README" 0 "cat README.md"
run_case "allow curl non-github" 0 "curl https://example.com/info"
# ---------- grok variant: camelCase input + ROBOCO_GUARD_SKIP_GIT ----------
# The grok CLI sends `toolInput` (camelCase) and the grok hook runs with
# ROBOCO_GUARD_SKIP_GIT=1 (git is handled by graceful native --deny). Exfil
# categories must STILL deny; git must now pass through to --deny.
run_case_grok() {
local label="$1" expected="$2" cmd="$3" json actual
# shellcheck disable=SC2016
json=$(python3 -c 'import json, sys; print(json.dumps({"toolName":"run_terminal_command","toolInput":{"command":sys.argv[1]}}))' "$cmd")
echo "$json" | ROBOCO_GUARD_SKIP_GIT=1 $HOOK >/dev/null 2>&1
actual=$?
if [[ "$actual" == "$expected" ]]; then
PASS=$((PASS + 1))
else
FAIL=$((FAIL + 1))
FAILS+=("[$label] expected $expected, got $actual | cmd: $cmd")
fi
}
run_case_grok "grok skip-git: git push allowed" 0 "git push origin main"
run_case_grok "grok camelCase: netrc denied" 2 "cat ~/.netrc"
run_case_grok "grok camelCase: env denied" 2 "env"
run_case_grok "grok camelCase: identity forgery" 2 "export ROBOCO_AGENT_ID=other"
run_case_grok "grok camelCase: allow ls" 0 "ls -la /workspace"
# ---------- Report ----------
echo
echo "===== bash-guard-hook tests ====="
echo " passed: $PASS"
echo " failed: $FAIL"
if (( FAIL > 0 )); then
echo " failures:"
for f in "${FAILS[@]}"; do
echo " - $f"
done
exit 1
fi
exit 0