Skip to main content

Overview

Permission requests are a critical security feature in ACP. Before executing potentially sensitive operations, the agent requests permission from the user through the client.

When Permissions are Requested

The agent calls requestPermission when:
  • Writing files
  • Executing terminal commands
  • Reading sensitive files
  • Making external API calls
  • Any other operation requiring user approval
The specific permission behavior depends on the agent’s current session mode.

Request Flow

Client                               Agent
  |                                    |
  |-- prompt request ---------------->|
  |                                    |
  |<-- tool_call update (requires_permission)|
  |<-- requestPermission request -----|
  |                                    |
  | (User reviews and decides)         |
  |                                    |
  |-- requestPermission response ---->|
  |                                    |
  |<-- tool_call update (running) ----|
  |<-- tool_call update (completed) --|

Request Structure

The requestPermission method receives:
interface RequestPermissionRequest {
  sessionId: string;
  toolCall: ToolCall;
  options: PermissionOption[];
}

interface ToolCall {
  toolCallId: string;
  title: string;        // e.g., "Write file: src/config.ts"
  status: ToolCallStatus;
  content: ToolCallContent[];
}

interface PermissionOption {
  optionId: string;
  name: string;         // e.g., "Allow", "Deny", "Edit"
  kind: PermissionOptionKind;
  data?: unknown;       // Additional data for the option
}

type PermissionOptionKind =
  | "allow"
  | "deny" 
  | "apply_patch"
  | "modify_request";

Response Structure

The client must respond with:
interface RequestPermissionResponse {
  outcome: RequestPermissionOutcome;
}

type RequestPermissionOutcome =
  | { outcome: "selected"; optionId: string }
  | { outcome: "cancelled" };
If the client sends a session/cancel notification while a permission request is pending, it MUST respond with { outcome: { outcome: "cancelled" } }.

Permission Option Types

Allow

Allow the operation to proceed as requested.
{
  optionId: "allow",
  name: "Allow",
  kind: "allow"
}

Deny

Reject the operation.
{
  optionId: "deny",
  name: "Deny",
  kind: "deny"
}

Apply Patch

For file write operations, the agent may offer alternative patches.
{
  optionId: "apply_patch_1",
  name: "Apply modified patch",
  kind: "apply_patch",
  data: {
    diff: "... modified patch content ...",
    path: "/absolute/path/to/file.ts"
  }
}

Modify Request

For commands, the agent may offer modified versions.
{
  optionId: "modify_1",
  name: "Run with --safe flag",
  kind: "modify_request",
  data: {
    command: "npm install",
    args: ["--save", "--save-exact"]
  }
}

Implementation Examples

Basic CLI Implementation

Here’s a simple command-line permission UI:
src/examples/client.ts:11-43
class ExampleClient implements acp.Client {
  async requestPermission(
    params: acp.RequestPermissionRequest,
  ): Promise<acp.RequestPermissionResponse> {
    console.log(`\n🔐 Permission requested: ${params.toolCall.title}`);

    console.log(`\nOptions:`);
    params.options.forEach((option, index) => {
      console.log(`   ${index + 1}. ${option.name} (${option.kind})`);
    });

    while (true) {
      const rl = readline.createInterface({
        input: process.stdin,
        output: process.stdout,
      });

      const answer = await rl.question("\nChoose an option: ");
      const trimmedAnswer = answer.trim();

      const optionIndex = parseInt(trimmedAnswer) - 1;
      if (optionIndex >= 0 && optionIndex < params.options.length) {
        return {
          outcome: {
            outcome: "selected",
            optionId: params.options[optionIndex].optionId,
          },
        };
      } else {
        console.log("Invalid option. Please try again.");
      }
    }
  }
}

GUI Implementation Pattern

For a graphical client:
class GUIClient implements acp.Client {
  async requestPermission(
    params: acp.RequestPermissionRequest
  ): Promise<acp.RequestPermissionResponse> {
    // Show modal dialog
    const dialog = new PermissionDialog({
      title: params.toolCall.title,
      toolCall: params.toolCall,
      options: params.options,
    });
    
    // Wait for user decision
    const result = await dialog.show();
    
    if (result.cancelled) {
      return { outcome: { outcome: "cancelled" } };
    }
    
    return {
      outcome: {
        outcome: "selected",
        optionId: result.selectedOptionId,
      },
    };
  }
}

UI Design Patterns

Displaying Tool Call Information

function renderToolCall(toolCall: ToolCall) {
  return (
    <div className="tool-call">
      <h3>{toolCall.title}</h3>
      {toolCall.content.map((content, i) => (
        <div key={i}>{renderToolCallContent(content)}</div>
      ))}
    </div>
  );
}

function renderToolCallContent(content: ToolCallContent) {
  switch (content.type) {
    case "text":
      return <p>{content.text}</p>;
    
    case "code":
      return (
        <CodeBlock
          code={content.code}
          language={content.language}
          path={content.path}
        />
      );
    
    case "patch":
      return (
        <DiffViewer
          diff={content.diff}
          path={content.path}
        />
      );
    
    default:
      return <pre>{JSON.stringify(content, null, 2)}</pre>;
  }
}

Permission Option Buttons

function PermissionOptions({ options, onSelect }) {
  return (
    <div className="permission-options">
      {options.map((option) => (
        <button
          key={option.optionId}
          className={`option option-${option.kind}`}
          onClick={() => onSelect(option.optionId)}
        >
          {getOptionIcon(option.kind)}
          {option.name}
        </button>
      ))}
    </div>
  );
}

function getOptionIcon(kind: PermissionOptionKind) {
  switch (kind) {
    case "allow": return "✓";
    case "deny": return "✗";
    case "apply_patch": return "✎";
    case "modify_request": return "⚙";
  }
}

Showing Alternative Patches

For apply_patch options:
function renderPatchOptions(options: PermissionOption[]) {
  const patchOptions = options.filter(opt => opt.kind === "apply_patch");
  
  return (
    <div>
      <h4>Available Patches:</h4>
      {patchOptions.map((option) => (
        <div key={option.optionId}>
          <h5>{option.name}</h5>
          <DiffViewer
            diff={option.data.diff}
            path={option.data.path}
          />
          <button onClick={() => selectOption(option.optionId)}>
            Apply this patch
          </button>
        </div>
      ))}
    </div>
  );
}

Handling Cancellation

When the user cancels the prompt:
class MyClient implements acp.Client {
  private pendingPermissions = new Map<string, (response: acp.RequestPermissionResponse) => void>();
  
  async requestPermission(
    params: acp.RequestPermissionRequest
  ): Promise<acp.RequestPermissionResponse> {
    return new Promise((resolve) => {
      // Store resolver
      this.pendingPermissions.set(params.toolCall.toolCallId, resolve);
      
      // Show UI
      this.showPermissionDialog(params, (response) => {
        this.pendingPermissions.delete(params.toolCall.toolCallId);
        resolve(response);
      });
    });
  }
  
  async cancelSession(sessionId: string) {
    // Cancel the prompt
    await this.connection.cancel({ sessionId });
    
    // Resolve all pending permissions with "cancelled"
    for (const [toolCallId, resolve] of this.pendingPermissions) {
      resolve({ outcome: { outcome: "cancelled" } });
    }
    this.pendingPermissions.clear();
  }
}

Security Considerations

Path Validation

Always validate file paths are within allowed directories:
import { resolve, relative } from "node:path";

function isPathAllowed(path: string, allowedRoot: string): boolean {
  const normalizedPath = resolve(path);
  const normalizedRoot = resolve(allowedRoot);
  const rel = relative(normalizedRoot, normalizedPath);
  
  // Path must be within root and not use ..
  return !rel.startsWith("..") && !path.isAbsolute(rel);
}

Command Validation

For terminal commands, warn about dangerous operations:
const DANGEROUS_COMMANDS = ["rm -rf", "sudo", "chmod", "mkfs"];

function validateCommand(command: string): { safe: boolean; warning?: string } {
  for (const dangerous of DANGEROUS_COMMANDS) {
    if (command.includes(dangerous)) {
      return {
        safe: false,
        warning: `This command contains potentially dangerous operation: ${dangerous}`,
      };
    }
  }
  return { safe: true };
}

User Preferences

Implement “always allow” / “always deny” preferences:
interface PermissionPreferences {
  alwaysAllow: Set<string>;  // e.g., "read:src/**"
  alwaysDeny: Set<string>;   // e.g., "write:/etc/**"
}

function checkPreferences(
  toolCall: ToolCall,
  prefs: PermissionPreferences
): "allow" | "deny" | "ask" {
  const pattern = getToolCallPattern(toolCall);
  
  if (prefs.alwaysAllow.has(pattern)) return "allow";
  if (prefs.alwaysDeny.has(pattern)) return "deny";
  return "ask";
}

Testing

Test your permission UI with various scenarios:
import { describe, it, expect } from "vitest";

describe("Permission Requests", () => {
  it("should handle allow option", async () => {
    const client = new MyClient();
    
    const response = await client.requestPermission({
      sessionId: "test-session",
      toolCall: {
        toolCallId: "tool-1",
        title: "Write file: test.ts",
        status: "requires_permission",
        content: [],
      },
      options: [
        { optionId: "allow", name: "Allow", kind: "allow" },
        { optionId: "deny", name: "Deny", kind: "deny" },
      ],
    });
    
    expect(response.outcome.outcome).toBe("selected");
    expect(response.outcome.optionId).toBe("allow");
  });
  
  it("should handle cancellation", async () => {
    const client = new MyClient();
    
    // Simulate cancellation
    setTimeout(() => client.cancelSession("test-session"), 100);
    
    const response = await client.requestPermission({
      sessionId: "test-session",
      toolCall: { /* ... */ },
      options: [ /* ... */ ],
    });
    
    expect(response.outcome.outcome).toBe("cancelled");
  });
});

See Also

Build docs developers (and LLMs) love