Skip to content

Carry the caller into agent runs, so agents can authorize like controllers - #443

Open
TonsOfFun wants to merge 3 commits into
mainfrom
claude/zealous-turing-4afxvn
Open

Carry the caller into agent runs, so agents can authorize like controllers#443
TonsOfFun wants to merge 3 commits into
mainfrom
claude/zealous-turing-4afxvn

Conversation

@TonsOfFun

Copy link
Copy Markdown
Contributor

Closes the gap #438 named and left open: SchemaTools ships actor: as the host's authorization seam, and nothing ever filled it.

The problem

Every dashboard run executed unattributed. AgentExecutionService#execute_tool ended at AgentToolbox.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 swallowed tools/list in #425.

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:

define_method(definition[:name]) { |**kwargs| service.execute_tool(definition[:name], **kwargs) }
# …
actor = kwargs.delete(:actor)   # same namespace as everything the model emitted

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 because actor is in no tool's schema today.

What a developer writes now

class TicketAgent < ApplicationAgent
  denies_with Pundit::NotAuthorizedError      # your gem's error counts as a refusal

  before_action :authorize_tickets!, only: [:find_tickets, :get_ticket]

  def find_tickets(**filters)
    TicketTools.call("find_tickets", actor: current_user, **filters)
  end

  private

  def authorize_tickets!
    authorize Ticket, :index?                  # plain Pundit, against current_user
  end
end

TicketAgent.as(current_user).ask("which tickets are overdue?").generate_now

current_user is 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_action is the same AbstractController chain, 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, and actor / current_user are stripped from a model's arguments before any tool sees them. A client sending params[params][actor] to /api/agents/:id/execute is 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

Surface Caller
Dashboard run the signed-in user (agent_actor)
MCP tools/call the key's own caller — the identity that authenticated the request
Either whatever ActionAgent.agent_actor_resolver returns, when a host sets it

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. Scoped SchemaTools reads 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 main while building this:

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

  • 435 runs / 2078 assertions green across the framework and engine suites (15 new: test/authorization_test.rb, actionagent/test/agent_authorization_test.rb, actionagent/test/mcp_authorization_test.rb).
  • Covered explicitly: a model-named actor is dropped; a client-named one is reserved; an actor survives to a worker and a dead one resolves to nobody; a scoped read is not cached across callers; a built-in never meets the actor as an argument; a refusal is a JSON-RPC error while a crash stays a tool result.
  • rubocop clean on every changed file.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NEwRNXQY7fUvZ4Z1UNpyyJ


Generated by Claude Code

TonsOfFun and others added 3 commits September 11, 2026 19:06
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
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.

1 participant