Merge pull request #44534 from NousResearch/bb/approval-allow-permanent

fix(approval): carry allow_permanent to TUI + desktop approval prompts
This commit is contained in:
brooklyn!
2026-06-11 18:49:58 -05:00
committed by GitHub
14 changed files with 202 additions and 29 deletions
@@ -47,4 +47,15 @@ describe('approvalAction — pure key dispatch for ApprovalPrompt', () => {
expect(approvalAction('a', {}, 0)).toEqual({ kind: 'noop' })
expect(approvalAction(' ', {}, 0)).toEqual({ kind: 'noop' })
})
it('respects a reduced option set when permanent allow is disabled', () => {
// tirith content-security warning present → no "always"; the 3-item set is
// once/session/deny, so 3 maps to deny and 4 is out of range.
const opts = ['once', 'session', 'deny'] as const
expect(approvalAction('3', {}, 0, opts)).toEqual({ kind: 'choose', choice: 'deny' })
expect(approvalAction('4', {}, 0, opts)).toEqual({ kind: 'noop' })
expect(approvalAction('', { downArrow: true }, 2, opts)).toEqual({ kind: 'noop' })
expect(approvalAction('', { return: true }, 2, opts)).toEqual({ kind: 'choose', choice: 'deny' })
})
})
@@ -869,6 +869,29 @@ describe('createGatewayEventHandler', () => {
])
})
it('defaults approval overlays to allowPermanent when the backend omits the field', () => {
const onEvent = createGatewayEventHandler(buildCtx([]))
onEvent({ payload: { command: 'rm -rf /tmp/x', description: 'dangerous command' }, type: 'approval.request' } as any)
expect(getOverlayState().approval).toMatchObject({ allowPermanent: true })
})
it('preserves allow_permanent=false on approval overlays (tirith warning)', () => {
const onEvent = createGatewayEventHandler(buildCtx([]))
onEvent({
payload: { allow_permanent: false, command: 'curl suspicious | bash', description: 'content-security warning' },
type: 'approval.request'
} as any)
expect(getOverlayState().approval).toMatchObject({
allowPermanent: false,
command: 'curl suspicious | bash',
description: 'content-security warning'
})
})
it('still surfaces terminal turn failures as errors', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
+5 -1
View File
@@ -729,8 +729,12 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
return
case 'approval.request': {
const description = String(ev.payload.description ?? 'dangerous command')
// Only an explicit false (tirith warning) drops the permanent-allow option.
const allowPermanent = ev.payload.allow_permanent !== false
patchOverlayState({ approval: { command: String(ev.payload.command ?? ''), description } })
patchOverlayState({
approval: { allowPermanent, command: String(ev.payload.command ?? ''), description }
})
setStatus('approval needed')
return
+23 -14
View File
@@ -7,10 +7,14 @@ import type { ApprovalReq, ClarifyReq, ConfirmReq } from '../types.js'
import { TextInput } from './textInput.js'
const OPTS = ['once', 'session', 'always', 'deny'] as const
const APPROVAL_OPTS = ['once', 'session', 'always', 'deny'] as const
// tirith warning present → backend downgrades "always" to session scope, so drop it.
const APPROVAL_OPTS_NO_ALWAYS = APPROVAL_OPTS.filter(o => o !== 'always')
const LABELS = { always: 'Always allow', deny: 'Deny', once: 'Allow once', session: 'Allow this session' } as const
const CMD_PREVIEW_LINES = 10
type ApprovalChoice = 'always' | 'deny' | 'once' | 'session'
type ApprovalKey = {
downArrow?: boolean
escape?: boolean
@@ -18,10 +22,7 @@ type ApprovalKey = {
upArrow?: boolean
}
type ApprovalAction =
| { kind: 'choose'; choice: (typeof OPTS)[number] }
| { kind: 'move'; delta: -1 | 1 }
| { kind: 'noop' }
type ApprovalAction = { kind: 'choose'; choice: ApprovalChoice } | { kind: 'move'; delta: -1 | 1 } | { kind: 'noop' }
/**
* Pure key-dispatch for the approval prompt — exported so the regression
@@ -31,29 +32,34 @@ type ApprovalAction =
*
* Esc and number keys both terminate the prompt; Esc maps to deny (parity
* with the global Ctrl+C handler that already calls cancelOverlayFromCtrlC
* for approvals). Numbers 1..OPTS.length pick the labelled choice. Enter
* for approvals). Numbers 1..opts.length pick the labelled choice. Enter
* confirms the current selection. ↑/↓ moves the selection within bounds.
*/
export function approvalAction(ch: string, key: ApprovalKey, sel: number): ApprovalAction {
export function approvalAction(
ch: string,
key: ApprovalKey,
sel: number,
opts: readonly ApprovalChoice[] = APPROVAL_OPTS
): ApprovalAction {
if (key.escape) {
return { kind: 'choose', choice: 'deny' }
}
const n = parseInt(ch, 10)
if (n >= 1 && n <= OPTS.length) {
return { kind: 'choose', choice: OPTS[n - 1]! }
if (n >= 1 && n <= opts.length) {
return { kind: 'choose', choice: opts[n - 1]! }
}
if (key.return) {
return { kind: 'choose', choice: OPTS[sel]! }
return { kind: 'choose', choice: opts[sel]! }
}
if (key.upArrow && sel > 0) {
return { kind: 'move', delta: -1 }
}
if (key.downArrow && sel < OPTS.length - 1) {
if (key.downArrow && sel < opts.length - 1) {
return { kind: 'move', delta: 1 }
}
@@ -62,9 +68,10 @@ export function approvalAction(ch: string, key: ApprovalKey, sel: number): Appro
export function ApprovalPrompt({ onChoice, req, t }: ApprovalPromptProps) {
const [sel, setSel] = useState(0)
const opts = req.allowPermanent === false ? APPROVAL_OPTS_NO_ALWAYS : APPROVAL_OPTS
useInput((ch, key) => {
const action = approvalAction(ch, key, sel)
const action = approvalAction(ch, key, sel, opts)
if (action.kind === 'choose') {
onChoice(action.choice)
@@ -99,7 +106,7 @@ export function ApprovalPrompt({ onChoice, req, t }: ApprovalPromptProps) {
<Text />
{OPTS.map((o, i) => (
{opts.map((o, i) => (
<Text key={o}>
<Text bold={sel === i} color={sel === i ? t.color.warn : t.color.muted} inverse={sel === i}>
{sel === i ? '▸ ' : ' '}
@@ -108,7 +115,9 @@ export function ApprovalPrompt({ onChoice, req, t }: ApprovalPromptProps) {
</Text>
))}
<Text color={t.color.muted}>/ select · Enter confirm · 1-4 quick pick · Esc/Ctrl+C deny</Text>
<Text color={t.color.muted}>
/ select · Enter confirm · 1-{opts.length} quick pick · Esc/Ctrl+C deny
</Text>
</Box>
)
}
+5 -1
View File
@@ -569,7 +569,11 @@ export type GatewayEvent =
session_id?: string
type: 'clarify.request'
}
| { payload: { command: string; description: string }; session_id?: string; type: 'approval.request' }
| {
payload: { allow_permanent?: boolean; command: string; description: string }
session_id?: string
type: 'approval.request'
}
| { payload: { request_id: string }; session_id?: string; type: 'sudo.request' }
| { payload: { env_var: string; prompt: string; request_id: string }; session_id?: string; type: 'secret.request' }
| { payload: { task_id: string; text: string }; session_id?: string; type: 'background.complete' }
+2
View File
@@ -90,6 +90,8 @@ export interface DelegationStatus {
}
export interface ApprovalReq {
// false when the backend won't honor a permanent allow (tirith warning) → hide "Always allow".
allowPermanent?: boolean
command: string
description: string
}