Skip to content

Agent permissions: allowedTools + runtime permission delegation #4

@appsechq-brian

Description

@appsechq-brian

Problem

This blocks all agent execution. When agents are spawned with --print --output-format stream-json, the Claude CLI still prompts for tool permissions. Since agents run as background subprocesses with no TTY, they block indefinitely waiting for input that never comes.

Discovered during first UAT: Archie spawned but accumulated 0 tokens and 0.69s CPU time over 8+ minutes — blocked waiting for permission to call get_project_context.

Design: Three-Layer Permission System

Layer 1: --permission-mode acceptEdits

All agents (Archie + workers) should spawn with --permission-mode acceptEdits. This auto-approves standard file operations: Read, Edit, Write, Glob, Grep, NotebookEdit. These are foundational dev tools that every agent needs.

Layer 2: --allowedTools whitelist

Pre-approve specific tools per agent role via arch.yaml config + sensible defaults.

Default allowed tools for ALL agents:

mcp__arch__send_message
mcp__arch__get_messages
mcp__arch__update_status
mcp__arch__save_progress
mcp__arch__report_completion
Bash(git status)
Bash(git diff *)
Bash(git add *)
Bash(git commit *)
Bash(git log *)
Bash(git branch *)
Bash(git checkout *)

Additional defaults for Archie:

mcp__arch__spawn_agent
mcp__arch__teardown_agent
mcp__arch__list_agents
mcp__arch__escalate_to_user
mcp__arch__request_merge
mcp__arch__get_project_context
mcp__arch__close_project
mcp__arch__update_brief
mcp__arch__gh_create_issue
mcp__arch__gh_list_issues
mcp__arch__gh_close_issue
mcp__arch__gh_update_issue
mcp__arch__gh_add_comment
mcp__arch__gh_create_milestone
mcp__arch__gh_list_milestones

User-configurable overrides in arch.yaml:

archie:
  persona: "personas/archie.md"
  model: "claude-opus-4-5"
  permissions:
    allowed_tools:  # extends defaults
      - "Bash(npm *)"
      - "Bash(python *)"

agent_pool:
  - id: frontend
    persona: "personas/frontend.md"
    permissions:
      allowed_tools:  # extends defaults
        - "Bash(npm *)"
        - "Bash(npx *)"
        - "Bash(node *)"
  - id: backend
    persona: "personas/backend.md"
    permissions:
      allowed_tools:
        - "Bash(python *)"
        - "Bash(pip *)"
        - "Bash(pytest *)"
  - id: qa
    persona: "personas/qa.md"
    permissions:
      allowed_tools:
        - "Bash(npm test *)"
        - "Bash(pytest *)"

The allowed_tools list in config extends the defaults — users only need to add project-specific patterns, not repeat the base set.

Layer 3: --permission-prompt-tool for runtime delegation

For any tool NOT covered by layers 1 & 2, delegate to a new MCP tool that surfaces the request through the dashboard.

New MCP tool: handle_permission_request

This is NOT called by agents directly — it's called by the Claude CLI permission system when an agent attempts to use a tool that isn't pre-approved.

claude --print \
  --permission-mode acceptEdits \
  --allowedTools "mcp__arch__send_message" "Bash(git *)" ... \
  --permission-prompt-tool mcp__arch__handle_permission_request \
  ...

Flow:

  1. Agent tries to use a tool not in --allowedTools (e.g., Bash(curl https://api.example.com))
  2. Claude CLI calls mcp__arch__handle_permission_request with the tool name and details
  3. MCP server receives the request, stores it as a pending decision (same pattern as escalate_to_user)
  4. Dashboard shows: ⚠ frontend-dev-1 requests: Bash(curl https://api.example.com) [y]once [a]lways [n]o
  5. User responds with one of three options
  6. MCP tool returns the response to Claude CLI
  7. Agent continues (or gets denial)

MCP tool schema:

handle_permission_request
  description: "Handle permission prompt from Claude CLI. Called automatically, not by agents directly."
  params:
    tool_name: string       # e.g., "Bash"
    tool_input: object      # tool-specific input (e.g., {"command": "curl ..."})
    agent_id: string        # which agent is requesting (from SSE path)
  returns:
    { allow: bool, message: string? }
  access: system_only  # Not visible to agents

"Always allow" runtime rules

When the user approves a permission request, they have three options:

  • y (once) — allow this one request, prompt again next time
  • a (always for this agent) — auto-approve this tool pattern for this agent for the rest of the session
  • n (deny) — deny the request

Implementation:

The MCP server maintains an in-memory dict[str, set[str]] mapping agent_id to approved tool patterns. A wildcard key "*" applies to all agents.

# In MCPServer
_runtime_allowed: dict[str, set[str]] = {}  # agent_id -> {tool_patterns}

def _check_runtime_allowed(self, agent_id: str, tool_name: str) -> bool:
    """Check if tool was previously approved via 'always allow'."""
    # Check agent-specific rules
    if agent_id in self._runtime_allowed:
        if tool_name in self._runtime_allowed[agent_id]:
            return True
    # Check wildcard rules
    if "*" in self._runtime_allowed:
        if tool_name in self._runtime_allowed["*"]:
            return True
    return False

When handle_permission_request is called:

  1. Check _runtime_allowed first — if match, return {allow: true} immediately (no prompt)
  2. Otherwise, create pending decision and block until user responds
  3. If user chose "always", add tool_name to _runtime_allowed[agent_id]

Rules are session-scoped (in-memory only). A fresh arch up starts with a clean slate. This is intentional — persistent permission grants belong in arch.yaml's allowed_tools config, not in runtime state. If a user finds themselves always-allowing the same tool, that's a signal to add it to their config.

Dashboard UI:

Permission requests appear in the escalation bar with three response options:

⚠ PERMISSION: frontend-dev-1 wants to use Bash(curl https://api.example.com)  [y]once [a]lways [n]o: _

The existing answer_escalation() method needs to pass through the user's choice (y/a/n) so the MCP handler can distinguish "allow once" from "always allow".

Implementation

Changes required:

1. Config (orchestrator.py)

  • Add allowed_tools: list[str] to PermissionsConfig
  • Add allowed_tools to ArchieConfig (Archie also gets configurable permissions)
  • Parse permissions.allowed_tools from arch.yaml
  • Define DEFAULT_ALLOWED_TOOLS_ALL, DEFAULT_ALLOWED_TOOLS_ARCHIE constants

2. Session spawn (session.py)

  • Add allowed_tools: list[str] and permission_prompt_tool: Optional[str] to AgentConfig
  • In Session.spawn(), build the --allowedTools args from config
  • Add --permission-mode acceptEdits to all agent commands
  • Add --permission-prompt-tool mcp__arch__handle_permission_request to all agent commands

3. MCP server (mcp_server.py)

  • Add handle_permission_request tool definition (with system_only access — not visible to agents)
  • Add _runtime_allowed: dict[str, set[str]] for session-scoped "always allow" rules
  • Implement handler: check _runtime_allowed first → auto-approve if match → otherwise create pending decision and block
  • On user response: if "always", add tool to _runtime_allowed[agent_id]
  • Re-use the escalate_to_user blocking pattern with asyncio.Event

4. Dashboard (dashboard.py)

  • Permission requests appear via the pending decisions mechanism
  • Change escalation UI to show [y]once [a]lways [n]o for permission-type decisions
  • Pass user choice (y/a/n) through answer_escalation() so MCP handler can distinguish
  • Differentiate permission requests from general escalations (e.g., ⚠ PERMISSION: prefix vs ⚠ ARCHIE ASKS:)

5. Orchestrator (orchestrator.py)

  • _spawn_archie(): Build allowed_tools list from defaults + archie config
  • _handle_spawn_agent(): Build allowed_tools list from defaults + pool entry config
  • Pass allowed_tools and permission_prompt_tool through to AgentConfig

CLI command changes:

The claude subprocess command changes from:

claude --model MODEL --output-format stream-json --mcp-config CONFIG --print PROMPT

To:

claude --model MODEL --output-format stream-json --mcp-config CONFIG --print \
  --permission-mode acceptEdits \
  --allowedTools "mcp__arch__send_message" "mcp__arch__get_messages" ... "Bash(git *)" \
  --permission-prompt-tool mcp__arch__handle_permission_request \
  PROMPT

Testing

  • Test that allowed_tools are correctly built from defaults + config overrides
  • Test that --allowedTools flags are properly formatted in the subprocess command
  • Test that --permission-prompt-tool is included in the command
  • Test handle_permission_request MCP handler creates pending decision and blocks
  • Test that permission request + user response flows correctly
  • Test _runtime_allowed auto-approves after "always allow" without creating a pending decision
  • Test that runtime rules are agent-scoped (agent A's "always" doesn't affect agent B)
  • Test that skip_permissions still works and overrides everything (uses --dangerously-skip-permissions)

Priority

P0 — This blocks all agent execution. Without this fix, no agent can do any work.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions