diff --git a/coworker/engine.py b/coworker/engine.py index c87db216..71739639 100644 --- a/coworker/engine.py +++ b/coworker/engine.py @@ -1132,6 +1132,23 @@ class TurnEngine: "your report which checks were degraded." ), } + elif _toolchain.describe(name) is None: + # Not in the pinned catalog: no card at all (owner-hit 2026-08-20 — agents + # routed ordinary brew/pip installs through the install card, which could + # only fail after approval). The agent has a shell with its own approval + # flow; steer it there instead of at the user. + catalog = ", ".join(sorted(_toolchain.MANAGED)) + result = { + "installed": False, + "error": ( + f"'{name}' is not in the pinned tool catalog ({catalog})." + ), + "guidance": ( + "Install it yourself with the shell (brew/pip/…, subject to the " + "normal command approval), or continue without it and say in your " + "report which checks were degraded." + ), + } else: # The prompt must say up front whether WE can install this (pinned build for # this platform) — a card that offers Install for a tool we can't fetch turns diff --git a/coworker/server/app.py b/coworker/server/app.py index 0e475423..ec62a10b 100644 --- a/coworker/server/app.py +++ b/coworker/server/app.py @@ -2073,6 +2073,20 @@ def create_app(manager: SessionManager) -> FastAPI: """ name = str(args.get("name", "")).strip() info = toolchain.describe(name) + if not info: + # Not in the pinned catalog: never show an install card that can only + # end in "no pinned build" AFTER approval (owner-hit 2026-08-20 — agents + # routed ordinary brew/pip installs through the card). Steer to the + # shell, which has its own approval flow. + return { + "installed": False, + "error": ( + f"'{name}' is not in the pinned tool catalog " + f"({', '.join(sorted(toolchain.MANAGED))}). Install it yourself " + "with the shell (brew/pip/…, subject to the normal command " + "approval), or proceed without it and note the gap." + ), + } item = manager.inbox.add_tool_request( session_id, f"Install {name}?" if name else "Install a tool?", diff --git a/coworker/tools/toolreq.py b/coworker/tools/toolreq.py index a0ad4040..2137cd70 100644 --- a/coworker/tools/toolreq.py +++ b/coworker/tools/toolreq.py @@ -17,8 +17,13 @@ from aisuite.agents import ToolMetadata, tool def request_tool_tool() -> object: def request_tool(name: str, reason: str) -> dict: - """Ask the user to install a command-line tool you need but can't find on this - machine (e.g. `gitleaks`, `osv-scanner`, `semgrep`). + """Ask the user to install one of the PINNED catalog tools you need but can't find + on this machine. The catalog is a small closed set — currently `gitleaks`, + `trivy`, `osv-scanner` — installed at a pinned, checksum-verified version. + + For ANY other missing CLI (semgrep, jq, kubectl, …) do NOT use this tool: install + it yourself with the shell (brew/pip/…), which goes through the normal command + approval, or proceed without it. Keep `reason` to ONE sentence: which check needs the tool. The prompt the user sees already explains what the install is (pinned version, publisher, checksum) diff --git a/tests/test_tool_request.py b/tests/test_tool_request.py index 6ca8e59b..1d58a822 100644 --- a/tests/test_tool_request.py +++ b/tests/test_tool_request.py @@ -118,7 +118,7 @@ async def test_decline_recheck_finds_a_copy_the_user_installed_themselves(tmp_pa async def test_event_tells_the_truth_about_installability(tmp_path, monkeypatch): """Owner-hit 2026-08-14: the card offered Install for a tool with no pinned build — the surface guessed because the event said nothing. The event must carry the - registry's verdict, and no metadata means NO.""" + registry's verdict for catalog tools.""" from coworker import toolchain monkeypatch.setattr(toolchain, "_platform_key", lambda: "darwin_arm64") @@ -133,10 +133,29 @@ async def test_event_tells_the_truth_about_installability(tmp_path, monkeypatch) assert data["summary"] assert data["source"] == "github.com/gitleaks" - events = await _run(_engine(tmp_path, requester, tool="not-a-managed-tool")) - data = [e for e in events if e.type is EventType.TOOL_REQUESTED][0].data - assert data["installable"] is False - assert data["version"] == "" and data["summary"] == "" + +@pytest.mark.asyncio +async def test_non_catalog_tool_gets_no_card_and_a_shell_steer(tmp_path, monkeypatch): + """Owner-hit 2026-08-20: agents routed ordinary brew/pip installs through the + install card, which could only fail AFTER the user approved. A non-catalog name + must produce NO prompt at all — just a result steering the agent to the shell.""" + from coworker import toolchain + + monkeypatch.setattr(toolchain, "_platform_key", lambda: "darwin_arm64") + called = [] + + async def requester(args, tool_call_id=None): + called.append(args) + return {"installed": False, "reason": "declined"} + + engine = _engine(tmp_path, requester, tool="semgrep") + events = await _run(engine) + assert not [e for e in events if e.type is EventType.TOOL_REQUESTED] + assert called == [] # the user was never asked + tool_msg = [m for m in engine.messages if m.get("role") == "tool"][-1] + body = str(tool_msg["content"]) + assert "not in the pinned tool catalog" in body + assert "shell" in body and "gitleaks" in body # the catalog is named @pytest.mark.asyncio