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,