Block MCP tools in plain Claude completions - #171
Conversation
merlerm
left a comment
There was a problem hiding this comment.
LGTM, maybe we can get a @tomsilver Claude review to be safe
tomsilver
left a comment
There was a problem hiding this comment.
The core finding is real and the one-line fix is right: --tools "" only covers built-ins, so MCP tools stayed live in what's supposed to be a plain LLM call. The unit test guards the flag in CI, and the Docker control/treatment pair is a good way to demonstrate the behavior. Two substantive things I'd change before merge, plus tooling friction that will show up on the first run_ci_checks.sh.
Substantive
1. --disallowedTools "*" leans on undocumented wildcard semantics, and nothing in CI would notice if it broke.
claude --help on 2.1.247 documents the flag as "Comma or space-separated list of tool names to deny (e.g. Bash(git *) Edit)". A bare * matching every tool isn't documented anywhere; it's an empirical observation on 2.1.233/2.1.247. If a future CLI version stops treating it as match-all, the failure is silent: tools come back, no error, and test_cli_denies_all_tools still passes because it only asserts the flag was passed, not that it had effect. The test that would catch it is opt-in, paid, and Docker-gated, so it will effectively never run again after this PR.
2. Production still loads user/project settings; the probe doesn't, so the test validates a stricter config than the one that ships.
record_cli appends --strict-mcp-config, --mcp-config, --setting-sources "", and --no-session-persistence before invoking the CLI. Production complete() passes none of those. In the real path --setting-sources is unset, so user/project/local settings load: MCP servers configured in ~/.claude.json are still spawned as processes (deny only stops the model from calling them), and settings can still inject hooks and permission rules into a call that is meant to be a stateless completion. The machine-dependent part of the attack surface is the part the test doesn't cover.
Both points have the same fix. In complete() (src/robocode/utils/llm/cli_client.py:45):
"--strict-mcp-config", # no --mcp-config given => zero MCP servers configured
"--setting-sources", "", # no user/project/local settings, hooks, or permission rulesThat makes the guarantee structural (servers are never configured) instead of dependent on deny-rule matching, and it makes the client behave the same on every machine. Keep --disallowedTools "*" as defense in depth. The test then exercises the real production arg list rather than an augmented one, and the probe only needs to add --mcp-config and --output-format stream-json. Worth confirming on one live run that --setting-sources "" doesn't disturb auth in the container: credentials aren't settings, so it shouldn't, but the sandbox mounts a throwaway ~/.claude.
3. The control test asserts on model behavior.
assert token in final["result"] fails if the model declines, paraphrases, or wraps the token. init["tools"] containing mcp__probe__probe_token plus a non-empty calls.txt already prove the tool was exposed and invoked. I'd drop the token assertion from the control and keep token not in ... in the deny case, which is cheap and meaningful.
Tooling friction (verified locally)
4. The _thread import moves depend on which isort you have. isort 9.0.0 accepts the new ordering (clean); isort 8.0.0 reverts both hunks back to the third-party block. CI's isort/isort-action@master pulls latest, so CI is happy and a local ./run_ci_checks.sh on an older isort is not. Pin it in the develop extra ("isort>=9") rather than letting the two fight.
5. run_autoformat.sh isn't clean on this branch. docformatter rewraps both new docstrings (wrap-descriptions = 88), e.g. in claude_mcp_probe.py:
-Both tests use this program. --without-deny-flag reproduces the old wrapper;
-otherwise all production restrictions remain. Each call has a $1 CLI budget.
+Both tests use this program. --without-deny-flag reproduces the old wrapper; otherwise
+all production restrictions remain. Each call has a $1 CLI budget.Not a CI job, but it means the next person's autoformat produces unrelated diff noise. Same for the test_llm.py module docstring.
6. The addopts change is safe. I checked the marker interaction empirically: a CLI -m pylint overrides the ini -m 'not integration' (last -m wins), so pytest . --pylint -m pylint in the lint job is unaffected, and pytest tests/ correctly deselects the two integration tests. black, mypy, and pylint are clean on the new files.
Nits
- The two integration tests are ~30 duplicated lines differing by one flag and three assertions.
@pytest.mark.parametrize("deny", [False, True])over a shared_run_probehelper would read better. The PR body defends the duplication as explicitness, so take or leave it. check=kwargs.pop("check", True)in bothrecord_cliandlaunch_probeflips subprocess's default for no reason: every call site incli_clientanddocker_sandboxpassescheckexplicitly, so the default is dead and the flip would surprise a future caller that relies oncheck=False. Justreal_run(args, **kwargs).- The probe hardcodes
"model": "claude-opus-5", which will drift fromexperiments/conf/approach/completion/cli_claude.yaml. Read it from the config or a shared constant. - $1 budget x 2 tests is ~$2 per integration run. Worth putting next to the run command in the README, not only in the PR body.
write_text/read_textin_create_mcp_tooland_parse_claude_outputomitencoding="utf-8", unlike the fixtures and the rest of the repo.cli_client.py's module docstring still says "with tools and system prompt stripped"; mention MCP now that that's the interesting part.
Summary
--disallowedTools "*"to the plain Claude completion client:--tools ""disables built-ins but does not block MCP tools.pytest.mark.integrationand exclude them from default pytest/CI runs. Run explicitly withpython -m pytest tests/utils/test_llm.py -m integration -v -s.Verification
.pylintrccontains obsolete/unrecognized options in the installed pylint. Full CI has not yet run.Scope and limitations
The tests use
run_genplan_in_docker()with production authentication, filtered source mounts, image entrypoint, firewall, and privilege dropping. They replace only the robot-training driver with a diagnostic probe calling the realClaudeCLIClient.This verifies model tool isolation, not containment of generated policy execution or full experiment readiness. The flag affects all users of the shared plain Claude completion client. Campaign YAMLs and unrelated lockfile changes are intentionally excluded.