Carry the caller into agent runs, so agents can authorize like controllers - #443
Open
TonsOfFun wants to merge 3 commits into
Open
Carry the caller into agent runs, so agents can authorize like controllers#443TonsOfFun wants to merge 3 commits into
TonsOfFun wants to merge 3 commits into
Conversation
The Tools tab was a grid of ~21 glyph cards over "Select the tools your
agent can use." It does not survive a schema-derived roster, it cannot
answer the questions you have while editing one — what does this tool do,
is it ever called, does it error, where does it come from — and there was
no way to enable an MCP service for an agent at all.
It is now two lists in the row vocabulary the Tools and MCP Services pages
already use: the services this agent can be given, each expanding to the
tools it offers, and the whole roster grouped by where each tool comes
from, with calls, errors, average latency and last-seen against every row
over a 24h / 7d / 30d window.
- `GET /api/agents/:id/tool_roster` (AgentToolRoster) joins the agent's own
configuration to ToolDiscovery, scoped to that agent's traces: the
catalog and any service its traffic names, each service's offered tools,
the dashboard capabilities, and the schema-derived tools its generations
offered. `usage_available` is false when no record source had rows in the
window, and the view then reads "—" rather than a row of zeroes.
- An agent's mcp_servers entry may now name an allow-list of tools
({ key:, name:, tools: [...] }); an entry with no list offers everything
the server serves. Bare string entries still round-trip unchanged.
- Agent-defined rows are read-only, like the MCP ones: the agent class
declares them, and a checkbox that can neither add nor remove a tool is a
control that changes nothing. Dashboard capabilities and services are
what the roster actually decides.
- Editing writes into the editor's form buffer, so the header's "unsaved"
badge, the tab's pending count and Save all follow one state; the tab's
own action bar replaces the editor's generic one while it is open.
navigateTo moves from ScenarioSuitePanel to utils/dashboardPath, which is
where the rest of the mount-relative routing lives.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEwRNXQY7fUvZ4Z1UNpyyJ
…-4afxvn # Conflicts: # actionagent/app/assets/builds/action_agent.js # actionagent/app/models/action_agent/agent.rb # actionagent/frontend/components/dashboard/AgentEditor.jsx
…llers
SchemaTools shipped with `actor:` as the host's authorization seam and the
dashboard never filled it. Every run executed unattributed, so a correctly
written Pundit scope resolved to the empty set and a well-wired agent
answered "there are no tickets" to someone with plenty — a wrong answer that
reads like a true one, and the same class of silent failure as a swallowed
tools/list.
Worse than unfilled: the seam was fed from model-controlled input.
`AgentToolbox.call` read `actor:` out of the same keyword hash as the
arguments a provider parsed from the model's tool call, and nothing filters
those against the declared inputSchema. An `actor` a model can name — via any
document it reads — is not an authorization boundary.
Framework:
- `ActiveAgent::Base#current_user` carries the caller, assigned out of band by
whatever authenticated the call and readable from every `before_action`, so
Pundit / CanCanCan / Action Policy authorize an agent the way they authorize
a controller. `MyAgent.as(user)` sets it; it chains with `with(...)` and
rides to a job as an ordinary ActiveJob argument.
- A refusal inside a tool call is returned to the model as `{ error: ... }` so
it can say it is not allowed; a refusal anywhere else is raised to whoever
asked. `denies_with` names a gem's own error as a refusal.
- The actor is deliberately not part of `params` and never a tool argument.
Dashboard:
- A run records the caller as a Global ID, so a worker on another machine
authorizes as the same person; one that no longer resolves yields nil rather
than a fallback.
- Tool calls are made with the run's actor, and `actor` / `current_user` are
stripped from a model's arguments before a tool sees them. Scoped SchemaTools
reads bypass the result cache, so one caller's rows are never replayed for
the next.
- `ActionAgent.agent_actor_resolver` is the host seam; unset means the
signed-in user, and for MCP the key's own caller.
- Over MCP, a refusal answers as a JSON-RPC error (-32003) rather than as an
empty result, which is the failure mode a nil actor produces on its own.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEwRNXQY7fUvZ4Z1UNpyyJ
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.
Closes the gap #438 named and left open:
SchemaToolsshipsactor:as the host's authorization seam, and nothing ever filled it.The problem
Every dashboard run executed unattributed.
AgentExecutionService#execute_toolended atAgentToolbox.call(name, **kwargs)with no actor, so a correctly written Pundit scope resolved to the empty set and a well-wired agent answered "there are no tickets" to someone with plenty. That is a wrong answer wearing a right one's clothes — the same class of silent failure as the swallowedtools/listin #425.Worse than unfilled, the seam was fed from model-controlled input.
AgentToolbox.callreadactor:out of the same keyword hash as the arguments a provider parsed from the model's tool call:Nothing filters those against the declared
inputSchema. An actor a model can name — through any document it reads — is not an authorization boundary. It was inert only becauseactoris in no tool's schema today.What a developer writes now
current_useris on the agent instance and readable from every callback, so Pundit, CanCanCan and Action Policy authorize an agent the way they authorize a controller —before_actionis the sameAbstractControllerchain,only:/except:included, so a roster is authorized tool by tool. The framework never interprets the actor.Two failure modes, kept distinct. A refusal inside a tool call is returned to the model as
{ error: … }, so it can say it is not allowed rather than dying mid-run or reporting an empty set as fact. A refusal anywhere else is raised to whoever asked. Over MCP that becomes a JSON-RPC error (-32003), never an empty result.The actor is never model input. It is not in
params, never a tool argument, andactor/current_userare stripped from a model's arguments before any tool sees them. A client sendingparams[params][actor]to/api/agents/:id/executeis refused the same way — a keyword splat wins over the arguments before it, so that one would otherwise have named its own caller.How it reaches a run
agent_actor)tools/callActionAgent.agent_actor_resolverreturns, when a host sets itA run records the caller as a Global ID, so a worker on another machine authorizes as the same person; one that no longer resolves yields
nilrather than a fallback. ScopedSchemaToolsreads now bypass the tool-result cache — one caller's rows must never be replayed for the next.Unset resolver, no owner model: the run is unattributed and a host scope reads that as "no access". That is the safe direction, and it is why nothing here defaults to something more privileged.
Review of today's issues
Checked against
mainwhile building this:Api::McpControlleralready is one, already inMCPCatalogas the first-party entry. The work there is extending that facade with the schema-tool roster, not a second server — which the issue's own "not a parallel implementation" line warns about. Itsactor_resolverisActionAgent.agent_actor_resolverhere, and its stated prerequisite Surface MCP tool-discovery failures instead of running tool-less and silent #434 has merged. Still open in Feature: a model's agent is also an MCP server, so its tools are callable in-process or by an external agent #439 and not in this PR: serving schema tools throughtools/list/tools/call.SchemaToolssubclasses", shipped in 46a04e2 (discovered_schema_tools,schema_tools_path). What remains is the generator and opt-in derivation; worth narrowing the issue so nobody redoes it.scope_by_policyexists, so the derived-scope half is done.SchemaTools.inheritedstill callssuper, so anonymous subclasses are still retained indescendants—ActionAgent.schema_tool_classesonly skips them during discovery, which hides the symptom without stopping the growth.Also in this branch
The agent detail Tools tab redesign (4b7ac6a): the ~21-card glyph grid replaced by an MCP services list and a grouped tool roster with calls, errors, latency and last-seen per row over a 24h/7d/30d window, plus
GET /api/agents/:id/tool_roster. Independent of the authorization work; say the word and I'll split it onto its own branch.Testing
test/authorization_test.rb,actionagent/test/agent_authorization_test.rb,actionagent/test/mcp_authorization_test.rb).rubocopclean on every changed file.🤖 Generated with Claude Code
https://claude.ai/code/session_01NEwRNXQY7fUvZ4Z1UNpyyJ
Generated by Claude Code