mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
fix(security): block gateway-internals import + agent-id forgery (#164)
Smoke-12: be-dev-1 (minimax-m2.7) bypassed the entire MCP boundary by
running `uv run python3 -c "import os;
os.environ['ROBOCO_AGENT_ID']='...'; from roboco.mcp.flow_server
import open_pr; open_pr(...)"` from the Bash tool. This voided the
per-role tool manifest (role-scoping is meaningless if the agent can
import any server module in-process), forged agent identity via an
env-var rewrite, and ran choreographer code outside the gateway's
tracing + auth.
bash-guard-hook.sh now adds two deny rules:
1. Any python/uv/poetry/pipenv/pdm/hatch invocation that imports or
`-m`-runs roboco.* internals (mcp/services/runtime/foundation/
api/enforcement). The whole command string — heredoc body
included — is matched, so quoting/heredoc forms are covered.
2. Any assignment or export of ROBOCO_AGENT_ID (identity forgery).
Reading roboco source for context (cat/grep) is still allowed — the
block is on *executing* internals, not viewing them. Normal python
one-liners without roboco imports still pass.
19 bash-guard tests pass (10 prior + 9 new). Note: takes effect on
agent-image rebuild (hook ships in the agent container).
This commit is contained in:
@@ -133,6 +133,34 @@ if echo "$low" | grep -qE '(^|[[:space:];&|])(python3?|perl|node|ruby|awk|sed)[[
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# --- gateway-internals import bypass (task #164) ------------------------------
|
||||
# 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 (task #164) --------------------------------------
|
||||
# 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
|
||||
|
||||
@@ -84,3 +84,90 @@ def test_github_url_still_uses_github_specific_deny() -> None:
|
||||
# The GitHub-specific message should appear, not the gateway message.
|
||||
combined = (result.stdout + result.stderr).lower()
|
||||
assert "github" in combined or "pat" in combined
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Task #164: gateway-internals import bypass + agent-identity forgery
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_blocks_uv_run_python_importing_flow_server() -> None:
|
||||
"""The exact smoke-12 bypass: uv run python -c importing the flow server."""
|
||||
assert (
|
||||
_run(
|
||||
'uv run python3 -c "from roboco.mcp.flow_server import open_pr; '
|
||||
"open_pr(task_id='x')\""
|
||||
)
|
||||
== _DENIED
|
||||
)
|
||||
|
||||
|
||||
def test_blocks_plain_python_c_import_roboco() -> None:
|
||||
assert _run('python3 -c "import roboco.services.gateway as g; g.foo()"') == _DENIED
|
||||
|
||||
|
||||
def test_blocks_python_heredoc_importing_roboco() -> None:
|
||||
"""Heredoc body is part of the command string — must still be caught."""
|
||||
cmd = (
|
||||
"uv run python3 << 'EOF'\n"
|
||||
"import os\n"
|
||||
"from roboco.mcp.do_server import commit\n"
|
||||
"commit(message='x')\n"
|
||||
"EOF"
|
||||
)
|
||||
assert _run(cmd) == _DENIED
|
||||
|
||||
|
||||
def test_blocks_python_m_roboco_module() -> None:
|
||||
assert _run("python -m roboco.mcp.flow_server") == _DENIED
|
||||
assert _run("uv run -m roboco.services.gateway") == _DENIED
|
||||
|
||||
|
||||
def test_blocks_poetry_run_python_import_roboco() -> None:
|
||||
assert _run('poetry run python -c "from roboco.runtime import x"') == _DENIED
|
||||
|
||||
|
||||
def test_blocks_setting_roboco_agent_id_inline() -> None:
|
||||
"""Forging identity via an inline env assignment before a command."""
|
||||
assert (
|
||||
_run(
|
||||
"ROBOCO_AGENT_ID=00000000-0000-0000-0001-000000000001 "
|
||||
"uv run python3 -c 'print(1)'"
|
||||
)
|
||||
== _DENIED
|
||||
)
|
||||
|
||||
|
||||
def test_blocks_export_roboco_agent_id() -> None:
|
||||
assert (
|
||||
_run("export ROBOCO_AGENT_ID=00000000-0000-0000-0001-000000000001") == _DENIED
|
||||
)
|
||||
|
||||
|
||||
def test_blocks_os_environ_roboco_agent_id_in_python() -> None:
|
||||
"""The smoke-12 form: os.environ['ROBOCO_AGENT_ID']=... then import roboco.
|
||||
|
||||
Caught by the import-bypass rule (references roboco import) even
|
||||
independent of the identity rule."""
|
||||
cmd = (
|
||||
'uv run python3 -c "import os; '
|
||||
"os.environ['ROBOCO_AGENT_ID']='00000000-0000-0000-0001-000000000001'; "
|
||||
"from roboco.mcp.flow_server import open_pr; open_pr(task_id='x')\""
|
||||
)
|
||||
assert _run(cmd) == _DENIED
|
||||
|
||||
|
||||
def test_allows_legitimate_python_without_roboco() -> None:
|
||||
"""A normal python one-liner that doesn't touch roboco internals or
|
||||
the identity var must still pass — don't over-block."""
|
||||
assert _run('python3 -c "print(2 + 2)"') == _ALLOWED
|
||||
|
||||
|
||||
def test_allows_reading_roboco_source_with_cat() -> None:
|
||||
"""Reading source files for context (cat/grep) is fine — the block is
|
||||
specifically on *executing* roboco internals, not viewing them."""
|
||||
assert _run("cat roboco/services/gateway/choreographer/_impl.py") == _ALLOWED
|
||||
|
||||
|
||||
def test_allows_grep_for_roboco_symbol() -> None:
|
||||
assert _run("grep -rn 'import roboco' tests/") == _ALLOWED
|
||||
|
||||
Reference in New Issue
Block a user