feat(agents): force agents to the Makefile — deny raw uv/pip/conda/poetry (CEO #15) (#518)

* fix(prompts): point agents at Makefile, drop raw uv run instructions

backend.md:23-26 literally instructed raw uv run ruff/mypy/pytest (copied from
the human-facing CLAUDE.md), so agents bypassed the Makefile's UV_NO_SYNC=1 +
private UV_CACHE_DIR venv-corruption guard. Replace with make targets across
backend/developer/qa/cell_pm + a universal rule in base.md. Regenerate verbs.md
from the updated regen script (baked instruction now make foundation-check) and
align the Makefile drift message. Ships with the bash-guard deny in the next
commit so agents don't loop fighting the guard.

* feat(bash-guard): deny raw uv/pip/conda/poetry, point at Makefile

When a Makefile is present, deny raw uv run/uv pip/uv lock/add/remove, pip/pip3
install/uninstall, conda install/create/run, poetry run/install/add and remediate
to make quality/gate/lint/test. Skipped when no Makefile (Makefile-less projects
not blocked). ROBOCO_GUARD_SKIP_PM=1 (grok path) nudges exit 0 instead of the
run-canceling exit 2. Overrides the prior bare-uv-run-allowed stance by CEO
direction; the /app-targeted blocks above keep priority.

* feat(grok): deny raw uv/pip/conda/poetry via native --deny + PM-skip nudge

Add _RAW_PM_DENY (uv run/pip install/lock/add/remove, pip/pip3 install, conda
install/create/run, poetry run/install/add) to _deny_rules so grok's graceful
native --deny blocks raw package-manager commands (model adapts to make, run
continues — unlike a hook deny which cancels the run). The bash-guard hook
keeps the compound-command fallback (cd x && uv run) and nudges exit 0 there via
ROBOCO_GUARD_SKIP_PM=1 in the grok hook env, never canceling.

* test(bash-guard): align existing tests with W1 Makefile-gate policy

Raw uv run / pip install are now Makefile-gated (W1, CEO item #15), so two
existing bash-guard invariants reverse:

- test_allows_pytest_even_if_suite_uses_requests keeps its HTTP-injection
  allow-path intent but uses bare `python -m pytest` (raw `uv run` is now
  denied); the deny case is covered by test_bash_guard_makefile_guardrail.
- test_allows_pip_install_in_workspace -> test_denies_pip_install_when_makefile_
  present: a workspace clone carries a Makefile, so bare pip install is now
  denied -> agents use `make` / `uv sync --extra dev`. Makefile-less skips
  stay covered.

Gate: 12994 passed, 439 skipped, 94.81% cov (DB env :55432 user renzof);
the lone flaky integration error passes in isolation (DB-state race, not W1).

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-15 04:32:27 +02:00
committed by GitHub
co-authored by Renn F
parent fe9940dec1
commit a9dee3b34e
14 changed files with 238 additions and 17 deletions
+1 -1
View File
@@ -558,7 +558,7 @@ foundation-check:
@echo "==> verb tables up-to-date (renders + git diff)"
@uv run python scripts/regenerate_verb_tables.py
@git diff --exit-code -- agents/prompts/_generated/ ':!agents/prompts/_generated/lifecycle-*.md' \
|| (echo "Verb tables are out of date. Run 'uv run python scripts/regenerate_verb_tables.py' and commit the diff." && exit 1)
|| (echo "Verb tables are out of date. Run 'make foundation-check' and commit the diff." && exit 1)
@echo "==> postgres enum parity (skip if no migrated DB)"
uv run python scripts/verify_postgres_enums.py
@echo "All foundation drift checks passed."
+1 -1
View File
@@ -3,7 +3,7 @@
# Per-role verb shapes (autogenerated)
Run `uv run python scripts/regenerate_verb_tables.py` after changing
Run `make foundation-check` after changing
any role config or schema. Role prompts reference this file's sections
as the source of truth for verb signatures.
+4
View File
@@ -87,3 +87,7 @@ The briefing also carries `company_goals` — the company's charter (north star,
## Substitute reasons (for `i_am_blocked`)
`low_context`, `out_of_scope_team`, `out_of_scope_role`, `task_complete`, `max_retries`, `blocked_external`.
## Makefile is the gate surface
Run `make <target>` for lint/test/typecheck — never raw `uv run`/`pip`/`conda`/`poetry`. The Makefile sets `UV_NO_SYNC=1` and a private `UV_CACHE_DIR` to prevent the concurrent-venv-corruption race; bare `uv run` bypasses both. Targets: `make quality` (full gate), `make gate` (fast pre-submit), `make lint`, `make test`, `make panel-gate` (frontend).
+1 -1
View File
@@ -177,7 +177,7 @@ The PM journal is what makes the cell legible to Main PM and CEO. Skipping entri
1. ✅ Every subtask under your task is in a terminal state (`completed` or `cancelled`) — gateway-enforced.
2. ✅ You inspected each child's PR (already merged into your branch via `complete`) — call `evidence(your_task_id)` for the aggregate diff.
3. ✅ Each acceptance criterion on YOUR cell-PM task is met by something in the aggregate (commit / merged PR / doc).
4. ✅ Tests/lint on the aggregate are green — your branch is the integration point for the cell, so run `make quality` (or equivalent) before submitting up.
4. ✅ Tests/lint on the aggregate are green — your branch is the integration point for the cell, so run `make quality` before submitting up.
5.`note(scope='reflect', task_id=...)` written — aggregate review.
6.`note(scope='decision', task_id=...)` written — submit-up rationale (gateway-required).
7.`notes` argument to `submit_up` >= 20 chars (gateway-enforced).
+1 -1
View File
@@ -103,7 +103,7 @@ The gateway enforces some of these; the rest are convention but failing one of t
1. ✅ At least one `commit()` on this branch (gateway-enforced).
2. ✅ Every acceptance criterion is met by actual code or test, not just intention. Re-read them via `evidence(task_id)`.
3. ✅ Tests/lint/typecheck pass locally — run them via `Bash`. If your project has `make quality` (or equivalent), run it. **`i_am_done` runs the fast gate (lint + typecheck) in your workspace and rejects the submit if it's red** — so run it yourself first and submit green on the first try; QA and CI run the full gate (incl. tests) too.
3. ✅ Tests/lint/typecheck pass locally — run `make quality` (or `make gate` for the fast pre-submit gate). Never raw `uv run`. **`i_am_done` runs the fast gate (lint + typecheck) in your workspace and rejects the submit if it's red** — so run it yourself first and submit green on the first try; QA and CI run the full gate (incl. tests) too.
4.`git diff` (call `evidence(task_id)` to inspect) shows nothing stray — no `print()` debugging, no commented-out code, no unrelated edits.
5.`note(scope='reflect', task_id=...)` walks through every criterion (gateway-enforced as `journal:reflect`).
6.`open_pr(task_id)` has been called and the response returned a PR number (gateway-enforced via `pr_number` set).
+1 -1
View File
@@ -51,7 +51,7 @@ A pass without evidence is a betrayal of your role: the entire downstream chain
3. If you need to re-inspect anything, call `evidence(task_id)`. **Do not** grep the workspace or run `Bash git diff` — the diff is in the response.
4. **Read the dev's `reflect` note** — it walks through every acceptance criterion and explains how each is met. Cross-check those claims against the actual diff.
5. For each acceptance criterion individually: confirm there is a referencing artifact (commit, progress entry, or file change) AND that the change actually meets it. Don't batch-approve criteria; check them one at a time.
6. Run tests/lint via `Bash` (e.g. `make quality` or `pytest`) — even if the dev says they passed, you re-run.
6. Run `make quality` — even if the dev says they passed, you re-run.
7. `note(scope='struggle', text='...')` if you can't decide — flag the ambiguity rather than guess. Then `dm(recipient=<dev>, text='<question>')` to ask before failing.
8. `note(scope='learning', text="<what worked / what would have caught the issue earlier / what pattern this work establishes>")` — required before pass/fail.
9. Pass: `pass(task_id, notes="<>=80 chars: overall review summary, edge cases tested, any caveats>", ac_verdicts=["criterion 1 — verified by <commit/file/line>", "criterion 2 — verified by <artifact>", ...])`**one entry per acceptance criterion, in the task's criterion order**; the gateway rejects a pass that leaves any criterion uncovered. If even one criterion does not hold, do NOT pass — `fail` instead. Fail: `fail(task_id, findings=[{"file": "path", "line": 42, "severity": "major", "criterion": "<ac id if applicable>", "expected": "...", "actual": "..."}, ...])` — one object per issue, capped at 10 (nudge above 5 — split or prioritize).
+7 -5
View File
@@ -19,11 +19,13 @@
## Development Standards
```bash
# Before any commit
uv run ruff format .
uv run ruff check .
uv run mypy roboco/
uv run pytest
# Before any commit — use the Makefile, never raw `uv run`/`pip`/`conda`/`poetry`.
# The Makefile sets UV_NO_SYNC=1 + a private UV_CACHE_DIR to prevent venv
# corruption; bare `uv run` bypasses both.
make lint # ruff format + ruff check + mypy + vulture (formats in place)
make gate # fast pre-submit: ruff format --check + ruff check + mypy + xenon
make quality # full merge gate (lint+types+tests+cov+xenon+bandit+audit+...)
make test # pytest with coverage
# Coverage target: 80%
```
+19
View File
@@ -383,4 +383,23 @@ if echo "$low" | grep -qE '(^|[[:space:];&|])(uv[[:space:]]+run|uvx)([[:space:]]
exit 2
fi
# --- 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
# Makefile-less projects aren't blocked. `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 && echo "$low" | grep -qE '(^|[[:space:];&|])(uv[[:space:]]+(run|pip[[:space:]]+(install|uninstall)|lock|add|remove)|pip3?[[:space:]]+(install|uninstall)|conda[[:space:]]+(install|create|run)|poetry[[:space:]]+(run|install|add))([[:space:]]|$)'; then
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
exit 0
+31 -2
View File
@@ -126,6 +126,29 @@ _GIT_MUTATE_DENY = (
)
_DESTRUCTIVE_DENY = ("Bash(rm -rf*)",)
# Raw package-manager / test-runner commands — use the Makefile (CEO direction).
# Native --deny is graceful (model adapts to `make`, run continues), so this is
# the primary gate on grok; the bash-guard hook only catches the compound
# commands the globs miss (cd x && uv run) and there nudges via
# ROBOCO_GUARD_SKIP_PM=1 instead of canceling the run.
_RAW_PM_DENY = (
"Bash(uv run*)",
"Bash(uv pip install*)",
"Bash(uv pip uninstall*)",
"Bash(uv lock*)",
"Bash(uv add*)",
"Bash(uv remove*)",
"Bash(pip install*)",
"Bash(pip3 install*)",
"Bash(pip uninstall*)",
"Bash(conda install*)",
"Bash(conda create*)",
"Bash(conda run*)",
"Bash(poetry run*)",
"Bash(poetry install*)",
"Bash(poetry add*)",
)
def render_config_toml(mcp_config: dict[str, Any]) -> str:
"""Translate Claude Code ``mcpServers`` into grok's ``[mcp_servers]`` TOML.
@@ -171,7 +194,7 @@ def _deny_rules(role: str) -> list[str]:
"""``--deny`` permission rules for a role (only bash-capable roles need any)."""
if role not in _BASH_ROLES:
return [] # bash removed entirely → nothing left to gate
return [*_DESTRUCTIVE_DENY, *_GIT_MUTATE_DENY]
return [*_DESTRUCTIVE_DENY, *_GIT_MUTATE_DENY, *_RAW_PM_DENY]
def _effort() -> str | None:
@@ -266,7 +289,13 @@ def bash_guard_hook_config(hook_path: str = BASH_GUARD_HOOK) -> dict[str, Any]:
{
"type": "command",
"command": hook_path,
"env": {"ROBOCO_GUARD_SKIP_GIT": "1"},
"env": {
"ROBOCO_GUARD_SKIP_GIT": "1",
# Raw-PM is gated by graceful native --deny above;
# the hook only nudges (exit 0) for compound
# commands the globs miss, never cancels the run.
"ROBOCO_GUARD_SKIP_PM": "1",
},
}
],
}
+1 -1
View File
@@ -191,7 +191,7 @@ def main() -> None:
"",
"# Per-role verb shapes (autogenerated)",
"",
"Run `uv run python scripts/regenerate_verb_tables.py` after changing",
"Run `make foundation-check` after changing",
"any role config or schema. Role prompts reference this file's sections",
"as the source of truth for verb signatures.",
"",
@@ -0,0 +1,40 @@
"""Agent prompts must steer the fleet at the Makefile, not raw package managers.
`agents/prompts/teams/backend.md` used to literally instruct `uv run ruff ...`
(copied from the human-facing CLAUDE.md), so agents ran raw `uv run` and bypassed
the Makefile's `UV_NO_SYNC=1` + private `UV_CACHE_DIR` — the guard against the
concurrent-venv-corruption race. This grep invariant keeps the prompts honest:
no agent-facing prompt instructs a raw `uv run`/`pip`/`conda`/`poetry`, and the
backend standards block names the make targets.
"""
from __future__ import annotations
from pathlib import Path
PROMPTS = Path(__file__).resolve().parents[3] / "agents" / "prompts"
RAW_PM = ["uv run ", "uv pip install", "pip install ", "conda install ", "poetry run "]
def _prompt_texts() -> dict[str, str]:
texts: dict[str, str] = {}
for path in PROMPTS.rglob("*.md"):
texts[str(path.relative_to(PROMPTS))] = path.read_text()
return texts
def test_no_prompt_instructs_raw_package_managers() -> None:
"""No agent-facing prompt tells an agent to run raw uv/pip/conda/poetry."""
offenders: list[str] = []
for name, body in _prompt_texts().items():
for bad in RAW_PM:
if bad in body:
offenders.append(f"{name}: '{bad.strip()}'")
assert not offenders, f"prompts still instruct raw PM commands: {offenders}"
def test_backend_prompt_names_make_targets() -> None:
"""The backend standards block must point at make targets, not uv run."""
body = (PROMPTS / "teams" / "backend.md").read_text()
assert "make quality" in body
assert "make gate" in body
@@ -0,0 +1,32 @@
"""Grok native --deny covers raw package-manager commands (Makefile guardrail).
Native ``--deny`` is graceful on grok (the model gets a permission error and
adapts to ``make``; the run continues unlike a hook deny, which cancels the
whole run). So raw-PM commands are denied via ``_RAW_PM_DENY`` in
``_deny_rules()``, mirroring ``_GIT_MUTATE_DENY``. The bash-guard hook handles
only the compound-command case the globs miss, and there
``ROBOCO_GUARD_SKIP_PM=1`` nudges instead of canceling.
"""
from __future__ import annotations
from roboco.llm.providers.grok_cli_config import _RAW_PM_DENY, _deny_rules
def test_raw_pm_deny_rules_present() -> None:
rules = _deny_rules("developer")
assert "Bash(uv run*)" in rules
assert "Bash(uv pip install*)" in rules
assert "Bash(pip install*)" in rules
assert "Bash(conda install*)" in rules
assert "Bash(poetry run*)" in rules
def test_raw_pm_deny_is_subset_of_deny_rules() -> None:
rules = set(_deny_rules("developer"))
assert set(_RAW_PM_DENY) <= rules
def test_raw_pm_deny_only_for_bash_roles() -> None:
"""Non-bash roles have bash removed entirely — no deny rules at all."""
assert _deny_rules("auditor") == []
@@ -0,0 +1,86 @@
"""bash-guard Makefile guardrail — deny raw uv/pip/conda/poetry, point at make.
CEO item: force agents to the Makefile. The existing hook deliberately allowed
bare ``uv run`` (workspace .venv, cwd-relative); this guard overrides that by
CEO direction when a ``Makefile`` is present, denying raw package-manager /
test-runner commands and remediating to the make targets. Skipped when no
Makefile exists so Makefile-less projects aren't blocked. On the grok path
(``ROBOCO_GUARD_SKIP_PM=1``) a deny cancels the whole run, so it nudges (exit 0)
instead.
"""
from __future__ import annotations
import json
import os
import subprocess
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[3]
HOOK = REPO_ROOT / "docker" / "scripts" / "bash-guard-hook.sh"
# Hook exits 2 to deny, 0 to allow. Named (not magic) for ruff PLR2004.
_DENIED = 2
_ALLOWED = 0
def _run_hook(
command: str, cwd: Path, env_over: dict[str, str] | None = None
) -> tuple[int, str]:
payload = json.dumps({"tool_input": {"command": command}})
env = dict(os.environ)
if env_over:
env.update(env_over)
proc = subprocess.run(
["bash", str(HOOK)],
input=payload,
capture_output=True,
text=True,
cwd=str(cwd),
env=env,
check=False,
)
return proc.returncode, proc.stderr
def test_denies_uv_run_when_makefile_present() -> None:
rc, err = _run_hook("uv run pytest", REPO_ROOT)
assert rc == _DENIED
assert "make" in err.lower()
def test_denies_pip_install() -> None:
rc, _ = _run_hook("pip install requests", REPO_ROOT)
assert rc == _DENIED
def test_denies_compound_uv_run() -> None:
rc, _ = _run_hook("cd svc && uv run ruff check .", REPO_ROOT)
assert rc == _DENIED
def test_denies_conda_and_poetry() -> None:
assert _run_hook("conda install numpy", REPO_ROOT)[0] == _DENIED
assert _run_hook("poetry run pytest", REPO_ROOT)[0] == _DENIED
def test_allows_make_quality() -> None:
rc, _ = _run_hook("make quality", REPO_ROOT)
assert rc != _DENIED
def test_allows_pnpm() -> None:
rc, _ = _run_hook("pnpm lint", REPO_ROOT)
assert rc != _DENIED
def test_skips_deny_without_makefile(tmp_path: Path) -> None:
rc, _ = _run_hook("uv run pytest", tmp_path)
assert rc != _DENIED
def test_grok_path_nudges_not_denies() -> None:
"""ROBOCO_GUARD_SKIP_PM=1 (grok) -> exit 0 nudge, not run-canceling exit 2."""
rc, err = _run_hook("uv run pytest", REPO_ROOT, {"ROBOCO_GUARD_SKIP_PM": "1"})
assert rc == _ALLOWED
assert "make" in err.lower()
+13 -4
View File
@@ -379,8 +379,13 @@ def test_allows_python_httpx_import_without_internal_host() -> None:
def test_allows_pytest_even_if_suite_uses_requests() -> None:
"""The command string is just the runner — no http-client token and
no internal host literal so it must pass."""
assert _run("uv run python -m pytest tests/unit/ -q") == _ALLOWED
no internal host literal so it must pass.
Bare ``python -m pytest`` (not ``uv run``): raw ``uv run`` is now
Makefile-gated (W1), so the runner here is the bare interpreter to keep
this test about the HTTP-injection allow path, not package-manager policy.
"""
assert _run("python -m pytest tests/unit/ -q") == _ALLOWED
# ---------------------------------------------------------------------------
@@ -435,8 +440,12 @@ def test_allows_uv_sync_in_workspace() -> None:
)
def test_allows_pip_install_in_workspace() -> None:
assert _run("pip install -r requirements.txt") == _ALLOWED
def test_denies_pip_install_when_makefile_present() -> None:
"""W1: raw ``pip install`` is Makefile-gated. A workspace clone carries
a ``Makefile`` (same repo), so a bare ``pip install`` is denied agents
use ``make`` / ``uv sync --extra dev``. Makefile-less projects skip the
deny (covered in test_bash_guard_makefile_guardrail.py)."""
assert _run("pip install -r requirements.txt") == _DENIED
def test_allows_reading_files_under_app() -> None: