Permissions¶
The adk_fluent._permissions package is adk-fluent’s decision-based permission
foundation. It mirrors Claude Agent SDK’s canUseTool
surface and the five
permission modes — default, accept_edits, plan, bypass, dont_ask —
while staying native to Google ADK’s plugin architecture.
A permission policy answers one question for every tool call:
Should this tool run, and with what arguments?
The answer is a structured PermissionDecision — not a
string — and the policy itself is a frozen dataclass you can compose, merge,
and hand around safely.
Quick start¶
from adk_fluent import Agent, H, PermissionMode
agent = (
Agent("coder", "gemini-2.5-flash")
.instruct("You are a senior engineer.")
.harness(
permissions=H.permissions(
mode=PermissionMode.ACCEPT_EDITS,
allow=["read_file", "grep"],
deny=["bash"],
ask=["write_file"],
),
)
)
import { Agent, H, PermissionMode } from "adk-fluent-ts";
const agent = new Agent("coder", "gemini-2.5-flash")
.instruct("You are a senior engineer.")
.harness({
permissions: H.permissions({
mode: PermissionMode.AcceptEdits,
allow: ["read_file", "grep"],
deny: ["bash"],
ask: ["write_file"],
}),
});
Everything the harness needs — policy, sandbox, usage, memory — is declared up front. The policy object is just data, so it travels across agents, subagents, and plugins without surprises.
The four pieces¶
Piece |
What it is |
Where it lives |
|---|---|---|
|
Declarative rules + mode |
|
|
Frozen dataclass describing the answer |
|
|
Session-scoped record of interactive approvals |
|
|
ADK |
|
All four are re-exported from the top-level adk_fluent package and from
the H harness namespace, so from adk_fluent import PermissionPolicy and
H.permissions(...) both work.
PermissionDecision¶
Every PermissionPolicy.check() call returns a PermissionDecision:
from adk_fluent import PermissionDecision
PermissionDecision.allow()
PermissionDecision.allow(updated_input={"path": "/safe/dir"})
PermissionDecision.deny("bash is disabled in this environment")
PermissionDecision.ask("Allow tool 'edit_file'?")
Four fields, three constructors, and a handful of predicates:
decision.is_allow # True if allowed
decision.is_deny # True if denied
decision.is_ask # True if deferred to a handler
decision.is_terminal # True if allow or deny (i.e. no handler needed)
decision.behavior # "allow" | "deny" | "ask"
decision.reason # populated on deny
decision.prompt # populated on ask
decision.updated_input # populated on allow when rewriting args
Decisions are frozen dataclasses, which means they are safe to cache,
compare, and pass between threads. The updated_input field is how policies
rewrite arguments before a tool runs — think “sanitise the path” or “strip
a secret”. The plugin applies it by mutating tool_args in place, the same
trick HookDecision.modify uses.
Permission modes¶
Five modes match Claude Agent SDK 1:1:
Mode |
Posture |
|---|---|
|
Ask for everything not explicitly allowed |
|
Auto-allow mutating file ops; ask for the rest |
|
Deny every mutating tool (read-only exploration) |
|
Allow everything except explicit denies |
|
Deny everything not explicitly allowed (non-interactive) |
PermissionMode provides the constants:
from adk_fluent import PermissionMode
PermissionMode.DEFAULT # "default"
PermissionMode.ACCEPT_EDITS # "accept_edits"
PermissionMode.PLAN # "plan"
PermissionMode.BYPASS # "bypass"
PermissionMode.DONT_ASK # "dont_ask"
The H namespace exposes one-liner factories for each mode:
H.permissions_plan() # plan mode, read-only by default
H.permissions_bypass() # bypass mode
H.permissions_accept_edits() # accept_edits mode
H.permissions_dont_ask(allow=["read_file"]) # non-interactive
These return a PermissionPolicy — they are shortcuts for
PermissionPolicy(mode=...) with sensible defaults.
Precedence rules¶
The policy’s check(tool_name, tool_input) method walks a fixed precedence
chain. Learn it once and the rest of the system becomes predictable.
1. tool_name in deny → deny
2. matches any deny pattern → deny
3. mode == BYPASS → allow
4. mode == PLAN and tool is mutating → deny
5. tool_name in allow → allow
6. matches any allow pattern → allow
7. mode == ACCEPT_EDITS and mutating → allow
8. mode == DONT_ASK → deny
9. tool_name in ask → ask
10. matches any ask pattern → ask
11. fallback based on mode → ask | deny
Three invariants fall out of this chain:
Deny always wins. You can never “allow through” an explicit deny.
Plan mode denies mutating tools even if they are on the allow list. The point of plan mode is to prove the agent can describe its plan without side effects. An allow list cannot override that.
Arguments are never inspected by the policy. Content-level filtering (path globs, command substrings) belongs in hooks, not the policy object. Keeping the policy argument-free is what makes it composable.
Composing policies¶
Policies compose via merge and are otherwise immutable:
base = H.auto_allow("read_file", "grep", "list_dir")
strict = H.deny_patterns("*secret*", "*.env")
policy = base.merge(strict).with_mode(PermissionMode.DEFAULT)
Merge semantics:
denyunions — any side’s deny wins.allowunions, minus anything in the combined deny.askunions, minus anything in the combined allow or deny.Pattern tuples concatenate.
The non-default
modewins if the two sides disagree.mutating_toolsunions.
Because every policy is frozen, you can keep a library of reusable fragments
(STRICT_NETWORK, READ_ONLY_FS, DESTRUCTIVE_COMMANDS) and merge them on
demand without worrying about shared mutable state.
Patterns: glob vs regex¶
Policies accept glob (default) or regex patterns:
PermissionPolicy(
allow_patterns=("read_*", "list_*"),
deny_patterns=("*secret*",),
pattern_mode="glob", # or "regex"
)
Regex patterns use re.fullmatch — you do not need explicit anchors, but
partial matches are rejected. Glob patterns use fnmatch.fnmatchcase, so
matching is case-sensitive and OS-independent.
Interactive approval¶
Policies that evaluate to ask defer to a user-provided handler. The
PermissionPlugin runs the handler exactly once per unique (tool, args)
pair and persists the answer in an ApprovalMemory so repeat calls are
resolved without re-prompting.
from adk_fluent import H
async def approve(tool_name, tool_args, decision):
print(f"Agent wants to run {tool_name}({tool_args}).")
print(f" reason: {decision.prompt}")
return input("Allow? [y/N] ").lower().startswith("y")
plugin = H.permission_plugin(
H.ask_before("bash", "edit_file"),
handler=approve,
memory=H.approval_memory(),
)
Both sync and async handlers are supported — the plugin awaits awaitables
automatically. Exceptions raised by a handler become deny decisions (same
defensive stance as the hooks layer).
Installing the plugin at the App layer¶
The canonical runtime for permissions is a session-scoped ADK plugin:
from adk_fluent.backends.adk import compile_app
app = compile_app(agent, plugins=[plugin])
Because ADK plugins are session-scoped and subagent-inherited, a single plugin covers every tool call — including calls made by child agents, manifold capabilities, and dynamically spawned subagents. You do not need to walk the agent tree or re-install the plugin per branch.
Agent-level callback adapter¶
For surfaces that still expose permissions as an agent-level callback — most
notably .harness() on the fluent builder — a synchronous adapter produces
an ADK-compatible before_tool_callback:
from adk_fluent._permissions._callback import make_permission_callback
cb = make_permission_callback(
policy,
handler=approve_sync, # must be sync in this path
memory=H.approval_memory(),
)
The adapter enforces the same precedence rules as PermissionPlugin but runs
in the sync ADK callback path. Use it when you cannot reach the App layer;
otherwise prefer the plugin.
ApprovalMemory¶
ApprovalMemory is a tiny in-memory store keyed by
(tool_name, sha256(json.dumps(args))):
mem = H.approval_memory()
mem.remember_specific("bash", {"cmd": "ls"}, True) # args-specific
mem.remember_tool("read_file", True) # tool-wide
mem.recall("bash", {"cmd": "ls"}) # → True
mem.recall("bash", {"cmd": "rm -rf /"}) # → None
mem.clear()
None means “not remembered”; True/False are the recorded verdicts.
Passing a memory object into the plugin is the difference between “ask me
once per session” and “ask me every time”.
Cookbook¶
Read-only exploration during planning¶
agent.harness(
permissions=H.permissions_plan(allow=["read_file", "grep", "list_dir"]),
)
Trusted CI runner (non-interactive)¶
agent.harness(
permissions=H.permissions_dont_ask(
allow=["read_file", "write_file", "bash"],
),
)
Coding agent with approval prompts for shell¶
agent.harness(
permissions=(
H.permissions_accept_edits(ask=["bash", "streaming_bash"])
.merge(H.deny("rm", "sudo", "curl"))
),
approval_handler=approve_sync,
)
Sanitising arguments before a tool runs¶
Subclass PermissionPolicy and return allow(updated_input=...):
from adk_fluent import PermissionPolicy, PermissionDecision
class ClampPathsPolicy(PermissionPolicy):
def check(self, tool_name, tool_input=None):
base = super().check(tool_name, tool_input)
if base.is_allow and tool_name == "read_file":
path = (tool_input or {}).get("path", "")
if path.startswith("/etc/"):
return PermissionDecision.deny("refusing to read /etc/*")
if ".." in path:
return PermissionDecision.allow(
updated_input={"path": path.replace("..", "")}
)
return base
The plugin mutates the tool’s argument dict in place, so by the time the tool runs, it sees the rewritten input.
Relationship to hooks¶
Permissions and hooks are orthogonal plugins:
Permissions answer “may this tool run?”.
Hooks transform the tool call — inject nudges, redact secrets, log, replace arguments.
The permission plugin sits before the hook plugin in the ADK chain, so a
denied tool never fires its hooks. Both plugins are session-scoped, so
installing them on the root App covers every subagent automatically.
API reference¶
PermissionPolicy¶
PermissionPolicy(
mode: str = PermissionMode.DEFAULT,
allow: frozenset[str] = frozenset(),
deny: frozenset[str] = frozenset(),
ask: frozenset[str] = frozenset(),
allow_patterns: tuple[str, ...] = (),
deny_patterns: tuple[str, ...] = (),
ask_patterns: tuple[str, ...] = (),
pattern_mode: str = "glob", # or "regex"
mutating_tools: frozenset[str] = DEFAULT_MUTATING_TOOLS,
)
Methods:
.check(tool_name, tool_input=None) → PermissionDecision.merge(other) → PermissionPolicy.with_mode(mode) → PermissionPolicy.is_mutating(tool_name) → bool
PermissionDecision¶
Constructors: .allow(updated_input=None), .deny(reason), .ask(prompt).
Predicates: .is_allow, .is_deny, .is_ask, .is_terminal.
PermissionPlugin¶
PermissionPlugin(
policy: PermissionPolicy,
*,
handler: PermissionHandler | None = None,
memory: ApprovalMemory | None = None,
name: str = "adkf_permission_plugin",
)
The plugin exposes .policy and .memory properties and implements the
ADK before_tool_callback hook.
ApprovalMemory¶
.remember_specific(tool_name, args, granted).remember_tool(tool_name, granted).recall(tool_name, args) → bool | None.clear()
H namespace factories¶
H.permissions(mode=..., allow=..., deny=..., ask=..., ...)H.permissions_plan(allow=...)H.permissions_bypass()H.permissions_accept_edits(ask=...)H.permissions_dont_ask(allow=...)H.ask_before(*tools)·H.auto_allow(*tools)·H.deny(*tools)H.allow_patterns(*patterns, mode="glob")·H.deny_patterns(*patterns)H.permission_plugin(policy, handler=None, memory=None)H.permission_decision()— returns thePermissionDecisionclassH.approval_memory()— freshApprovalMemoryinstance