diff --git a/integrations/antigravity/skills/lemoncrow/SKILL.md b/integrations/antigravity/skills/lemoncrow/SKILL.md index 792e1621c..b6d0cd27b 100644 --- a/integrations/antigravity/skills/lemoncrow/SKILL.md +++ b/integrations/antigravity/skills/lemoncrow/SKILL.md @@ -55,10 +55,10 @@ Global scope by default. Add `--workspace ` only if the user names a specif Unknown key → run `lc settings show`, relay the valid keys. -5. **"what are my savings?" / cost questions** — shell available (Claude Code, Codex CLI): run `lc usage` (add `optimize` for savings analysis, `optimize detail` for the per-operation breakdown) and relay it. No shell (chat-only host): call the broker by exact name — it returns a markdown panel, relay it verbatim: +5. **"what are my savings?" / cost questions** — shell available (Claude Code, Codex CLI): run `lc usage` (add `optimize` for savings analysis, `optimize detail` for the per-operation breakdown) and relay it. No shell (chat-only host): call the `statusline_segment` tool by exact name, not through the `tool` broker (which refuses it) — it returns a markdown panel, relay it verbatim: ```json - {"name": "tool", "arguments": {"action": "call", "name": "statusline_segment", "arguments": {"format": "markdown"}}} + {"name": "statusline_segment", "arguments": {"format": "markdown"}} ``` `format` accepts `markdown` (chat panel), `json` (raw report), `segment` (one statusline frame). Never recompute or restate the numbers. diff --git a/integrations/claude/plugin/skills/lemoncrow/SKILL.md b/integrations/claude/plugin/skills/lemoncrow/SKILL.md index 792e1621c..b6d0cd27b 100644 --- a/integrations/claude/plugin/skills/lemoncrow/SKILL.md +++ b/integrations/claude/plugin/skills/lemoncrow/SKILL.md @@ -55,10 +55,10 @@ Global scope by default. Add `--workspace ` only if the user names a specif Unknown key → run `lc settings show`, relay the valid keys. -5. **"what are my savings?" / cost questions** — shell available (Claude Code, Codex CLI): run `lc usage` (add `optimize` for savings analysis, `optimize detail` for the per-operation breakdown) and relay it. No shell (chat-only host): call the broker by exact name — it returns a markdown panel, relay it verbatim: +5. **"what are my savings?" / cost questions** — shell available (Claude Code, Codex CLI): run `lc usage` (add `optimize` for savings analysis, `optimize detail` for the per-operation breakdown) and relay it. No shell (chat-only host): call the `statusline_segment` tool by exact name, not through the `tool` broker (which refuses it) — it returns a markdown panel, relay it verbatim: ```json - {"name": "tool", "arguments": {"action": "call", "name": "statusline_segment", "arguments": {"format": "markdown"}}} + {"name": "statusline_segment", "arguments": {"format": "markdown"}} ``` `format` accepts `markdown` (chat panel), `json` (raw report), `segment` (one statusline frame). Never recompute or restate the numbers. diff --git a/integrations/codex/plugin/skills/lemoncrow/SKILL.md b/integrations/codex/plugin/skills/lemoncrow/SKILL.md index 792e1621c..b6d0cd27b 100644 --- a/integrations/codex/plugin/skills/lemoncrow/SKILL.md +++ b/integrations/codex/plugin/skills/lemoncrow/SKILL.md @@ -55,10 +55,10 @@ Global scope by default. Add `--workspace ` only if the user names a specif Unknown key → run `lc settings show`, relay the valid keys. -5. **"what are my savings?" / cost questions** — shell available (Claude Code, Codex CLI): run `lc usage` (add `optimize` for savings analysis, `optimize detail` for the per-operation breakdown) and relay it. No shell (chat-only host): call the broker by exact name — it returns a markdown panel, relay it verbatim: +5. **"what are my savings?" / cost questions** — shell available (Claude Code, Codex CLI): run `lc usage` (add `optimize` for savings analysis, `optimize detail` for the per-operation breakdown) and relay it. No shell (chat-only host): call the `statusline_segment` tool by exact name, not through the `tool` broker (which refuses it) — it returns a markdown panel, relay it verbatim: ```json - {"name": "tool", "arguments": {"action": "call", "name": "statusline_segment", "arguments": {"format": "markdown"}}} + {"name": "statusline_segment", "arguments": {"format": "markdown"}} ``` `format` accepts `markdown` (chat panel), `json` (raw report), `segment` (one statusline frame). Never recompute or restate the numbers. diff --git a/integrations/skills/lemoncrow/SKILL.md b/integrations/skills/lemoncrow/SKILL.md index b9d1e6a70..6a949c61e 100644 --- a/integrations/skills/lemoncrow/SKILL.md +++ b/integrations/skills/lemoncrow/SKILL.md @@ -53,10 +53,10 @@ Global scope by default. Add `--workspace ` only if the user names a specif Unknown key → run `lc settings show`, relay the valid keys. -5. **"what are my savings?" / cost questions** — shell available (Claude Code, Codex CLI): run `lc usage` (add `optimize` for savings analysis, `optimize detail` for the per-operation breakdown) and relay it. No shell (chat-only host): call the broker by exact name — it returns a markdown panel, relay it verbatim: +5. **"what are my savings?" / cost questions** — shell available (Claude Code, Codex CLI): run `lc usage` (add `optimize` for savings analysis, `optimize detail` for the per-operation breakdown) and relay it. No shell (chat-only host): call the `statusline_segment` tool by exact name, not through the `tool` broker (which refuses it) — it returns a markdown panel, relay it verbatim: ```json - {"name": "tool", "arguments": {"action": "call", "name": "statusline_segment", "arguments": {"format": "markdown"}}} + {"name": "statusline_segment", "arguments": {"format": "markdown"}} ``` `format` accepts `markdown` (chat panel), `json` (raw report), `segment` (one statusline frame). Never recompute or restate the numbers. diff --git a/src/lemoncrow/gateway/adapters/mcp/broker_policy.py b/src/lemoncrow/gateway/adapters/mcp/broker_policy.py new file mode 100644 index 000000000..4f2a02b12 --- /dev/null +++ b/src/lemoncrow/gateway/adapters/mcp/broker_policy.py @@ -0,0 +1,82 @@ +"""What the MCP ``tool`` broker may run (PRD-739 FR4). Fork-only. + +The broker calls a registered tool by exact name, and the review agents that +hold it read diffs written by PR authors. So it runs only tools that read: +anything not on :data:`BROKER_READ_ONLY` is refused, and a tool registered later +stays unreachable until it is classified (``test_cap_tools_list_gate`` fails +until it is). + +Refused because the code shows a write, execute or network path: + +* ``scan`` runs the ast-grep binary in a subprocess. +* ``context`` records the task on the session ledger. +* ``statusline_segment`` rewrites the session's statusline sidecar in its + default ``segment`` format; ``markdown`` and ``json`` fold unfolded session + ledgers into the persisted savings aggregate. The lemoncrow skill calls it + directly by name instead. +* ``search`` stores each query's results in the workspace search cache + (``smart_state.json``). +* ``graph kind=index_docs`` writes the design-doc store; ``recall_docs`` embeds + its query through the configured embedder (the OpenAI one posts to the + network) and creates the store schema on connect; ``pr_risk`` folds each + changed file into the machine-wide semantic file index. ``enable`` only + switches ``index_docs`` indexing on, and is refused outright. + +Not counted as writes: the code-intel engine building and syncing the workspace +index, and telemetry, which every engine-backed read triggers on its direct +route too. ``read`` also folds the file it reads into the semantic file index; +it stays because it is advertised, so the broker adds no route to that write. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +BROKER_READ_ONLY: frozenset[str] = frozenset( + { + "blame", + "code_changes", + "code_coverage_check", + "code_query", + "code_search", + "graph", + "grep", + "orient", + "read", + "relations", + } +) + +# `graph` runs only these kinds, which read the index or git history. +GRAPH_READ_ONLY_KINDS: frozenset[str] = frozenset( + { + "blast_radius", + "centrality", + "commit_provenance", + "coupling", + "cycles", + "dead_code", + "design_gaps", + "topology", + "verify_design", + } +) + +_ALTERNATIVES = "Read-only alternatives: read, code_search, relations, code_query." + + +def broker_refusal(name: str, arguments: Mapping[str, Any]) -> str | None: + """Why the broker must not run *name* with *arguments*; ``None`` when it may.""" + if name not in BROKER_READ_ONLY: + return f"{name!r} is not reachable through the broker, which runs read-only tools only. {_ALTERNATIVES}" + if name == "graph": + if "enable" in arguments: + return f"graph `enable` is not reachable through the broker: it switches on indexing. {_ALTERNATIVES}" + kind = arguments.get("kind", "blast_radius") + if not isinstance(kind, str) or kind not in GRAPH_READ_ONLY_KINDS: + return ( + f"graph kind={kind!r} is not reachable through the broker, which runs only the kinds " + f"{', '.join(sorted(GRAPH_READ_ONLY_KINDS))}. {_ALTERNATIVES}" + ) + return None diff --git a/src/lemoncrow/gateway/adapters/mcp_server.py b/src/lemoncrow/gateway/adapters/mcp_server.py index 4eceade4e..06348cd29 100644 --- a/src/lemoncrow/gateway/adapters/mcp_server.py +++ b/src/lemoncrow/gateway/adapters/mcp_server.py @@ -80,6 +80,8 @@ from lemoncrow.gateway.adapters.mcp.bash import ( tool_bash as tool_bash, ) +from lemoncrow.gateway.adapters.mcp.broker_policy import BROKER_READ_ONLY as _BROKER_READ_ONLY +from lemoncrow.gateway.adapters.mcp.broker_policy import broker_refusal as _broker_refusal from lemoncrow.gateway.adapters.mcp.deferral import ( # noqa: F401 (re-exported for back-compat) _defer_bash_enabled, _defer_web_fetch_enabled, @@ -325,7 +327,7 @@ def _warm_pricing_table() -> None: } ) -# Tools the shadowed ``core/environment.py`` hides that we advertise anyway. +# Tools upstream's ``core/environment.py`` hides that the fork advertises anyway. # # `relations` is the only *enumerative* symbol tool. Hidden, an agent under the # core profile sees exactly one code-intel tool -- `code_search` -- which ranks; @@ -337,8 +339,9 @@ def _warm_pricing_table() -> None: # symbol, not a diff -- it would have to make the edit first and then ask what # broke, which is backwards. # -# HIDDEN_LLM_TOOLS lives in a module that resolves from a compiled `.so` when an -# engine is vendored, so the override belongs here, in live source. +# Fork policy differs from upstream's HIDDEN_LLM_TOOLS. The override lives here, +# in live source, rather than as an edit to core/environment.py, so upstream's +# changes to that set do not conflict on every merge. _FORCE_VISIBLE_TOOLS: frozenset[str] = frozenset({"relations"}) # --------------------------------------------------------------------------- # @@ -10132,10 +10135,10 @@ def tool_statusline_segment(format: str = "segment") -> str: that render chat markdown and have no shell to run the CLI. - ``format="json"``: the raw savings report payload, JSON-encoded. - Hidden from tools/list (see HIDDEN_LLM_TOOLS) but reachable by exact name - through the `tool` broker (anything not currently advertised is, unless it - is in _BROKER_DENIED), which is how the lemoncrow skill answers "what are - my savings?" without a shell. + Hidden from tools/list (see HIDDEN_LLM_TOOLS) but callable by exact name, + which is how the lemoncrow skill answers "what are my savings?" without a + shell. The `tool` broker refuses it: every format writes (the sidecar, or the + savings aggregate). """ fmt = (format or "segment").strip().lower() if fmt in {"markdown", "md", "json"}: @@ -11374,28 +11377,26 @@ def tool_compact( "search for those. Use search once for a rare capability, then call its exact name." ) -# Tools the broker must never reach, whatever the advertised surface says. -# `tool` itself would recurse; the other four spawn subagents, proxy arbitrary -# external servers, run arbitrary SQL, or rewrite the tree en masse -- reaching -# any of those through a generic escape hatch is not a fallback, it is a -# footgun. Everything else that is merely unadvertised is fair game: the broker -# exists precisely so a hidden tool is still reachable by exact name. -_BROKER_DENIED = frozenset({"agent", "codemod", "mcp", "sql", "tool", "workflow"}) +# The broker reaches read-only tools only (PRD-739 FR4). Agents that hold it read +# diffs written by PR authors, so a shell, a writer, an outbound fetch or another +# agent must not be one call away. _BROKER_READ_ONLY and the per-call refusal +# (graph is allowed kind by kind) live in the fork-only broker_policy module. def _broker_reachable(tool_name: str, spec: dict[str, Any]) -> bool: """True when the broker may search for, and call, *tool_name*. - The guard is "is it advertised right now", not "is it in the core profile". - The two are not the same question, and conflating them is what limited this - broker to a single reachable tool: `relations` and `grep` are in both - ``_CORE_MCP_TOOLS`` and ``HIDDEN_LLM_TOOLS``, so the old code refused them - as "already exposed" while nothing ever advertised them. + Only allow-listed (read-only) tools qualify. Among those, the guard is "is it + advertised right now", not "is it in the core profile". The two are not the + same question, and conflating them is what limited this broker to a single + reachable tool: `relations` and `grep` are in both ``_CORE_MCP_TOOLS`` and + ``HIDDEN_LLM_TOOLS``, so the old code refused them as "already exposed" + while nothing ever advertised them. search and call share this predicate on purpose -- search must never return a tool that call would then refuse. """ - if tool_name in _BROKER_DENIED: + if tool_name not in _BROKER_READ_ONLY: return False return not _tool_advertised_now(tool_name, spec) @@ -11427,16 +11428,18 @@ def _tool_broker_handler(args: dict[str, Any]) -> dict[str, Any] | Any: target = str(args.get("name") or "").strip() if not target: raise _ToolArgumentError("tool call requires an exact name") - # Deny first: the answer must not depend on whether the name happens to - # be registered. `tool` itself is not in TOOLS at all. - if target in _BROKER_DENIED: - raise _ToolArgumentError(f"{target!r} is not reachable through the broker") - call_spec = TOOLS.get(target) - if call_spec is None: + # An unregistered name is a typo. A registered name off the read-only + # allow-list -- or `tool` itself, which is not in TOOLS -- is refused + # before anything runs, with read-only alternatives in the message. + if target not in TOOLS and target != "tool": raise _ToolArgumentError(f"unknown tool: {target}") arguments = args.get("arguments") or {} if not isinstance(arguments, dict): raise _ToolArgumentError("tool arguments must be an object") + refusal = _broker_refusal(target, arguments) + if refusal is not None: + raise _ToolArgumentError(refusal) + call_spec = TOOLS[target] handler = cast(Callable[[dict[str, Any]], Any], call_spec["handler"]) result = handler(arguments) # An advertised tool used to be refused here as "call it directly" -- diff --git a/src/lemoncrow/infra/code_intel/change_impact.py b/src/lemoncrow/infra/code_intel/change_impact.py index 3b1a6acc8..faae7b210 100644 --- a/src/lemoncrow/infra/code_intel/change_impact.py +++ b/src/lemoncrow/infra/code_intel/change_impact.py @@ -75,7 +75,7 @@ class GitUnavailable(RuntimeError): - """*repo_root* is not a git worktree, or git could not be run there.""" + """*repo_root* is not a git worktree, git could not be run there, or the ref is unusable.""" @dataclass(frozen=True) @@ -258,7 +258,7 @@ def _diff_ref(repo_root: Path, base_ref: str) -> str: independently. Falls back to *base_ref* itself when there is no common ancestor (unrelated histories, or a ref that is not a commit). """ - merge_base = _git(repo_root, "merge-base", base_ref, "HEAD") + merge_base = _git(repo_root, "merge-base", "--end-of-options", base_ref, "HEAD") if merge_base.returncode == 0 and merge_base.stdout.strip(): return merge_base.stdout.strip() return base_ref @@ -331,13 +331,21 @@ def flush() -> None: def collect_changes( repo_root: Path, base_ref: str = "HEAD", paths: list[str] | None = None ) -> tuple[str, list[FileChange]]: - """Run the diff and parse it. Returns ``(diff_ref, changes)``.""" + """Run the diff and parse it. Returns ``(diff_ref, changes)``. + + *base_ref* is caller-supplied, so one that starts with ``-`` is refused + before git sees it: git would parse it as an option (``--output=`` + makes ``git diff`` write anywhere). ``--end-of-options`` backs that up on + every git call that takes the ref. + """ + if base_ref.startswith("-"): + raise GitUnavailable(f"base_ref {base_ref!r} is not a ref: it starts with '-'") if not (repo_root / ".git").exists(): probe = _git(repo_root, "rev-parse", "--git-dir") if probe.returncode != 0: raise GitUnavailable(f"{repo_root} is not a git worktree") diff_ref = _diff_ref(repo_root, base_ref) - args = ["diff", "--unified=0", "--no-color", "--find-renames", diff_ref] + args = ["diff", "--unified=0", "--no-color", "--find-renames", "--end-of-options", diff_ref] if paths: args.extend(["--", *paths]) result = _git(repo_root, *args) diff --git a/tests/gateway/test_cap_tools_list_gate.py b/tests/gateway/test_cap_tools_list_gate.py index bcb4d59bc..2337f1fd1 100644 --- a/tests/gateway/test_cap_tools_list_gate.py +++ b/tests/gateway/test_cap_tools_list_gate.py @@ -121,9 +121,11 @@ def _stub_handler(monkeypatch: pytest.MonkeyPatch, name: str) -> None: monkeypatch.setitem(mcp_server.TOOLS, name, spec) -@pytest.mark.parametrize("name", ["graph", "blame"]) +@pytest.mark.parametrize("name", ["blame", "graph", "grep", "orient"]) def test_broker_calls_tools_that_are_hidden_under_the_core_profile(monkeypatch: pytest.MonkeyPatch, name: str) -> None: - """A tool hidden from tools/list must still be reachable through the broker. + """A read-only tool hidden from tools/list must still be reachable through the broker. + + The parametrization is every allow-listed tool the core profile hides. The old guard refused a tool as "already exposed" whenever it sat in _CORE_MCP_TOOLS, even when HIDDEN_LLM_TOOLS meant nothing ever advertised @@ -192,8 +194,11 @@ def test_advertised_relations_is_not_also_broker_reachable(monkeypatch: pytest.M def test_broker_search_returns_hidden_tools(monkeypatch: pytest.MonkeyPatch) -> None: """`search` used to filter to *visible* tools, so it could never match.""" monkeypatch.setenv("LEMONCROW_MCP_TOOL_PROFILE", "core") + from lemoncrow.gateway.adapters import mcp_server + found = {match["name"] for match in _broker({"action": "search", "query": ""})["matches"]} assert found + assert found <= mcp_server._BROKER_READ_ONLY assert "blame" in {match["name"] for match in _broker({"action": "search", "query": "blame"})["matches"]} @@ -216,13 +221,13 @@ def test_broker_runs_an_already_advertised_tool_and_points_at_the_direct_route( and the reviewer that hit this had to hand-drive raw JSON-RPC. """ monkeypatch.setenv("LEMONCROW_MCP_TOOL_PROFILE", "core") - assert "read" in {tool["name"] for tool in _list()} - _stub_handler(monkeypatch, "read") + assert "relations" in {tool["name"] for tool in _list()} + _stub_handler(monkeypatch, "relations") - result = _broker({"action": "call", "name": "read", "arguments": {"files": ["x.py"]}}) + result = _broker({"action": "call", "name": "relations", "arguments": {"symbol": "merge"}}) - assert result["called"] == "read" - assert result["args"] == {"files": ["x.py"]} + assert result["called"] == "relations" + assert result["args"] == {"symbol": "merge"} assert "reconnect" in result["broker_note"] @@ -235,15 +240,73 @@ def test_broker_note_is_absent_when_the_tool_is_genuinely_hidden(monkeypatch: py assert "broker_note" not in _broker({"action": "call", "name": "blame", "arguments": {}}) -@pytest.mark.parametrize("name", sorted({"agent", "codemod", "mcp", "sql", "tool", "workflow"})) -def test_broker_deny_list_holds(monkeypatch: pytest.MonkeyPatch, name: str) -> None: - """Denied tools are refused by `call` AND absent from `search`. +# --------------------------------------------------------------------------- # +# PRD-739 FR4 -- the broker reaches read-only tools only # +# --------------------------------------------------------------------------- # + +# Every registered tool the broker refuses (PLN-2027 PR 3). The broker reads only +# its allow-list; these sets exist so that a new tool fails +# test_every_registered_tool_is_classified until someone decides where it goes. +# Each one executes commands, writes files or LemonCrow state (index, cache, +# memory, session, review data), reaches the network, or calls other tools. +_BROKER_DENIED = frozenset( + { + "agent", + "bash", + "cache", + "codemod", + "compact", + "edit", + "index", + "mcp", + "memory", + "rescue", + "review_evidence", + "review_feedback_addressed", + "review_rationale", + "search", + "sql", + "statusline_segment", + "tool", + "trace", + "verify", + "web_fetch", + "workflow", + } +) +# Denied until shown read-only, and the code shows otherwise: `scan` runs the +# ast-grep binary, `context` records the task on the session ledger. +_BROKER_DENIED_UNTIL_SHOWN_READ_ONLY = frozenset({"context", "scan"}) +_GRAPH_KINDS_REFUSED = frozenset({"index_docs", "pr_risk", "recall_docs"}) + + +def test_every_registered_tool_is_classified() -> None: + """Each registered tool sits in exactly one class, so a new one fails here until classified.""" + from lemoncrow.gateway.adapters import mcp_server + from lemoncrow.gateway.adapters.mcp.broker_policy import GRAPH_READ_ONLY_KINDS + + classes = (mcp_server._BROKER_READ_ONLY, _BROKER_DENIED, _BROKER_DENIED_UNTIL_SHOWN_READ_ONLY) + registered = set(mcp_server.TOOLS) | {"tool"} # `tool` is the broker itself, not a TOOLS key + assert sorted(name for name in registered if sum(name in cls for cls in classes) != 1) == [] + assert set().union(*classes) == registered + # `graph` is allowed kind by kind, and every kind is classified too. + assert GRAPH_READ_ONLY_KINDS | _GRAPH_KINDS_REFUSED == mcp_server._GRAPH_KINDS + assert not GRAPH_READ_ONLY_KINDS & _GRAPH_KINDS_REFUSED + + +@pytest.mark.parametrize("name", sorted(_BROKER_DENIED | _BROKER_DENIED_UNTIL_SHOWN_READ_ONLY)) +def test_broker_refuses_execution_write_and_network_tools(monkeypatch: pytest.MonkeyPatch, name: str) -> None: + """Refused by `call` AND absent from `search`, advertised or not. - search must never surface something call would then refuse. + search must never surface something call would then refuse. The handler is + stubbed, so a broken guard shows up as a call that returned rather than as a + shell that ran. """ from lemoncrow.gateway.adapters import mcp_server monkeypatch.setenv("LEMONCROW_MCP_TOOL_PROFILE", "core") + if name in mcp_server.TOOLS: + _stub_handler(monkeypatch, name) with pytest.raises(mcp_server._ToolArgumentError, match="not reachable through the broker"): _broker({"action": "call", "name": name, "arguments": {}}) @@ -251,6 +314,56 @@ def test_broker_deny_list_holds(monkeypatch: pytest.MonkeyPatch, name: str) -> N assert name not in found +@pytest.mark.parametrize( + "arguments", + [ + {"kind": "index_docs"}, + {"kind": "recall_docs", "query": "design"}, + {"kind": "pr_risk", "paths": ["a.py"]}, + {"kind": "dead_code", "enable": True}, + ], + ids=["index_docs", "recall_docs", "pr_risk", "enable"], +) +def test_broker_refuses_graph_write_kinds(monkeypatch: pytest.MonkeyPatch, arguments: dict) -> None: + """`graph` runs through the broker for the kinds that only read; the rest are refused by kind.""" + from lemoncrow.gateway.adapters import mcp_server + + monkeypatch.setenv("LEMONCROW_MCP_TOOL_PROFILE", "core") + _stub_handler(monkeypatch, "graph") + with pytest.raises(mcp_server._ToolArgumentError, match="not reachable through the broker"): + _broker({"action": "call", "name": "graph", "arguments": arguments}) + + # Per kind, not per tool: a read-only kind still runs. + assert _broker({"action": "call", "name": "graph", "arguments": {"kind": "dead_code"}})["called"] == "graph" + + +def test_broker_refusal_names_read_only_alternatives(monkeypatch: pytest.MonkeyPatch) -> None: + """Over JSON-RPC, a refusal is an argument error that says where to go instead; the session carries on.""" + from lemoncrow.gateway.adapters import mcp_server + + monkeypatch.setenv("LEMONCROW_MCP_TOOL_PROFILE", "core") + _stub_handler(monkeypatch, "bash") + + def call_broker(request_id: int, arguments: dict) -> dict: + response = mcp_server._handle( + { + "jsonrpc": "2.0", + "id": request_id, + "method": "tools/call", + "params": {"name": "tool", "arguments": arguments}, + } + ) + assert isinstance(response, dict) + return response + + refused = call_broker(1, {"action": "call", "name": "bash", "arguments": {"command": "true"}}) + assert "Read-only alternatives: read, code_search, relations, code_query." in refused["error"]["message"] + + followup = call_broker(2, {"action": "search", "query": "blame"}) + assert "error" not in followup + assert "blame" in str(followup["result"]) + + def test_broker_rejects_an_unregistered_name(monkeypatch: pytest.MonkeyPatch) -> None: from lemoncrow.gateway.adapters import mcp_server diff --git a/tests/infra/code_intel/test_change_impact.py b/tests/infra/code_intel/test_change_impact.py index 3efd5249e..79418cecb 100644 --- a/tests/infra/code_intel/test_change_impact.py +++ b/tests/infra/code_intel/test_change_impact.py @@ -183,6 +183,17 @@ def test_non_git_directory_is_an_error_not_an_empty_report(tmp_path: Path) -> No analyze_changes(repo_root=tmp_path) +def test_option_shaped_base_ref_is_refused_and_writes_nothing(tmp_path: Path) -> None: + repo = tmp_path / "repo" + _init_repo(repo, {"a.py": _ALPHA}) + (repo / "a.py").write_text(_ALPHA + "# edited\n", encoding="utf-8") + target = tmp_path / "written_by_git_diff.txt" + + with pytest.raises(GitUnavailable, match="starts with '-'"): + analyze_changes(base_ref=f"--output={target}", repo_root=repo) + assert not target.exists() + + def test_edited_body_reports_its_symbol_and_callers( workspace_root: Path, make_workspace: WorkspaceFactory,