What answers session/request_permission, per tool.
Gate 3 of Fountain's ADR 0014, built in #939; the issue numbers here are that repository's.
Every runtime the platform shipped used to run with its safety rail removed
by a vendor flag. Three of those four flags went with the legacy spawn path
(#671-#675); what replaced them was this module's predecessor — a constant in
Managoat.ACP.Peer that answered every request by picking allow_always,
else allow_once, else whatever was offered first. The rail was still off,
but it was off in one function we own, which is what made it a policy
problem rather than a fork-four-CLIs problem.
The shape
A policy is a map of key to verdict, plus a "default" key:
%{"default" => "auto_allow", "execute" => "ask"}A request is matched most-specific-first (verdict_for_request/2): the tool
card's title, then ACP's coarse kind, then "default".
Reach for kind unless you know the runtime's titles. claude-agent-acp,
which every shipped agent runs, titles a tool call with the command itself
— curl -sS … https://example.com — so a title key matches exactly one
invocation and nothing else. kind is the stable half: execute, edit,
read, delete, move, search, fetch, think, other. Measured live
on 2026-08-22; see verdict_for_request/2.
Narrow, never widen
An agent holds a policy; a launch may supply its own. The launch may only
make the policy more restrictive. That single rule is the no-escalation
guarantee, and it is why there is no allowed_permission_policies list beside
it the way environment_id needs allowed_environment_ids — a launch cannot
reach anything the agent did not already permit, so there is nothing to
allow-list.
It is enforced twice, deliberately:
check_narrows/2rejects a widening launch at the door, so the caller gets an error naming the tool rather than a silent downgrade.effective/2clamps — the result is the more restrictive of the two for every tool. This is the invariant the peer actually depends on, and it means an agent that tightens its policy later also tightens every conversation already running under it. Storing a pre-merged policy on the conversation would have frozen the old, looser one.
Verdicts
| verdict | what answers |
|---|---|
auto_allow | today's behaviour: allow_always, else allow_once, else the first option offered |
auto_deny | a reject_* option when the agent offered one, cancelled when it did not |
ask | a human, over the stream, within the timeout — then deny (#940) |
auto_allow is the default, so adopting a policy changes nothing until
someone writes one.
Never synthesise an option
auto_deny picks from what the agent offered and never invents an id. An
adapter that offers no rejection gets cancelled, which is the protocol's own
"no option was selected" and the only honest answer available. Inventing an
optionId the agent never advertised would at best error and at worst select
something unrelated.
The client does not remember an answer, and "always" often does not either
An answer is relayed to the peer and forgotten. Every "remember this"
semantic belongs to the agent, and each of the three that ask implements it
differently. Measured live against production on 2026-08-22 — do not
re-derive, and do not assume an allow_always option means what it says:
| runtime | what allow_always does |
|---|---|
| claude | Writes a rule into .claude/settings.local.json in the sandbox. Holds across turns, dies with the sandbox. Except where the command writes outside the working directory — see below. |
| codex | "Allow for Session" lives in the codex process, so it lasts exactly one turn. "Allow Commands Starting With …" (accept_execpolicy_amendment) is written to disk and holds. |
| opencode | Never asks, so there is nothing to grant (#959). |
Two consequences worth holding on to. codex's session grant is the client's, not a defect: a connection that ends at turn end tears that process down, which is the same lever as #817. And every grant dies with the sandbox, so a sandbox reclaim silently resets consent.
claude's exception is upstream and is a prompt loop no clicking resolves
(anthropics/claude-code#88919). A shell redirect to a path outside the
session's working directory is gated by a filesystem write check that no
Bash(...) or Read(...) rule participates in, and allow_always only ever
proposes those. So it writes a well-formed, general, irrelevant rule, and the
byte-identical command asks again — measured twice within a single turn, in
one process. The rule of thumb: allow_always holds only where the command
writes nothing outside its cwd.
None of this is worked around here. #996 is where the question of whether the client should own grants at all is being decided; a matcher written before that decision would be a second permission authority arriving by accident.
Configuration
None. The policy is an argument, and ask_timeout_ms/1 takes the
configured number of seconds as its argument too — the host reads its own
configuration and passes it, so the library never reads anyone's.
Summary
Functions
How long a held ask waits for a human before it is denied, in
milliseconds, from a configured number of seconds.
Whether verdict can be honoured.
Verdicts that can be honoured.
Whether launch is at least as restrictive as agent for every tool.
The verdict applied when a policy names neither the tool nor a default.
The outcome that refuses a request, from the options the agent offered.
Merge an agent's policy with a launch's, taking the more restrictive of the two for every tool either of them names.
Whether a policy asks for anything the runtime has to act on.
The session/request_permission result for params under policy.
The policy keys one request matches, most specific first.
Which verdict of the two withholds more.
ACP's own tool kinds, which are the policy keys worth offering in a form.
The tool a permission request is about, as the transcript labels it.
The verdict policy gives tool.
The verdict policy gives one session/request_permission.
Every verdict a policy may name.
Functions
@spec ask_timeout_ms(term()) :: pos_integer()
How long a held ask waits for a human before it is denied, in
milliseconds, from a configured number of seconds.
Deny on expiry is the only safe default. configured is whatever the host
read from its own configuration: a positive integer, a numeric string, or
nil for the default (300 seconds). Anything
else is the default too — a misconfiguration must not become "never deny".
A value at or above the host's idle-reclaim bound is a misconfiguration,
for the reason in the comment above this function.
Whether verdict can be honoured.
@spec buildable_verdicts() :: [String.t()]
Verdicts that can be honoured.
All three, since #940 built somewhere to ask: the peer holds the request
open, the request renders as a permission_request block, and
POST /api/conversations/:id/requests/:request_id answers it. Before that
ask was a valid policy value with nowhere to ask, and both doors refused
it.
Kept as its own list rather than folded into verdicts/0 because the
published OpenAPI enum reads from here: a verdict the API would 422 on must
not be advertised as accepted.
@spec check_narrows(map() | nil, map() | nil) :: :ok | {:error, {:permission_policy_widens, String.t()}}
Whether launch is at least as restrictive as agent for every tool.
Returns :ok, or {:error, {:permission_policy_widens, tool}} naming the
first tool the launch would loosen. The check spans the union of both key
sets and the defaults, because a launch that only lowers "default" still
loosens every tool the agent covered by its own default.
@spec default_verdict() :: String.t()
The verdict applied when a policy names neither the tool nor a default.
The outcome that refuses a request, from the options the agent offered.
Public because #940's timeout and end-of-turn paths refuse a held request
directly, without a policy in hand — the policy already said ask, and what
is being decided at that point is only that nobody answered.
Merge an agent's policy with a launch's, taking the more restrictive of the two for every tool either of them names.
Clamping rather than replacing is what makes the launch override safe by construction: no merge can produce a verdict looser than the agent's, whatever was stored, and a later tightening of the agent applies to conversations already running.
Whether a policy asks for anything the runtime has to act on.
auto_allow everywhere is what the peer would do with no policy at all, so it
needs nothing from the runtime. Anything else has to reach the agent as an
answer to session/request_permission — which is why a host that knows a
runtime never sends one should refuse such a policy at the door.
The session/request_permission result for params under policy.
Returns the inner outcome object — the caller wraps it, because ACP's
response body is {"outcome": {"outcome": …}}.
The policy keys one request matches, most specific first.
The tool card's title, then ACP's kind. Empty when the request carries no
tool call at all, which falls through to the policy's default.
Which verdict of the two withholds more.
@spec tool_kinds() :: [String.t()]
ACP's own tool kinds, which are the policy keys worth offering in a form.
The protocol's closed list, so it is the stable half of a request: a title
matches one invocation of one command on claude (#958), while a kind means
the same thing on every runtime and every turn. A policy may still name a
title — verdict_for_request/2 tries that first — it is simply not something
to invite someone to type.
The tool a permission request is about, as the transcript labels it.
Mirrors Managoat.ACP.Blocks' tool naming so a policy key is the string a user reads on
the tool card. nil when the request carries no tool call at all, which falls
through to the policy's default.
The verdict policy gives tool.
Falls back to the policy's own "default", then to auto_allow.
The verdict policy gives one session/request_permission.
Tries the request's keys in order — the tool card's title first, then ACP's
coarse kind — and falls through to the policy's "default".
The kind fallback is what makes a per-tool policy writable. Measured
against claude-agent-acp 0.66 in production on 2026-08-22: its toolCall.title
is the command itself (curl -sS -o /dev/null -w "%{http_code}" https://…),
not the tool. A title key can therefore only ever match one invocation, which
left "default" as the only usable key on the runtime every shipped agent
runs — a per-tool policy in name only. kind (execute, edit, read,
delete, move, search, fetch, think, other) is the stable half of
the same request, so %{"execute" => "ask"} means "ask before running
anything in the shell" and keeps working across invocations.
Title stays first: an exact-title rule is the more specific of the two, and a
runtime whose titles are tool names (Bash, Edit) keeps behaving as
0014 gate 3 described.
@spec verdicts() :: [String.t()]
Every verdict a policy may name.