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
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>;
}
}
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