Skip to content

Commit efda00d

Browse files
Merge pull request #9901 from gitbutlerapp/permissions-for-claude-code
Permissions for claude code
2 parents 3752f7d + 3983273 commit efda00d

File tree

17 files changed

+296
-25
lines changed

17 files changed

+296
-25
lines changed

apps/desktop/src/components/codegen/CodegenClaudeMessage.svelte

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@
66
77
type Props = {
88
message: Message;
9+
onApproval?: (id: string) => Promise<void>;
10+
onRejection?: (id: string) => Promise<void>;
911
};
10-
const { message }: Props = $props();
12+
const { message, onApproval, onRejection }: Props = $props();
1113
1214
let toolCallsExpanded = $state(false);
1315
@@ -74,6 +76,17 @@
7476
</div>
7577
{/if}
7678
{/if}
79+
{#if message.toolCallsPendingApproval.length > 0}
80+
{#each message.toolCallsPendingApproval as toolCall}
81+
<CodegenToolCall
82+
{toolCall}
83+
requiresApproval={{
84+
onApproval: async (id) => await onApproval?.(id),
85+
onRejection: async (id) => await onRejection?.(id)
86+
}}
87+
/>
88+
{/each}
89+
{/if}
7790
{/snippet}
7891
</CodegenMessage>
7992
{/if}

apps/desktop/src/components/codegen/CodegenPage.svelte

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
const stackService = inject(STACK_SERVICE);
2828
2929
const stacks = $derived(stackService.stacks(projectId));
30+
const permissionRequests = $derived(claudeCodeService.permissionRequests({ projectId }));
3031
3132
let message = $state('');
3233
let selectedBranch = $state<{ stackId: string; head: string }>();
@@ -77,6 +78,13 @@
7778
await promise;
7879
}
7980
81+
async function onApproval(id: string) {
82+
await claudeCodeService.updatePermissionRequest({ projectId, requestId: id, approval: true });
83+
}
84+
async function onRejection(id: string) {
85+
await claudeCodeService.updatePermissionRequest({ projectId, requestId: id, approval: false });
86+
}
87+
8088
const events = $derived(
8189
claudeCodeService.messages({ projectId, stackId: selectedBranch?.stackId || '' })
8290
);
@@ -104,10 +112,13 @@
104112
<Button disabled kind="ghost" size="tag" icon="kebab" />
105113
{/snippet}
106114
{#snippet messages()}
107-
<ReduxResult result={events?.current} {projectId}>
108-
{#snippet children(events, { projectId: _projectId })}
109-
{#each formatMessages(events) as message}
110-
<CodegenClaudeMessage {message} />
115+
<ReduxResult
116+
result={combineResults(events?.current, permissionRequests.current)}
117+
{projectId}
118+
>
119+
{#snippet children([events, permissionRequests], { projectId: _projectId })}
120+
{#each formatMessages(events, permissionRequests) as message}
121+
<CodegenClaudeMessage {message} {onApproval} {onRejection} />
111122
{/each}
112123
{@const lastUserMessageSent = lastUserMessageSentAt(events)}
113124
{#if currentStatus(events) === 'running' && lastUserMessageSent}

apps/desktop/src/components/codegen/CodegenToolCall.svelte

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
<script lang="ts">
22
import { toolCallLoading, type ToolCall } from '$lib/codegen/messages';
3-
import { Button, Icon, Markdown } from '@gitbutler/ui';
3+
import { AsyncButton, Button, Icon, Markdown } from '@gitbutler/ui';
4+
5+
export type RequiresApproval = {
6+
onApproval: (id: string) => Promise<void>;
7+
onRejection: (id: string) => Promise<void>;
8+
};
49
510
type Props = {
611
toolCall: ToolCall;
12+
requiresApproval?: RequiresApproval;
713
};
8-
const { toolCall }: Props = $props();
14+
const { toolCall, requiresApproval = undefined }: Props = $props();
915
10-
let expanded = $state(false);
16+
let expanded = $derived(!!requiresApproval);
1117
</script>
1218

1319
<div class="tool-call">
@@ -18,9 +24,25 @@
1824
size="tag"
1925
onclick={() => (expanded = !expanded)}
2026
/>
21-
<p>{toolCall.name}</p>
22-
{#if toolCallLoading(toolCall)}
27+
{#if requiresApproval}
28+
<div class="flex items-center justify-between grow gap-12">
29+
<p>{toolCall.name} requires approval</p>
30+
<div class="flex gap-8">
31+
<AsyncButton
32+
kind="outline"
33+
action={async () => await requiresApproval.onRejection(toolCall.id)}>Reject</AsyncButton
34+
>
35+
<AsyncButton
36+
style="pop"
37+
action={async () => await requiresApproval.onApproval(toolCall.id)}>Approve</AsyncButton
38+
>
39+
</div>
40+
</div>
41+
{:else if toolCallLoading(toolCall)}
42+
<p>{toolCall.name}</p>
2343
<Icon name="spinner" />
44+
{:else}
45+
<p>{toolCall.name}</p>
2446
{/if}
2547
</div>
2648
{#if expanded}

apps/desktop/src/lib/codegen/claude.ts

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1-
import { type ClaudeMessage, type ClaudeSessionDetails } from '$lib/codegen/types';
1+
import {
2+
type ClaudeMessage,
3+
type ClaudePermissionRequest,
4+
type ClaudeSessionDetails
5+
} from '$lib/codegen/types';
26
import { hasBackendExtra } from '$lib/state/backendQuery';
3-
import { providesItem, ReduxTag } from '$lib/state/tags';
7+
import { invalidatesItem, providesItem, ReduxTag } from '$lib/state/tags';
48
import { InjectionToken } from '@gitbutler/shared/context';
59
import type { ClientState } from '$lib/state/clientState.svelte';
610

@@ -21,6 +25,14 @@ export class ClaudeCodeService {
2125
return this.api.endpoints.getMessages.useQuery;
2226
}
2327

28+
get permissionRequests() {
29+
return this.api.endpoints.getPermissionRequests.useQuery;
30+
}
31+
32+
get updatePermissionRequest() {
33+
return this.api.endpoints.updatePermissionRequest.mutate;
34+
}
35+
2436
sessionDetails(projectId: string, sessionId: string) {
2537
return this.api.endpoints.getSessionDetails.useQuery({
2638
projectId,
@@ -79,6 +91,49 @@ function injectEndpoints(api: ClientState['backendApi']) {
7991
await lifecycleApi.cacheEntryRemoved;
8092
unsubscribe();
8193
}
94+
}),
95+
getPermissionRequests: build.query<ClaudePermissionRequest[], { projectId: string }>({
96+
extraOptions: { command: 'claude_list_permission_requests' },
97+
query: (args) => args,
98+
providesTags: (_result, _error, args) => [
99+
...providesItem(ReduxTag.ClaudePermissionRequests, args.projectId)
100+
],
101+
async onCacheEntryAdded(arg, lifecycleApi) {
102+
if (!hasBackendExtra(lifecycleApi.extra)) {
103+
throw new Error('Redux dependency Backend not found!');
104+
}
105+
const { listen, invoke } = lifecycleApi.extra.backend;
106+
await lifecycleApi.cacheDataLoaded;
107+
const unsubscribe = listen<unknown>(
108+
`project://${arg.projectId}/claude-permission-requests`,
109+
async (_) => {
110+
const value = await invoke<ClaudePermissionRequest[]>(
111+
'claude_list_permission_requests',
112+
{ projectId: arg.projectId }
113+
);
114+
lifecycleApi.updateCachedData(() => value);
115+
}
116+
);
117+
await lifecycleApi.cacheEntryRemoved;
118+
unsubscribe();
119+
}
120+
}),
121+
updatePermissionRequest: build.mutation<
122+
undefined,
123+
{
124+
projectId: string;
125+
requestId: string;
126+
approval: boolean;
127+
}
128+
>({
129+
extraOptions: {
130+
command: 'claude_update_permission_request',
131+
actionName: 'Update Permission Request'
132+
},
133+
query: (args) => args,
134+
invalidatesTags: (_result, _error, args) => [
135+
invalidatesItem(ReduxTag.ClaudePermissionRequests, args.projectId)
136+
]
82137
})
83138
})
84139
});

apps/desktop/src/lib/codegen/messages.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
*/
44

55
import { isDefined } from '@gitbutler/ui/utils/typeguards';
6-
import type { ClaudeMessage, ClaudeStatus } from '$lib/codegen/types';
6+
import type { ClaudeMessage, ClaudePermissionRequest, ClaudeStatus } from '$lib/codegen/types';
77

88
export type Message =
99
/* This is strictly only things that the real fleshy human has said */
@@ -16,6 +16,7 @@ export type Message =
1616
type: 'claude';
1717
message: string;
1818
toolCalls: ToolCall[];
19+
toolCallsPendingApproval: ToolCall[];
1920
};
2021

2122
export type ToolCall = {
@@ -29,7 +30,15 @@ export function toolCallLoading(toolCall: ToolCall): boolean {
2930
return toolCall.result === undefined;
3031
}
3132

32-
export function formatMessages(events: ClaudeMessage[]): Message[] {
33+
export function formatMessages(
34+
events: ClaudeMessage[],
35+
permissionRequests: ClaudePermissionRequest[]
36+
): Message[] {
37+
const permReqsById: Record<string, ClaudePermissionRequest> = {};
38+
for (const request of permissionRequests) {
39+
permReqsById[request.id] = request;
40+
}
41+
3342
const out: Message[] = [];
3443
// A mapping to better handle tool call responses when they come in.
3544
const toolCalls: Record<string, ToolCall> = {};
@@ -41,6 +50,7 @@ export function formatMessages(events: ClaudeMessage[]): Message[] {
4150
type: 'user',
4251
message: event.content.subject.message
4352
});
53+
lastAssistantMessage = undefined;
4454
} else if (event.content.type === 'claudeOutput') {
4555
const subject = event.content.subject;
4656
// We've either triggered a tool call, or sent a message
@@ -50,7 +60,8 @@ export function formatMessages(events: ClaudeMessage[]): Message[] {
5060
lastAssistantMessage = {
5161
type: 'claude',
5262
message: message.content[0]!.text,
53-
toolCalls: []
63+
toolCalls: [],
64+
toolCallsPendingApproval: []
5465
};
5566
out.push(lastAssistantMessage);
5667
} else if (message.content[0]!.type === 'tool_use') {
@@ -65,11 +76,18 @@ export function formatMessages(events: ClaudeMessage[]): Message[] {
6576
lastAssistantMessage = {
6677
type: 'claude',
6778
message: '',
68-
toolCalls: []
79+
toolCalls: [],
80+
toolCallsPendingApproval: []
6981
};
7082
out.push(lastAssistantMessage);
7183
}
72-
lastAssistantMessage.toolCalls.push(toolCall);
84+
85+
const permReq = permReqsById[toolCall.id];
86+
if (permReq && !isDefined(permReq.approved)) {
87+
lastAssistantMessage.toolCallsPendingApproval.push(toolCall);
88+
} else {
89+
lastAssistantMessage.toolCalls.push(toolCall);
90+
}
7391
toolCalls[toolCall.id] = toolCall;
7492
}
7593
} else if (subject.type === 'user') {

apps/desktop/src/lib/codegen/types.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,3 +120,18 @@ export function sessionMessage(sessionDetails: ClaudeSessionDetails): string | u
120120
}
121121

122122
export type ClaudeStatus = 'disabled' | 'enabled' | 'running' | 'completed';
123+
124+
export type ClaudePermissionRequest = {
125+
/** Maps to the tool_use_id from the MCP request */
126+
id: string;
127+
/** When the request was made */
128+
createdAt: string;
129+
/** When the request was updated */
130+
updatedAt: string;
131+
/** The tool for which permission is requested */
132+
toolName: string;
133+
/** The input for the tool */
134+
input: unknown;
135+
/** The status of the request or null if not yet handled */
136+
approved?: boolean;
137+
};

apps/desktop/src/lib/state/tags.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export enum ReduxTag {
2424
WorkspaceRules = 'WorkspaceRules',
2525
Project = 'Project',
2626
ClaudeCodeTranscript = 'ClaudeCodeTranscript',
27+
ClaudePermissionRequests = 'ClaudePermissionPrompts',
2728
ClaudeSessionDetails = 'ClaudeSessionDetails',
2829
InitalEditListing = 'InitialEditListing',
2930
EditChangesSinceInitial = 'EditChangesSinceInitial'

crates/but-api/src/commands/claude.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,3 +71,39 @@ pub fn claude_get_session_details(
7171
last_prompt: transcript.prompt(),
7272
})
7373
}
74+
75+
#[derive(Deserialize)]
76+
#[serde(rename_all = "camelCase")]
77+
pub struct ListPermissionRequestsParams {
78+
pub project_id: ProjectId,
79+
}
80+
81+
pub fn claude_list_permission_requests(
82+
app: &App,
83+
params: ListPermissionRequestsParams,
84+
) -> Result<Vec<but_claude::ClaudePermissionRequest>, Error> {
85+
let project = gitbutler_project::get(params.project_id)?;
86+
let mut ctx = CommandContext::open(&project, app.app_settings.get()?.clone())?;
87+
Ok(but_claude::db::list_all_permission_requests(&mut ctx)?)
88+
}
89+
90+
#[derive(Deserialize)]
91+
#[serde(rename_all = "camelCase")]
92+
pub struct UpdatePermissionRequestParams {
93+
pub project_id: ProjectId,
94+
pub request_id: String,
95+
pub approval: bool,
96+
}
97+
98+
pub fn claude_update_permission_request(
99+
app: &App,
100+
params: UpdatePermissionRequestParams,
101+
) -> Result<(), Error> {
102+
let project = gitbutler_project::get(params.project_id)?;
103+
let mut ctx = CommandContext::open(&project, app.app_settings.get()?.clone())?;
104+
Ok(but_claude::db::update_permission_request(
105+
&mut ctx,
106+
&params.request_id,
107+
params.approval,
108+
)?)
109+
}

crates/but-claude/src/bridge.rs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
2222
use crate::{
2323
ClaudeMessage, ClaudeMessageContent, UserInput,
24-
claude_config::fmt_claude_config,
24+
claude_config::{fmt_claude_mcp, fmt_claude_settings},
2525
db,
2626
rules::{create_claude_assignment_rule, list_claude_assignment_rules},
2727
};
@@ -171,7 +171,8 @@ fn spawn_command(
171171
project_path: std::path::PathBuf,
172172
) -> Result<Child> {
173173
// Write and obtain our own claude hooks path.
174-
let config = fmt_claude_config()?;
174+
let settings = fmt_claude_settings()?;
175+
let mcp_config = fmt_claude_mcp()?;
175176

176177
let mut command = Command::new("claude");
177178
command.stdout(writer);
@@ -181,8 +182,11 @@ fn spawn_command(
181182
"-p",
182183
"--output-format=stream-json",
183184
"--verbose",
184-
"--dangerously-skip-permissions",
185-
&format!("--settings={config}"),
185+
// "--dangerously-skip-permissions",
186+
&format!("--settings={settings}"),
187+
&format!("--mcp-config={mcp_config}"),
188+
"--permission-prompt-tool=mcp__but-security__approval_prompt",
189+
"--permission-mode=acceptEdits",
186190
]);
187191
if create_new {
188192
command.arg(format!("--session-id={}", session.id));

0 commit comments

Comments
 (0)