From 909c2698ad1e7cd7cf9d62516673ccb6bad6a479 Mon Sep 17 00:00:00 2001 From: Antawari Date: Wed, 21 Oct 2026 14:54:03 -0600 Subject: [PATCH 01/10] Pin the tools presence layer to built-in base names Red first. A permission pattern such as Bash(git status) in the SDK tools list makes the CLI drop that tool, so the presence layer must carry base names, without mcp entries, de-duplicated. The approval layer keeps the grant verbatim. The old pin that duplicates reached the presence layer verbatim is rewritten as part of this contract. Co-Authored-By: Claude Opus 5.5 --- tests/unit/test_sdk_backend_tool_presence.py | 73 ++++++++++++++++++-- 1 file changed, 69 insertions(+), 4 deletions(-) diff --git a/tests/unit/test_sdk_backend_tool_presence.py b/tests/unit/test_sdk_backend_tool_presence.py index 00d8f387..7fe9f257 100644 --- a/tests/unit/test_sdk_backend_tool_presence.py +++ b/tests/unit/test_sdk_backend_tool_presence.py @@ -387,19 +387,26 @@ async def test_unicode_tool_names_propagate(self) -> None: assert list(captured["tools"]) == ["读取", "Write"] - async def test_duplicate_tool_names_propagate_verbatim(self) -> None: - """Duplicates in incoming list are NOT deduplicated by sdk_backend.""" + async def test_duplicate_tool_names_dedup_in_presence_kept_in_approval(self) -> None: + """PRESENCE de-duplicates in first-seen order; APPROVAL keeps duplicates.""" + # Old pin (test_duplicate_tool_names_propagate_verbatim): duplicates reached + # tools= verbatim. Changed because tools= now holds de-duplicated base names. captured, FakeOptions = _make_capture() + incoming = ["Read", "Read", "Write", "Read"] with ( patch("bonfire.dispatch.sdk_backend.ClaudeAgentOptions", FakeOptions), patch("bonfire.dispatch.sdk_backend.query", _empty_query), ): backend = ClaudeSDKBackend() - options = DispatchOptions(tools=["Read", "Read", "Write"]) + options = DispatchOptions(tools=incoming) await backend.execute(_envelope(), options=options) - assert list(captured["tools"]) == ["Read", "Read", "Write"] + assert list(captured["tools"]) == ["Read", "Write"], ( + f"tools= must be de-duplicated in first-seen order from {len(incoming)} entries; " + f"got {captured['tools']!r}" + ) + assert list(captured["allowed_tools"]) == incoming async def test_very_large_tool_list_propagates(self) -> None: """100-tool list propagates without truncation.""" @@ -432,6 +439,64 @@ async def test_tool_case_preserved(self) -> None: assert list(captured["tools"]) == ["read", "BASH", "WrItE"] +# =========================================================================== +# 6b. PRESENCE layer holds BUILT-IN BASE names only +# =========================================================================== +# +# The CLI's ``tools=`` flag accepts only built-in tool names. A permission +# pattern such as ``Bash(git status)`` passed there silently DROPS the tool +# (measured against the bundled CLI), and ``mcp__*`` names are not governed by +# that flag at all. The PRESENCE layer therefore derives base names; the +# APPROVAL layer keeps the caller's grant exactly. + + +async def _capture_for(tools: list[str]) -> dict[str, Any]: + """Run one execute() with ``tools`` and return the captured option kwargs.""" + captured, FakeOptions = _make_capture() + with ( + patch("bonfire.dispatch.sdk_backend.ClaudeAgentOptions", FakeOptions), + patch("bonfire.dispatch.sdk_backend.query", _empty_query), + ): + backend = ClaudeSDKBackend() + await backend.execute(_envelope(), options=DispatchOptions(tools=tools)) + return captured + + +class TestPresenceLayerBaseNames: + """``tools=`` = de-duplicated built-in base names; ``allowed_tools=`` = verbatim grant.""" + + async def test_permission_patterns_reduce_to_base_names(self) -> None: + """``Bash(...)`` patterns become ``Bash`` once; approval keeps all patterns.""" + grant = ["Read", "Bash(git diff:*)", "Bash(pytest:*)", "Edit"] + captured = await _capture_for(list(grant)) + + assert list(captured["tools"]) == ["Read", "Bash", "Edit"], ( + f"from {len(grant)} granted entries expected 3 base names; got {captured['tools']!r}" + ) + assert list(captured["allowed_tools"]) == grant + + async def test_mcp_entries_excluded_from_presence_kept_in_approval(self) -> None: + """``mcp__*`` names are not built-ins: absent from ``tools=``, kept in approval.""" + grant = ["Read", "mcp__lexicon__memory_read"] + captured = await _capture_for(list(grant)) + + assert list(captured["tools"]) == ["Read"], ( + f"from {len(grant)} granted entries expected only 'Read'; got {captured['tools']!r}" + ) + assert list(captured["allowed_tools"]) == grant + + async def test_pattern_only_custom_policy_grant_keeps_the_tool_present(self) -> None: + """A policy granting only a pattern must not silently empty the PRESENCE layer.""" + grant = ["Bash(git status)"] + captured = await _capture_for(list(grant)) + + assert list(captured["tools"]) == ["Bash"], ( + f"pattern-only grant of {len(grant)} entry must expose 'Bash', " + f"not drop it; got {captured['tools']!r}" + ) + assert list(captured["allowed_tools"]) == grant + + # =========================================================================== # 7. ``options.role`` NOT consumed by sdk_backend in BON-337 (D7 footer) # =========================================================================== From b7d31b349558b0c0c63c12856ccf8a030e8fd726 Mon Sep 17 00:00:00 2001 From: Antawari Date: Wed, 21 Oct 2026 15:04:07 -0600 Subject: [PATCH 02/10] Give the SDK presence list built-in base names only A permission pattern such as Bash(git status) in the presence list made the bundled CLI drop that tool. The presence list is now derived from the grant: base names before the first parenthesis, no mcp entries, no empty names, de-duplicated in first-seen order. The approval list keeps the grant verbatim. The same derivation lives in the private lineage of this module; the two change together. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 11 ++++++++++ src/bonfire/dispatch/sdk_backend.py | 34 +++++++++++++++++++++++++++-- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a96efc81..6d9c8778 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,6 +80,17 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and exits non-zero when the config cannot be read. - Configs written by `bonfire scan` on 1.0.1 are migrated on load: a legacy `[bonfire.persona]` table is relocated to `bonfire.profile`. +- The SDK backend no longer passes a custom `ToolPolicy` grant verbatim + as the presence list (`tools=`). The presence list now carries base + built-in names (`Bash(git status)` becomes `Bash`, `mcp__*` entries + are left out, an entry with no name before `(` is left out), + de-duplicated in first-seen order, while the approval list + (`allowed_tools=`) keeps the grant verbatim. Why: a permission + pattern in the presence list was observed, in a manual check against + the bundled CLI, to remove that tool from the agent. That CLI + behaviour is not exercised by this repo's test suite; the unit tests + pin the derivation only. This is a behaviour change: duplicate names + in a grant no longer reach the presence list. ### Changed diff --git a/src/bonfire/dispatch/sdk_backend.py b/src/bonfire/dispatch/sdk_backend.py index 505e7930..f0794407 100644 --- a/src/bonfire/dispatch/sdk_backend.py +++ b/src/bonfire/dispatch/sdk_backend.py @@ -26,7 +26,7 @@ from bonfire.models.envelope import Envelope, ErrorDetail if TYPE_CHECKING: - from collections.abc import AsyncIterator, Callable + from collections.abc import AsyncIterator, Callable, Sequence from bonfire.events.bus import EventBus from bonfire.protocols import DispatchOptions @@ -144,6 +144,36 @@ def _resolve_setting_sources(cwd: str | None) -> list[str]: return [] +# Mirror: the private v1 lineage of this module (same path, src/bonfire/dispatch/sdk_backend.py) +# carries the identical derivation; change both together. +def _builtin_tools(declared: Sequence[str]) -> list[str]: + """Derive the PRESENCE-layer tool list (``tools=``) from a declared grant. + + Contract: each entry reduces to its base name, the text before the + first ``(`` (``"Bash(pytest:*)"`` -> ``"Bash"``); entries starting with + ``mcp__`` are skipped; an entry whose base is empty (``""`` or + ``"(x)"``) is skipped, so no empty name reaches the presence list; + duplicates are removed keeping first-seen order. Whitespace and case + are NOT altered, including whitespace before ``(`` (``"Bash (x)"`` -> + ``"Bash "``). Always returns a NEW list (empty in -> ``[]``). + + Why: the CLI's presence list governs built-in tools only, and a + permission pattern placed there was observed in a manual check against + the bundled CLI (not exercised by this repo's unit tests, which pin the + derivation only) to silently drop that tool from the agent. The + APPROVAL layer (``allowed_tools=``) keeps the grant verbatim, so a + skipped entry still reaches the CLI there, unchanged. + """ + result: list[str] = [] + for entry in declared: + if entry.startswith("mcp__"): + continue + base = entry.split("(", 1)[0] + if base and base not in result: + result.append(base) + return result + + def _map_thinking(depth: str) -> tuple[dict[str, Any], str]: """Map ``thinking_depth`` to SDK thinking config and effort level.""" mapping: dict[str, tuple[dict[str, Any], str]] = { @@ -225,7 +255,7 @@ async def _do_execute( # noqa: C901 max_budget_usd=options.max_budget_usd, cwd=options.cwd or None, permission_mode=options.permission_mode, - tools=list(options.tools), + tools=_builtin_tools(options.tools), allowed_tools=options.tools, hooks=_build_security_hooks_dict( options.security_hooks, From 25e4da730cdc0d49061e54192e33b90cca43ad98 Mon Sep 17 00:00:00 2001 From: Antawari Date: Wed, 21 Oct 2026 15:09:06 -0600 Subject: [PATCH 03/10] Give the presence-list derivation its own test module The base-name derivation is its own contract, so its tests move out of the tool-presence module unchanged, which brings that module back under the 500-line cap. The helper's docstring is shortened to the contract and the reason. Co-Authored-By: Claude Opus 5.5 --- src/bonfire/dispatch/sdk_backend.py | 24 +--- .../test_sdk_backend_presence_base_names.py | 122 ++++++++++++++++++ tests/unit/test_sdk_backend_tool_presence.py | 81 +----------- 3 files changed, 129 insertions(+), 98 deletions(-) create mode 100644 tests/unit/test_sdk_backend_presence_base_names.py diff --git a/src/bonfire/dispatch/sdk_backend.py b/src/bonfire/dispatch/sdk_backend.py index f0794407..cd324cab 100644 --- a/src/bonfire/dispatch/sdk_backend.py +++ b/src/bonfire/dispatch/sdk_backend.py @@ -144,25 +144,13 @@ def _resolve_setting_sources(cwd: str | None) -> list[str]: return [] -# Mirror: the private v1 lineage of this module (same path, src/bonfire/dispatch/sdk_backend.py) -# carries the identical derivation; change both together. +# Mirror: the private v1 lineage keeps this derivation at the same path; change both together. def _builtin_tools(declared: Sequence[str]) -> list[str]: - """Derive the PRESENCE-layer tool list (``tools=``) from a declared grant. - - Contract: each entry reduces to its base name, the text before the - first ``(`` (``"Bash(pytest:*)"`` -> ``"Bash"``); entries starting with - ``mcp__`` are skipped; an entry whose base is empty (``""`` or - ``"(x)"``) is skipped, so no empty name reaches the presence list; - duplicates are removed keeping first-seen order. Whitespace and case - are NOT altered, including whitespace before ``(`` (``"Bash (x)"`` -> - ``"Bash "``). Always returns a NEW list (empty in -> ``[]``). - - Why: the CLI's presence list governs built-in tools only, and a - permission pattern placed there was observed in a manual check against - the bundled CLI (not exercised by this repo's unit tests, which pin the - derivation only) to silently drop that tool from the agent. The - APPROVAL layer (``allowed_tools=``) keeps the grant verbatim, so a - skipped entry still reaches the CLI there, unchanged. + """Derive the PRESENCE-layer ``tools=`` list: base name before the first ``(``. + + Skips ``mcp__`` entries and empty bases, de-duplicates first-seen, returns a + fresh list; whitespace and case are untouched. Why: a permission pattern in the + presence list was seen, in a manual check against the bundled CLI, to drop the tool. """ result: list[str] = [] for entry in declared: diff --git a/tests/unit/test_sdk_backend_presence_base_names.py b/tests/unit/test_sdk_backend_presence_base_names.py new file mode 100644 index 00000000..491fc225 --- /dev/null +++ b/tests/unit/test_sdk_backend_presence_base_names.py @@ -0,0 +1,122 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 BonfireAI + +"""Contract: the SDK backend's PRESENCE list (``tools=``) holds base built-in names. + +PRESENCE = base names (text before the first ``(``), no ``mcp__`` entries, no +empty names, de-duplicated in first-seen order. APPROVAL (``allowed_tools=``) +keeps the caller's grant verbatim. +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import patch + +from bonfire.dispatch.sdk_backend import ClaudeSDKBackend +from bonfire.models.envelope import Envelope +from bonfire.protocols import DispatchOptions + + +def _make_capture() -> tuple[dict[str, Any], type]: + """Return ``(captured_kwargs_dict, FakeClaudeAgentOptions)`` pair.""" + captured: dict[str, Any] = {} + + class _FakeClaudeAgentOptions: + """Mimics SDK's ``ClaudeAgentOptions`` — captures every kwarg.""" + + def __init__(self, **kwargs: Any) -> None: + captured.update(kwargs) + for k, v in kwargs.items(): + setattr(self, k, v) + + return captured, _FakeClaudeAgentOptions + + +async def _empty_query(*, prompt: str = "", options: Any = None): # type: ignore[no-untyped-def] + """Async-gen that yields nothing — closes immediately.""" + if False: # pragma: no cover + yield None + + +def _envelope(agent: str = "warrior-agent") -> Envelope: + return Envelope(task="do work", agent_name=agent, model="claude-opus-4-7") + + +# =========================================================================== +# PRESENCE layer holds BUILT-IN BASE names only +# =========================================================================== +# +# The CLI's ``tools=`` flag accepts only built-in tool names. A permission +# pattern such as ``Bash(git status)`` passed there silently DROPS the tool +# (measured against the bundled CLI), and ``mcp__*`` names are not governed by +# that flag at all. The PRESENCE layer therefore derives base names; the +# APPROVAL layer keeps the caller's grant exactly. + + +async def _capture_for(tools: list[str]) -> dict[str, Any]: + """Run one execute() with ``tools`` and return the captured option kwargs.""" + captured, FakeOptions = _make_capture() + with ( + patch("bonfire.dispatch.sdk_backend.ClaudeAgentOptions", FakeOptions), + patch("bonfire.dispatch.sdk_backend.query", _empty_query), + ): + backend = ClaudeSDKBackend() + await backend.execute(_envelope(), options=DispatchOptions(tools=tools)) + return captured + + +class TestPresenceLayerBaseNames: + """``tools=`` = de-duplicated built-in base names; ``allowed_tools=`` = verbatim grant.""" + + async def test_permission_patterns_reduce_to_base_names(self) -> None: + """``Bash(...)`` patterns become ``Bash`` once; approval keeps all patterns.""" + grant = ["Read", "Bash(git diff:*)", "Bash(pytest:*)", "Edit"] + captured = await _capture_for(list(grant)) + + assert list(captured["tools"]) == ["Read", "Bash", "Edit"], ( + f"from {len(grant)} granted entries expected 3 base names; got {captured['tools']!r}" + ) + assert list(captured["allowed_tools"]) == grant + + async def test_mcp_entries_excluded_from_presence_kept_in_approval(self) -> None: + """``mcp__*`` names are not built-ins: absent from ``tools=``, kept in approval.""" + grant = ["Read", "mcp__lexicon__memory_read"] + captured = await _capture_for(list(grant)) + + assert list(captured["tools"]) == ["Read"], ( + f"from {len(grant)} granted entries expected only 'Read'; got {captured['tools']!r}" + ) + assert list(captured["allowed_tools"]) == grant + + async def test_pattern_only_custom_policy_grant_keeps_the_tool_present(self) -> None: + """A policy granting only a pattern must not silently empty the PRESENCE layer.""" + grant = ["Bash(git status)"] + captured = await _capture_for(list(grant)) + + assert list(captured["tools"]) == ["Bash"], ( + f"pattern-only grant of {len(grant)} entry must expose 'Bash', " + f"not drop it; got {captured['tools']!r}" + ) + assert list(captured["allowed_tools"]) == grant + + async def test_duplicate_tool_names_dedup_in_presence_kept_in_approval(self) -> None: + """PRESENCE de-duplicates in first-seen order; APPROVAL keeps duplicates.""" + # Old pin (test_duplicate_tool_names_propagate_verbatim): duplicates reached + # tools= verbatim. Changed because tools= now holds de-duplicated base names. + captured, FakeOptions = _make_capture() + incoming = ["Read", "Read", "Write", "Read"] + + with ( + patch("bonfire.dispatch.sdk_backend.ClaudeAgentOptions", FakeOptions), + patch("bonfire.dispatch.sdk_backend.query", _empty_query), + ): + backend = ClaudeSDKBackend() + options = DispatchOptions(tools=incoming) + await backend.execute(_envelope(), options=options) + + assert list(captured["tools"]) == ["Read", "Write"], ( + f"tools= must be de-duplicated in first-seen order from {len(incoming)} entries; " + f"got {captured['tools']!r}" + ) + assert list(captured["allowed_tools"]) == incoming diff --git a/tests/unit/test_sdk_backend_tool_presence.py b/tests/unit/test_sdk_backend_tool_presence.py index 7fe9f257..937f44f9 100644 --- a/tests/unit/test_sdk_backend_tool_presence.py +++ b/tests/unit/test_sdk_backend_tool_presence.py @@ -22,7 +22,7 @@ Knight-A adversarial tests elevated to mandatory: fresh-list identity across repeat calls; adversarial tool content - (whitespace, unicode, duplicates, large lists, case preservation); + (whitespace, unicode, large lists, case preservation); existing-kwargs-preserved (model, max_turns, setting_sources, stderr); empty-tools kill-switch semantics. """ @@ -387,27 +387,6 @@ async def test_unicode_tool_names_propagate(self) -> None: assert list(captured["tools"]) == ["读取", "Write"] - async def test_duplicate_tool_names_dedup_in_presence_kept_in_approval(self) -> None: - """PRESENCE de-duplicates in first-seen order; APPROVAL keeps duplicates.""" - # Old pin (test_duplicate_tool_names_propagate_verbatim): duplicates reached - # tools= verbatim. Changed because tools= now holds de-duplicated base names. - captured, FakeOptions = _make_capture() - incoming = ["Read", "Read", "Write", "Read"] - - with ( - patch("bonfire.dispatch.sdk_backend.ClaudeAgentOptions", FakeOptions), - patch("bonfire.dispatch.sdk_backend.query", _empty_query), - ): - backend = ClaudeSDKBackend() - options = DispatchOptions(tools=incoming) - await backend.execute(_envelope(), options=options) - - assert list(captured["tools"]) == ["Read", "Write"], ( - f"tools= must be de-duplicated in first-seen order from {len(incoming)} entries; " - f"got {captured['tools']!r}" - ) - assert list(captured["allowed_tools"]) == incoming - async def test_very_large_tool_list_propagates(self) -> None: """100-tool list propagates without truncation.""" captured, FakeOptions = _make_capture() @@ -439,64 +418,6 @@ async def test_tool_case_preserved(self) -> None: assert list(captured["tools"]) == ["read", "BASH", "WrItE"] -# =========================================================================== -# 6b. PRESENCE layer holds BUILT-IN BASE names only -# =========================================================================== -# -# The CLI's ``tools=`` flag accepts only built-in tool names. A permission -# pattern such as ``Bash(git status)`` passed there silently DROPS the tool -# (measured against the bundled CLI), and ``mcp__*`` names are not governed by -# that flag at all. The PRESENCE layer therefore derives base names; the -# APPROVAL layer keeps the caller's grant exactly. - - -async def _capture_for(tools: list[str]) -> dict[str, Any]: - """Run one execute() with ``tools`` and return the captured option kwargs.""" - captured, FakeOptions = _make_capture() - with ( - patch("bonfire.dispatch.sdk_backend.ClaudeAgentOptions", FakeOptions), - patch("bonfire.dispatch.sdk_backend.query", _empty_query), - ): - backend = ClaudeSDKBackend() - await backend.execute(_envelope(), options=DispatchOptions(tools=tools)) - return captured - - -class TestPresenceLayerBaseNames: - """``tools=`` = de-duplicated built-in base names; ``allowed_tools=`` = verbatim grant.""" - - async def test_permission_patterns_reduce_to_base_names(self) -> None: - """``Bash(...)`` patterns become ``Bash`` once; approval keeps all patterns.""" - grant = ["Read", "Bash(git diff:*)", "Bash(pytest:*)", "Edit"] - captured = await _capture_for(list(grant)) - - assert list(captured["tools"]) == ["Read", "Bash", "Edit"], ( - f"from {len(grant)} granted entries expected 3 base names; got {captured['tools']!r}" - ) - assert list(captured["allowed_tools"]) == grant - - async def test_mcp_entries_excluded_from_presence_kept_in_approval(self) -> None: - """``mcp__*`` names are not built-ins: absent from ``tools=``, kept in approval.""" - grant = ["Read", "mcp__lexicon__memory_read"] - captured = await _capture_for(list(grant)) - - assert list(captured["tools"]) == ["Read"], ( - f"from {len(grant)} granted entries expected only 'Read'; got {captured['tools']!r}" - ) - assert list(captured["allowed_tools"]) == grant - - async def test_pattern_only_custom_policy_grant_keeps_the_tool_present(self) -> None: - """A policy granting only a pattern must not silently empty the PRESENCE layer.""" - grant = ["Bash(git status)"] - captured = await _capture_for(list(grant)) - - assert list(captured["tools"]) == ["Bash"], ( - f"pattern-only grant of {len(grant)} entry must expose 'Bash', " - f"not drop it; got {captured['tools']!r}" - ) - assert list(captured["allowed_tools"]) == grant - - # =========================================================================== # 7. ``options.role`` NOT consumed by sdk_backend in BON-337 (D7 footer) # =========================================================================== From b26ddbff5a3f072904fa5877bb7ad39a045a1749 Mon Sep 17 00:00:00 2001 From: Antawari Date: Wed, 21 Oct 2026 15:10:02 -0600 Subject: [PATCH 04/10] Raise the dispatch and unit-test package budgets for the presence-list fix Two raises to exactly the measured size, each with its reason and the alternatives rejected: 18 lines for the derivation helper in src/bonfire/dispatch and 108 lines for its contract tests in tests/unit. Both packages were frozen at their measurement with no headroom, so no change to either could land without one. Co-Authored-By: Claude Opus 5.5 --- file-budget.json | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/file-budget.json b/file-budget.json index c7b7f348..c83ff323 100644 --- a/file-budget.json +++ b/file-budget.json @@ -145,12 +145,12 @@ } }, "packages": { - "src/bonfire/dispatch": 2883, + "src/bonfire/dispatch": 2901, "src/bonfire/engine": 1981, "src/bonfire/handlers": 3280, "src/bonfire/onboard": 4460, "tests/integration": 1702, - "tests/unit": 74187 + "tests/unit": 74295 }, "package_raises": [ { @@ -248,6 +248,22 @@ "lines": 164, "reason": "The 164 lines are tests/unit/test_sdk_backend_stream_closing.py, the contract for the stream-closing helper above, written and observed failing before the helper existed. It pins the same stream back, closing on early break for generators and for non-generator streams with aclose(), exception propagation, exactly one close on full consumption, pass-through for streams without aclose(), and the backend call site itself (a patched query() on the rate-limit return must be closed exactly once). Three mutants of the helper were killed by it: bare iteration at the call site, gating on the generator class instead of the capability, and a double close. Measured by cf-file-budget on the final tree: 'package tests/unit is 74187 lines (frozen 74023)'. Raised to exactly the measurement, with no headroom.", "approver": "Anta gates via PR merge -- raise surfaced in the PR body and in the lane report, not taken silently" + }, + { + "package": "src/bonfire/dispatch", + "from": 2883, + "to": 2901, + "lines": 18, + "reason": "The 18 lines are _builtin_tools in sdk_backend.py plus its Sequence import: the derivation of the SDK presence list (tools=) from a ToolPolicy grant. The presence list used to be the grant verbatim, and a permission pattern such as Bash(git status) placed there was seen, in a manual check against the bundled CLI, to remove that tool from the agent, so a custom ToolPolicy written with patterns lost its tools silently. The helper has to live at the dispatch layer, beside the one ClaudeAgentOptions call that consumes it; the private lineage keeps the identical derivation at the same path and the two change together. The docstring was cut from 17 lines to 5 before this raise was measured. Explicitly rejected: a purpose entry for sdk_backend.py, which would drop the whole module out of the ratchet to accommodate 18 lines, the same trade the two earlier raises on this package rejected. Measured by cf-file-budget on the final tree: 'package src/bonfire/dispatch is 2901 lines (frozen 2883)'. Raised to exactly the measurement, with no headroom.", + "approver": "Anta gates via PR merge -- raise surfaced in the PR body and in the lane report, not taken silently" + }, + { + "package": "tests/unit", + "from": 74187, + "to": 74295, + "lines": 108, + "reason": "The 108 lines are tests/unit/test_sdk_backend_presence_base_names.py (122 lines), less 14 net lines removed from test_sdk_backend_tool_presence.py when its old verbatim-duplicate pin was replaced. Four tests, three of which failed on origin/main before the helper existed: a pattern grant reduces to its base name while the approval list keeps the pattern, an mcp__ grant stays out of the presence list but in the approval list, and a pattern-only grant keeps the tool present rather than producing an empty list. The fourth replaces a pin whose contract changed on purpose: duplicates are now removed from the presence list and still reach the approval list verbatim. The tests moved into their own module because the derivation is its own contract and the presence module would otherwise have crossed the 500-line cap. Explicitly rejected: an unbudgeted tree such as tests/dispatch, which dodges the ratchet and hides the contract from whoever maintains sdk_backend.py; and a purpose entry for the new file, an exemption in everything but name. Measured by cf-file-budget on the final tree: 'package tests/unit is 74295 lines (frozen 74187)'. Raised to exactly the measurement, with no headroom.", + "approver": "Anta gates via PR merge -- raise surfaced in the PR body and in the lane report, not taken silently" } ] } From 787c9f7014cc32837d82d88279376b90abda5922 Mon Sep 17 00:00:00 2001 From: Antawari Date: Wed, 21 Oct 2026 15:14:51 -0600 Subject: [PATCH 05/10] Re-anchor the persona allowlist after the changelog entry The Fixed entry moved the CHANGELOG lines the persona-name allowlist pins by eleven. The anchors move, the expected text does not, with a breadcrumb as earlier changelog additions left. The tests/unit raise is re-measured to include the breadcrumb. Co-Authored-By: Claude Opus 5.5 --- file-budget.json | 8 ++++---- tests/unit/test_no_persona_names_in_public_docs.py | 11 +++++++---- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/file-budget.json b/file-budget.json index c83ff323..ce793e1c 100644 --- a/file-budget.json +++ b/file-budget.json @@ -150,7 +150,7 @@ "src/bonfire/handlers": 3280, "src/bonfire/onboard": 4460, "tests/integration": 1702, - "tests/unit": 74295 + "tests/unit": 74298 }, "package_raises": [ { @@ -260,9 +260,9 @@ { "package": "tests/unit", "from": 74187, - "to": 74295, - "lines": 108, - "reason": "The 108 lines are tests/unit/test_sdk_backend_presence_base_names.py (122 lines), less 14 net lines removed from test_sdk_backend_tool_presence.py when its old verbatim-duplicate pin was replaced. Four tests, three of which failed on origin/main before the helper existed: a pattern grant reduces to its base name while the approval list keeps the pattern, an mcp__ grant stays out of the presence list but in the approval list, and a pattern-only grant keeps the tool present rather than producing an empty list. The fourth replaces a pin whose contract changed on purpose: duplicates are now removed from the presence list and still reach the approval list verbatim. The tests moved into their own module because the derivation is its own contract and the presence module would otherwise have crossed the 500-line cap. Explicitly rejected: an unbudgeted tree such as tests/dispatch, which dodges the ratchet and hides the contract from whoever maintains sdk_backend.py; and a purpose entry for the new file, an exemption in everything but name. Measured by cf-file-budget on the final tree: 'package tests/unit is 74295 lines (frozen 74187)'. Raised to exactly the measurement, with no headroom.", + "to": 74298, + "lines": 111, + "reason": "The 111 lines are tests/unit/test_sdk_backend_presence_base_names.py (122 lines), less 14 net lines removed from test_sdk_backend_tool_presence.py when its old verbatim-duplicate pin was replaced, plus a 3-line history breadcrumb in test_no_persona_names_in_public_docs.py, whose CHANGELOG line anchors moved by 11 when this rung's Fixed entry was written (anchors only, expected text unchanged, as every earlier CHANGELOG addition did). Four tests, three of which failed on origin/main before the helper existed: a pattern grant reduces to its base name while the approval list keeps the pattern, an mcp__ grant stays out of the presence list but in the approval list, and a pattern-only grant keeps the tool present rather than producing an empty list. The fourth replaces a pin whose contract changed on purpose: duplicates are now removed from the presence list and still reach the approval list verbatim. The tests moved into their own module because the derivation is its own contract and the presence module would otherwise have crossed the 500-line cap. Explicitly rejected: an unbudgeted tree such as tests/dispatch, which dodges the ratchet and hides the contract from whoever maintains sdk_backend.py; and a purpose entry for the new file, an exemption in everything but name. Measured by cf-file-budget on the final tree: 'package tests/unit is 74298 lines (frozen 74187)'. Raised to exactly the measurement, with no headroom.", "approver": "Anta gates via PR merge -- raise surfaced in the PR body and in the lane report, not taken silently" } ] diff --git a/tests/unit/test_no_persona_names_in_public_docs.py b/tests/unit/test_no_persona_names_in_public_docs.py index fbab6e60..b4149773 100644 --- a/tests/unit/test_no_persona_names_in_public_docs.py +++ b/tests/unit/test_no_persona_names_in_public_docs.py @@ -192,24 +192,27 @@ # Doc-truth pass: shifted 615/617/618/635 -> 662/664/665/682 when # the CHANGELOG entries owed by three merged PRs were written # under [Unreleased]. Anchors only; expected text is unchanged. + # Presence-list fix entry: shifted 684/686/687/704 -> 695/697/698/715 + # when a Fixed entry was added under [Unreleased]. Anchors only; + # expected text is unchanged. ( "CHANGELOG.md", - 684, + 695, "predecessor persona (Passelewe, the Chamberlain) was retired; the", ), ( "CHANGELOG.md", - 686, + 697, "`docs/_lore/passelewe.md`. The persona builtins directory", ), ( "CHANGELOG.md", - 687, + 698, "`src/bonfire/persona/builtins/passelewe/` was deleted; a new", ), ( "CHANGELOG.md", - 704, + 715, 'to ban `"passelewe"` in src/ (the predecessor persona is gone, so', ), # --- CLAUDE.md — constellation-pointer breadcrumbs -------------- From 0c28ece7b05600b3ac0931c1b13d9f9a92392504 Mon Sep 17 00:00:00 2001 From: Antawari Date: Wed, 21 Oct 2026 15:18:35 -0600 Subject: [PATCH 06/10] Correct the itemisation in the two package raises Reason text only: what the 18 dispatch lines are, the docstring's real length, the rejected compressed variant, and that all four contract tests fail on main. Ceilings unchanged. Co-Authored-By: Claude Opus 5.5 --- file-budget.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/file-budget.json b/file-budget.json index ce793e1c..5293fcc4 100644 --- a/file-budget.json +++ b/file-budget.json @@ -254,7 +254,7 @@ "from": 2883, "to": 2901, "lines": 18, - "reason": "The 18 lines are _builtin_tools in sdk_backend.py plus its Sequence import: the derivation of the SDK presence list (tools=) from a ToolPolicy grant. The presence list used to be the grant verbatim, and a permission pattern such as Bash(git status) placed there was seen, in a manual check against the bundled CLI, to remove that tool from the agent, so a custom ToolPolicy written with patterns lost its tools silently. The helper has to live at the dispatch layer, beside the one ClaudeAgentOptions call that consumes it; the private lineage keeps the identical derivation at the same path and the two change together. The docstring was cut from 17 lines to 5 before this raise was measured. Explicitly rejected: a purpose entry for sdk_backend.py, which would drop the whole module out of the ratchet to accommodate 18 lines, the same trade the two earlier raises on this package rejected. Measured by cf-file-budget on the final tree: 'package src/bonfire/dispatch is 2901 lines (frozen 2883)'. Raised to exactly the measurement, with no headroom.", + "reason": "The 18 lines are _builtin_tools in sdk_backend.py (a one-line mirror comment, the 15-line function and its 2 blank separators; the Sequence import joined an existing line and costs none): the derivation of the SDK presence list (tools=) from a ToolPolicy grant. The presence list used to be the grant verbatim, and a permission pattern such as Bash(git status) placed there was seen, in a manual check against the bundled CLI, to remove that tool from the agent, so a custom ToolPolicy written with patterns lost its tools silently. The helper has to live at the dispatch layer, beside the one ClaudeAgentOptions call that consumes it; the private lineage keeps the identical derivation at the same path and the two change together. The docstring was cut from 17 lines to 6 before this raise was measured. The loop body could fold into about two statements with dict.fromkeys and a comprehension, saving about six lines; rejected because the explicit loop states each rule of the contract on its own line. Explicitly rejected: a purpose entry for sdk_backend.py, which would drop the whole module out of the ratchet to accommodate 18 lines, the same trade the two earlier raises on this package rejected. Measured by cf-file-budget on the final tree: 'package src/bonfire/dispatch is 2901 lines (frozen 2883)'. Raised to exactly the measurement, with no headroom.", "approver": "Anta gates via PR merge -- raise surfaced in the PR body and in the lane report, not taken silently" }, { @@ -262,7 +262,7 @@ "from": 74187, "to": 74298, "lines": 111, - "reason": "The 111 lines are tests/unit/test_sdk_backend_presence_base_names.py (122 lines), less 14 net lines removed from test_sdk_backend_tool_presence.py when its old verbatim-duplicate pin was replaced, plus a 3-line history breadcrumb in test_no_persona_names_in_public_docs.py, whose CHANGELOG line anchors moved by 11 when this rung's Fixed entry was written (anchors only, expected text unchanged, as every earlier CHANGELOG addition did). Four tests, three of which failed on origin/main before the helper existed: a pattern grant reduces to its base name while the approval list keeps the pattern, an mcp__ grant stays out of the presence list but in the approval list, and a pattern-only grant keeps the tool present rather than producing an empty list. The fourth replaces a pin whose contract changed on purpose: duplicates are now removed from the presence list and still reach the approval list verbatim. The tests moved into their own module because the derivation is its own contract and the presence module would otherwise have crossed the 500-line cap. Explicitly rejected: an unbudgeted tree such as tests/dispatch, which dodges the ratchet and hides the contract from whoever maintains sdk_backend.py; and a purpose entry for the new file, an exemption in everything but name. Measured by cf-file-budget on the final tree: 'package tests/unit is 74298 lines (frozen 74187)'. Raised to exactly the measurement, with no headroom.", + "reason": "The 111 lines are tests/unit/test_sdk_backend_presence_base_names.py (122 lines), less 14 net lines removed from test_sdk_backend_tool_presence.py when its old verbatim-duplicate pin was replaced, plus a 3-line history breadcrumb in test_no_persona_names_in_public_docs.py, whose CHANGELOG line anchors moved by 11 when this rung's Fixed entry was written (anchors only, expected text unchanged, as every earlier CHANGELOG addition did). Four tests, all four of which fail on origin/main: a pattern grant reduces to its base name while the approval list keeps the pattern, an mcp__ grant stays out of the presence list but in the approval list, and a pattern-only grant keeps the tool present rather than producing an empty list. The fourth replaces a pin whose contract changed on purpose: duplicates are now removed from the presence list and still reach the approval list verbatim. The tests moved into their own module because the derivation is its own contract and the presence module would otherwise have crossed the 500-line cap. Explicitly rejected: an unbudgeted tree such as tests/dispatch, which dodges the ratchet and hides the contract from whoever maintains sdk_backend.py; and a purpose entry for the new file, an exemption in everything but name. Measured by cf-file-budget on the final tree: 'package tests/unit is 74298 lines (frozen 74187)'. Raised to exactly the measurement, with no headroom.", "approver": "Anta gates via PR merge -- raise surfaced in the PR body and in the lane report, not taken silently" } ] From 930f6bc0a8e91950102f45c500a6788888b24cae Mon Sep 17 00:00:00 2001 From: Antawari Date: Wed, 21 Oct 2026 15:21:26 -0600 Subject: [PATCH 07/10] Pin that dispatched agents get no claude.ai account connectors Red first. A logged-in CLI attaches the user's claude.ai connectors, which no tool grant declares; the backend must switch them off and add no MCP servers or strict MCP config of its own. Co-Authored-By: Claude Opus 5.5 --- .../test_sdk_backend_presence_base_names.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/unit/test_sdk_backend_presence_base_names.py b/tests/unit/test_sdk_backend_presence_base_names.py index 491fc225..831c6deb 100644 --- a/tests/unit/test_sdk_backend_presence_base_names.py +++ b/tests/unit/test_sdk_backend_presence_base_names.py @@ -120,3 +120,24 @@ async def test_duplicate_tool_names_dedup_in_presence_kept_in_approval(self) -> f"got {captured['tools']!r}" ) assert list(captured["allowed_tools"]) == incoming + + +class TestAccountConnectorsExcluded: + """No grant declares the user's claude.ai account connectors; the CLI must not load them.""" + + async def test_every_grant_disables_claude_ai_connectors(self) -> None: + grants = [["Read", "Write", "Bash"], ["Read", "Grep"], []] + seen: list[Any] = [] + for grant in grants: + env = (await _capture_for(list(grant))).get("env") or {} + seen.append(env.get("ENABLE_CLAUDEAI_MCP_SERVERS")) + assert len(seen) == len(grants) == 3, f"ran {len(seen)} of {len(grants)} grant cases" + assert seen == ["false"] * len(grants), ( + f"ENABLE_CLAUDEAI_MCP_SERVERS per grant (of {len(grants)}) must be 'false'; " + f"got {seen!r}" + ) + + async def test_backend_adds_no_mcp_servers_and_no_strict_mcp_config(self) -> None: + captured = await _capture_for(["Read", "Grep"]) + leaked = sorted({"mcp_servers", "strict_mcp_config"} & captured.keys()) + assert leaked == [], f"of 2 forbidden kwargs, backend passed {leaked!r}" From 936cc1036531d2038ca1f41b6c3abbd376cd4672 Mon Sep 17 00:00:00 2001 From: Antawari Date: Wed, 21 Oct 2026 15:36:40 -0600 Subject: [PATCH 08/10] Keep the user's claude.ai connectors away from dispatched agents A logged-in CLI attaches the user's claude.ai account connectors, which no tool grant declares; a live check saw Claude Docs and Google Drive tools reach a read-only agent. The agent environment now sets ENABLE_CLAUDEAI_MCP_SERVERS=false. No MCP server or strict MCP config is set, so a user's own project MCP servers still load. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 +++ src/bonfire/dispatch/sdk_backend.py | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d9c8778..8ddac39f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -91,6 +91,9 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). behaviour is not exercised by this repo's test suite; the unit tests pin the derivation only. This is a behaviour change: duplicate names in a grant no longer reach the presence list. + Dispatched agents also no longer see the user's claude.ai account + connectors: the backend sets `ENABLE_CLAUDEAI_MCP_SERVERS=false` for the + agent, after a manual check saw a connector reach a read-only agent. ### Changed diff --git a/src/bonfire/dispatch/sdk_backend.py b/src/bonfire/dispatch/sdk_backend.py index cd324cab..fa9c27e4 100644 --- a/src/bonfire/dispatch/sdk_backend.py +++ b/src/bonfire/dispatch/sdk_backend.py @@ -51,6 +51,9 @@ logger = logging.getLogger(__name__) +# A logged-in CLI attaches the user's claude.ai account connectors; no grant declares them. +_AGENT_ENV: dict[str, str] = {"ENABLE_CLAUDEAI_MCP_SERVERS": "false"} + # --------------------------------------------------------------------------- # Traceback redaction @@ -253,6 +256,7 @@ async def _do_execute( # noqa: C901 setting_sources=_resolve_setting_sources(options.cwd), thinking=thinking_config, effort=effort_level, + env={**_AGENT_ENV}, stderr=lambda line: logger.warning("[CLI stderr] %s", line), ) From f0cef4fd84a2d9f12ad01c96e1b1be2f49ee037a Mon Sep 17 00:00:00 2001 From: Antawari Date: Wed, 21 Oct 2026 15:36:40 -0600 Subject: [PATCH 09/10] Re-anchor the persona allowlist after the connector changelog lines Anchors move by three more, expected text unchanged. Co-Authored-By: Claude Opus 5.5 --- tests/unit/test_no_persona_names_in_public_docs.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/unit/test_no_persona_names_in_public_docs.py b/tests/unit/test_no_persona_names_in_public_docs.py index b4149773..1409643b 100644 --- a/tests/unit/test_no_persona_names_in_public_docs.py +++ b/tests/unit/test_no_persona_names_in_public_docs.py @@ -192,27 +192,27 @@ # Doc-truth pass: shifted 615/617/618/635 -> 662/664/665/682 when # the CHANGELOG entries owed by three merged PRs were written # under [Unreleased]. Anchors only; expected text is unchanged. - # Presence-list fix entry: shifted 684/686/687/704 -> 695/697/698/715 - # when a Fixed entry was added under [Unreleased]. Anchors only; - # expected text is unchanged. + # Presence-list fix entry: shifted 684/686/687/704 -> 698/700/701/718 + # when a Fixed entry was added under [Unreleased] (11 lines, then 3 more + # for the connector switch). Anchors only; expected text is unchanged. ( "CHANGELOG.md", - 695, + 698, "predecessor persona (Passelewe, the Chamberlain) was retired; the", ), ( "CHANGELOG.md", - 697, + 700, "`docs/_lore/passelewe.md`. The persona builtins directory", ), ( "CHANGELOG.md", - 698, + 701, "`src/bonfire/persona/builtins/passelewe/` was deleted; a new", ), ( "CHANGELOG.md", - 715, + 718, 'to ban `"passelewe"` in src/ (the predecessor persona is gone, so', ), # --- CLAUDE.md — constellation-pointer breadcrumbs -------------- From 26d95e6e8f4efe31c95c640b3b8ed916aeb0ed38 Mon Sep 17 00:00:00 2001 From: Antawari Date: Wed, 21 Oct 2026 15:36:40 -0600 Subject: [PATCH 10/10] Re-measure the two package raises after the connector switch dispatch 2883 to 2905 and tests/unit 74187 to 74319, both read from cf-file-budget on the final tree, with the added lines itemised. Co-Authored-By: Claude Opus 5.5 --- file-budget.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/file-budget.json b/file-budget.json index 5293fcc4..b5858369 100644 --- a/file-budget.json +++ b/file-budget.json @@ -145,12 +145,12 @@ } }, "packages": { - "src/bonfire/dispatch": 2901, + "src/bonfire/dispatch": 2905, "src/bonfire/engine": 1981, "src/bonfire/handlers": 3280, "src/bonfire/onboard": 4460, "tests/integration": 1702, - "tests/unit": 74298 + "tests/unit": 74319 }, "package_raises": [ { @@ -252,17 +252,17 @@ { "package": "src/bonfire/dispatch", "from": 2883, - "to": 2901, - "lines": 18, - "reason": "The 18 lines are _builtin_tools in sdk_backend.py (a one-line mirror comment, the 15-line function and its 2 blank separators; the Sequence import joined an existing line and costs none): the derivation of the SDK presence list (tools=) from a ToolPolicy grant. The presence list used to be the grant verbatim, and a permission pattern such as Bash(git status) placed there was seen, in a manual check against the bundled CLI, to remove that tool from the agent, so a custom ToolPolicy written with patterns lost its tools silently. The helper has to live at the dispatch layer, beside the one ClaudeAgentOptions call that consumes it; the private lineage keeps the identical derivation at the same path and the two change together. The docstring was cut from 17 lines to 6 before this raise was measured. The loop body could fold into about two statements with dict.fromkeys and a comprehension, saving about six lines; rejected because the explicit loop states each rule of the contract on its own line. Explicitly rejected: a purpose entry for sdk_backend.py, which would drop the whole module out of the ratchet to accommodate 18 lines, the same trade the two earlier raises on this package rejected. Measured by cf-file-budget on the final tree: 'package src/bonfire/dispatch is 2901 lines (frozen 2883)'. Raised to exactly the measurement, with no headroom.", + "to": 2905, + "lines": 22, + "reason": "The 22 lines are, first, 18 for _builtin_tools in sdk_backend.py (a one-line mirror comment, the 15-line function and its 2 blank separators; the Sequence import joined an existing line and costs none): the derivation of the SDK presence list (tools=) from a ToolPolicy grant; and 4 for the agent environment that switches off the user's claude.ai account connectors (a one-line comment, the _AGENT_ENV constant, its blank separator and the env= kwarg), after a live check saw a Claude Docs and a Google Drive connector reach a read-only agent in 2 of 9 runs. The presence list used to be the grant verbatim, and a permission pattern such as Bash(git status) placed there was seen, in a manual check against the bundled CLI, to remove that tool from the agent, so a custom ToolPolicy written with patterns lost its tools silently. The helper has to live at the dispatch layer, beside the one ClaudeAgentOptions call that consumes it; the private lineage keeps the identical derivation at the same path and the two change together. The docstring was cut from 17 lines to 6 before this raise was measured. The loop body could fold into about two statements with dict.fromkeys and a comprehension, saving about six lines; rejected because the explicit loop states each rule of the contract on its own line. Explicitly rejected: a purpose entry for sdk_backend.py, which would drop the whole module out of the ratchet to accommodate 18 lines, the same trade the two earlier raises on this package rejected. Measured by cf-file-budget on the final tree: 'package src/bonfire/dispatch is 2905 lines (frozen 2883)'. Raised to exactly the measurement, with no headroom.", "approver": "Anta gates via PR merge -- raise surfaced in the PR body and in the lane report, not taken silently" }, { "package": "tests/unit", "from": 74187, - "to": 74298, - "lines": 111, - "reason": "The 111 lines are tests/unit/test_sdk_backend_presence_base_names.py (122 lines), less 14 net lines removed from test_sdk_backend_tool_presence.py when its old verbatim-duplicate pin was replaced, plus a 3-line history breadcrumb in test_no_persona_names_in_public_docs.py, whose CHANGELOG line anchors moved by 11 when this rung's Fixed entry was written (anchors only, expected text unchanged, as every earlier CHANGELOG addition did). Four tests, all four of which fail on origin/main: a pattern grant reduces to its base name while the approval list keeps the pattern, an mcp__ grant stays out of the presence list but in the approval list, and a pattern-only grant keeps the tool present rather than producing an empty list. The fourth replaces a pin whose contract changed on purpose: duplicates are now removed from the presence list and still reach the approval list verbatim. The tests moved into their own module because the derivation is its own contract and the presence module would otherwise have crossed the 500-line cap. Explicitly rejected: an unbudgeted tree such as tests/dispatch, which dodges the ratchet and hides the contract from whoever maintains sdk_backend.py; and a purpose entry for the new file, an exemption in everything but name. Measured by cf-file-budget on the final tree: 'package tests/unit is 74298 lines (frozen 74187)'. Raised to exactly the measurement, with no headroom.", + "to": 74319, + "lines": 132, + "reason": "The 132 lines are tests/unit/test_sdk_backend_presence_base_names.py (143 lines: 122 for the presence-list contract and 21 for the connector contract, one driving test and one pin that the backend sets no mcp_servers or strict_mcp_config), less 14 net lines removed from test_sdk_backend_tool_presence.py when its old verbatim-duplicate pin was replaced, plus a 3-line history breadcrumb in test_no_persona_names_in_public_docs.py, whose CHANGELOG line anchors moved by 14 when this rung's Fixed entry was written (anchors only, expected text unchanged, as every earlier CHANGELOG addition did). Four tests, all four of which fail on origin/main: a pattern grant reduces to its base name while the approval list keeps the pattern, an mcp__ grant stays out of the presence list but in the approval list, and a pattern-only grant keeps the tool present rather than producing an empty list. The fourth replaces a pin whose contract changed on purpose: duplicates are now removed from the presence list and still reach the approval list verbatim. The tests moved into their own module because the derivation is its own contract and the presence module would otherwise have crossed the 500-line cap. Explicitly rejected: an unbudgeted tree such as tests/dispatch, which dodges the ratchet and hides the contract from whoever maintains sdk_backend.py; and a purpose entry for the new file, an exemption in everything but name. Measured by cf-file-budget on the final tree: 'package tests/unit is 74319 lines (frozen 74187)'. Raised to exactly the measurement, with no headroom.", "approver": "Anta gates via PR merge -- raise surfaced in the PR body and in the lane report, not taken silently" } ]