Skip to content

feat: per-tool permission policies with session-based escalation #139

@sk-ticmint

Description

@sk-ticmint

Summary

Add a per-tool permission policy system that enables orchestrating agents to control which tools are auto-approved, auto-denied, or escalated — using acpx's existing session persistence for the escalation flow.

This addresses the Permission policies item from the ACP coverage roadmap — per-tool rules beyond the current all-or-nothing modes. The escalation flow naturally enables multi-agent orchestration as a downstream use case.

Problem

Today, session/request_permission supports three modes: approve-all, approve-reads, deny-all. When no TTY is available (agent-to-agent, CI/CD), permission requests are either silently denied or cause the session to crash (nonInteractivePermissions: fail).

This forces orchestrators to use approve-all — losing all safety guarantees. There is no way to:

  1. Auto-approve safe tools (Read, Glob, Grep) while blocking dangerous ones (rm, Write)
  2. Let an orchestrating agent review specific tool calls before allowing them
  3. Have the sub-agent pause and let the orchestrator decide

Relationship with #49

#49 proposes --allowed-tools for tool availability — controlling which tools the agent can see. This proposal is about tool approval — controlling which available tools require permission before execution.

They compose naturally:

--allowed-tools ["Read","Glob","Bash"]   → agent can only use these 3 tools
--policy '{"autoApprove":["Read","Glob"],"escalate":["Bash"]}'  → of those 3, Bash needs approval

Complementary, not overlapping.

Proposed Solution

1. Per-tool policy (new permission mode)

A new --policy flag accepts a JSON file or inline JSON:

# Via policy file
acpx --policy ./policies/read-only.json claude -s task "Check this repo"

# Via inline JSON
acpx --policy '{"autoApprove":["Read","Glob","Grep"],"autoDeny":["Write"],"escalate":["Bash"]}' claude -s task "Check this repo"

Policy schema:

{
  "autoApprove": ["Read", "Glob", "Grep", "WebSearch"],
  "autoDeny": ["Write", "WebFetch"],
  "escalate": ["Bash", "Edit"],
  "defaultAction": "escalate"
}
  • autoApprove — allow without prompting (scoped version of approve-all)
  • autoDeny — reject without prompting
  • escalate — gracefully deny with structured escalation info (see below)
  • defaultAction — fallback for tools not in any list ("escalate" | "approve" | "deny")

Tool matching should support both tool names (e.g., "Bash", "Read") and tool kinds (e.g., "read", "execute"), building on acpx's existing tool kind inference.

2. Session-based escalation flow

When a tool call matches escalate and no TTY is available, acpx:

  1. Gracefully denies the tool call with a structured message indicating escalation
  2. Includes escalation details in the session output so the caller knows what was blocked
  3. The turn ends — control returns to the orchestrator
  4. The orchestrator reviews the escalation, decides to approve or deny
  5. If approved, the orchestrator resumes the session with a new prompt instructing the agent to retry, and updates the policy to allow the tool

This leverages acpx's existing session persistence (session/load, -s named sessions) — no new IPC mechanism needed.

Example flow

# Step 1: Orchestrator spawns session with restrictive policy
$ acpx --policy '{"autoApprove":["Read","Glob"],"escalate":["Bash"]}' \
    claude -s task "Run the integration tests"

# Claude reads files (auto-approved), then wants to run bash...
# Output includes:
#   [escalation] Bash denied: "bash infra/scripts/check-services.sh"
#   Tool kind: execute
#   Session paused. Resume with updated policy to approve.

# Step 2: Orchestrator reviews, decides to approve bash for infra/scripts/*
$ acpx --policy '{"autoApprove":["Read","Glob","Bash"],"escalate":[]}' \
    claude -s task "The previously blocked Bash command is now approved. Please continue."

# Claude retries, Bash is now auto-approved, work continues...

Structured escalation output

When a tool is escalated, acpx includes structured JSON in the output (when --format json):

{
  "type": "escalation",
  "tool": "Bash",
  "kind": "execute",
  "title": "bash infra/scripts/check-services.sh",
  "input": {"command": "bash infra/scripts/check-services.sh"},
  "sessionId": "abc-123",
  "sessionName": "task"
}

This gives orchestrators everything they need to make a decision and resume.

3. TTY fallback preserved

When a TTY is available, escalate tools prompt interactively via stdin (existing behavior). The session-based flow only activates when no TTY is detected.

Use Cases

Agent-to-agent orchestration:

Orchestrator spawns: acpx --policy read-and-test.json claude -s task "Run tests"
Claude wants Read  → autoApprove → ✅ instant
Claude wants Bash  → escalate    → ⏸ turn ends, escalation returned
Orchestrator reviews → approves  → resumes session with updated policy
Claude retries Bash  → autoApprove → ✅ continues

CI/CD with human gate:

GitHub Action runs: acpx --policy ci.json claude -s pr "Review PR #42"
Claude wants Edit  → escalate → CI posts to Slack for approval
Human approves     → CI resumes session with Edit allowed

Local development (TTY available):

Dev runs: acpx --policy dev.json claude -s work "Implement feature"
Claude wants Bash  → escalate → terminal prompt "Allow? (y/N)" → existing behavior

Progressive trust:

# Start restrictive
acpx --policy '{"autoApprove":["Read"],"defaultAction":"escalate"}' claude -s task "Fix bug"
# After reviewing first few escalations, loosen policy
acpx --policy '{"autoApprove":["Read","Bash","Edit"],"defaultAction":"deny"}' claude -s task "Continue"

Non-goals

To keep this proposal focused, the following are explicitly out of scope:

  • Path-based rules (e.g., allow reads to src/, deny writes to .env) — valuable but a separate layer of complexity. Can be added as a follow-up enhancement to the policy schema.
  • Runtime policy updates mid-session — policies are set at session start. Changing policy requires resuming with a new --policy value, not hot-reloading.
  • Tool argument inspection — this proposal matches on tool name only, not on the arguments passed to the tool (e.g., cannot distinguish bash ls from bash rm -rf /).
  • Authentication/identity for escalation reviewers — the orchestrator is trusted. Access control for who can approve escalations is outside acpx's scope.

Design Decisions

  1. Why session-based, not file polling? — Reuses existing acpx session persistence. No new IPC mechanism, no filesystem coordination, no polling. The orchestrator already knows how to send prompts to named sessions.

  2. Why graceful denial, not pause? — ACP requestPermission is a synchronous request/response within a JSON-RPC stream. The agent blocks waiting for a response. True pause would require protocol changes. Graceful denial with structured escalation info achieves the same outcome using existing primitives.

  3. Why policy files, not just CLI flags? — Policies can be reusable presets (read-only.json, full-dev.json, ci-safe.json). CLI inline JSON is supported for ad-hoc use. Both work.

  4. Why not extend --approve-reads?approve-reads is a single hardcoded rule. Per-tool policies are a generalization: {"autoApprove":["Read","Search"]} is equivalent to --approve-reads, but you can also express --approve-reads-and-bash-in-infra which isn't possible today.

Related

  • #49--allowed-tools flag for tool availability (complementary)
  • #91disableExec config option (precedent for tool-level restrictions)
  • #120 — permission denial stats accounting (existing permission infrastructure)
  • #94 / #92 — CLI passthrough flags, _meta.claudeCode.options plumbing
  • ACP coverage roadmap — lists permission policies as a known gap

Happy to implement this. Would appreciate feedback on:

  • The policy schema — is the autoApprove/autoDeny/escalate split right?
  • The escalation output format — what fields do orchestrators need?
  • Whether --policy should be a global flag or per-agent subcommand flag

This proposal was drafted with AI assistance.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions