Authorization for tool calls (and, opt-in, resource reads and prompt
gets): decides :allow, :ask or :deny for one call.
Evaluation is pure: no I/O, no processes, no telemetry. It runs in the
caller, on the final arguments (after pre-hooks), and returns a
ConduitMcp.Client.Policy.Decision carrying the matching rule's id and a
closed reason atom for the pipeline's telemetry.
policy =
ConduitMcp.Client.Policy.compile(
deny: ["*/delete_*", "shell/*"],
ask: ["github/create_*", {"fs", ~r/^write/}],
allow: ["github/get_*", {:all, ["fs/*", {:annotation, :read_only, true}]}],
trusted: ["fs"]
)
ConduitMcp.Client.Policy.evaluate(policy, call, tool, %{has_approver?: true})Precedence (PO-1)
Fixed and not configurable: deny > pin > ask > allow > default. List
order and rule specificity never change the outcome; a narrow allow cannot
carve an exception out of a broad deny. Within one list, list order only
picks which rule_id is reported.
Rules
Every rule matches the structured target {server_id, name}, one segment
at a time, never a namespaced string (PO-3), so server a_b + tool c
and server a + tool b_c are never confused.
"server/tool"-*globs within one segment; a bare"tool"means any server.{server, %Regex{}}- the regex is matched against the tool name;serveris a segment glob.fn %ConduitMcp.Client.Tool{}, ctx -> boolean end.{:annotation, :read_only | :destructive | :open_world | :idempotent, boolean}- compares the tool's effective annotation (see "Annotations").
{:args, fun}-fun.(args)orfun.(args, ctx)on the call's final arguments;ConduitMcp.Client.Policy.Pathsbuilds path predicates.{:all, [rule, ...]}- matches when every member matches, for example{:all, ["fs/write", {:args, Paths.inside("path", "/data")}]}.{:rule, id, rule}- any rule above with an explicitrule_id(an atom or a string); otherwise the id is{list, index}.
Function rules get the pipeline ctx, including ctx.server_id (where
the call goes) and ctx.taint (the servers whose output is in the
model's context, HB-3); tainted/1 builds the common taint rule.
With :resources / :prompts in :scope, these also apply:
"resource:server/uri"(glob on the URI;*may span/),{:resource, server, %Regex{}}"prompt:server/name",{:prompt, server, %Regex{}}
Tool rules never match resources or prompts and vice versa. Server ids
never contain :, so a server segment with : is rejected (this catches
"resource:repo://x", which needs a server: "resource:*/repo://x").
Asymmetric matching (PO-5)
Deny and ask rules match loosely: both segments are compared Unicode
lower-cased with -, . and _ equivalent, and a regex is tried against
both the name and that folded form. So deny: ["*/delete_repo"] also
denies Delete_Repo, delete-repo and DELETE.REPO. Allow rules match
exactly and case-sensitively: allow: ["*/delete_repo"] does not
allow Delete_Repo.
Failing predicates
Function, argument and annotation predicates run in the caller. A
predicate that raises, exits, throws or returns a non-boolean counts as
matching in deny and ask lists (reason: :predicate_failed) and as
not matching in the allow list: failures never widen access.
Annotations (PO-8)
Tool annotations are untrusted unless the server is listed in :trusted.
For an untrusted server every annotation rule sees the conservative value
regardless of the hints: not read-only, destructive, open-world,
non-idempotent. For a trusted server the boolean hint is used, with the
spec defaults when absent, and a read-only tool is not destructive.
Pinning and new tools (PO-6)
The pipeline passes the approved baseline in
ctx[:approved_fingerprints]: a map {server_id, tool_name} => fingerprint (ConduitMcp.Client.Fingerprint). A missing key is treated
as an empty map (every tool is new), which fails closed.
pin_tools: true(default) - a tool whose current fingerprint (tool.fingerprint, or computed fromtool.rawwhennil) differs from its approved one is decided by the pin, right after deny rules::ask(or:denywithout an approver),source: :pin,reason: :definition_changed.allow_new_tools: false(default) - a tool with no approved fingerprint is matched only by exact allow rules: a string rule whose server and tool segments are both literal ("fs/read"), or an:allcontaining one. Globs, bare names, regexes, functions and annotation or argument predicates do not allow it; it falls through to the default. Deny and ask rules apply to new tools as usual.
Default and approvers (PO-2)
ctx[:has_approver?] (default false) tells evaluation whether an
approver (on_ask) will run. Without one, evaluation never returns
:ask: an ask rule becomes :deny with reason: :no_approver, the pin
and an :ask default become :deny with their own reasons. The no-match
default is default: :ask (the default; :deny without an approver) or
default: :deny.
Runtime updates
update/2 stores a compiled policy in :persistent_term under
{ConduitMcp.Client.Policy, name}, and get/1 reads it in the caller.
There is no server process. :persistent_term updates are global and
trigger a scan of every process's heap, so update rarely (operator
changes, "always allow" persistence), not per call.
Summary
Types
A compiled rule: its id, matcher, and whether it names one exact target.
A compiled policy. Treat as opaque apart from reading options.
Functions
A policy that allows every tool call: allow: ["*/*"] with
allow_new_tools: true and pin_tools: false. ConduitMcp.Client.Hub
accepts policy: :allow_all for it; it is never a default (PO-2).
Compiles a policy. Raises ArgumentError naming the option or entry on
any invalid input.
Removes the policy stored under name, if any.
Decides a tool call. call and tool must name the same
{server_id, name} (ArgumentError otherwise).
Decides a prompts/get of prompt name on server_id, when :prompts is
in the policy's scope (otherwise :allow with reason: :out_of_scope).
Only prompt rules apply; there is no pinning. Reads ctx[:has_approver?]
like evaluate/4.
Decides a resources/read of uri on server_id, when :resources is
in the policy's scope (otherwise :allow with reason: :out_of_scope).
Only resource rules apply; there is no pinning. Reads
ctx[:has_approver?] like evaluate/4.
A function rule matching a call made while another server's output is in
the model's context (HB-3): the call's :taint set (ctx.taint, see
ConduitMcp.Client.Pipeline) holds a server id other than the called
tool's own server and not in :ignore.
Compiles policy (unless already compiled) and stores it under name in
:persistent_term, replacing any previous one. Raises like compile/1;
on a raise the stored policy is unchanged.
Types
A compiled rule: its id, matcher, and whether it names one exact target.
@type scope() :: :tools | :resources | :prompts
@type t() :: %ConduitMcp.Client.Policy{ allow: [rule()], allow_new_tools: boolean(), ask: [rule()], default: :ask | :deny, deny: [rule()], pin_tools: boolean(), scope: [scope()], trusted: true | MapSet.t(String.t()) }
A compiled policy. Treat as opaque apart from reading options.
Functions
@spec allow_all() :: t()
A policy that allows every tool call: allow: ["*/*"] with
allow_new_tools: true and pin_tools: false. ConduitMcp.Client.Hub
accepts policy: :allow_all for it; it is never a default (PO-2).
Compiles a policy. Raises ArgumentError naming the option or entry on
any invalid input.
Options
:deny,:ask,:allow- rule lists (default[]); see the moduledoc.:default-:ask(default) or:deny, for calls no rule matches.:scope- a non-empty subset of[:tools, :resources, :prompts](default[:tools]). Resource and prompt rules require their scope.:pin_tools- defaulttrue.:allow_new_tools- defaultfalse.:trusted- servers whose annotations are trusted:false(default),true(every server), a list orMapSetof server ids, or a map of server id to boolean.
A compiled policy is returned unchanged.
@spec delete(term()) :: :ok
Removes the policy stored under name, if any.
@spec evaluate(t(), ConduitMcp.Client.ToolCall.t(), ConduitMcp.Client.Tool.t(), map()) :: ConduitMcp.Client.Policy.Decision.t()
Decides a tool call. call and tool must name the same
{server_id, name} (ArgumentError otherwise).
ctx is the pipeline context; evaluation reads:
:has_approver?- whether an approver will run (defaultfalse);:approved_fingerprints-%{{server_id, tool_name} => fingerprint}(default%{});
and passes the whole ctx to function and {:args, fun/2} predicates.
Returns an :allow decision with reason: :out_of_scope when :tools is
not in the policy's scope.
@spec evaluate_prompt(t(), String.t(), String.t(), map()) :: ConduitMcp.Client.Policy.Decision.t()
Decides a prompts/get of prompt name on server_id, when :prompts is
in the policy's scope (otherwise :allow with reason: :out_of_scope).
Only prompt rules apply; there is no pinning. Reads ctx[:has_approver?]
like evaluate/4.
@spec evaluate_resource(t(), String.t(), String.t(), map()) :: ConduitMcp.Client.Policy.Decision.t()
Decides a resources/read of uri on server_id, when :resources is
in the policy's scope (otherwise :allow with reason: :out_of_scope).
Only resource rules apply; there is no pinning. Reads
ctx[:has_approver?] like evaluate/4.
The policy stored under name by update/2, or nil.
@spec tainted(keyword()) :: (ConduitMcp.Client.Tool.t(), map() -> boolean())
A function rule matching a call made while another server's output is in
the model's context (HB-3): the call's :taint set (ctx.taint, see
ConduitMcp.Client.Pipeline) holds a server id other than the called
tool's own server and not in :ignore.
ask: [{:all, [{:annotation, :open_world, true}, Policy.tainted(ignore: ["docs"])]}],
deny: [{:all, ["mail/send", Policy.tainted()]}]A call without a :taint option is never tainted. Use it in deny and ask
lists; in the allow list it would only widen access for tainted calls.
Options
:ignore- server ids whose output does not count (default[]).
Compiles policy (unless already compiled) and stores it under name in
:persistent_term, replacing any previous one. Raises like compile/1;
on a raise the stored policy is unchanged.