Every operation runs in a scope: Wardrik, who acts, and what they're allowed. Nothing is allowed unless it's granted.

See it in action: Who may do what

Setup

In a fresh IEx session:

Mix.install([{:wardrik, "~> 0.1.0-rc.1"}])

{:ok, _} = Wardrik.start_link(name: MyApp.AgentRuntime)
scope = Wardrik.as(MyApp.AgentRuntime, "acme/@maria", roles: [:owner], resources: ["acme/@maria/*"])

{:ok, _} = Wardrik.Agent.define(scope, "acme/@maria/books", model: "anthropic:claude-haiku-4-5")

Scopes and grants

Wardrik.as/3 takes Wardrik, a principal and its grants. Here Jon may read and message Maria's agents:

jon =
  Wardrik.as(MyApp.AgentRuntime, "acme/@jon",
    roles: [:reader, :messenger],
    resources: ["acme/@maria/agent/*"]
  )

{:ok, _agent} = Wardrik.Agent.read(jon, "acme/@maria/books")

Wardrik.Agent.stop(jon, "acme/@maria/books")
#=> {:error, %Wardrik.Error{type: :forbidden, message: "You may not do this to the agent acme/@maria/books."}}

jon = Wardrik.grant(jon, roles: [:definer], resources: ["acme/@maria/agent/books"])

{:ok, _agent} = Wardrik.Agent.stop(jon, "acme/@maria/books")

The principal is an ID, or a Wardrik.principal/2 with attributes your authorizer reads. The grants are one statement's options, or a list of Wardrik.Statements. Wardrik.grant/2 adds one more statement.

A statement allows actions on resources, as IAM statements do:

ThingResource
The agent acme/booksacme/agent/books
The agent acme/@maria/booksacme/@maria/agent/books
The org's MCP server linearacme/mcp_server/linear
Maria's GitHub accountacme/@maria/connection/github
Tokens, nodes, the audit, reloadingdeployment
RoleActions
:readeragent.read, agent.events, connection.read, mcp_server.read, ceiling.read
:messengeragent.ask, agent.cancel, agent.clear
:approveragent.decide
:defineragent.define, agent.spawn, agent.stop, agent.forget, connection.connect, mcp_server.add
:workeragent.serve, to offer tools to the agents it covers
:ownerevery action

An agent no statement reaches is invisible to the scope. Operations on it return not_found, and lists leave it out. An agent a statement reaches, without the action, returns forbidden:

sam = Wardrik.as(MyApp.AgentRuntime, "acme/@sam")

Wardrik.Agent.read(sam, "acme/@maria/books")
#=> {:error, %Wardrik.Error{type: :not_found, message: "There's no agent acme/@maria/books."}}

API tokens

People and programs reach Wardrik over HTTP with a token. Making one needs deployment.tokens on deployment. The secret is returned once, when the token is made:

admin = Wardrik.grant(scope, actions: ["deployment.tokens"], resources: ["deployment"])

{:ok, issued} =
  Wardrik.Tokens.create(admin,
    name: "ci",
    roles: ["reader", "messenger"],
    resources: ["acme/@maria/agent/*"]
  )
#=> {:ok,
#=>  %Wardrik.TokenIssued{
#=>    token: %Wardrik.Token{
#=>      id: "3f9a61c0d4e2",
#=>      name: "ci",
#=>      principal: "acme/@maria",
#=>      statements: [%Wardrik.Statement{actions: ["agent.read", ...], resources: ["acme/@maria/agent/*"]}],
#=>      expires: ~U[2027-01-28 18:00:00Z],
#=>      ...
#=>    },
#=>    secret: "wrk_3f9a61c0d4e2_9c1e..."
#=>  }}

{:ok, ci} = Wardrik.authenticate(MyApp.AgentRuntime, issued.secret)

{:ok, _agent} = Wardrik.Agent.read(ci, "acme/@maria/books")

A token acts for whoever made it, and is stored as the SHA-256 of its secret.

Options

  • :name - required. What it's listed as.
  • :roles - required. What it may do.
  • :resources - required. Resource patterns it reaches.
  • :expires_in - seconds it works for. 90 days unless set.
  • :policy - a policy every agent it defines is held to, ahead of the agent's own. A definition can narrow it, never widen it.

Wardrik.Tokens.list/2 lists tokens. With resources:, it lists only those whose every resource the patterns cover:

{:ok, [%Wardrik.Token{name: "ci"}]} = Wardrik.Tokens.list(admin, resources: ["acme/@maria/*"])

Wardrik.Tokens.revoke/3 refuses a token from then on. Agents it defined keep running, under the policy they were defined with:

{:ok, _token} = Wardrik.Tokens.revoke(admin, issued.token)

Wardrik.authenticate(MyApp.AgentRuntime, issued.secret)
#=> {:error, %Wardrik.Error{type: :unauthorized, ...}}

No wider than its maker

A token can't do more than whoever makes it:

  • Each action its roles allow, on each resource it names, must be one its maker may take there.
  • A token made by someone held to a policy is held to it too.
  • A token made by a token expires no later than that token.

So admin, who owns acme/@maria/* alone, makes tokens over it alone:

Wardrik.Tokens.create(admin, name: "wide", roles: ["definer"], resources: ["acme/agent/*"])
#=> {:error, %Wardrik.Error{type: :forbidden, message: "A token can't do more than the one that makes it, on more of it."}}

Write your own authorizer

Use one when your app already decides who may do what. This one lets each person act on what's under their own ID, and nothing else:

defmodule MyApp.Authorizer do
  @behaviour Wardrik.Authorizer

  @impl true
  def authorize(_config, %Wardrik.Scope{principal: principal}, _action, resource) do
    if String.starts_with?(resource, principal.id <> "/"), do: :ok, else: {:error, :not_found}
  end
end

Start Wardrik with it, in a fresh IEx session:

{:ok, _} = Wardrik.start_link(name: MyApp.AgentRuntime, authorizer: {MyApp.Authorizer, []})
scope = Wardrik.as(MyApp.AgentRuntime, "acme/@maria")

Wardrik.Agent.define(scope, "acme/@maria/books", model: "anthropic:claude-haiku-4-5")
#=> {:ok, %Wardrik.Agent{id: "acme/@maria/books", ...}}

Wardrik.Agent.define(scope, "acme/books", model: "anthropic:claude-haiku-4-5")
#=> {:error, %Wardrik.Error{type: :not_found, message: "There's no agent acme/books."}}

authorize/4 returns:

  • :ok - allowed.
  • {:error, :not_found} - the scope can't see the resource.
  • {:error, :forbidden} - it can see it, but lacks the action.

The grants you gave are in the scope's grants. Implement the optional holds?/3 to decide which tokens a scope may make; see Wardrik.Authorizer.