Skip to content

toolprune: protect Codex packages, and recheck protection when a list is used - #188

Merged
Aaron Li (aaron-jx-li) merged 2 commits into
microsoft:mainfrom
rgrvlsk:codex-protect-packages
Nov 10, 2026
Merged

Aaron Li (aaron-jx-li) merged 2 commits into
microsoft:mainfrom
rgrvlsk:codex-protect-packages

Conversation

@rgrvlsk

Copy link
Copy Markdown
Contributor

Partly addresses #48.

On Codex, tool pruning works on capability packages (collaboration, goals, image_media), not on single tools. The Codex fold never read toolprune_protect. A person who set toolprune_protect=collaboration to keep sub-agents still had them pruned once the package had gone unused long enough. #48 suggested leaving Codex as it is because the setting names tools. But on Codex a package is the smallest thing that can be pruned, so naming the package is the only way to keep one.

What changed

  • The Codex fold leaves protected packages out of its proposal. It also gives back a package applied before it was protected, as the generic fold does for tools: it removes only the plugin's own lines, never in a .codex/config.toml the repository commits (apply refuses those too). An unreadable config is left as it is and the fold finishes.
  • fold.unprotected(ctx, items) drops what is protected now. apply_pending and cli._proposal (default toolprune apply, show, measure) both call it, so a pending auto list or a stored proposal formed before a protection was added no longer writes that tool. apply_pending also removes such entries from the stored proposal, which Pending automatic deny lists ignore newly configured tool protections #48 asked for.
  • Copilot lists name MCP servers, which can share a name with a protected Copilot tool such as sql, so they are left as they are. Pending automatic deny lists ignore newly configured tool protections #48's Copilot case (dropping a server once one of its catalogued tools is protected) is not covered here.
  • The toolprune_protect row in plugin/README.md names the Codex packages.

Tests

tests/test_live_hooks.py:

  • test_codex_never_proposes_a_protected_capability: thirty prompts, so image_media crosses its evidence threshold too. An unprotected control proposes all three packages; with collab*,image_media protected, the next fold proposes only goals.
  • test_a_codex_package_applied_before_it_was_protected_comes_back
  • test_a_protected_codex_package_in_a_committed_or_unreadable_config_stays
  • test_a_tool_protected_after_the_list_formed_is_not_written: the pending list, _proposal and the stored proposal.
  • test_copilot_server_lists_are_not_filtered_as_tool_names

All but the committed-or-unreadable test fail on main. That one guards the new give-back path, since main gives nothing back. python -m pytest tests gives 855 passed and 16 skipped on Python 3.10 and 3.13 (macOS), and experiments/tests gives 394 passed.

… is used

On Codex, tool pruning works on capability packages, so naming a tool in
`toolprune_protect` cannot keep one: a person who set
`toolprune_protect=collaboration` still had sub-agents pruned. The Codex
fold now leaves the packages the list names out of its proposal, and gives
back one applied before it was protected, as the generic fold does for
tools: only the plugin's own lines, never in a config the repository
commits, and an unreadable config leaves the fold to finish.

A pending `auto` list and the proposal `toolprune apply` reads are checked
against the protection in force when they are used, and protected entries
leave the stored proposal. Copilot lists name MCP servers and are left as
they are.

Partly addresses microsoft#48 (its Copilot case, a server whose tool became
protected, is not covered). Five tests in tests/test_live_hooks.py; four
fail on main.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I read the Codex fold, unprotected, and the two places it is now applied (apply_pending and cli._proposal) against main. I checked that _Protected.__contains__ matches patterns with fnmatchcase, so collab* works on package names the same way it does on tools. I also checked that no package name collides with PROTECTED_TOOLS / INTERACTIVE_PROTECTED, so unprotected can't drop a package by accident. And run_session in these tests calls hook.main in-process, so the monkeypatch of _git_tracked / restore really does reach the fold. The behaviour looks right to me.

One small thing about consistency: the generic fold gives a tool back only when not imported (tokeneconomizer/plugin/fold.py:155), and its docstring says a session read from history "changes nothing else: no deny rule is held, written or given back". _fold_toolprune doesn't pass imported to _fold_toolprune_codex (fold.py:128). Until now that didn't matter, because the Codex path never edited a file during the fold. With the new give-back, a Codex history import would be the first thing to change .codex/config.toml from an imported session. Only Claude Code has keeps_history = True today, so this can't happen yet. Passing imported through and adding it to the guard at fold.py:292 would keep the rule true if Codex history import arrives later:

if (not imported and any(c in protected for c in applied) and not ctx.config.shadow
        and not denylist._git_tracked(ctx.repo.root, denylist.CODEX_CONFIG_REL)):

Not a blocker.

Reviewed with help from Claude Code.

Record logical_capabilities before a newly protected package is given back, so the session that ran with it switched off is not counted as loading it.
@aaron-jx-li
Aaron Li (aaron-jx-li) merged commit c1a665a into microsoft:main Nov 10, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants