Skip to content

Commit fbdec30

Browse files
committed
feat: interactive permission requests via Discord buttons
When Claude wants to use a tool that isn't pre-approved (e.g. Bash, Write), the bot now shows an Allow/Deny embed with buttons in Discord instead of auto-denying. This makes the 'default' permission mode usable for Discord. - New module: claude/permission-request.ts (types, embed builder, button parser) - canUseTool callback delegates to onPermissionRequest when available - createPermissionRequestHandler wired through handler-registry - Embed shows tool name, action description, input preview - Turns green/red after user clicks Allow/Deny
1 parent ac5345b commit fbdec30

File tree

5 files changed

+230
-1
lines changed

5 files changed

+230
-1
lines changed

claude/client.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { query as claudeQuery, type SDKMessage, type AgentDefinition as SDKAgentDefinition, type ModelInfo as SDKModelInfo, type SdkBeta, type McpServerConfig, type HookEvent, type HookCallbackMatcher } from "@anthropic-ai/claude-agent-sdk";
22
import { setActiveQuery, trackMessageId, clearTrackedMessages } from "./query-manager.ts";
33
import type { AskUserQuestionInput, AskUserCallback } from "./user-question.ts";
4+
import type { PermissionRequestCallback } from "./permission-request.ts";
45
import * as path from "https://deno.land/std@0.208.0/path/mod.ts";
56

67
// Load MCP server configs from .claude/mcp.json
@@ -146,6 +147,10 @@ export interface ClaudeModelOptions {
146147
/** Callback for AskUserQuestion tool — Claude asks the user mid-session.
147148
* If provided, the AskUserQuestion tool is enabled and routed through this callback. */
148149
onAskUser?: AskUserCallback;
150+
/** Callback for interactive permission requests — replaces auto-deny.
151+
* When Claude wants to use a tool that isn't pre-approved, this callback
152+
* presents Allow/Deny buttons in Discord and waits for a response. */
153+
onPermissionRequest?: PermissionRequestCallback;
149154
}
150155

151156
// Wrapper for Claude Code SDK query function
@@ -279,6 +284,20 @@ export async function sendToClaudeCode(
279284
return { behavior: 'allow' as const, updatedInput: input };
280285
}
281286

287+
// Interactive permission request — show Discord buttons for Allow/Deny
288+
if (modelOptions?.onPermissionRequest) {
289+
try {
290+
const allowed = await modelOptions.onPermissionRequest(toolName, input);
291+
if (allowed) {
292+
return { behavior: 'allow' as const, updatedInput: input };
293+
}
294+
return { behavior: 'deny' as const, message: `User denied tool: ${toolName}` };
295+
} catch (err) {
296+
console.error(`[PermissionRequest] Error for ${toolName}:`, err);
297+
return { behavior: 'deny' as const, message: `Permission request failed for: ${toolName}` };
298+
}
299+
}
300+
282301
return { behavior: 'deny' as const, message: `Tool ${toolName} not pre-approved` };
283302
},
284303
env: envVars,

claude/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,4 +56,7 @@ export { buildHooks } from "./hooks.ts";
5656
export type { HookConfig, HookEvent_Discord } from "./hooks.ts";
5757
// AskUserQuestion — interactive question flow (SDK v0.1.71+)
5858
export { buildQuestionMessages, parseAskUserButtonId, parseAskUserConfirmId } from "./user-question.ts";
59-
export type { AskUserCallback, AskUserQuestionInput, AskUserQuestionItem, AskUserOption } from "./user-question.ts";
59+
export type { AskUserCallback, AskUserQuestionInput, AskUserQuestionItem, AskUserOption } from "./user-question.ts";
60+
// PermissionRequest — interactive tool-permission flow (replaces TUI prompt)
61+
export { buildPermissionEmbed, describeToolAction, parsePermissionButtonId } from "./permission-request.ts";
62+
export type { PermissionRequestCallback } from "./permission-request.ts";

claude/permission-request.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
/**
2+
* PermissionRequest — Interactive tool-permission flow for Claude ↔ Discord.
3+
*
4+
* When Claude wants to use a tool that isn't pre-approved (e.g. Bash, Write)
5+
* in `default` or `acceptEdits` permission modes, the SDK fires `canUseTool`.
6+
* Instead of auto-denying, this module presents an Allow / Deny embed with
7+
* buttons in Discord and waits for the user's click.
8+
*
9+
* This replaces the terminal TUI "Allow tool X? [Y/n]" prompt with a
10+
* Discord-native interaction.
11+
*
12+
* @module claude/permission-request
13+
*/
14+
15+
// ================================
16+
// Types
17+
// ================================
18+
19+
/**
20+
* Callback that presents a tool-permission request to the user and returns
21+
* `true` (allow) or `false` (deny).
22+
*
23+
* Throwing rejects the tool use (deny).
24+
*/
25+
export type PermissionRequestCallback = (
26+
toolName: string,
27+
toolInput: Record<string, unknown>,
28+
) => Promise<boolean>;
29+
30+
// ================================
31+
// Helpers
32+
// ================================
33+
34+
/** Max characters to show from the tool input JSON in the embed. */
35+
const INPUT_PREVIEW_MAX = 800;
36+
37+
/** Truncate a JSON string for embed display. */
38+
function truncateJson(obj: Record<string, unknown>, maxLen: number): string {
39+
const raw = JSON.stringify(obj, null, 2);
40+
if (raw.length <= maxLen) return raw;
41+
return raw.slice(0, maxLen) + '\n… (truncated)';
42+
}
43+
44+
/**
45+
* Build a short human-readable description of what the tool is about to do.
46+
* Covers the most common built-in tools.
47+
*/
48+
export function describeToolAction(toolName: string, input: Record<string, unknown>): string {
49+
switch (toolName) {
50+
case 'Bash':
51+
case 'bash':
52+
return `Run command: \`${String(input.command ?? input.cmd ?? '').slice(0, 200)}\``;
53+
case 'Write':
54+
case 'write':
55+
case 'CreateFile':
56+
return `Write file: \`${String(input.file_path ?? input.path ?? 'unknown')}\``;
57+
case 'Edit':
58+
case 'edit':
59+
return `Edit file: \`${String(input.file_path ?? input.path ?? 'unknown')}\``;
60+
case 'MultiEdit':
61+
return `Multi-edit file: \`${String(input.file_path ?? input.path ?? 'unknown')}\``;
62+
default:
63+
return `Use tool: **${toolName}**`;
64+
}
65+
}
66+
67+
/**
68+
* Build the Discord embed data for a permission request.
69+
*
70+
* Returned as a plain object so the caller (index.ts) can wrap it
71+
* in the Discord.js EmbedBuilder.
72+
*/
73+
export function buildPermissionEmbed(toolName: string, input: Record<string, unknown>): {
74+
color: number;
75+
title: string;
76+
description: string;
77+
fields: Array<{ name: string; value: string; inline: boolean }>;
78+
footer: { text: string };
79+
} {
80+
const action = describeToolAction(toolName, input);
81+
const preview = truncateJson(input, INPUT_PREVIEW_MAX);
82+
83+
return {
84+
color: 0xff9900, // Orange — "waiting for you"
85+
title: `🔐 Permission Request: ${toolName}`,
86+
description: `Claude wants to ${action}\n\nClick **Allow** to proceed or **Deny** to block this action.`,
87+
fields: [
88+
{ name: 'Tool', value: `\`${toolName}\``, inline: true },
89+
{ name: 'Input', value: `\`\`\`json\n${preview}\n\`\`\``, inline: false },
90+
],
91+
footer: { text: 'Claude is waiting for your decision — this controls what tools are permitted' },
92+
};
93+
}
94+
95+
/**
96+
* Parse a permission-request button custom ID.
97+
*
98+
* Custom IDs follow the pattern: `perm-req:<nonce>:allow` or `perm-req:<nonce>:deny`.
99+
*/
100+
export function parsePermissionButtonId(customId: string): { nonce: string; allowed: boolean } | null {
101+
const match = customId.match(/^perm-req:([^:]+):(allow|deny)$/);
102+
if (!match) return null;
103+
return { nonce: match[1], allowed: match[2] === 'allow' };
104+
}

core/handler-registry.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { infoCommands, createInfoCommandHandlers } from "../claude/index.ts";
3030
import { cleanSessionId, ClaudeSessionManager } from "../claude/index.ts";
3131
import type { ClaudeModelOptions } from "../claude/index.ts";
3232
import type { AskUserCallback } from "../claude/index.ts";
33+
import type { PermissionRequestCallback } from "../claude/index.ts";
3334
import { buildHooks } from "../claude/hooks.ts";
3435
import type { HookEvent_Discord } from "../claude/hooks.ts";
3536
import { THINKING_MODES, OPERATION_MODES, EFFORT_LEVELS } from "../settings/index.ts";
@@ -173,6 +174,9 @@ export interface HandlerRegistryDeps {
173174
/** Late-bound callback for AskUserQuestion tool — Claude asks the Discord user mid-session.
174175
* Set from index.ts after bot is created. */
175176
onAskUser?: AskUserCallback;
177+
/** Late-bound callback for interactive permission requests — replaces auto-deny.
178+
* Shows Allow/Deny buttons in Discord when Claude wants to use an unapproved tool. */
179+
onPermissionRequest?: PermissionRequestCallback;
176180
}
177181

178182
/**
@@ -490,6 +494,11 @@ export function createAllHandlers(
490494
if (deps.onAskUser) {
491495
opts.onAskUser = deps.onAskUser;
492496
}
497+
498+
// PermissionRequest — interactive Allow/Deny buttons (late-bound from index.ts)
499+
if (deps.onPermissionRequest) {
500+
opts.onPermissionRequest = deps.onPermissionRequest;
501+
}
493502

494503
return opts;
495504
}

index.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
import { getGitInfo } from "./git/index.ts";
2222
import { createClaudeSender, expandableContent, type DiscordSender, type ClaudeMessage } from "./claude/index.ts";
2323
import { buildQuestionMessages, parseAskUserButtonId, parseAskUserConfirmId, type AskUserQuestionInput } from "./claude/index.ts";
24+
import { buildPermissionEmbed, parsePermissionButtonId, type PermissionRequestCallback } from "./claude/index.ts";
2425
import { claudeCommands, enhancedClaudeCommands } from "./claude/index.ts";
2526
import { additionalClaudeCommands } from "./claude/additional-index.ts";
2627
import { initModels } from "./claude/enhanced-client.ts";
@@ -120,6 +121,11 @@ export async function createClaudeCodeBot(config: BotConfig) {
120121
// and waits for the user's click.
121122
// Uses an object wrapper so TypeScript doesn't narrow the closure to `never`.
122123
const askUserState: { handler: ((input: AskUserQuestionInput) => Promise<Record<string, string>>) | null } = { handler: null };
124+
125+
// Late-bound PermissionRequest handler — set after bot is created.
126+
// When Claude wants to use a tool that isn't pre-approved, this shows
127+
// Allow/Deny buttons in Discord and returns the user's decision.
128+
const permReqState: { handler: PermissionRequestCallback | null } = { handler: null };
123129

124130
// Create sendClaudeMessages function that uses the sender when available
125131
const sendClaudeMessages = async (messages: ClaudeMessage[]) => {
@@ -136,6 +142,15 @@ export async function createClaudeCodeBot(config: BotConfig) {
136142
return await askUserState.handler(input);
137143
};
138144

145+
// Create onPermissionRequest wrapper — delegates to permReqState.handler once bot is ready
146+
const onPermissionRequest: PermissionRequestCallback = async (toolName, toolInput) => {
147+
if (!permReqState.handler) {
148+
console.warn('[PermissionRequest] Handler not initialized — auto-denying');
149+
return false;
150+
}
151+
return await permReqState.handler(toolName, toolInput);
152+
};
153+
139154
// Create all handlers using the registry (centralized handler creation)
140155
const allHandlers: AllHandlers = createAllHandlers(
141156
{
@@ -153,6 +168,7 @@ export async function createClaudeCodeBot(config: BotConfig) {
153168
claudeSessionManager,
154169
sendClaudeMessages,
155170
onAskUser,
171+
onPermissionRequest,
156172
onBotSettingsUpdate: (settings) => {
157173
botSettings.mentionEnabled = settings.mentionEnabled;
158174
botSettings.mentionUserId = settings.mentionUserId;
@@ -212,6 +228,9 @@ export async function createClaudeCodeBot(config: BotConfig) {
212228

213229
// Initialize AskUserQuestion handler — sends questions to Discord, waits for button clicks
214230
askUserState.handler = createAskUserDiscordHandler(bot);
231+
232+
// Initialize PermissionRequest handler — shows Allow/Deny buttons for unapproved tools
233+
permReqState.handler = createPermissionRequestHandler(bot);
215234

216235
// Check for updates (non-blocking)
217236
runVersionCheck().then(async ({ updateAvailable, embed }) => {
@@ -431,6 +450,81 @@ function createAskUserDiscordHandler(bot: any): (input: AskUserQuestionInput) =>
431450
return answers;
432451
};
433452
}
453+
454+
/**
455+
* Create the PermissionRequest handler that uses the Discord channel.
456+
*
457+
* When Claude wants to use a tool that isn't pre-approved:
458+
* 1. Builds an embed showing the tool name and input preview
459+
* 2. Adds Allow / Deny buttons
460+
* 3. Sends to the bot's channel
461+
* 4. Waits for a button click (no timeout — user decides)
462+
* 5. Returns true (allow) or false (deny)
463+
*/
464+
// deno-lint-ignore no-explicit-any
465+
function createPermissionRequestHandler(bot: any): PermissionRequestCallback {
466+
// Simple incrementing nonce to disambiguate concurrent requests
467+
let nonce = 0;
468+
469+
return async (toolName: string, toolInput: Record<string, unknown>): Promise<boolean> => {
470+
const channel = bot.getChannel();
471+
if (!channel) {
472+
console.warn('[PermissionRequest] No channel — auto-denying');
473+
return false;
474+
}
475+
476+
const reqNonce = String(++nonce);
477+
const embedData = buildPermissionEmbed(toolName, toolInput);
478+
479+
const { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, ComponentType } = await import("npm:discord.js@14.14.1");
480+
481+
const embed = new EmbedBuilder()
482+
.setColor(embedData.color)
483+
.setTitle(embedData.title)
484+
.setDescription(embedData.description)
485+
.setFooter({ text: embedData.footer.text })
486+
.setTimestamp();
487+
488+
for (const field of embedData.fields) {
489+
embed.addFields({ name: field.name, value: field.value, inline: field.inline });
490+
}
491+
492+
const row = new ActionRowBuilder().addComponents(
493+
new ButtonBuilder()
494+
.setCustomId(`perm-req:${reqNonce}:allow`)
495+
.setLabel('✅ Allow')
496+
.setStyle(ButtonStyle.Success),
497+
new ButtonBuilder()
498+
.setCustomId(`perm-req:${reqNonce}:deny`)
499+
.setLabel('❌ Deny')
500+
.setStyle(ButtonStyle.Danger),
501+
);
502+
503+
const msg = await channel.send({ embeds: [embed], components: [row] });
504+
505+
// Wait for exactly one button click — no timeout
506+
// deno-lint-ignore no-explicit-any
507+
const interaction: any = await msg.awaitMessageComponent({
508+
componentType: ComponentType.Button,
509+
});
510+
511+
const parsed = parsePermissionButtonId(interaction.customId);
512+
const allowed = parsed?.allowed ?? false;
513+
514+
// Update the embed to reflect the decision
515+
embed.setColor(allowed ? 0x00ff00 : 0xff4444)
516+
.setFooter({ text: allowed ? `✅ Allowed by user` : `❌ Denied by user` });
517+
518+
await interaction.update({
519+
embeds: [embed],
520+
components: [], // Remove buttons after decision
521+
});
522+
523+
console.log(`[PermissionRequest] Tool "${toolName}" — ${allowed ? 'ALLOWED' : 'DENIED'} by user`);
524+
return allowed;
525+
};
526+
}
527+
434528
/**
435529
* Setup signal handlers for graceful shutdown.
436530
*/

0 commit comments

Comments
 (0)