WardrikPolicy (wardrik_policy v0.1.0-rc.1)
Copy MarkdownDecides each tool call an agent makes: run it, ask a person first, or refuse it.
iex> policy =
...> WardrikPolicy.new!(
...> allow: ["web_search", "write_file(notes/*)"],
...> ask: %{"books/create_bill(amount >= 1000)" => "A bill this size needs a person."},
...> deny: ["shell/*"]
...> )
iex> WardrikPolicy.decide(policy, "web_search")
:allow
iex> WardrikPolicy.decide(policy, "books__create_bill", arguments: %{"amount" => 9400})
{:ask, "A bill this size needs a person."}
iex> WardrikPolicy.decide(policy, "shell__exec")
{:deny, "shell/exec is not allowed for this agent."}
iex> WardrikPolicy.explain(policy, "shell__exec")
%WardrikPolicy.Decision{
effect: :deny,
message: "shell/exec is not allowed for this agent.",
rule: "shell/*",
cause: :rule
}The strictest rule wins: deny, then ask, then allow.
Rules
:allow- tool patterns that run.:ask- tool patterns that wait for a person. A map of pattern to message sets the prompt.:deny- tool patterns that are refused. A map of pattern to message sets the refusal.:allow_after- a map of label to patterns that stay allowed after the run reads content with that label. See "Content from outside" below.:default- what a call no rule names gets::allow,:ask,:deny, or a map by annotation.:allowunless set.:budget- caps what the agent spends on model calls. SeeWardrikPolicy.Budget.:judge- asks a model about each call. SeeWardrikPolicy.Judge.
A pattern names a tool, what the call acts on, or its arguments:
"web_search" # one tool
"github/*" # every tool from the github MCP server
"write_file(notes/*)" # what the call acts on, given as :resources
"books/create_bill(amount >= 1000)" # an argument; =, !=, >, >=, < and <=
"email/send(to != *@acme.com)" # a comma means all conditions must holdAn MCP tool named github__create_issue also matches as github/create_issue.
Defaults by annotation
A call no rule names follows :default. The default can depend on the tool's annotations:
iex> policy =
...> WardrikPolicy.new!(
...> allow: ["write_file(notes/*)"],
...> default: [read_only: :allow, else: :ask]
...> )
iex> WardrikPolicy.decide(policy, "write_file", resources: ["notes/today.md"])
:allow
iex> WardrikPolicy.decide(policy, "write_file", resources: ["secrets/key"])
{:ask, "write_file needs your approval."}
iex> WardrikPolicy.decide(policy, "read_file", annotations: %{read_only: true})
:allowWhen a tool has several annotations, the strictest wins. else covers a tool with none of them,
and is :allow unless set.
Policy files
Parse the YAML into a map, then call decode/2:
iex> {:ok, policy} =
...> WardrikPolicy.decode(%{"deny" => ["email/send(to != *@acme.com)"], "default" => "ask"})
iex> WardrikPolicy.decide(policy, "email__send", arguments: %{"to" => "x@evil.example"})
{:deny, "email/send is not allowed for this agent."}
iex> WardrikPolicy.decide(policy, "web_search")
{:ask, "web_search needs your approval."}to_json/1 writes a policy back as that map.
Content from outside
A run that has read content from outside, such as a web page, may have been told what to call.
So once a run carries the "outside" label, a call to a gated tool asks a person, even if a rule
allows it. Pass the run's labels as :labels. allow_after keeps a call allowed for tools a
stranger can't misuse:
iex> policy =
...> WardrikPolicy.new!(
...> allow: ["linear/*"],
...> allow_after: %{"outside" => ["github/add_labels"]}
...> )
iex> gated = %{gated: true}
iex> WardrikPolicy.decide(policy, "linear__create_issue", annotations: gated, labels: ["outside"])
{:ask, "linear/create_issue is allowed, but this run has read content from outside, which may be what asked for it."}
iex> WardrikPolicy.decide(policy, "github__add_labels", annotations: gated, labels: ["outside"])
:allowA tool is gated when its annotations include gated: true. Any label that allow_after names
holds gated calls the same way "outside" does. Other labels hold nothing.
Summary
Functions
Decides a call to the tool name. Returns :allow, {:ask, prompt} or {:deny, message}.
Reads a policy from a map with string keys, such as one parsed from YAML.
Decides a call like decide/3, but returns a WardrikPolicy.Decision that says what decided.
Builds a policy from a keyword list or map. See the module docs for the keys.
Encodes a policy as the map decode/2 reads, such as to store it as JSON.
Types
@type effect() :: :allow | :ask | :deny
@type t() :: %WardrikPolicy{ budget: WardrikPolicy.Budget.t() | nil, by_annotation: %{required(String.t()) => effect()}, default: effect(), judge: WardrikPolicy.Judge.t() | nil, name: String.t() | nil, rules: list() }
Functions
Decides a call to the tool name. Returns :allow, {:ask, prompt} or {:deny, message}.
Given a list of policies, the strictest decision wins.
Options
:arguments- the call's arguments, as a map with string keys.%{}unless set.:resources- what the call acts on, such as file paths.[]unless set.:annotations- the tool's annotations, as%{read_only: true}.%{}unless set.:labels- labels for what the run has read, as["outside"].[]unless set.:evaluate- asks a judge's model. SeeWardrikPolicy.Judge.evaluate/0.
Reads a policy from a map with string keys, such as one parsed from YAML.
Returns {:error, message} for an unknown key or a rule that can't be parsed. Takes the same
options as new!/2.
@spec explain(t() | [t()], String.t(), keyword()) :: WardrikPolicy.Decision.t()
Decides a call like decide/3, but returns a WardrikPolicy.Decision that says what decided.
Given a list of policies, the strictest decision wins. If any policy denies the call, it's denied. Otherwise, if any asks, it asks.
Builds a policy from a keyword list or map. See the module docs for the keys.
WardrikPolicy.new!([deny: ["shell/*"], budget: [usd: 5.00]], name: "acme")Raises ArgumentError with the message decode/2 would return if a rule, budget or judge
can't be read.
Options
:name- names the policy in eachWardrikPolicy.Decision.nilunless set.
Encodes a policy as the map decode/2 reads, such as to store it as JSON.
Leaves out what the policy doesn't set. The policy's :name is not part of it.