ConduitMcp.Client.Policy (ConduitMCP v0.11.0)

Copy Markdown View Source

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; server is 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) or fun.(args, ctx) on the call's final arguments; ConduitMcp.Client.Policy.Paths builds 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 explicit rule_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 from tool.raw when nil) differs from its approved one is decided by the pin, right after deny rules: :ask (or :deny without 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 :all containing 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.

t()

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.

The policy stored under name by update/2, or nil.

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

rule()

@type rule() :: {term(), matcher(), boolean()}

A compiled rule: its id, matcher, and whether it names one exact target.

scope()

@type scope() :: :tools | :resources | :prompts

t()

@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

allow_all()

@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).

compile(policy)

@spec compile(keyword() | t()) :: t()

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 - default true.
  • :allow_new_tools - default false.
  • :trusted - servers whose annotations are trusted: false (default), true (every server), a list or MapSet of server ids, or a map of server id to boolean.

A compiled policy is returned unchanged.

delete(name)

@spec delete(term()) :: :ok

Removes the policy stored under name, if any.

evaluate(policy, call, tool, ctx)

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 (default false);
  • :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.

evaluate_prompt(policy, server_id, name, ctx)

@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.

evaluate_resource(policy, server_id, uri, ctx)

@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.

get(name)

@spec get(term()) :: t() | nil

The policy stored under name by update/2, or nil.

tainted(opts \\ [])

@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 []).

update(name, policy)

@spec update(term(), keyword() | t()) :: :ok

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.