Skip to content

Commit 4bf10c5

Browse files
authored
Merge pull request #18 from closedloop-ai/campaign-prd-739-20260915-c3
feat(mcp): tool broker reaches read-only tools only (PLN-2027 PR 3)
2 parents 2c4d1c2 + 52e179d commit 4bf10c5

9 files changed

Lines changed: 266 additions & 49 deletions

File tree

integrations/antigravity/skills/lemoncrow/SKILL.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,10 @@ Global scope by default. Add `--workspace <dir>` only if the user names a specif
5555

5656
Unknown key → run `lc settings show`, relay the valid keys.
5757

58-
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:
58+
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:
5959

6060
```json
61-
{"name": "tool", "arguments": {"action": "call", "name": "statusline_segment", "arguments": {"format": "markdown"}}}
61+
{"name": "statusline_segment", "arguments": {"format": "markdown"}}
6262
```
6363

6464
`format` accepts `markdown` (chat panel), `json` (raw report), `segment` (one statusline frame). Never recompute or restate the numbers.

integrations/claude/plugin/skills/lemoncrow/SKILL.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,10 @@ Global scope by default. Add `--workspace <dir>` only if the user names a specif
5555

5656
Unknown key → run `lc settings show`, relay the valid keys.
5757

58-
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:
58+
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:
5959

6060
```json
61-
{"name": "tool", "arguments": {"action": "call", "name": "statusline_segment", "arguments": {"format": "markdown"}}}
61+
{"name": "statusline_segment", "arguments": {"format": "markdown"}}
6262
```
6363

6464
`format` accepts `markdown` (chat panel), `json` (raw report), `segment` (one statusline frame). Never recompute or restate the numbers.

integrations/codex/plugin/skills/lemoncrow/SKILL.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,10 @@ Global scope by default. Add `--workspace <dir>` only if the user names a specif
5555

5656
Unknown key → run `lc settings show`, relay the valid keys.
5757

58-
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:
58+
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:
5959

6060
```json
61-
{"name": "tool", "arguments": {"action": "call", "name": "statusline_segment", "arguments": {"format": "markdown"}}}
61+
{"name": "statusline_segment", "arguments": {"format": "markdown"}}
6262
```
6363

6464
`format` accepts `markdown` (chat panel), `json` (raw report), `segment` (one statusline frame). Never recompute or restate the numbers.

integrations/skills/lemoncrow/SKILL.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,10 @@ Global scope by default. Add `--workspace <dir>` only if the user names a specif
5353

5454
Unknown key → run `lc settings show`, relay the valid keys.
5555

56-
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:
56+
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:
5757

5858
```json
59-
{"name": "tool", "arguments": {"action": "call", "name": "statusline_segment", "arguments": {"format": "markdown"}}}
59+
{"name": "statusline_segment", "arguments": {"format": "markdown"}}
6060
```
6161

6262
`format` accepts `markdown` (chat panel), `json` (raw report), `segment` (one statusline frame). Never recompute or restate the numbers.
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""What the MCP ``tool`` broker may run (PRD-739 FR4). Fork-only.
2+
3+
The broker calls a registered tool by exact name, and the review agents that
4+
hold it read diffs written by PR authors. So it runs only tools that read:
5+
anything not on :data:`BROKER_READ_ONLY` is refused, and a tool registered later
6+
stays unreachable until it is classified (``test_cap_tools_list_gate`` fails
7+
until it is).
8+
9+
Refused because the code shows a write, execute or network path:
10+
11+
* ``scan`` runs the ast-grep binary in a subprocess.
12+
* ``context`` records the task on the session ledger.
13+
* ``statusline_segment`` rewrites the session's statusline sidecar in its
14+
default ``segment`` format; ``markdown`` and ``json`` fold unfolded session
15+
ledgers into the persisted savings aggregate. The lemoncrow skill calls it
16+
directly by name instead.
17+
* ``search`` stores each query's results in the workspace search cache
18+
(``smart_state.json``).
19+
* ``graph kind=index_docs`` writes the design-doc store; ``recall_docs`` embeds
20+
its query through the configured embedder (the OpenAI one posts to the
21+
network) and creates the store schema on connect; ``pr_risk`` folds each
22+
changed file into the machine-wide semantic file index. ``enable`` only
23+
switches ``index_docs`` indexing on, and is refused outright.
24+
25+
Not counted as writes: the code-intel engine building and syncing the workspace
26+
index, and telemetry, which every engine-backed read triggers on its direct
27+
route too. ``read`` also folds the file it reads into the semantic file index;
28+
it stays because it is advertised, so the broker adds no route to that write.
29+
"""
30+
31+
from __future__ import annotations
32+
33+
from collections.abc import Mapping
34+
from typing import Any
35+
36+
BROKER_READ_ONLY: frozenset[str] = frozenset(
37+
{
38+
"blame",
39+
"code_changes",
40+
"code_coverage_check",
41+
"code_query",
42+
"code_search",
43+
"graph",
44+
"grep",
45+
"orient",
46+
"read",
47+
"relations",
48+
}
49+
)
50+
51+
# `graph` runs only these kinds, which read the index or git history.
52+
GRAPH_READ_ONLY_KINDS: frozenset[str] = frozenset(
53+
{
54+
"blast_radius",
55+
"centrality",
56+
"commit_provenance",
57+
"coupling",
58+
"cycles",
59+
"dead_code",
60+
"design_gaps",
61+
"topology",
62+
"verify_design",
63+
}
64+
)
65+
66+
_ALTERNATIVES = "Read-only alternatives: read, code_search, relations, code_query."
67+
68+
69+
def broker_refusal(name: str, arguments: Mapping[str, Any]) -> str | None:
70+
"""Why the broker must not run *name* with *arguments*; ``None`` when it may."""
71+
if name not in BROKER_READ_ONLY:
72+
return f"{name!r} is not reachable through the broker, which runs read-only tools only. {_ALTERNATIVES}"
73+
if name == "graph":
74+
if "enable" in arguments:
75+
return f"graph `enable` is not reachable through the broker: it switches on indexing. {_ALTERNATIVES}"
76+
kind = arguments.get("kind", "blast_radius")
77+
if not isinstance(kind, str) or kind not in GRAPH_READ_ONLY_KINDS:
78+
return (
79+
f"graph kind={kind!r} is not reachable through the broker, which runs only the kinds "
80+
f"{', '.join(sorted(GRAPH_READ_ONLY_KINDS))}. {_ALTERNATIVES}"
81+
)
82+
return None

src/lemoncrow/gateway/adapters/mcp_server.py

Lines changed: 29 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,8 @@
8080
from lemoncrow.gateway.adapters.mcp.bash import (
8181
tool_bash as tool_bash,
8282
)
83+
from lemoncrow.gateway.adapters.mcp.broker_policy import BROKER_READ_ONLY as _BROKER_READ_ONLY
84+
from lemoncrow.gateway.adapters.mcp.broker_policy import broker_refusal as _broker_refusal
8385
from lemoncrow.gateway.adapters.mcp.deferral import ( # noqa: F401 (re-exported for back-compat)
8486
_defer_bash_enabled,
8587
_defer_web_fetch_enabled,
@@ -325,7 +327,7 @@ def _warm_pricing_table() -> None:
325327
}
326328
)
327329

328-
# Tools the shadowed ``core/environment.py`` hides that we advertise anyway.
330+
# Tools upstream's ``core/environment.py`` hides that the fork advertises anyway.
329331
#
330332
# `relations` is the only *enumerative* symbol tool. Hidden, an agent under the
331333
# core profile sees exactly one code-intel tool -- `code_search` -- which ranks;
@@ -337,8 +339,9 @@ def _warm_pricing_table() -> None:
337339
# symbol, not a diff -- it would have to make the edit first and then ask what
338340
# broke, which is backwards.
339341
#
340-
# HIDDEN_LLM_TOOLS lives in a module that resolves from a compiled `.so` when an
341-
# engine is vendored, so the override belongs here, in live source.
342+
# Fork policy differs from upstream's HIDDEN_LLM_TOOLS. The override lives here,
343+
# in live source, rather than as an edit to core/environment.py, so upstream's
344+
# changes to that set do not conflict on every merge.
342345
_FORCE_VISIBLE_TOOLS: frozenset[str] = frozenset({"relations"})
343346

344347
# --------------------------------------------------------------------------- #
@@ -10132,10 +10135,10 @@ def tool_statusline_segment(format: str = "segment") -> str:
1013210135
that render chat markdown and have no shell to run the CLI.
1013310136
- ``format="json"``: the raw savings report payload, JSON-encoded.
1013410137

10135-
Hidden from tools/list (see HIDDEN_LLM_TOOLS) but reachable by exact name
10136-
through the `tool` broker (anything not currently advertised is, unless it
10137-
is in _BROKER_DENIED), which is how the lemoncrow skill answers "what are
10138-
my savings?" without a shell.
10138+
Hidden from tools/list (see HIDDEN_LLM_TOOLS) but callable by exact name,
10139+
which is how the lemoncrow skill answers "what are my savings?" without a
10140+
shell. The `tool` broker refuses it: every format writes (the sidecar, or the
10141+
savings aggregate).
1013910142
"""
1014010143
fmt = (format or "segment").strip().lower()
1014110144
if fmt in {"markdown", "md", "json"}:
@@ -11374,28 +11377,26 @@ def tool_compact(
1137411377
"search for those. Use search once for a rare capability, then call its exact name."
1137511378
)
1137611379

11377-
# Tools the broker must never reach, whatever the advertised surface says.
11378-
# `tool` itself would recurse; the other four spawn subagents, proxy arbitrary
11379-
# external servers, run arbitrary SQL, or rewrite the tree en masse -- reaching
11380-
# any of those through a generic escape hatch is not a fallback, it is a
11381-
# footgun. Everything else that is merely unadvertised is fair game: the broker
11382-
# exists precisely so a hidden tool is still reachable by exact name.
11383-
_BROKER_DENIED = frozenset({"agent", "codemod", "mcp", "sql", "tool", "workflow"})
11380+
# The broker reaches read-only tools only (PRD-739 FR4). Agents that hold it read
11381+
# diffs written by PR authors, so a shell, a writer, an outbound fetch or another
11382+
# agent must not be one call away. _BROKER_READ_ONLY and the per-call refusal
11383+
# (graph is allowed kind by kind) live in the fork-only broker_policy module.
1138411384

1138511385

1138611386
def _broker_reachable(tool_name: str, spec: dict[str, Any]) -> bool:
1138711387
"""True when the broker may search for, and call, *tool_name*.
1138811388

11389-
The guard is "is it advertised right now", not "is it in the core profile".
11390-
The two are not the same question, and conflating them is what limited this
11391-
broker to a single reachable tool: `relations` and `grep` are in both
11392-
``_CORE_MCP_TOOLS`` and ``HIDDEN_LLM_TOOLS``, so the old code refused them
11393-
as "already exposed" while nothing ever advertised them.
11389+
Only allow-listed (read-only) tools qualify. Among those, the guard is "is it
11390+
advertised right now", not "is it in the core profile". The two are not the
11391+
same question, and conflating them is what limited this broker to a single
11392+
reachable tool: `relations` and `grep` are in both ``_CORE_MCP_TOOLS`` and
11393+
``HIDDEN_LLM_TOOLS``, so the old code refused them as "already exposed"
11394+
while nothing ever advertised them.
1139411395

1139511396
search and call share this predicate on purpose -- search must never return
1139611397
a tool that call would then refuse.
1139711398
"""
11398-
if tool_name in _BROKER_DENIED:
11399+
if tool_name not in _BROKER_READ_ONLY:
1139911400
return False
1140011401
return not _tool_advertised_now(tool_name, spec)
1140111402

@@ -11427,16 +11428,18 @@ def _tool_broker_handler(args: dict[str, Any]) -> dict[str, Any] | Any:
1142711428
target = str(args.get("name") or "").strip()
1142811429
if not target:
1142911430
raise _ToolArgumentError("tool call requires an exact name")
11430-
# Deny first: the answer must not depend on whether the name happens to
11431-
# be registered. `tool` itself is not in TOOLS at all.
11432-
if target in _BROKER_DENIED:
11433-
raise _ToolArgumentError(f"{target!r} is not reachable through the broker")
11434-
call_spec = TOOLS.get(target)
11435-
if call_spec is None:
11431+
# An unregistered name is a typo. A registered name off the read-only
11432+
# allow-list -- or `tool` itself, which is not in TOOLS -- is refused
11433+
# before anything runs, with read-only alternatives in the message.
11434+
if target not in TOOLS and target != "tool":
1143611435
raise _ToolArgumentError(f"unknown tool: {target}")
1143711436
arguments = args.get("arguments") or {}
1143811437
if not isinstance(arguments, dict):
1143911438
raise _ToolArgumentError("tool arguments must be an object")
11439+
refusal = _broker_refusal(target, arguments)
11440+
if refusal is not None:
11441+
raise _ToolArgumentError(refusal)
11442+
call_spec = TOOLS[target]
1144011443
handler = cast(Callable[[dict[str, Any]], Any], call_spec["handler"])
1144111444
result = handler(arguments)
1144211445
# An advertised tool used to be refused here as "call it directly" --

src/lemoncrow/infra/code_intel/change_impact.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@
7575

7676

7777
class GitUnavailable(RuntimeError):
78-
"""*repo_root* is not a git worktree, or git could not be run there."""
78+
"""*repo_root* is not a git worktree, git could not be run there, or the ref is unusable."""
7979

8080

8181
@dataclass(frozen=True)
@@ -258,7 +258,7 @@ def _diff_ref(repo_root: Path, base_ref: str) -> str:
258258
independently. Falls back to *base_ref* itself when there is no common
259259
ancestor (unrelated histories, or a ref that is not a commit).
260260
"""
261-
merge_base = _git(repo_root, "merge-base", base_ref, "HEAD")
261+
merge_base = _git(repo_root, "merge-base", "--end-of-options", base_ref, "HEAD")
262262
if merge_base.returncode == 0 and merge_base.stdout.strip():
263263
return merge_base.stdout.strip()
264264
return base_ref
@@ -331,13 +331,21 @@ def flush() -> None:
331331
def collect_changes(
332332
repo_root: Path, base_ref: str = "HEAD", paths: list[str] | None = None
333333
) -> tuple[str, list[FileChange]]:
334-
"""Run the diff and parse it. Returns ``(diff_ref, changes)``."""
334+
"""Run the diff and parse it. Returns ``(diff_ref, changes)``.
335+
336+
*base_ref* is caller-supplied, so one that starts with ``-`` is refused
337+
before git sees it: git would parse it as an option (``--output=<file>``
338+
makes ``git diff`` write anywhere). ``--end-of-options`` backs that up on
339+
every git call that takes the ref.
340+
"""
341+
if base_ref.startswith("-"):
342+
raise GitUnavailable(f"base_ref {base_ref!r} is not a ref: it starts with '-'")
335343
if not (repo_root / ".git").exists():
336344
probe = _git(repo_root, "rev-parse", "--git-dir")
337345
if probe.returncode != 0:
338346
raise GitUnavailable(f"{repo_root} is not a git worktree")
339347
diff_ref = _diff_ref(repo_root, base_ref)
340-
args = ["diff", "--unified=0", "--no-color", "--find-renames", diff_ref]
348+
args = ["diff", "--unified=0", "--no-color", "--find-renames", "--end-of-options", diff_ref]
341349
if paths:
342350
args.extend(["--", *paths])
343351
result = _git(repo_root, *args)

0 commit comments

Comments
 (0)