2026-04-21 04:21:08 +02:00
#!/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": "..." } }
2026-06-19 09:15:01 +02:00
# 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.
2026-04-21 04:21:08 +02:00
#
# 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())
2026-06-19 09:15:01 +02:00
ti = d.get("tool_input") or d.get("toolInput") or {}
2026-04-21 04:21:08 +02:00
print(ti.get("command", ""))
except Exception:
print("")
' 2>/dev/null)
[[ -z " $cmd " ]] && exit 0
low = $( printf '%s' " $cmd " | tr "[:upper:]" "[:lower:]" )
2026-06-19 09:15:01 +02:00
# Skeletonize the command for the git-ops check ONLY: strip heredoc
2026-05-16 02:20:08 +02:00
# 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:]" )
2026-04-21 04:21:08 +02:00
# --- git network / auth ops ---------------------------------------------------
2026-06-19 09:15:01 +02:00
# 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
2026-05-12 04:37:01 +02:00
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
2026-04-21 04:21:08 +02:00
exit 2
fi
2026-06-19 20:51:02 +02:00
# --- 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
2026-04-21 04:21:08 +02:00
# --- 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.
2026-07-03 03:05:37 +02:00
#
# .credentials.json / .claude.json: the host's Claude Code OAuth credential
# store (~/.claude, ~/.claude.json) is bind-mounted read-write into every
# agent container — the shared subscription auth every spawned agent uses.
# No agent role's job ever needs to read its own harness's auth, so treat it
# as a credential file like .netrc/.git-credentials above.
if echo " $low " | grep -qE '(\.git/config|\.gitconfig|\.git-credentials|\.netrc|\.ssh/|id_rsa|id_ed25519|id_ecdsa|known_hosts|\.credentials\.json|\.claude\.json)' ; then
echo "Denied: command references a credential file or SSH key. Don't read git credentials or the harness's Claude Code auth — the PAT is injected subprocess-side by the MCP layer (commit / complete verbs) and never lands in these files." >& 2
2026-04-21 04:21:08 +02:00
exit 2
fi
2026-04-21 17:48:45 +02:00
# /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
2026-04-21 04:21:08 +02:00
if echo " $low " | grep -qE '(^|[[:space:];&|])(curl|wget|http|https)[[:space:]][^|]*(github\.com|api\.github\.com)' ; then
2026-05-09 03:15:09 +02:00
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
2026-04-21 04:21:08 +02:00
exit 2
fi
2026-07-03 03:05:37 +02:00
# --- remote code execution: piping a fetched payload straight into a shell ---
# `curl url | sh` (or bash/zsh/dash/ksh), `bash <(curl url)`, and
# `eval "$(curl url)"` all execute untrusted remote content regardless of the
# destination host — the github-specific and internal-host checks above only
# gate specific DESTINATIONS, so `curl https://raw.githubusercontent.com/... |
# bash` (not github.com itself) or any other external host was a blind spot.
# Scoped to actual shells only (sh/bash/zsh/dash/ksh) — piping into a
# non-executing consumer (`curl url | tar xz`, `curl url | jq`, `curl url -o
# file`) is untouched and still allowed; there's no legitimate reason to feed
# a shell interpreter's stdin from a network fetch.
if echo " $low " | grep -qE '(curl|wget|httpie)\b[^|;&]*\|[[:space:]]*(sudo[[:space:]]+)?(sh|bash|zsh|dash|ksh)([[:space:]]|$)' ; then
echo "Denied: piping a downloaded payload straight into a shell executes untrusted remote code. Download to a file and inspect it, or use your normal toolchain (uv / pnpm) to install a package." >& 2
exit 2
fi
if echo " $low " | grep -qE '(^|[[:space:];&|])(sh|bash|zsh|dash|ksh|source|\.)[[:space:]]+<\([[:space:]]*(curl|wget)\b' ; then
echo "Denied: process-substitution execution of a curl/wget payload runs untrusted remote code." >& 2
exit 2
fi
if echo " $low " | grep -qE 'eval[[:space:]]+"?\$\([[:space:]]*(curl|wget)\b' ; then
echo "Denied: eval of a curl/wget payload runs untrusted remote code." >& 2
exit 2
fi
2026-05-03 06:11:38 +02:00
# --- internal API calls -------------------------------------------------------
# Agents must reach the orchestrator through their MCP manifest verbs, never
2026-05-03 06:17:37 +02:00
# 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 ...`
2026-06-19 09:15:01 +02:00
# Interpreter / library-driven HTTP is handled by the rule below.
2026-05-18 00:08:04 +02:00
# KNOWN GAP (still out of scope here):
2026-05-03 06:11:38 +02:00
# - Variable expansion: `URL=http://orchestrator/x; curl $URL` — the guard
# sees `curl $URL`, not the expanded URL, so this slips through. The
2026-06-19 09:15:01 +02:00
# server-side X-Agent-Role check is the second gate.
2026-05-03 06:17:37 +02:00
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
2026-05-03 06:11:38 +02:00
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
2026-06-19 09:15:01 +02:00
# --- interpreter/library HTTP to an internal host ----------------------------
2026-05-18 00:08:04 +02:00
# The curl/wget rule above only fires when the FIRST token is an HTTP CLI.
2026-06-19 09:15:01 +02:00
# A live run showed an agent reach the orchestrator with forged X-Agent-*
2026-05-18 00:08:04 +02:00
# 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
2026-06-19 09:15:01 +02:00
# roboco.* (slips the roboco-internals import check). Close it language-agnostically:
2026-05-18 00:08:04 +02:00
# 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
2026-04-21 04:21:08 +02:00
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
2026-04-21 17:48:45 +02:00
# 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.
2026-07-03 03:05:37 +02:00
if echo " $low " | grep -qE '(^|[[:space:];&|])(source|\.)[[:space:]]+[^|;&]*(\.env|/etc/environment|/proc/[^[:space:]]*environ|\.profile|\.bashrc|\.zshrc|\.git/config|\.netrc|\.credentials\.json|\.claude\.json)' ; then
2026-04-21 17:48:45 +02:00
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.
2026-07-03 03:05:37 +02:00
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|\.credentials\.json|\.claude\.json)' ; then
2026-04-21 17:48:45 +02:00
echo "Denied: encoding/inspecting a credential file is still exfiltration." >& 2
exit 2
fi
# Interpreter one-liners reading credential paths.
2026-07-03 03:05:37 +02:00
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|\.credentials\.json|\.claude\.json)' ; then
2026-04-21 17:48:45 +02:00
echo "Denied: interpreter snippet reads a credential file. Ask orchestrator for the value you need." >& 2
exit 2
fi
2026-06-19 09:15:01 +02:00
# --- gateway-internals import bypass ------------------------------------------
2026-05-16 00:59:26 +02:00
# 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
2026-06-19 09:15:01 +02:00
# --- agent-identity forgery --------------------------------------------------
2026-05-16 00:59:26 +02:00
# 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
2026-04-21 17:48:45 +02:00
# 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
2026-04-21 04:21:08 +02:00
# --- 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
2026-06-20 22:51:02 +02:00
# --- 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
2026-06-30 09:58:36 +02:00
# `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).
2026-06-29 05:38:21 +02:00
# 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
2026-06-30 09:58:36 +02:00
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
2026-06-29 05:38:21 +02:00
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
2026-07-15 04:32:27 +02:00
# --- raw package-manager / test-runner commands — use the Makefile -----------
# CEO direction: force the fleet to the Makefile. The blocks above deliberately
# allowed bare `uv run` (workspace .venv); this overrides that when a Makefile is
# present, denying raw uv/pip/conda/poetry and remediating to the make targets.
# The Makefile sets UV_NO_SYNC=1 + a private UV_CACHE_DIR for consistent gate
# behaviour; bare `uv run` bypasses both. Skipped when no Makefile exists so
2026-07-19 17:52:17 +02:00
# Makefile-less projects aren't blocked, AND skipped when a Makefile exists but
# declares none of the remediation targets (e.g. a Go/Rust Makefile with only
# `build`/`run`) — existence alone isn't enough, or the remediation below sends
# the agent into a dead-end loop calling a target that doesn't exist.
# `make`-internal uv (hook inspects the agent's command string, not
# subprocesses) and WorkspaceService's uv sync (subprocess, not the agent Bash
# tool) are untouched. On grok a deny cancels the whole run, so
# ROBOCO_GUARD_SKIP_PM=1 nudges (exit 0) instead.
if test -f Makefile && grep -qE '^(quality|gate|lint|test):' Makefile && \
echo " $low " | grep -qE '(^|[[:space:];&|])(uv[[:space:]]+(run|sync|pip[[:space:]]+(install|uninstall)|lock|add|remove)|pip3?[[:space:]]+(install|uninstall)|conda[[:space:]]+(install|create|run)|poetry[[:space:]]+(run|install|add))([[:space:]]|$)' ; then
2026-07-15 04:32:27 +02:00
if [ -n " ${ ROBOCO_GUARD_SKIP_PM :- } " ] ; then
echo "Nudge: raw package-manager commands are blocked — use \`make quality\` / \`make gate\` / \`make lint\` / \`make test\`. The Makefile sets UV_NO_SYNC=1 + a private cache; bare \`uv run\` bypasses that." >& 2
exit 0
fi
echo "Denied: raw package-manager commands are blocked — use the Makefile. Run \`make quality\` (full gate), \`make gate\` (fast pre-submit), \`make lint\`, or \`make test\`. The Makefile sets UV_NO_SYNC=1 + a private cache to prevent venv corruption; bare \`uv run\` bypasses that." >& 2
exit 2
fi
2026-04-21 04:21:08 +02:00
exit 0