feat(#1452): add exact per-action tool allowlist - #1453
Draft
jejeje-dot wants to merge 2 commits into
Draft
Conversation
…llowlist
An allowlist only fails in one direction that matters: permitting more than
the operator named. Every existing test asserted that an allowed action is
allowed and a plainly-different one is not, and none of those can see a
compare that was loosened to a prefix, lowercased "to be forgiving", or split
into two independent some() calls — each of which widens the list while
keeping the suite green.
Added tests for the near-misses:
- prefix / extra segment, on both halves of the pair
- a prefix near-miss on the REAL dispatch route: queue:cancel must not admit
queue:cancel_queued. Both are genuine members of queue's action enum, so
unlike a synthetic name this one survives schema validation and actually
reaches the gate — and cancel_queued drops the entire pending queue
- case, on the incoming CALL. The rule grammar already rejects an uppercase
RULE, but nothing constrained the side a caller controls
- the cross product: queue:list,download_model:download must not admit
queue:download
- leading/trailing whitespace
- that an action list ALONE makes the policy active, since withToolSurfaceFilter
returns the registrar unwrapped when it is not, which would make the whole
gate dead code
Also tightened one branch rather than leaving it defaulted: an `action` that is
PRESENT BUT NOT A STRING was returning allowed, resolving the one shape the gate
cannot classify in the permissive direction. It now refuses. An absent or null
action is still allowed — that is not an action call, and stays governed by the
tool-level policy. All 34 live `action` fields are required z.enum([...]) and
both dispatch routes validate before the handler runs, so nothing real reaches
that branch today; that is why it needed a decision instead of a default.
Mutation-tested, because these tests are worth exactly what they can kill: six
widening mutations, six killed, zero survivors.
Refs artokun#1452
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
artokun
force-pushed
the
codex/per-action-tool-policy
branch
from
August 16, 2026 18:24
003869e to
3ce3a31
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Refs #1452
What changed
COMFYUI_MCP_TOOL_ACTION_ALLOWas an exact comma-separatedtool:actionallowlist.call_toolcatalogWhy
#873 / #1383 made tool names enforceable, but consolidated tools mix actions with very different blast radii. An operator currently cannot allow
queue:listand targetedqueue:cancelwithout also exposingqueue:editand globalqueue:clear.The allowlist is exact and intentionally has no wildcard support. New actions introduced by a future release therefore remain denied until the operator names them explicitly.
Calls without an
actionfield are unchanged and remain governed by the existing tool-level preset/allow/deny policy.Reachability
An allowlist that is implemented but never consulted is the failure mode worth checking before any other, so each route was pinned:
registerAllTools→withToolSurfaceFilterwrapsserver.tool, gating the handlercall_toolcollectToolCatalogwrapscatalog.asRegistrar(), so the catalog stores the wrapped handler thatcall_tooldispatchescollectToolCatalog()dispatch, no fetch mock — reaching the handler would mean reaching ComfyUIregisterPanelToolscallstoolActionPolicyErrorbefore the handlerMcpServer+ClientoverInMemoryTransportcreatePanelMcpServer, sameDefault-deny holds: once
COMFYUI_MCP_TOOL_ACTION_ALLOWis set, a call carrying a stringactionis refused unless a rule names that exacttool:action. A tool with no rule at all can dispatch no action.Over-permissive cases now covered
The direction that ships a hole is "permits more than intended", and a test that only proves the allowed action is allowed cannot see any of it. Added:
queue:listmust not admitlist_all,listall,lis,list:extra, nor toolsqueue_admin,queuex,my_queue,enqueue.queue:cancelmust not admitcancel_queued. Both are genuine members of queue's action enum, so unlike a synthetic name this one survives schema validation and actually reaches the gate — andcancel_queueddrops the entire pending queue, someone else's work included.QUEUE:list,queue:LIST,Queue:Listare refused.queue:list,download_model:downloadmust not admitqueue:downloadordownload_model:list. This is the classic allowlist bug (some(tool) && some(action)); it reads correct and passes every single-rule test." list"/"list "are notlist.withToolSurfaceFilterreturns the registrar unwrapped and the entire gate is dead code.One behaviour was tightened rather than left defaulted: an
actionthat is present but not a string was returning allowed, resolving the one shape the gate cannot classify in the permissive direction. It now refuses. An absent ornullaction is still allowed, since that is not an action call. All 34 liveactionfields are requiredz.enum([...])and both dispatch routes validate before the handler runs, so nothing real reaches that branch today — which is exactly why it needed deciding instead of defaulting.Validation
Re-run on Windows against current
main:origin/mainmerged into the branch; no conflicts.npm run lint(tsc --noEmit) clean.npm run check:vocabularyandnpm run check:unknown-collapseclean.tool-surface-filter,panel-tools-strict-schema,panel-secrets— 200/200.tool:actionpair in it is real (get_system_stats:stats|logs|health,get_history:list|diagnose,create_workflow:create|modify|validate|node_info,enqueue_workflow:enqueue,queue:list|status|cancel). A wrong pair there would hand an operator a config that silently withholds something they meant to allow.Full suite: 9,444 passed / 21 failed across 12 files. Every one of the 21 is
Test timed out in 30000ms, on a host that was running ~280 concurrent node processes (the run reported 4,019s transform and 9,710s import against 691s wall). All of them pass in isolation:tool-surface-filter,vocabulary,vocabulary-handshake— 320/320.ui-bridge > names the versioned panel-sync remedy (#706)— 91ms isolated, and that file contains no reference totool-surface-filter,toolActionAllowed, orCOMFYUI_MCP_TOOL_ACTION_ALLOW.No failure is attributable to this branch. Stating the limit of that claim plainly: the reason they are unrelated is machine load, so this is evidence about this host, not a green CI run. No CI checks are currently reported on this branch.
Reviewer notes
COMFYUI_MCP_TOOL_ACTION_ALLOW, so the model learns the mechanism exists. That is a deliberate departure from the No way to restrict the tool surface — blocks production/multi-user deployments #873 tool-level design, where a denied tool is absent so the model never learns of it — and it is unavoidable here, since the tool must stay registered for its allowed actions. Worth a conscious ruling rather than inheriting by accident.COMFYUI_MCP_TOOL_ACTION_ALLOWleaves every action-free tool fully reachable. The docs say to pair it withCOMFYUI_MCP_TOOL_ALLOW, which is correct, but an operator who reads the variable name as a lockdown would be wrong.