Files
roboco/docker/scripts/bash-guard-hook.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

358 lines
20 KiB
Bash
Executable File

#!/bin/bash
# PreToolUse guard for Bash.
#
# Deny-listed patterns in base_deny (permissions.deny) only match the first
# binary in a Bash command. Compound commands like
# bash -c "cd /workspace && git fetch origin"
# slip through because the first token is `cd`. This hook inspects the full
# command string and rejects any shell-level git network/auth op, redirecting
# the agent to the MCP equivalent.
#
# Claude Code passes the PreToolUse event on stdin as JSON:
# { "tool_name": "Bash", "tool_input": { "command": "...", "description": "..." } }
# The grok CLI passes the same event with camelCase keys (toolName / toolInput);
# the extractor accepts either, so the one tested script guards both runtimes.
# Exit 0 = allow. Exit 2 = deny.
#
# ROBOCO_GUARD_SKIP_GIT=1 skips the git-ops category. The grok path sets it because
# grok handles git via NATIVE --deny rules, which deny GRACEFULLY (the agent gets
# a permission error and recovers) — whereas a grok hook deny CANCELS the whole
# run. So grok keeps git on --deny (operational reflex → recoverable) and uses
# this hook only for the exfil categories (no legit use → a hard cancel is the
# right response). Claude has no such --deny, so it keeps the git block here.
#
# Deny categories:
# - Network git ops (require token injection only done by the MCP layer)
# - Shell-level PR / merge that bypass the PM hierarchy
# - Credential exfiltration vectors the existing deny list doesn't catch
# (compound cat/env/curl/wget)
set -u
input=$(cat 2>/dev/null || true)
[[ -z "$input" ]] && exit 0
cmd=$(printf '%s' "$input" | python3 -c '
import json, sys
try:
d = json.loads(sys.stdin.read())
ti = d.get("tool_input") or d.get("toolInput") or {}
print(ti.get("command", ""))
except Exception:
print("")
' 2>/dev/null)
[[ -z "$cmd" ]] && exit 0
low=$(printf '%s' "$cmd" | tr "[:upper:]" "[:lower:]")
# Skeletonize the command for the git-ops check ONLY: strip heredoc
# bodies and echo/printf literal arguments. Those are data the shell writes
# to a file, never commands the shell executes — so a README/heredoc that
# merely documents `git commit` must not be mistaken for invoking git.
# Quoted args to a shell interpreter (`bash -c "... && git fetch"`) ARE
# executed, are not echo/printf/heredoc bodies, and so survive untouched.
# Every other rule below still inspects the full command ($low).
git_skel=$(printf '%s' "$cmd" | python3 -c '
import sys, re
src = sys.stdin.read()
lines = src.split("\n")
opener = re.compile(r"<<-?\s*[^\sA-Za-z_]*([A-Za-z_]\w*)")
kept = []
i = 0
n = len(lines)
while i < n:
line = lines[i]
kept.append(line)
m = opener.search(line)
if m:
delim = m.group(1)
dash = "<<-" in line
i += 1
while i < n:
body = lines[i]
cand = body.strip() if dash else body
if cand == delim:
kept.append(body)
break
i += 1
i += 1
skel = "\n".join(kept)
skel = re.sub(r"(^|[\n;&|]|&&|\|\|)\s*(echo|printf)\b[^\n;&|]*", r"\1", skel)
sys.stdout.write("__SKEL_OK__" + skel)
' 2>/dev/null)
# A successful run is prefixed with the sentinel even when the skeleton is
# legitimately empty (whole command was echo/heredoc). No sentinel means
# python failed — fail closed by inspecting the full command.
if [[ "$git_skel" == __SKEL_OK__* ]]; then
git_skel="${git_skel#__SKEL_OK__}"
else
git_skel="$cmd"
fi
git_skel_low=$(printf '%s' "$git_skel" | tr "[:upper:]" "[:lower:]")
# --- git network / auth ops ---------------------------------------------------
# Skipped on grok (handled by native --deny so a blocked git op is recoverable,
# not a run-cancelling hook deny). See the header.
if [[ "${ROBOCO_GUARD_SKIP_GIT:-}" != "1" ]] && \
echo "$git_skel_low" | grep -qE '(^|[[:space:];&|])git[[:space:]]+(fetch|pull|push|clone|remote|ls-remote|checkout|commit|merge|rebase|reset|cherry-pick|revert|tag[[:space:]]+-d|update-ref|reflog[[:space:]]+delete)'; then
echo "Denied: shell git for network / auth / branch-mutating ops is blocked." >&2
echo "Use the verb listed in your role's State→Verb table (e.g. commit, complete, i_am_done)." >&2
exit 2
fi
# --- git verbs hidden in command substitutions ($(...) / backticks) ----------
# The skeletonizer above strips echo/printf/heredoc DATA, but a command
# substitution inside that data is EXPANDED by the shell before the wrapping
# command runs — so `echo $(git push)` would slip past the git check above.
# Detect a denied git verb inside an EXPANDABLE substitution. Single-quoted
# strings (literal) and heredoc bodies (treated as data, matching the
# skeletonizer above) are excluded so a README that merely documents git verbs
# is not a false positive — this targets the echo/printf substitution class
# (`echo $(git push)`). Same SKIP_GIT guard as the check above — on grok this
# stays the native --deny's job, never a run-cancelling hook deny.
if [[ "${ROBOCO_GUARD_SKIP_GIT:-}" != "1" ]]; then
subst_git=$(printf '%s' "$cmd" | python3 -c '
import sys, re
src = sys.stdin.read()
q = chr(39)
lines = src.split(chr(10))
opener = re.compile(r"<<-?\s*[^\sA-Za-z_]*([A-Za-z_]\w*)")
kept = []
i = 0
n = len(lines)
while i < n:
line = lines[i]
kept.append(line)
m = opener.search(line)
if m:
delim = m.group(1)
dash = "<<-" in line
i += 1
while i < n:
cand = lines[i].strip() if dash else lines[i]
if cand == delim:
kept.append(lines[i])
break
i += 1
i += 1
text = chr(10).join(kept)
text = re.sub(q + "[^" + q + "]*" + q, " ", text)
bt = chr(96)
verbs = r"(fetch|pull|push|clone|remote|ls-remote|checkout|commit|merge|rebase|reset|cherry-pick|revert|tag\s+-d|update-ref|reflog\s+delete)"
gitre = re.compile(r"(^|[\s;&|()$" + bt + r"])git\s+" + verbs, re.IGNORECASE)
subst = re.compile(r"\$\(([^()]*(?:\([^()]*\)[^()]*)*)\)|" + bt + r"([^" + bt + r"]*)" + bt, re.DOTALL)
deny = "no"
for m in subst.finditer(text):
inner = m.group(1) or m.group(2) or ""
if gitre.search(inner):
deny = "yes"
break
sys.stdout.write("__OK__" + deny)
' 2>/dev/null)
# Fail closed: anything other than the clean sentinel (incl. a python
# failure that yields an empty string) is treated as a deny.
if [[ "$subst_git" != "__OK__no" ]]; then
echo "Denied: a git verb inside a command substitution (\$(...) or backticks) is evaluated by the shell before the wrapping echo/printf/heredoc runs." >&2
echo "Use the verb listed in your role's State→Verb table (e.g. commit, complete, i_am_done)." >&2
exit 2
fi
fi
# --- credential / secret exfil ------------------------------------------------
# Block ANY bash command that references a credential file path — catches
# `cat .git/config`, `python -c "open('.git/config')..."`, `grep token .git/
# config`, `strings ~/.netrc`, etc. The token is scrubbed from .git/config
# post-clone so the file is uninteresting, but a leaked PAT is unrecoverable
# so belt + suspenders applies.
if echo "$low" | grep -qE '(\.git/config|\.gitconfig|\.git-credentials|\.netrc|\.ssh/|id_rsa|id_ed25519|id_ecdsa|known_hosts)'; then
echo "Denied: command references a credential file or SSH key. Don't read git credentials — the PAT is injected subprocess-side by the MCP layer (commit / complete verbs) and never lands in these files." >&2
exit 2
fi
# /proc-based env/secret exfil: /proc/<pid>/environ, /proc/self/environ, etc.
if echo "$low" | grep -qE '/proc/(self|[0-9]+|\$\$|\$\{.*\})/(environ|cmdline|cwd|exe)'; then
echo "Denied: reading /proc/*/environ or /proc/*/cmdline can leak credentials from another process. Ask the orchestrator for the specific value you need." >&2
exit 2
fi
if echo "$low" | grep -qE '(^|[[:space:];&|])(curl|wget|http|https)[[:space:]][^|]*(github\.com|api\.github\.com)'; then
echo "Denied: direct GitHub HTTP calls bypass the PAT handler. Use the role-appropriate MCP verb: roboco-do commit (devs/docs), roboco-flow complete (PMs), or roboco-git-readonly status/log/diff/branch_list (any role)." >&2
exit 2
fi
# --- internal API calls -------------------------------------------------------
# Agents must reach the orchestrator through their MCP manifest verbs, never
# raw HTTP. Two-step check: (a) is this a curl/wget/http/https/httpie command,
# AND (b) does the line reference a forbidden internal host. Both must match.
# This catches all forms uniformly:
# - scheme-ful: `curl http://roboco-orchestrator:8000/api`
# - scheme-less: `curl roboco-orchestrator:8000/api`
# - protocol-relative: `curl //roboco-orchestrator:8000/api`
# - any flag ordering: `curl -s -X POST http://localhost:8000/x -d ...`
# Interpreter / library-driven HTTP is handled by the rule below.
# KNOWN GAP (still 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
# server-side X-Agent-Role check is the second gate.
if echo "$low" | grep -qE '(^|[[:space:];&|])(curl|wget|http|https|httpie)[[:space:]]' && \
echo "$low" | grep -qE '((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
# --- interpreter/library HTTP to an internal host ----------------------------
# The curl/wget rule above only fires when the FIRST token is an HTTP CLI.
# A live run showed an agent reach the orchestrator with forged X-Agent-*
# identity headers via:
# python3 << 'EOF'
# import httpx
# httpx.post("http://roboco-orchestrator:8000/api/v2/flow/developer/i_will_work_on",
# headers={"X-Agent-ID": "<self>", "X-Agent-Role": "developer"})
# EOF
# The binary is python3 (slips the CLI check) and it imports httpx, not
# roboco.* (slips the roboco-internals import check). Close it language-agnostically:
# deny when the command pairs an HTTP-client token with a forbidden
# internal host. The whole command (heredoc body included) is in $low,
# consistent with the curl/wget sibling above. Legitimate shell work does
# not both name an internal host AND drive an HTTP client; external HTTP
# (pypi, docs.python.org, github — github also hits its own rule earlier)
# has no internal host so it still passes.
if echo "$low" | grep -qE '(httpx|requests|urllib|aiohttp|http\.client|httplib|http\.request|net/http|net::http|httparty|faraday|lwp|libwww|httpurlconnection|okhttp|node-fetch|axios|xmlhttprequest|websocket|fetch[[:space:]]*\()' && \
echo "$low" | grep -qE '((http|https|ws|wss)://)?/?(roboco-[a-z0-9_-]+|localhost|127\.0\.0\.1|0\.0\.0\.0)[:/]'; then
echo "Denied: reaching an internal host via an HTTP client (httpx / requests / urllib / aiohttp / fetch / Net::HTTP / ...) bypasses the gateway, role manifest, tracing and auth — and lets you forge X-Agent-* identity headers. Use your role's MCP verbs (roboco-flow / roboco-do / roboco-git-readonly / roboco-optimal / roboco-docs); they are the only sanctioned path to the orchestrator." >&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
echo "Denied: env / printenv can leak secrets. Ask for the specific value you need via the task description." >&2
exit 2
fi
fi
# Shell built-ins that dump variables / exported env. `set -e`, `set -u`,
# `set -o pipefail` etc. must still pass — so we only deny `set` with no args
# or followed by a terminator (`|`, `;`, `&&`, newline/EOL).
if echo "$low" | grep -qE '(^|[[:space:];&|])set([[:space:]]*$|[[:space:]]*[|;&])'; then
echo "Denied: bare \`set\` dumps all shell variables including exported credentials." >&2
exit 2
fi
if echo "$low" | grep -qE '(^|[[:space:];&|])(declare|typeset)[[:space:]]+-[[:alpha:]]*[xp]'; then
echo "Denied: \`declare -x\` / \`typeset -p\` dumps exported variables." >&2
exit 2
fi
if echo "$low" | grep -qE '(^|[[:space:];&|])export[[:space:]]+-p([[:space:]]|$)'; then
echo "Denied: \`export -p\` dumps exported variables." >&2
exit 2
fi
if echo "$low" | grep -qE '(^|[[:space:];&|])compgen[[:space:]]+-[[:alpha:]]*[ve]'; then
echo "Denied: \`compgen -v\` / \`compgen -e\` enumerates variables/exports." >&2
exit 2
fi
# Sourcing credential-bearing files via `source` or `.` dot-sourcing.
if echo "$low" | grep -qE '(^|[[:space:];&|])(source|\.)[[:space:]]+[^|;&]*(\.env|/etc/environment|/proc/[^[:space:]]*environ|\.profile|\.bashrc|\.zshrc|\.git/config|\.netrc)'; then
echo "Denied: sourcing credential-bearing files exposes secrets in the current shell." >&2
exit 2
fi
# Binary/encoding tools pointed at credential files — catches `base64 .env`,
# `xxd ~/.netrc`, `strings .git/config`, `od -c .git-credentials`, etc.
if echo "$low" | grep -qE '(^|[[:space:];&|])(base64|od|xxd|hexdump|strings|uuencode)[[:space:]]+[^|;&]*(\.env|\.git/config|\.gitconfig|\.git-credentials|\.netrc|\.ssh/|id_rsa|id_ed25519)'; then
echo "Denied: encoding/inspecting a credential file is still exfiltration." >&2
exit 2
fi
# Interpreter one-liners reading credential paths.
if echo "$low" | grep -qE '(^|[[:space:];&|])(python3?|perl|node|ruby|awk|sed)[[:space:]]+[^|;&]*-[ce][[:space:]]+[^|;&]*(\.env|\.git/config|\.gitconfig|\.git-credentials|\.netrc|/proc/[^[:space:]]*environ|id_rsa|id_ed25519)'; then
echo "Denied: interpreter snippet reads a credential file. Ask orchestrator for the value you need." >&2
exit 2
fi
# --- gateway-internals import bypass ------------------------------------------
# An agent must reach the orchestrator ONLY through its manifest-bound MCP
# verbs. Importing the server package directly
# uv run python3 -c "from roboco.mcp.flow_server import open_pr; open_pr(...)"
# python3 << 'EOF' ... import roboco.services.gateway ... EOF
# python -m roboco.mcp.do_server
# bypasses the per-role tool manifest entirely (role-scoping becomes
# meaningless if the agent can call any verb in-process) and lets the agent
# run choreographer/service code outside the gateway's tracing + auth.
# The whole command string (heredoc body included) is in $low, so a flat
# substring match on a roboco import is sufficient and robust to quoting.
if echo "$low" | grep -qE '(python3?|uv[[:space:]]+run|poetry[[:space:]]+run|pipenv[[:space:]]+run|pdm[[:space:]]+run|hatch[[:space:]]+run)' && \
echo "$low" | grep -qE '(import[[:space:]]+roboco|from[[:space:]]+roboco|-m[[:space:]]+roboco|roboco\.(mcp|services|runtime|foundation|api|enforcement)\b)'; then
echo "Denied: importing or running roboco.* internals from the shell bypasses the MCP role manifest, tracing, and auth. Use your role's MCP verbs (roboco-flow / roboco-do / roboco-git-readonly / roboco-optimal / roboco-docs) — they are the only sanctioned path to the orchestrator." >&2
exit 2
fi
# --- agent-identity forgery --------------------------------------------------
# ROBOCO_AGENT_ID is the agent's identity. It is injected by the orchestrator
# at spawn and the agent process must never rewrite it — doing so lets one
# agent act as another (forged audit trail, bypassed ownership checks). No
# legitimate agent shell command sets this variable; deny any assignment or
# export of it (already lowercased into $low).
if echo "$low" | grep -qE '(^|[[:space:];&|]|env[[:space:]]+|export[[:space:]]+)roboco_agent_id[[:space:]]*='; then
echo "Denied: ROBOCO_AGENT_ID is your injected identity — overriding it forges another agent's identity. Never set or export it. Call your MCP verbs with your real identity instead." >&2
exit 2
fi
# Redirected reads from /proc/self/environ: `read -r var < /proc/self/environ`,
# `while read … < /proc/…/environ`, etc.
if echo "$low" | grep -qE '<[[:space:]]*/proc/(self|[0-9]+)/(environ|cmdline)'; then
echo "Denied: redirecting from /proc/*/environ leaks credentials." >&2
exit 2
fi
# --- destructive ops on system paths ------------------------------------------
# Agents should only rm -rf inside their own workspace. Block system paths
# outright. Cross-workspace rm isn't regex-decidable here (we don't know the
# agent's slug at hook time) — defer that to the Write(workspace/**) allow
# list + file-ownership at the OS level.
if echo "$low" | grep -qE '(^|[[:space:];&|])rm[[:space:]]+[^|;&]*-[[:alpha:]]*[rRf][[:alpha:]]*[[:space:]]'; then
if echo "$low" | grep -qE '(^|[[:space:];&|])rm[[:space:]]+[^|;&]*(/app($|[[:space:]/])|/root|/etc|/var|/usr|/bin|/sbin|/lib|/home)'; then
echo "Denied: rm on a system path. Operate inside your own workspace only." >&2
exit 2
fi
if echo "$low" | grep -qE '(^|[[:space:];&|])rm[[:space:]]+[^|;&]*[[:space:]]/[[:space:]]*(;|\||&|$)'; then
echo "Denied: rm on root filesystem." >&2
exit 2
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
# `uv run --active` is denied as a footgun: the contract is bare `uv run`
# (workspace .venv, cwd-relative). VIRTUAL_ENV is no longer image-baked (it
# leaked into every workspace `uv run` as a warning), so --active has no active
# env and errors; an explicit /app target still bricks the gateway (next block).
# Bare `uv run` (workspace .venv, cwd-relative) is untouched.
if echo "$low" | grep -qE '(^|[[:space:];&|])uv[[:space:]]+run([[:space:]]|$)' && \
echo "$low" | grep -qE '(^|[[:space:]=])--active([[:space:]]|$)'; then
echo "Denied: \`uv run --active\` is not the contract — use bare \`uv run\` (it uses your workspace .venv under /data/workspaces, never /app). If /app's environment looks broken, report it via your blocked / escalation verb." >&2
exit 2
fi
if echo "$low" | grep -qE '(^|[[:space:];&|])(uv[[:space:]]+run|uvx)([[:space:]]|$)' && \
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: running uv against /app targets the image-baked MCP-gateway venv (/app/.venv) and rebuilds it, bricking your own gateway tools. Use bare \`uv run\` from your workspace clone under /data/workspaces. If /app's environment looks broken, report it via your blocked / escalation verb." >&2
exit 2
fi
exit 0