Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/daemon-core/src/providers/approval-utils.d.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import type { ProviderModule } from './contracts.js';
export declare function isUnsafeApprovalButtonLabel(value: string): boolean;
export declare function getApprovalPositiveHints(provider?: Pick<ProviderModule, 'approvalPositiveHints'> | null): string[];
export declare function pickApprovalButton(buttons: string[] | null | undefined, provider?: Pick<ProviderModule, 'approvalPositiveHints'> | null): {
index: number;
label: string;
unsafe?: boolean;
};
export declare function formatAutoApprovalMessage(modalMessage?: string, buttonLabel?: string): string;
27 changes: 22 additions & 5 deletions packages/daemon-core/src/providers/approval-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ const DEFAULT_APPROVAL_POSITIVE_HINTS = [
'always allow',
];

const UNSAFE_APPROVAL_LABEL_PATTERN = /\b(?:abort|cancel|deny|discard|do\s+not|end|interrupt|no|reject|stop|terminate)\b/i;

function normalizeApprovalLabel(value: string): string {
return String(value || '')
.toLowerCase()
Expand All @@ -24,6 +26,10 @@ function normalizeApprovalLabel(value: string): string {
.trim();
}

export function isUnsafeApprovalButtonLabel(value: string): boolean {
return UNSAFE_APPROVAL_LABEL_PATTERN.test(normalizeApprovalLabel(value));
}

export function getApprovalPositiveHints(provider?: Pick<ProviderModule, 'approvalPositiveHints'> | null): string[] {
const customHints = Array.isArray(provider?.approvalPositiveHints)
? provider.approvalPositiveHints
Expand All @@ -36,27 +42,38 @@ export function getApprovalPositiveHints(provider?: Pick<ProviderModule, 'approv
export function pickApprovalButton(
buttons: string[] | null | undefined,
provider?: Pick<ProviderModule, 'approvalPositiveHints'> | null,
): { index: number; label: string } {
): { index: number; label: string; unsafe?: boolean } {
const labels = (buttons || []).map((button) => String(button || '').trim()).filter(Boolean);
if (labels.length === 0) {
return { index: 0, label: 'Approve' };
}

const normalizedButtons = labels.map((label) => normalizeApprovalLabel(label));
const hints = getApprovalPositiveHints(provider);
const safeCandidate = (index: number) => (
index >= 0 && !isUnsafeApprovalButtonLabel(labels[index])
? { index, label: labels[index] }
: null
);

for (const hint of hints) {
const exactIndex = normalizedButtons.findIndex((label) => label === hint);
if (exactIndex >= 0) return { index: exactIndex, label: labels[exactIndex] };
const exact = safeCandidate(exactIndex);
if (exact) return exact;

const prefixIndex = normalizedButtons.findIndex((label) => label.startsWith(hint));
if (prefixIndex >= 0) return { index: prefixIndex, label: labels[prefixIndex] };
const prefix = safeCandidate(prefixIndex);
if (prefix) return prefix;

const includeIndex = normalizedButtons.findIndex((label) => label.includes(hint));
if (includeIndex >= 0) return { index: includeIndex, label: labels[includeIndex] };
const include = safeCandidate(includeIndex);
if (include) return include;
}

return { index: 0, label: labels[0] };
const nonUnsafeIndex = labels.findIndex((label) => !isUnsafeApprovalButtonLabel(label));
if (nonUnsafeIndex >= 0) return { index: nonUnsafeIndex, label: labels[nonUnsafeIndex] };

return { index: 0, label: labels[0], unsafe: true };
}

export function formatAutoApprovalMessage(modalMessage?: string, buttonLabel?: string): string {
Expand Down
11 changes: 10 additions & 1 deletion packages/daemon-core/src/providers/cli-provider-instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -926,7 +926,16 @@ export class CliProviderInstance implements ProviderInstance {
this.autoApproveBusyTimer = null;
}, 2000);
const modal = adapterStatus.activeModal;
const { index: buttonIndex, label: buttonLabel } = pickApprovalButton(modal?.buttons, this.provider);
const { index: buttonIndex, label: buttonLabel, unsafe } = pickApprovalButton(modal?.buttons, this.provider);
if (unsafe) {
LOG.warn('CLI', `[${this.type}] auto-approve skipped unsafe button "${buttonLabel}"`);
if (this.autoApproveBusyTimer) {
clearTimeout(this.autoApproveBusyTimer);
this.autoApproveBusyTimer = null;
}
this.autoApproveBusy = false;
return false;
}
this.recordAutoApproval(modal?.message, buttonLabel, now);
setTimeout(() => {
this.adapter.resolveModal(buttonIndex);
Expand Down
24 changes: 16 additions & 8 deletions packages/daemon-core/src/providers/ide-provider-instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ export class IdeProviderInstance implements ProviderInstance {
const autoApproveActive = (
this.currentStatus === 'waiting_approval'
|| this.cachedChat?.status === 'waiting_approval'
) && this.canAutoApprove();
) && this.canAutoApprove(this.cachedChat);
const visibleStatus = (autoApproveActive ? 'generating' : this.currentStatus) as ProviderState['status'];

// Collect extension status
Expand Down Expand Up @@ -184,7 +184,7 @@ export class IdeProviderInstance implements ProviderInstance {
const autoApproveActive = (
this.currentStatus === 'waiting_approval'
|| this.cachedChat?.status === 'waiting_approval'
) && this.canAutoApprove();
) && this.canAutoApprove(this.cachedChat);
const visibleStatus = autoApproveActive ? 'generating' : this.currentStatus;
return {
id: this.instanceId,
Expand Down Expand Up @@ -441,7 +441,7 @@ export class IdeProviderInstance implements ProviderInstance {
const rawAgentStatus = (chatStatus === 'streaming' || chatStatus === 'generating') ? 'generating'
: chatStatus === 'waiting_approval' ? 'waiting_approval'
: 'idle';
const autoApproveActive = rawAgentStatus === 'waiting_approval' && this.canAutoApprove();
const autoApproveActive = rawAgentStatus === 'waiting_approval' && this.canAutoApprove(chatData);
const agentStatus = autoApproveActive ? 'generating' : rawAgentStatus;
const lastMsg = Array.isArray(chatData?.messages) && chatData.messages.length > 0
? chatData.messages[chatData.messages.length - 1]
Expand Down Expand Up @@ -673,10 +673,14 @@ export class IdeProviderInstance implements ProviderInstance {
if (this.context) this.context.cdp = cdp;
}

private canAutoApprove(): boolean {
return this.settings.autoApprove !== false
&& typeof this.provider.scripts?.resolveAction === 'function'
&& !!this.context?.cdp?.isConnected;
private canAutoApprove(chatData?: any): boolean {
if (this.settings.autoApprove === false) return false;
if (typeof this.provider.scripts?.resolveAction !== 'function') return false;
if (!this.context?.cdp?.isConnected) return false;
if (chatData?.activeModal?.buttons) {
return !pickApprovalButton(chatData.activeModal.buttons, this.provider).unsafe;
}
return true;
}

// ─── Auto-approve via CDP script ────────────────────
Expand All @@ -694,7 +698,11 @@ export class IdeProviderInstance implements ProviderInstance {

this.autoApproveBusy = true;
try {
const { label: targetButton } = pickApprovalButton(_chatData?.activeModal?.buttons, this.provider);
const { label: targetButton, unsafe } = pickApprovalButton(_chatData?.activeModal?.buttons, this.provider);
if (unsafe) {
LOG.warn('IdeInstance', `[IdeInstance:${this.type}] autoApprove skipped unsafe button "${targetButton}"`);
return;
}

const script = scriptFn({ action: 'approve', button: targetButton, buttonText: targetButton });
if (!script) return;
Expand Down
21 changes: 21 additions & 0 deletions packages/daemon-core/test/providers/approval-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,25 @@ describe('approval-utils', () => {
approvalPositiveHints: ['yes', 'allow', 'always allow'],
})).toEqual({ index: 0, label: '1 Yes' })
})

it('marks all-destructive choices unsafe so auto-approve can leave them alone', () => {
expect(pickApprovalButton([
'Terminate current task',
'Cancel',
])).toEqual({ index: 0, label: 'Terminate current task', unsafe: true })
})

it('falls back to a non-destructive choice when no positive hint matches', () => {
expect(pickApprovalButton([
'Terminate current task',
'Keep waiting',
])).toEqual({ index: 1, label: 'Keep waiting' })
})

it('does not let positive words make a destructive button auto-approvable', () => {
expect(pickApprovalButton([
'Confirm termination',
'Keep running',
])).toEqual({ index: 1, label: 'Keep running' })
})
})