Skip to content

Commit 603f9a5

Browse files
authored
feat(claw): add default permissions selector to settings tab (#1475)
## Summary Makes permission preset selection available outside of the onboarding flow for KiloClaw. ## Verification - Verified the SettingsTab diff is surgical — only the new imports, the new `PermissionPresetSection` component, and the render insertion are changed; no formatting churn in existing code - Could not run `pnpm typecheck` because `node_modules` are not installed in this environment (shallow clone), but all new code follows existing patterns (same `mutations` API, same `toast`/`Button`/`Save` imports already used by the file) ## Visual Changes https://github.com/user-attachments/assets/b8dbca19-6cb8-4937-9766-e84e5e432a6f ## Reviewer Notes - The `PermissionPresetCards` shared component is intentionally kept in the `claw/components/` directory rather than `components/shared/` since it's only used within the claw feature - The `selected` prop on `PermissionPresetCards` is optional — when omitted (as in the onboarding flow), no card shows the active highlight - There is currently no API to **read** the current exec preset from the backend, so the settings section starts with no card selected. Users pick a preset, then save. A future improvement could add a `getExecPreset` query to pre-populate the selection.
2 parents a8523f4 + 0875acf commit 603f9a5

File tree

8 files changed

+205
-76
lines changed

8 files changed

+205
-76
lines changed

kiloclaw/src/durable-objects/kiloclaw-instance/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1153,6 +1153,8 @@ export class KiloClawInstance extends DurableObject<KiloClawEnv> {
11531153
trackedImageDigest: string | null;
11541154
googleConnected: boolean;
11551155
gmailNotificationsEnabled: boolean;
1156+
execSecurity: string | null;
1157+
execAsk: string | null;
11561158
}> {
11571159
await this.loadState();
11581160

@@ -1189,6 +1191,8 @@ export class KiloClawInstance extends DurableObject<KiloClawEnv> {
11891191
trackedImageDigest: this.s.trackedImageDigest,
11901192
googleConnected: this.s.googleCredentials !== null,
11911193
gmailNotificationsEnabled: this.s.gmailNotificationsEnabled,
1194+
execSecurity: this.s.execSecurity,
1195+
execAsk: this.s.execAsk,
11921196
};
11931197
}
11941198

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
'use client';
2+
3+
import { AlertCircle, ShieldAlert, ShieldCheck } from 'lucide-react';
4+
import { cn } from '@/lib/utils';
5+
import type { ExecPreset } from './claw.types';
6+
7+
export function PermissionPresetCards({
8+
selected,
9+
onSelect,
10+
}: {
11+
selected?: ExecPreset | null;
12+
onSelect: (preset: ExecPreset) => void;
13+
}) {
14+
return (
15+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
16+
<PresetCard
17+
onClick={() => onSelect('never-ask')}
18+
active={selected === 'never-ask'}
19+
icon={<ShieldAlert className="h-5 w-5 text-amber-400" />}
20+
iconBg="bg-amber-900/50"
21+
title="Allow everything"
22+
description="The bot acts immediately without asking — a.k.a. YOLO mode. Best for autonomous workflows, but review what it can access first."
23+
caution="Use with caution"
24+
/>
25+
<PresetCard
26+
onClick={() => onSelect('always-ask')}
27+
active={selected === 'always-ask'}
28+
icon={<ShieldCheck className="h-5 w-5 text-emerald-400" />}
29+
iconBg="bg-emerald-900/50"
30+
title="Ask for permission"
31+
description="The bot pauses and asks you before taking any action. Best when you want full control over what it does."
32+
badge="Recommended"
33+
/>
34+
</div>
35+
);
36+
}
37+
38+
export function PresetCard({
39+
onClick,
40+
active,
41+
icon,
42+
iconBg,
43+
title,
44+
description,
45+
badge,
46+
caution,
47+
}: {
48+
onClick: () => void;
49+
active?: boolean;
50+
icon: React.ReactNode;
51+
iconBg: string;
52+
title: string;
53+
description: string;
54+
badge?: string;
55+
caution?: string;
56+
}) {
57+
return (
58+
<button
59+
type="button"
60+
onClick={onClick}
61+
className={cn(
62+
'relative flex cursor-pointer flex-col gap-4 rounded-xl border p-5 text-left transition-colors',
63+
active
64+
? 'border-blue-500 ring-1 ring-blue-500/40'
65+
: 'border-border hover:border-muted-foreground/40'
66+
)}
67+
>
68+
{/* Top row: icon + badge */}
69+
<div className="flex items-start justify-between">
70+
<div className={cn('flex h-10 w-10 items-center justify-center rounded-lg', iconBg)}>
71+
{icon}
72+
</div>
73+
{badge ? (
74+
<span className="rounded-full border border-emerald-700 px-2.5 py-0.5 text-[10px] font-semibold tracking-wider text-emerald-400 uppercase">
75+
{badge}
76+
</span>
77+
) : null}
78+
</div>
79+
80+
{/* Title + description */}
81+
<div className="flex flex-col gap-1.5">
82+
<p className="text-sm font-bold">{title}</p>
83+
<p className="text-muted-foreground text-xs leading-relaxed">{description}</p>
84+
</div>
85+
86+
{/* Caution label */}
87+
{caution && (
88+
<div className="flex items-center gap-1.5 text-amber-400">
89+
<AlertCircle className="h-3.5 w-3.5" />
90+
<span className="text-xs font-medium">{caution}</span>
91+
</div>
92+
)}
93+
</button>
94+
);
95+
}
Lines changed: 2 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
'use client';
22

3-
import { AlertCircle, ShieldAlert, ShieldCheck } from 'lucide-react';
4-
import { cn } from '@/lib/utils';
53
import type { ExecPreset } from './claw.types';
64
import { OnboardingStepView } from './OnboardingStepView';
5+
import { PermissionPresetCards } from './PermissionPresetCards';
76

87
export function PermissionStep({
98
instanceRunning,
@@ -20,79 +19,7 @@ export function PermissionStep({
2019
description="Choose how your KiloClaw bot handles actions on your behalf."
2120
showProvisioningBanner={!instanceRunning}
2221
>
23-
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
24-
<PresetCard
25-
onClick={() => onSelect('never-ask')}
26-
icon={<ShieldAlert className="h-5 w-5 text-amber-400" />}
27-
iconBg="bg-amber-900/50"
28-
title="Allow everything"
29-
description="The bot acts immediately without asking — a.k.a. YOLO mode. Best for autonomous workflows, but review what it can access first."
30-
caution="Use with caution"
31-
/>
32-
<PresetCard
33-
onClick={() => onSelect('always-ask')}
34-
icon={<ShieldCheck className="h-5 w-5 text-emerald-400" />}
35-
iconBg="bg-emerald-900/50"
36-
title="Ask for permission"
37-
description="The bot pauses and asks you before taking any action. Best when you want full control over what it does."
38-
badge="Recommended"
39-
/>
40-
</div>
22+
<PermissionPresetCards onSelect={onSelect} />
4123
</OnboardingStepView>
4224
);
4325
}
44-
45-
function PresetCard({
46-
onClick,
47-
icon,
48-
iconBg,
49-
title,
50-
description,
51-
badge,
52-
caution,
53-
}: {
54-
onClick: () => void;
55-
icon: React.ReactNode;
56-
iconBg: string;
57-
title: string;
58-
description: string;
59-
badge?: string;
60-
caution?: string;
61-
}) {
62-
return (
63-
<button
64-
type="button"
65-
onClick={onClick}
66-
className={cn(
67-
'relative flex cursor-pointer flex-col gap-4 rounded-xl border p-5 text-left transition-colors',
68-
'border-border hover:border-muted-foreground/40'
69-
)}
70-
>
71-
{/* Top row: icon + badge */}
72-
<div className="flex items-start justify-between">
73-
<div className={cn('flex h-10 w-10 items-center justify-center rounded-lg', iconBg)}>
74-
{icon}
75-
</div>
76-
{badge ? (
77-
<span className="rounded-full border border-emerald-700 px-2.5 py-0.5 text-[10px] font-semibold tracking-wider text-emerald-400 uppercase">
78-
{badge}
79-
</span>
80-
) : null}
81-
</div>
82-
83-
{/* Title + description */}
84-
<div className="flex flex-col gap-1.5">
85-
<p className="text-sm font-bold">{title}</p>
86-
<p className="text-muted-foreground text-xs leading-relaxed">{description}</p>
87-
</div>
88-
89-
{/* Caution label */}
90-
{caution && (
91-
<div className="flex items-center gap-1.5 text-amber-400">
92-
<AlertCircle className="h-3.5 w-3.5" />
93-
<span className="text-xs font-medium">{caution}</span>
94-
</div>
95-
)}
96-
</button>
97-
);
98-
}

src/app/(app)/claw/components/SettingsTab.tsx

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ import { ConfirmActionDialog } from './ConfirmActionDialog';
5555
import { PairingSection } from './PairingSection';
5656
import { VersionPinCard } from './VersionPinCard';
5757
import { WorkspaceFileEditor } from './WorkspaceFileEditor';
58+
import { PermissionPresetCards } from './PermissionPresetCards';
59+
import { type ExecPreset, configToExecPreset, execPresetToConfig } from './claw.types';
5860

5961
type ClawMutations = ReturnType<typeof useKiloClawMutations>;
6062

@@ -433,6 +435,86 @@ function GoogleAccountCard({
433435
);
434436
}
435437

438+
// ---------------------------------------------------------------------------
439+
// Default Permissions section
440+
// ---------------------------------------------------------------------------
441+
442+
function PermissionPresetSection({
443+
isRunning,
444+
status,
445+
mutations,
446+
onRedeploy,
447+
}: {
448+
isRunning: boolean;
449+
status: KiloClawDashboardStatus;
450+
mutations: ClawMutations;
451+
onRedeploy?: () => void;
452+
}) {
453+
const currentPreset = configToExecPreset(status.execSecurity, status.execAsk);
454+
const [selected, setSelected] = useState<ExecPreset | null>(currentPreset);
455+
const saving = mutations.patchExecPreset.isPending || mutations.patchOpenclawConfig.isPending;
456+
const dirty = selected !== null && selected !== currentPreset;
457+
458+
function handleSave() {
459+
if (!selected) return;
460+
const { security, ask } = execPresetToConfig(selected);
461+
462+
// Persist to durable storage (survives redeploys)
463+
mutations.patchExecPreset.mutate(
464+
{ security, ask },
465+
{
466+
onError: (err: { message: string }) => toast.error(`Failed to save: ${err.message}`),
467+
}
468+
);
469+
470+
// Apply to the live openclaw.json if the instance is running
471+
if (isRunning) {
472+
mutations.patchOpenclawConfig.mutate(
473+
{ patch: { tools: { exec: { security, ask } } } },
474+
{
475+
onSuccess: () => {
476+
toast.success('Default permissions saved. Redeploy to ensure the change persists.', {
477+
duration: 8000,
478+
...(onRedeploy && {
479+
action: { label: 'Redeploy', onClick: onRedeploy },
480+
}),
481+
});
482+
},
483+
onError: (err: { message: string }) =>
484+
toast.error(`Saved to storage but failed to apply live: ${err.message}`),
485+
}
486+
);
487+
} else {
488+
toast.success(
489+
'Default permissions saved. Start your instance and redeploy for the change to take effect.'
490+
);
491+
}
492+
}
493+
494+
return (
495+
<div>
496+
<h2 className="text-foreground mb-3 text-base font-semibold">Default Permissions</h2>
497+
<div className="rounded-lg border p-5">
498+
<p className="text-muted-foreground mb-1 text-sm">
499+
Choose how your bot handles actions by default. This sets the{' '}
500+
<strong className="text-foreground">default permission level</strong> for all tool
501+
executions.
502+
</p>
503+
<p className="mb-4 text-xs text-amber-400">
504+
You must redeploy your instance for this change to take effect.
505+
</p>
506+
<PermissionPresetCards selected={selected} onSelect={setSelected} />
507+
<div className="mt-4 flex justify-end">
508+
<Button size="sm" disabled={!dirty || saving} onClick={handleSave}>
509+
<Save className="h-4 w-4" />
510+
{saving ? 'Saving...' : 'Save'}
511+
</Button>
512+
</div>
513+
</div>
514+
</div>
515+
);
516+
}
517+
436518
// ---------------------------------------------------------------------------
437519
// SettingsTab
438520
// ---------------------------------------------------------------------------
@@ -727,6 +809,14 @@ export function SettingsTab({
727809
</div>
728810
</div>
729811

812+
{/* ── Default Permissions ── */}
813+
<PermissionPresetSection
814+
isRunning={isRunning}
815+
status={status}
816+
mutations={mutations}
817+
onRedeploy={onRedeploy}
818+
/>
819+
730820
{/* ── Messaging Channels ── */}
731821
<div>
732822
<h2 className="text-foreground mb-3 text-base font-semibold">Messaging Channels</h2>

src/app/(app)/claw/components/claw.types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@ export function execPresetToConfig(preset: ExecPreset): { security: string; ask:
1717
}
1818
}
1919

20+
/** Reverse-map stored exec config values back to a preset, or null if unrecognised. */
21+
export function configToExecPreset(security: string | null, ask: string | null): ExecPreset | null {
22+
if (security === 'full' && ask === 'off') return 'never-ask';
23+
if (security === 'allowlist' && ask === 'on-miss') return 'always-ask';
24+
return null;
25+
}
26+
2027
/**
2128
* Build the openclaw.json config patch that enables a channel with its token(s).
2229
* The shape must match what the controller writes in config-writer.ts.

src/app/(app)/claw/components/withStatusQueryBoundary.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ const baseStatus: KiloClawDashboardStatus = {
2626
trackedImageDigest: null,
2727
googleConnected: false,
2828
gmailNotificationsEnabled: false,
29+
execSecurity: null,
30+
execAsk: null,
2931
workerUrl: 'https://claw.kilo.ai',
3032
};
3133

src/hooks/useKiloClaw.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,9 @@ export function useKiloClawMutations() {
232232
},
233233
})
234234
),
235-
patchExecPreset: useMutation(trpc.kiloclaw.patchExecPreset.mutationOptions()),
235+
patchExecPreset: useMutation(
236+
trpc.kiloclaw.patchExecPreset.mutationOptions({ onSuccess: invalidateStatus })
237+
),
236238
patchOpenclawConfig: useMutation(trpc.kiloclaw.patchOpenclawConfig.mutationOptions()),
237239
disconnectGoogle: useMutation(
238240
trpc.kiloclaw.disconnectGoogle.mutationOptions({ onSuccess: invalidateStatus })

src/lib/kiloclaw/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,8 @@ export type PlatformStatusResponse = {
137137
trackedImageDigest: string | null;
138138
googleConnected: boolean;
139139
gmailNotificationsEnabled: boolean;
140+
execSecurity: string | null;
141+
execAsk: string | null;
140142
};
141143

142144
/** Response from GET /api/platform/debug-status (internal/admin only). */

0 commit comments

Comments
 (0)