Ask Permissions
In this guide you will learn how the ask-permissions gem gates every tool call before it runs. You will write allow / ask / deny rules, classify calls with PermissionRules#classify, choose an ApprovalPolicy mode, read tool metadata (risk_level, side_effect_scope, always_ask), and resolve human approvals through ApprovalQueue.
By the end you will know what the host owns, what the safe defaults are, and what the gem deliberately does not do.
The stack is three classes under Ask::Permissions::*:
| Class | Job |
|---|---|
PermissionRules | Ordered allow / ask / deny patterns, evaluated on each call |
PermissionRuleSet | Composes default and project rule layers with deny-first precedence |
ApprovalPolicy | Mode-aware before_tool hook (full_access / ask_before_changes / read_only) that applies rules plus tool metadata |
ApprovalQueue | Holds pending actions (submit / pending_actions / approve / reject) plus versioned snapshot / restore_pending |
PermissionRules,ApprovalPolicy, andApprovalQueueused to live underAsk::Agent.ask-agentdepends onask-permissionsat runtime, soapproval:sessions work without extra setup. Projects that reference these classes directly must also declaregem "ask-permissions".
1. Install the gate
Add the gem and require it only when you build rules, policies, or queues yourself. Passing approval: to a session pulls the stack in through ask-agent.
# Gemfile
gem "ask-permissions"
gem "ask-tools" # if you define Ask::Tool classes or use tool metadata
require "ask-tools"
require "ask-permissions"
2. Write PermissionRules with allow / ask / deny
PermissionRules is an ordered list. You declare tool patterns and optional argument patterns. First match wins when you call #classify.
Tool patterns accept an exact name ("bash"), a Symbol (:bash), a Regexp, or :all. Argument patterns accept a Regexp, a substring, or nothing at all (match any arguments). Hash arguments are matched as JSON.
require "ask-permissions"
rules = Ask::Permissions::PermissionRules.new do |r|
r.allow :bash, /git (status|log|diff)/
r.ask :bash, /rm -rf/
r.deny :write, %r{/\.env(\.local)?}
r.ask :destroy
end
rules.classify(:bash, { command: "git status" })
# => :allow — first rule matches, runs without asking
rules.classify(:bash, { command: "rm -rf /tmp/cache" })
# => :ask — queued for a human
rules.classify(:write, { path: "config/.env" })
# => :deny — blocked outright, never queued
What to remember:
:denyblocks.:askqueues for a human.:allowproceeds.- Rules are evaluated in declaration order. Put specific rules first.
- A call that matches no rule falls through to the policy default (see section 4). There is no implicit deny: an empty ruleset is an open door.
- The gem does not choose or persist project identity or policy. Hosts may store a versioned rules snapshot using their own trusted storage and authorization boundary.
3. Layer project rules without weakening defaults
Use PermissionRuleSet when a host supplies workspace-specific rules on top of application defaults. A matching deny in either layer always wins. If neither layer denies, a matching project rule overrides the default; if the project layer has no match, the default decision applies. An unmatched call falls through to ApprovalPolicy as before.
defaults = Ask::Permissions::PermissionRules.new do |r|
r.deny :bash, /rm\s+-rf/
r.ask :write
end
project = Ask::Permissions::PermissionRules.new do |r|
r.allow :write # permit writes in this workspace
r.allow :read_file
end
approval = {
rules: defaults,
project_rules: project,
mode: :ask_before_changes
}
session = Ask::Agent::Session.new(model: "gpt-4o", tools: tools, approval: approval)
The project allow cannot override the default bash deny (nor can any project rule override a deny in either layer). Project rules are evaluated against the same tool name and arguments as defaults.
PermissionRules#snapshot and PermissionRuleSet#snapshot return versioned, JSON-safe data. Restore with .from_snapshot; validate and authorize the workspace identity in the host before using restored rules. The permissions gem intentionally provides no database adapter or project directory: hosts such as a Rails application decide where rules live, who can edit them, and which verified project they apply to. This is separate from project_grants, which records approval of a whole tool for future calls.
Wire the rules into a session:
require "ask-agent"
session = Ask::Agent::Session.new(
model: "gpt-4o",
tools: [Bash, Write, Destroy],
approval: { rules: rules }
)
session.run("Check git status, then clean the cache")
# git status runs, rm -rf waits in session.approval_queue
4. Dangerous allows become asks by default
A universal :allow on a code-executing tool is too broad to be safe. If you allow bash, code, repl, or :all without an argument pattern, the ruleset rewrites that rule to :ask automatically. “Approve once” can never silently become “approve anything”.
require "ask-permissions"
rules = Ask::Permissions::PermissionRules.new do |r|
r.allow :bash # universal allow — converted to :ask
end
rules.classify(:bash, { command: "echo hello" })
# => :ask — the dangerous-allow guard rewrote it
rules.dangerous_rules
# => lists the rewritten rules so you can see what was caught
Opt out only when you mean it, for example in a throwaway sandbox:
require "ask-permissions"
rules = Ask::Permissions::PermissionRules.new(auto_allow_dangerous: true) do |r|
r.allow :bash
end
rules.classify(:bash, { command: "echo hello" })
# => :allow — you accepted the risk explicitly
Keep the default. Pass
auto_allow_dangerous: trueonly in environments where arbitrary code execution is already expected, never for a user-facing agent.
5. Pick an ApprovalPolicy mode
ApprovalPolicy is the before_tool hook that sits between the model and execution. It combines your PermissionRules with a coarse mode:
| Mode | Effect |
|---|---|
:full_access | :deny rules still block and :ask rules still queue; otherwise calls proceed, including high-risk tools unless always_ask is set |
:ask_before_changes | :deny blocks, :ask queues, and side-effecting or high/critical-risk tools queue unless an explicit :allow rule matches |
:read_only | Tools with side effects are blocked; rules still apply to read-only calls |
require "ask-permissions"
queue = Ask::Permissions::ApprovalQueue.new
policy = Ask::Permissions::ApprovalPolicy.new(
queue: queue,
rules: rules,
tools: tool_registry,
mode: :ask_before_changes
)
decision = policy.before_tool_call(tool_call, context)
case decision[:action]
when :proceed then executor.run(tool_call)
when :block then executor.refuse(decision[:reason])
when :pending then executor.pause(tool_call, decision[:action_id])
end
When the reviewer responds, resolve by id through the queue (see section 6):
queue.approve(decision[:action_id])
# or:
queue.reject(decision[:action_id])
Use the mode for the environment and the rules for the call. Production that should never write is :read_only. Staging that may write with a human in the loop is :ask_before_changes. See per-environment permissions for how the Rails harness sets env.mode.
Harness environment modes
Ask::Ruby::Harness turns that per-environment config into the session’s approval mode. The env.mode for the running environment is forwarded to Ask Agent as approval: { mode: ... } when agent_session builds the session, so the policy from this section is what enforces the environment — you configure once and the same modes, queue, and metadata rules apply:
Ask::Ruby::Harness.configure do |config|
config.environment :production do |env|
env.mode = :read_only
env.allowed_commands = [/^bundle /]
env.denied_commands = [/rm/, /dropdb/]
end
end
session = Ask::Ruby::Harness.agent_session
# the session runs ApprovalPolicy with mode: :read_only
Three details matter in practice:
- The command allow/deny filters are additional
RunCommandchecks, notPermissionRules. They narrow that one tool on top of whatever the mode and your rules already decide; every other tool is left alone. :ask_before_changesis actionable, not advisory. Queued calls land insession.approval_queue.pending_actions, and you resolve each id withqueue.approve(id)orqueue.reject(id)— the same queue as section 6, reached through the harness-built session.:read_onlynever queues side effects: calls with a side-effectingside_effect_scope,:unknownincluded, are blocked outright (section 5).
An explicit mode that conflicts with the configured env.mode is an ArgumentError, not an override — the harness refuses to guess which one you meant.
6. Read tool metadata: risk, scope, and always_ask
Rules see the tool name and arguments. Metadata sees what kind of tool it is. ApprovalPolicy consults both. Precedence matters: :deny and :ask rules are handled first, then always_ask, then read-only side-effect blocking, then an explicit :allow rule. The ordinary mode and risk checks apply after those decisions.
Declare metadata on the tool:
require "ask-tools"
class SendEmail < Ask::Tool
risk_level :high # or :critical for irreversible actions
side_effect_scope :external
always_ask true # hard confirmation, never bypassed
param :to, type: :string, desc: "Recipient", required: true
param :body, type: :string, desc: "Message", required: true
def execute(to:, body:)
Ask::Result.ok(data: "Email sent to #{to}")
end
end
What each field means:
risk_level—:highor:criticalmarks a tool as changing something important. In:ask_before_changesmode these queue even without a matchingaskrule. An explicit:allowrule can override this ordinary risk check;always_askcannot be overridden.:full_accessalso bypasses the ordinary risk check.side_effect_scope— where the effect lands::none,:session,:workspace,:project,:system,:external, or:unknown. Treat:unknownas side-effecting. Any scope other than:nonequeues under:ask_before_changesand blocks under:read_only, unless an earlier explicit:ask/:denyrule already decided the call. An explicit:allowrule bypasses the:ask_before_changesscope check, but not read-only mode.always_ask— hard confirmation. Even an:allowrule never bypasses it. Use it for irreversible or outward-facing tools (send email, publish, delete, charge, deploy).
require "ask-permissions"
rules = Ask::Permissions::PermissionRules.new do |r|
r.allow :send_email, /bob@example\.com/
end
# SendEmail has always_ask true, so the policy still queues it:
rules.classify(:send_email, { to: "bob@example.com" }) # => :allow
# ApprovalPolicy#before_tool_call returns :pending — always_ask wins
If you remember one sentence:
allowmeans “you may skip the queue”,always_askmeans “there is no queue-skipping for this tool”.
7. Resolve the ApprovalQueue
The queue holds pending actions in process memory. You submit, list, approve, or reject. The queue invokes its callback; the host decides how the approval or rejection resumes the saved call.
action_id = queue.submit(
tool_call_id: "call_123",
tool_name: "send_email",
args: { to: "bob@example.com" },
message: "Send this email?"
)
action = queue[action_id]
queue.pending_actions
# => [#<Action id: 1, tool_name: "send_email", args: {...}, status: :pending, ...>]
queue.approve(action_id) # => [resolved Action]; invokes on_approve(action)
# Instead of approving, reject the same still-pending action:
queue.reject(action_id) # => [resolved Action]; invokes on_reject(action)
The queue itself does not execute tools. Its one-argument callbacks are where the host resumes or refuses the saved tool call. In an Ask::Agent::Session, the session wires those callbacks for you.
Through a session the same queue is exposed as session.approval_queue:
session.approval_queue.pending_actions
session.approval_queue.approve(1) # or reject(1), not both
The agent never blocks on approval: the tool call resolves as pending, the conversation continues, and the completed result re-enters the loop when you approve.
Snapshot and restore pending only
snapshot captures a versioned copy of pending actions. restore_pending replaces the pending list with that copy. It restores data only: no callbacks fire on restore, and decided actions (approved / rejected) are not carried over.
snapshot = queue.snapshot
# => { version: 1, next_id: 1, pending_actions: [...] }
restored_queue = Ask::Permissions::ApprovalQueue.new(
on_approve: ->(action) { resume_saved_call(action) },
on_reject: ->(action) { refuse_saved_call(action) }
)
restored_queue.restore_pending(snapshot)
# Restores into an empty queue; no on_submit / on_approve / on_reject fires.
Use snapshots to survive a host restart or to hand pending work to another process. On restore, re-present each action in your review UI because the original callbacks will not re-fire.
Approval scopes in Ask Agent
The permissions queue records whether an explicit approval is :once, :session, or :project. The queue does not apply those choices itself: the host decides which scopes it supports. Ask Agent applies :session by granting that whole tool for the current session; later matching calls skip the ordinary approval queue. :once stays one-shot, and Ask Agent does not turn :project into a session grant.
Ask Agent includes session grants in both Session.persist! / Session.load and SessionAdapter snapshots / resume. Other hosts using ApprovalPolicy directly must persist and restore SessionPermissionGrants#snapshot themselves. The app-server offers project only when a workspace identity is available; it stores those grants by a hashed canonical workspace identity in its configured ask-state-providers backend. Without a workspace it offers only once and session, and rejects a project request instead of silently downgrading it.
Approval scopes in the AskAgent adapter
The AskAgent adapter from ask-coding-providers (Ask::CodingProviders, registry name :ask_agent) answers the scope question for hosts that drive an ask-agent session through an adapter. It exposes two methods, and both apply the scopes the queue records:
adapter.approve_action(sid, action_id, scope: :once) # this call only
adapter.approve_action(sid, action_id, scope: :session) # this tool, this session
adapter.approve_all(sid, scope: :session) # drain the pending queue
scope: accepts :once or :session. :project is unsupported and rejected rather than downgraded: the adapter does not inject a project grant collaborator, so a project grant would have nowhere to live. Offer :project only through a host that provides the store — the app-server, in the paragraph above.
8. What the host owns
The gem classifies and queues. Your app executes, pauses, resumes, and renders. Concretely, the host is responsible for:
- Adapting its call object to
before_tool_call(tool_call, context)and honoring the three decisions::proceed,:block,:pending. - Storing suspended calls (
tool_call_id/action_id) and resuming or refusing them afterapprove/reject. - Building the review UI: who sees pending actions, in what order, with what argument preview and redaction.
- Persisting an audit trail. The queue is in-memory by design, so pair it with an append-only log (the
AuditLogpolicy, or the Rails audit log) if you need a durable record of what was approved and what ran. - Choosing
snapshotdiscipline: when to snapshot, where to store the versioned payload, and how to re-present restored pending actions. A host that restores pending work must reconnect each action to its saved tool call; the queue snapshot alone cannot recreate host execution state.
9. Safe defaults checklist
Start here, then relax deliberately:
- Mode
:ask_before_changesunless the environment is explicitly full access or read-only. - Keep the dangerous-allow guard on. Do not pass
auto_allow_dangerous: truein user-facing apps. - Add a
denyrule for secrets first, for exampler.deny :write, %r{/\.env(\.local)?}andr.ask :bash, /rm -rf/. - Mark tools that must always require a person with
always_ask true. Userisk_level :highor:criticaland an honestside_effect_scopeto strengthen ordinary modes; remember that:full_accessand an explicit:allowrule bypass ordinary risk checks. Remember:unknownmeans most restrictive. - Offer
sessionandprojectscopes only when the host can apply and retain those grants; otherwise offer only the scopes it actually supports. - Resolve or snapshot pending approvals before shutdown. Accept that an unsnapshotted restart loses the queue.
- Log every decision. Classification without an audit trail is not a safety story.
10. What permissions does not do
To avoid surprises, the gem deliberately does not:
- Persist rules or provide a project-grant store. There are no remembered
PermissionRulesto load later; rebuild them from reviewable code on every boot.SessionPermissionGrantsholds whole-tool grants in memory and exposes a versioned snapshot. Ask Agent persists that snapshot as session state; hosts using the permissions gem directly must persist and restore it. - Persist project grants. The protocol can carry
once,session, andprojectresolution choices, and the queue records the selected choice on its resolvedAction; the host decides which scopes it can honor and applies the matching grant. Ask App Server provides a workspace-scoped store backed byask-state-providers; other hosts must provide their own. This is distinct from a tool’sside_effect_scope, which describes its impact rather than how long an approval lasts. - Execute tools, pause sessions, or render UI. Those are host responsibilities (see section 7).
More in this series
- The Agent Loop — how
approval:sessions enqueue:askcalls and resume afterapprove/reject. - Tools and Execution — declaring tools and their metadata (
risk_level,side_effect_scope,always_ask). - Rails Setup — Per-Environment Permissions — setting
env.modeso each environment gets its own default. - Core Components — the full component index.
Next: build a small ruleset for your own tools, run it through #classify with safe and dangerous arguments, and confirm the dangerous-allow guard and always_ask behave as this guide describes before wiring it to a live agent.