# `ConduitMcp.Client.Policy`
[🔗](https://github.com/nyo16/conduit_mcp/blob/v0.11.0/lib/conduit_mcp/client/policy.ex#L1)

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.

# `rule`

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

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

# `scope`

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

# `t`

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

# `allow_all`

```elixir
@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`

```elixir
@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`

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

Removes the policy stored under `name`, if any.

# `evaluate`

```elixir
@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 (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`

```elixir
@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`

```elixir
@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`

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

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

# `tainted`

```elixir
@spec tainted(keyword()) :: (ConduitMcp.Client.Tool.t(), map() -&gt; 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`

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

---

*Consult [api-reference.md](api-reference.md) for complete listing*
