From a9dee3b34e665532de666b1f84565addd478cf70 Mon Sep 17 00:00:00 2001 From: Renzo F <45401804+rennf93@users.noreply.github.com> Date: Wed, 15 Jul 2026 04:32:27 +0200 Subject: [PATCH] =?UTF-8?q?feat(agents):=20force=20agents=20to=20the=20Mak?= =?UTF-8?q?efile=20=E2=80=94=20deny=20raw=20uv/pip/conda/poetry=20(CEO=20#?= =?UTF-8?q?15)=20(#518)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- Makefile | 2 +- agents/prompts/_generated/verbs.md | 2 +- agents/prompts/base.md | 4 + agents/prompts/roles/cell_pm.md | 2 +- agents/prompts/roles/developer.md | 2 +- agents/prompts/roles/qa.md | 2 +- agents/prompts/teams/backend.md | 12 +-- docker/scripts/bash-guard-hook.sh | 19 ++++ roboco/llm/providers/grok_cli_config.py | 33 ++++++- scripts/regenerate_verb_tables.py | 2 +- .../unit/agents/test_prompts_use_makefile.py | 40 +++++++++ .../llm/providers/test_grok_raw_pm_deny.py | 32 +++++++ .../test_bash_guard_makefile_guardrail.py | 86 +++++++++++++++++++ tests/unit/scripts/test_bash_guard.py | 17 +++- 14 files changed, 238 insertions(+), 17 deletions(-) create mode 100644 tests/unit/agents/test_prompts_use_makefile.py create mode 100644 tests/unit/llm/providers/test_grok_raw_pm_deny.py create mode 100644 tests/unit/runtime/test_bash_guard_makefile_guardrail.py diff --git a/Makefile b/Makefile index 49935162..a036f3a4 100644 --- a/Makefile +++ b/Makefile @@ -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." diff --git a/agents/prompts/_generated/verbs.md b/agents/prompts/_generated/verbs.md index ccdc1951..5dcb2a23 100644 --- a/agents/prompts/_generated/verbs.md +++ b/agents/prompts/_generated/verbs.md @@ -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. diff --git a/agents/prompts/base.md b/agents/prompts/base.md index 7367ff1c..f37a26d9 100644 --- a/agents/prompts/base.md +++ b/agents/prompts/base.md @@ -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 ` 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). diff --git a/agents/prompts/roles/cell_pm.md b/agents/prompts/roles/cell_pm.md index a55a5467..cb5ae212 100644 --- a/agents/prompts/roles/cell_pm.md +++ b/agents/prompts/roles/cell_pm.md @@ -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). diff --git a/agents/prompts/roles/developer.md b/agents/prompts/roles/developer.md index ac725c11..233472e4 100644 --- a/agents/prompts/roles/developer.md +++ b/agents/prompts/roles/developer.md @@ -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). diff --git a/agents/prompts/roles/qa.md b/agents/prompts/roles/qa.md index a17dc689..27c4c3e4 100644 --- a/agents/prompts/roles/qa.md +++ b/agents/prompts/roles/qa.md @@ -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=, text='')` to ask before failing. 8. `note(scope='learning', text="")` — 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 ", "criterion 2 — verified by ", ...])` — **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": "", "expected": "...", "actual": "..."}, ...])` — one object per issue, capped at 10 (nudge above 5 — split or prioritize). diff --git a/agents/prompts/teams/backend.md b/agents/prompts/teams/backend.md index a901ba3c..62b3ba60 100644 --- a/agents/prompts/teams/backend.md +++ b/agents/prompts/teams/backend.md @@ -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% ``` diff --git a/docker/scripts/bash-guard-hook.sh b/docker/scripts/bash-guard-hook.sh index f5e0997c..c47e6679 100755 --- a/docker/scripts/bash-guard-hook.sh +++ b/docker/scripts/bash-guard-hook.sh @@ -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 diff --git a/roboco/llm/providers/grok_cli_config.py b/roboco/llm/providers/grok_cli_config.py index c99df772..44f460b8 100644 --- a/roboco/llm/providers/grok_cli_config.py +++ b/roboco/llm/providers/grok_cli_config.py @@ -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", + }, } ], } diff --git a/scripts/regenerate_verb_tables.py b/scripts/regenerate_verb_tables.py index 4d56abb5..ef23bc3c 100644 --- a/scripts/regenerate_verb_tables.py +++ b/scripts/regenerate_verb_tables.py @@ -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.", "", diff --git a/tests/unit/agents/test_prompts_use_makefile.py b/tests/unit/agents/test_prompts_use_makefile.py new file mode 100644 index 00000000..4264ab10 --- /dev/null +++ b/tests/unit/agents/test_prompts_use_makefile.py @@ -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 diff --git a/tests/unit/llm/providers/test_grok_raw_pm_deny.py b/tests/unit/llm/providers/test_grok_raw_pm_deny.py new file mode 100644 index 00000000..bc042923 --- /dev/null +++ b/tests/unit/llm/providers/test_grok_raw_pm_deny.py @@ -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") == [] diff --git a/tests/unit/runtime/test_bash_guard_makefile_guardrail.py b/tests/unit/runtime/test_bash_guard_makefile_guardrail.py new file mode 100644 index 00000000..6dfd463e --- /dev/null +++ b/tests/unit/runtime/test_bash_guard_makefile_guardrail.py @@ -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() diff --git a/tests/unit/scripts/test_bash_guard.py b/tests/unit/scripts/test_bash_guard.py index 7e36bd31..55c97dab 100644 --- a/tests/unit/scripts/test_bash_guard.py +++ b/tests/unit/scripts/test_bash_guard.py @@ -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: