Skip to content

Commit ae01e61

Browse files
Merge pull request #11149 from gitbutlerapp/permissions
Add option to have wildcard permissions
2 parents 521da05 + 691a130 commit ae01e61

File tree

15 files changed

+220
-103
lines changed

15 files changed

+220
-103
lines changed

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@
1212
type Props = {
1313
projectId: string;
1414
message: Message;
15-
onPermissionDecision?: (id: string, decision: PermissionDecision) => Promise<void>;
15+
onPermissionDecision?: (
16+
id: string,
17+
decision: PermissionDecision,
18+
useWildcard: boolean
19+
) => Promise<void>;
1620
toolCallsExpandedState?: Map<string, boolean>;
1721
};
1822
const { projectId, message, onPermissionDecision, toolCallsExpandedState }: Props = $props();
@@ -51,7 +55,8 @@
5155
style="standalone"
5256
{toolCall}
5357
requiresApproval={{
54-
onPermissionDecision: async (id, decision) => await onPermissionDecision?.(id, decision)
58+
onPermissionDecision: async (id, decision, useWildcard) =>
59+
await onPermissionDecision?.(id, decision, useWildcard)
5560
}}
5661
/>
5762
{/each}

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

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,8 +186,17 @@
186186
}
187187
}
188188
189-
async function onPermissionDecision(id: string, decision: PermissionDecision) {
190-
await claudeCodeService.updatePermissionRequest({ projectId, requestId: id, decision });
189+
async function onPermissionDecision(
190+
id: string,
191+
decision: PermissionDecision,
192+
useWildcard: boolean
193+
) {
194+
await claudeCodeService.updatePermissionRequest({
195+
projectId,
196+
requestId: id,
197+
decision,
198+
useWildcard
199+
});
191200
}
192201
async function onAbort() {
193202
await claudeCodeService.cancelSession({ projectId, stackId });

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

Lines changed: 143 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
<script lang="ts">
22
import { toolCallLoading, type ToolCall } from '$lib/codegen/messages';
33
import { formatToolCall, getToolIcon } from '$lib/utils/codegenTools';
4-
import { DropdownButton, ContextMenuItem, Icon } from '@gitbutler/ui';
4+
import { DropdownButton, ContextMenuItem, Icon, Select, SelectItem } from '@gitbutler/ui';
55
import type { PermissionDecision } from '$lib/codegen/types';
66
77
export type RequiresApproval = {
8-
onPermissionDecision: (id: string, decision: PermissionDecision) => Promise<void>;
8+
onPermissionDecision: (
9+
id: string,
10+
decision: PermissionDecision,
11+
useWildcard: boolean
12+
) => Promise<void>;
913
};
1014
1115
type Props = {
@@ -21,14 +25,14 @@
2125
let allowDropdownButton = $state<ReturnType<typeof DropdownButton>>();
2226
let denyDropdownButton = $state<ReturnType<typeof DropdownButton>>();
2327
24-
// Persisted state for selected permission scopes
2528
type AllowDecision = 'allowOnce' | 'allowSession' | 'allowProject' | 'allowAlways';
2629
type DenyDecision = 'denyOnce' | 'denySession' | 'denyProject' | 'denyAlways';
30+
type WildcardDecision = 'precise' | 'wild';
2731
2832
let selectedAllowDecision = $state<AllowDecision>('allowSession');
2933
let selectedDenyDecision = $state<DenyDecision>('denySession');
34+
let selectedWildcardDecision = $state<WildcardDecision>('precise');
3035
31-
// Labels for each decision type
3236
const allowLabels: Record<AllowDecision, string> = {
3337
allowOnce: 'Allow once',
3438
allowProject: 'Allow this project',
@@ -42,6 +46,31 @@
4246
denyProject: 'Deny this project',
4347
denyAlways: 'Deny always'
4448
};
49+
50+
// The wildcard selector only shows up for certain tool calls
51+
const wildcardSelector = $derived.by<
52+
{ show: false } | { show: true; options: { label: string; value: WildcardDecision }[] }
53+
>(() => {
54+
if (toolCall.name === 'Edit' || toolCall.name === 'Write') {
55+
return {
56+
show: true,
57+
options: [
58+
{ value: 'precise', label: 'This file' },
59+
{ value: 'wild', label: 'Any files in the same folder' }
60+
]
61+
};
62+
} else if (toolCall.name === 'Bash') {
63+
return {
64+
show: true,
65+
options: [
66+
{ value: 'precise', label: 'This command' },
67+
{ value: 'wild', label: 'Any subcommands or flags' }
68+
]
69+
};
70+
} else {
71+
return { show: false };
72+
}
73+
});
4574
</script>
4675

4776
<div class="tool-call {style}" class:full-width={fullWidth}>
@@ -55,93 +84,118 @@
5584
<span class="tool-name text-12">{toolCall.name}</span>
5685

5786
<span class="summary truncate grow clr-text-2">{formatToolCall(toolCall)}</span>
87+
</div>
5888

59-
{#if requiresApproval}
60-
<div class="flex gap-4 m-l-8">
61-
<DropdownButton
62-
bind:this={denyDropdownButton}
63-
style="error"
64-
kind="outline"
65-
onclick={async () => {
66-
await requiresApproval.onPermissionDecision(toolCall.id, selectedDenyDecision);
67-
denyDropdownButton?.close();
89+
{#if requiresApproval}
90+
<div class="flex gap-4">
91+
{#if wildcardSelector.show}
92+
<Select
93+
value={selectedWildcardDecision}
94+
options={wildcardSelector.options}
95+
wide
96+
onselect={(value) => {
97+
selectedWildcardDecision = value as WildcardDecision;
6898
}}
6999
>
70-
{denyLabels[selectedDenyDecision]}
71-
{#snippet contextMenuSlot()}
72-
<ContextMenuItem
73-
label="Deny once"
74-
onclick={() => {
75-
selectedDenyDecision = 'denyOnce';
76-
denyDropdownButton?.close();
77-
}}
78-
/>
79-
<ContextMenuItem
80-
label="Deny in this session"
81-
onclick={() => {
82-
selectedDenyDecision = 'denySession';
83-
denyDropdownButton?.close();
84-
}}
85-
/>
86-
<ContextMenuItem
87-
label="Deny in this project"
88-
onclick={() => {
89-
selectedDenyDecision = 'denyProject';
90-
denyDropdownButton?.close();
91-
}}
92-
/>
93-
<ContextMenuItem
94-
label="Deny always"
95-
onclick={() => {
96-
selectedDenyDecision = 'denyAlways';
97-
denyDropdownButton?.close();
98-
}}
99-
/>
100+
{#snippet itemSnippet({ item, highlighted })}
101+
<SelectItem selected={item.value === selectedWildcardDecision} {highlighted}>
102+
{item.label}
103+
</SelectItem>
100104
{/snippet}
101-
</DropdownButton>
102-
<DropdownButton
103-
bind:this={allowDropdownButton}
104-
style="pop"
105-
onclick={async () => {
106-
await requiresApproval.onPermissionDecision(toolCall.id, selectedAllowDecision);
107-
allowDropdownButton?.close();
108-
}}
109-
>
110-
{allowLabels[selectedAllowDecision]}
111-
{#snippet contextMenuSlot()}
112-
<ContextMenuItem
113-
label="Allow once"
114-
onclick={() => {
115-
selectedAllowDecision = 'allowOnce';
116-
allowDropdownButton?.close();
117-
}}
118-
/>
119-
<ContextMenuItem
120-
label="Allow in this session"
121-
onclick={() => {
122-
selectedAllowDecision = 'allowSession';
123-
allowDropdownButton?.close();
124-
}}
125-
/>
126-
<ContextMenuItem
127-
label="Allow in this project"
128-
onclick={() => {
129-
selectedAllowDecision = 'allowProject';
130-
allowDropdownButton?.close();
131-
}}
132-
/>
133-
<ContextMenuItem
134-
label="Allow always"
135-
onclick={() => {
136-
selectedAllowDecision = 'allowAlways';
137-
allowDropdownButton?.close();
138-
}}
139-
/>
140-
{/snippet}
141-
</DropdownButton>
142-
</div>
143-
{/if}
144-
</div>
105+
</Select>
106+
{/if}
107+
108+
<DropdownButton
109+
bind:this={denyDropdownButton}
110+
style="error"
111+
kind="outline"
112+
onclick={async () => {
113+
await requiresApproval.onPermissionDecision(
114+
toolCall.id,
115+
selectedDenyDecision,
116+
selectedWildcardDecision === 'wild'
117+
);
118+
denyDropdownButton?.close();
119+
}}
120+
>
121+
{denyLabels[selectedDenyDecision]}
122+
{#snippet contextMenuSlot()}
123+
<ContextMenuItem
124+
label="Deny once"
125+
onclick={() => {
126+
selectedDenyDecision = 'denyOnce';
127+
denyDropdownButton?.close();
128+
}}
129+
/>
130+
<ContextMenuItem
131+
label="Deny in this session"
132+
onclick={() => {
133+
selectedDenyDecision = 'denySession';
134+
denyDropdownButton?.close();
135+
}}
136+
/>
137+
<ContextMenuItem
138+
label="Deny in this project"
139+
onclick={() => {
140+
selectedDenyDecision = 'denyProject';
141+
denyDropdownButton?.close();
142+
}}
143+
/>
144+
<ContextMenuItem
145+
label="Deny always"
146+
onclick={() => {
147+
selectedDenyDecision = 'denyAlways';
148+
denyDropdownButton?.close();
149+
}}
150+
/>
151+
{/snippet}
152+
</DropdownButton>
153+
<DropdownButton
154+
bind:this={allowDropdownButton}
155+
style="pop"
156+
onclick={async () => {
157+
await requiresApproval.onPermissionDecision(
158+
toolCall.id,
159+
selectedAllowDecision,
160+
selectedWildcardDecision === 'wild'
161+
);
162+
allowDropdownButton?.close();
163+
}}
164+
>
165+
{allowLabels[selectedAllowDecision]}
166+
{#snippet contextMenuSlot()}
167+
<ContextMenuItem
168+
label="Allow once"
169+
onclick={() => {
170+
selectedAllowDecision = 'allowOnce';
171+
allowDropdownButton?.close();
172+
}}
173+
/>
174+
<ContextMenuItem
175+
label="Allow in this session"
176+
onclick={() => {
177+
selectedAllowDecision = 'allowSession';
178+
allowDropdownButton?.close();
179+
}}
180+
/>
181+
<ContextMenuItem
182+
label="Allow in this project"
183+
onclick={() => {
184+
selectedAllowDecision = 'allowProject';
185+
allowDropdownButton?.close();
186+
}}
187+
/>
188+
<ContextMenuItem
189+
label="Allow always"
190+
onclick={() => {
191+
selectedAllowDecision = 'allowAlways';
192+
allowDropdownButton?.close();
193+
}}
194+
/>
195+
{/snippet}
196+
</DropdownButton>
197+
</div>
198+
{/if}
145199
</div>
146200

147201
<style lang="postcss">
@@ -153,6 +207,8 @@
153207
154208
padding: 1px 32px 1px 12px;
155209
overflow: hidden;
210+
211+
gap: 12px;
156212
user-select: text;
157213
158214
&:not(.full-width) {

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,7 @@ function injectEndpoints(api: ClientState['backendApi']) {
233233
projectId: string;
234234
requestId: string;
235235
decision: PermissionDecision;
236+
useWildcard: boolean;
236237
}
237238
>({
238239
extraOptions: {

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,8 @@ export type ClaudePermissionRequest = {
210210
input: unknown;
211211
/** The permission decision or null if not yet handled */
212212
decision?: PermissionDecision;
213+
/** Whether to use wildcard permissions (optional, for backward compatibility) */
214+
useWildcard?: boolean;
213215
};
214216

215217
export type ClaudeTodo = {

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,13 +121,15 @@ pub fn claude_update_permission_request(
121121
project_id: ProjectId,
122122
request_id: String,
123123
decision: but_claude::PermissionDecision,
124+
use_wildcard: bool,
124125
) -> Result<(), Error> {
125126
let project = gitbutler_project::get(project_id)?;
126127
let mut ctx = CommandContext::open(&project, AppSettings::load_from_default_path_creating()?)?;
127128
Ok(but_claude::db::update_permission_request(
128129
&mut ctx,
129130
&request_id,
130131
decision,
132+
use_wildcard,
131133
)?)
132134
}
133135

crates/but-claude/src/db.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,11 +197,12 @@ pub fn update_permission_request(
197197
ctx: &mut CommandContext,
198198
id: &str,
199199
decision: crate::PermissionDecision,
200+
use_wildcard: bool,
200201
) -> anyhow::Result<()> {
201202
let decision_str = serde_json::to_string(&decision)?;
202203
ctx.db()?
203204
.claude_permission_requests()
204-
.set_decision(id, Some(decision_str))?;
205+
.set_decision_and_wildcard(id, Some(decision_str), use_wildcard)?;
205206
Ok(())
206207
}
207208

@@ -348,6 +349,7 @@ impl TryFrom<but_db::ClaudePermissionRequest> for crate::ClaudePermissionRequest
348349
tool_name: value.tool_name,
349350
input: serde_json::from_str(&value.input)?,
350351
decision,
352+
use_wildcard: value.use_wildcard,
351353
})
352354
}
353355
}
@@ -366,6 +368,7 @@ impl TryFrom<crate::ClaudePermissionRequest> for but_db::ClaudePermissionRequest
366368
tool_name: value.tool_name,
367369
input: serde_json::to_string(&value.input)?,
368370
decision,
371+
use_wildcard: value.use_wildcard,
369372
})
370373
}
371374
}

crates/but-claude/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,8 @@ pub struct ClaudePermissionRequest {
404404
pub input: serde_json::Value,
405405
/// The permission decision or None if not yet handled
406406
pub decision: Option<PermissionDecision>,
407+
/// Whether to use wildcard permissions for this request
408+
pub use_wildcard: bool,
407409
}
408410

409411
/// Represents the thinking level for Claude Code.

0 commit comments

Comments
 (0)