From 6a394c59a7e8e8ebfcd28f20cba84e87bf8ec88c Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Thu, 28 May 2026 06:59:44 -0300 Subject: [PATCH 1/3] feat(approval): add permission modes, allowlist, and approve-always Introduce per-session permission modes (manual / auto / full) enforced server-side in the turn-orchestrator hook, replacing the client-only auto-accept toggle. - manual: prompt for everything except yaml allows + approved_always - auto: additionally skip the user-curated always_allow list - full: skip every prompt (persistent banner + one-click revert) Backend: new approval_settings state scope with set_mode / add_always_allow / remove_always_allow / approve_always / get_settings / clear_settings handlers (all human-only; the hook hard-denies them for agent calls). consultBefore snapshots settings and applies full > approved_always (all modes) > always_allow (auto only) > yaml policy. A still-parked sibling is released when a later approve-always or mode switch covers it. Frontend: mode picker in the composer, full-permissions banner, inline "approve always" button on prompts, and a Configuration-screen allowlist manager (tree grouped by ::, tri-state branches, queued destructive confirmation, orchestration/runtime namespaces filtered out). Default mode + allowlist persist in localStorage and seed new conversations. --- .../src/components/chat/AutoAcceptToggle.tsx | 86 ---- console/web/src/components/chat/ChatPanel.tsx | 2 - console/web/src/components/chat/ChatView.tsx | 50 ++- console/web/src/components/chat/Composer.tsx | 28 +- .../src/components/chat/FunctionCallGroup.tsx | 12 + .../components/chat/FunctionCallMessage.tsx | 25 +- console/web/src/components/chat/Message.tsx | 17 +- .../web/src/components/chat/MessageList.tsx | 8 + .../permissions/AlwaysAllowButton.tsx | 104 +++++ .../DefaultPermissionModePicker.tsx | 69 ++++ .../permissions/FullModeConfirmDialog.tsx | 64 +++ .../permissions/FullPermissionsBanner.tsx | 38 ++ .../permissions/FunctionAllowlistTree.tsx | 378 ++++++++++++++++++ .../permissions/PermissionModePicker.tsx | 58 +++ .../web/src/hooks/use-approval-settings.ts | 206 ++++++++++ .../hooks/use-auto-accept-approvals.test.ts | 20 - .../src/hooks/use-auto-accept-approvals.ts | 110 ----- console/web/src/hooks/use-conversations.ts | 13 - .../web/src/lib/backend/approval-settings.ts | 127 ++++++ .../web/src/lib/backend/auto-accept.test.ts | 145 ------- console/web/src/lib/backend/auto-accept.ts | 88 ---- console/web/src/lib/chat-activity.test.ts | 1 - .../lib/permissions/allowlist-filter.test.ts | 76 ++++ .../src/lib/permissions/allowlist-filter.ts | 74 ++++ console/web/src/lib/storage.ts | 62 ++- console/web/src/pages/Configuration/index.tsx | 117 +++++- .../Examples/sections/composer-variants.tsx | 20 +- console/web/src/pages/Playground/index.tsx | 6 - console/web/src/types/chat.ts | 11 - harness/docs/workers/approval-gate.md | 70 +++- harness/src/approval-gate/main.ts | 9 +- harness/src/approval-gate/schemas.ts | 32 ++ .../settings/add-always-allow.ts | 54 +++ .../approval-gate/settings/approve-always.ts | 56 +++ .../approval-gate/settings/clear-settings.ts | 31 ++ .../approval-gate/settings/get-settings.ts | 24 ++ .../src/approval-gate/settings/human-only.ts | 14 + .../src/approval-gate/settings/register.ts | 36 ++ .../settings/remove-always-allow.ts | 48 +++ harness/src/approval-gate/settings/reply.ts | 9 + .../src/approval-gate/settings/set-mode.ts | 45 +++ harness/src/approval-gate/settings/store.ts | 43 ++ harness/src/approval-gate/settings/types.ts | 15 + harness/src/approval-gate/settings/verdict.ts | 36 ++ harness/src/index.ts | 8 +- .../function-awaiting-approval/ports.ts | 6 + .../function-awaiting-approval/run.ts | 14 +- harness/src/turn-orchestrator/hook.ts | 50 ++- harness/tests/approval-gate/settings.test.ts | 116 ++++++ .../integration/parallel-approval.e2e.test.ts | 37 ++ harness/tests/turn-orchestrator/hook.test.ts | 168 ++++++++ 51 files changed, 2382 insertions(+), 554 deletions(-) delete mode 100644 console/web/src/components/chat/AutoAcceptToggle.tsx create mode 100644 console/web/src/components/permissions/AlwaysAllowButton.tsx create mode 100644 console/web/src/components/permissions/DefaultPermissionModePicker.tsx create mode 100644 console/web/src/components/permissions/FullModeConfirmDialog.tsx create mode 100644 console/web/src/components/permissions/FullPermissionsBanner.tsx create mode 100644 console/web/src/components/permissions/FunctionAllowlistTree.tsx create mode 100644 console/web/src/components/permissions/PermissionModePicker.tsx create mode 100644 console/web/src/hooks/use-approval-settings.ts delete mode 100644 console/web/src/hooks/use-auto-accept-approvals.test.ts delete mode 100644 console/web/src/hooks/use-auto-accept-approvals.ts create mode 100644 console/web/src/lib/backend/approval-settings.ts delete mode 100644 console/web/src/lib/backend/auto-accept.test.ts delete mode 100644 console/web/src/lib/backend/auto-accept.ts create mode 100644 console/web/src/lib/permissions/allowlist-filter.test.ts create mode 100644 console/web/src/lib/permissions/allowlist-filter.ts create mode 100644 harness/src/approval-gate/settings/add-always-allow.ts create mode 100644 harness/src/approval-gate/settings/approve-always.ts create mode 100644 harness/src/approval-gate/settings/clear-settings.ts create mode 100644 harness/src/approval-gate/settings/get-settings.ts create mode 100644 harness/src/approval-gate/settings/human-only.ts create mode 100644 harness/src/approval-gate/settings/register.ts create mode 100644 harness/src/approval-gate/settings/remove-always-allow.ts create mode 100644 harness/src/approval-gate/settings/reply.ts create mode 100644 harness/src/approval-gate/settings/set-mode.ts create mode 100644 harness/src/approval-gate/settings/store.ts create mode 100644 harness/src/approval-gate/settings/types.ts create mode 100644 harness/src/approval-gate/settings/verdict.ts create mode 100644 harness/tests/approval-gate/settings.test.ts diff --git a/console/web/src/components/chat/AutoAcceptToggle.tsx b/console/web/src/components/chat/AutoAcceptToggle.tsx deleted file mode 100644 index 55cd6550..00000000 --- a/console/web/src/components/chat/AutoAcceptToggle.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { useId } from 'react' -import { cn } from '@/lib/utils' - -interface AutoAcceptToggleProps { - value: boolean - onChange: (next: boolean) => void - disabled?: boolean - className?: string -} - -/** - * Per-conversation toggle that flips the auto-accept-all-approvals - * mode. Lives in the composer's bottom bar next to the mode picker. - * - * The label is rendered verbatim ("auto-accept: on" / "auto-accept: - * off") rather than as an icon because this is a foot-gun: when ON, - * every safe `agent_trigger` from the model is auto-resolved without a - * human click (state-mutating, destructive, and egress calls remain - * gated client-side by `auto-accept-policy.ts`). The user needs to - * read the state at a glance. - * - * Accessibility: - * - `role="switch"` + `aria-checked` carry the binary state to SRs; - * the visible "on"/"off" text matches the announced state so the - * "label in name" requirement is satisfied. - * - `focus-visible` rings the control for keyboard navigation. - * - The explanatory text lives in a visually-hidden node and is - * wired in via `aria-describedby` so keyboard/SR users get the - * same context that mouse users get from the `title` tooltip. - * - `aria-disabled` rather than `disabled` so the control stays in - * the tab order and an SR can still read the current policy - * while streaming. - * - * Contrast: the ON state uses `bg-ink text-bg` (light theme: - * near-black on near-white, ~16:1; dark theme: near-white on - * near-black, similar) rather than `bg-accent text-bg`, which - * measured ~3.2:1 on light bg and failed WCAG 1.4.3. - */ -export function AutoAcceptToggle({ - value, - onChange, - disabled, - className, -}: AutoAcceptToggleProps) { - const descId = useId() - const interactive = !disabled - - const handleClick = () => { - if (!interactive) return - onChange(!value) - } - - return ( - <> - - - {value - ? 'Auto-accept is on. Approval prompts for safe calls (reads, lookups, listings) are resolved automatically. Destructive or state-mutating calls still require a click.' - : 'Auto-accept is off. Every approval prompt waits for an explicit click.'} - - - ) -} diff --git a/console/web/src/components/chat/ChatPanel.tsx b/console/web/src/components/chat/ChatPanel.tsx index 0bb09122..655f2c08 100644 --- a/console/web/src/components/chat/ChatPanel.tsx +++ b/console/web/src/components/chat/ChatPanel.tsx @@ -41,7 +41,6 @@ export function ChatPanel({ density = 'route' }: ChatPanelProps) { remove, setModel, setMode, - setAutoAccept, appendMessage, updateMessage, compactConversation, @@ -85,7 +84,6 @@ export function ChatPanel({ density = 'route' }: ChatPanelProps) { density={density} onUpdateModel={setModel} onUpdateMode={setMode} - onUpdateAutoAccept={setAutoAccept} onAppendMessage={appendMessage} onPatchMessage={updateMessage} onCompactConversation={compactConversation} diff --git a/console/web/src/components/chat/ChatView.tsx b/console/web/src/components/chat/ChatView.tsx index d29ffa46..05a6e112 100644 --- a/console/web/src/components/chat/ChatView.tsx +++ b/console/web/src/components/chat/ChatView.tsx @@ -1,8 +1,9 @@ import { Copy } from 'lucide-react' import { useCallback, useMemo, useRef, useState } from 'react' +import { FullPermissionsBanner } from '@/components/permissions/FullPermissionsBanner' import { LiveRegion } from '@/components/ui/LiveRegion' import { StatusDot } from '@/components/ui/StatusDot' -import { useAutoAcceptApprovals } from '@/hooks/use-auto-accept-approvals' +import { useApprovalSettings } from '@/hooks/use-approval-settings' import { uid } from '@/hooks/use-conversations' import { useFunctionsCatalog } from '@/hooks/use-functions-catalog' import { useLiveAnnouncer } from '@/hooks/use-live-announcer' @@ -74,7 +75,6 @@ interface ChatViewProps { density?: 'route' | 'dock' onUpdateModel: (id: string, model: ModelId) => void onUpdateMode: (id: string, mode: Mode) => void - onUpdateAutoAccept: (id: string, autoAccept: boolean) => void onAppendMessage: (id: string, message: Message) => void onPatchMessage: (id: string, messageId: string, patch: MessagePatch) => void onCompactConversation: (id: string, marker: Message) => void @@ -88,7 +88,6 @@ export function ChatView({ density = 'route', onUpdateModel, onUpdateMode, - onUpdateAutoAccept, onAppendMessage, onPatchMessage, onCompactConversation, @@ -130,22 +129,21 @@ export function ChatView({ ) => fn(sessionId, functionCallId, decision) }, [backend]) - useAutoAcceptApprovals({ - conversationId: conversation.id, - enabled: !!conversation.autoAccept, - messages: conversation.messages, - resolveApproval, - onAccepted: (functionId) => { - announcer.announce(`auto-accepted: ${functionId}`) - }, - onDenied: (functionId) => { - /* The policy refused — leave the card for manual click. Tell - * SR users so they know to navigate to it. */ - announcer.announce( - `auto-accept refused for ${functionId}: high-risk call requires manual approval`, - ) - }, - }) + const approvalSettings = useApprovalSettings(sessionId) + + const handleAlwaysAllow = useMemo(() => { + const resolveFn = backend.resolveApproval + if (!resolveFn) return undefined + // "Approve always" is a per-session grant honored in every mode, so the + // button shows on every prompt (full mode never produces prompts, so + // it's moot there). Approves this call and stops asking for the same + // function for the rest of the conversation. + return async (sId: string, functionCallId: string, functionId: string) => { + await approvalSettings.approveAlways(functionId) + await resolveFn(sId, functionCallId, 'allow') + announcer.announce(`approved always this session: ${functionId}`) + } + }, [backend, approvalSettings, announcer]) const handleCopySessionId = useCallback(() => { if (typeof navigator === 'undefined' || !navigator.clipboard) return @@ -555,11 +553,18 @@ export function ChatView({ + {approvalSettings.settings.mode === 'full' ? ( + void approvalSettings.setMode('manual')} + /> + ) : null} + @@ -571,11 +576,12 @@ export function ChatView({ modelOptions={modelOptions} catalogLoading={catalogLoading} functionEntries={functionEntries} - autoAccept={conversation.autoAccept} + permissionMode={approvalSettings.settings.mode} + permissionModeLoading={!approvalSettings.loaded} onModeChange={(next) => onUpdateMode(conversation.id, next)} onModelChange={(next) => onUpdateModel(conversation.id, next)} - onAutoAcceptChange={(next) => - onUpdateAutoAccept(conversation.id, next) + onPermissionModeChange={(next) => + void approvalSettings.setMode(next) } onSubmit={handleSubmit} onStop={handleStop} diff --git a/console/web/src/components/chat/Composer.tsx b/console/web/src/components/chat/Composer.tsx index 996d96e7..53e8cf1f 100644 --- a/console/web/src/components/chat/Composer.tsx +++ b/console/web/src/components/chat/Composer.tsx @@ -1,11 +1,12 @@ import type { LexicalEditor } from 'lexical' import { useCallback, useRef, useState } from 'react' +import { PermissionModePicker } from '@/components/permissions/PermissionModePicker' import { Button } from '@/components/ui/Button' +import type { PermissionMode } from '@/lib/backend/approval-settings' import type { FunctionEntry } from '@/lib/functions' import type { Attachment, Mode, ModelId, ModelOption } from '@/types/chat' import { AttachmentButton } from './AttachmentButton' import { AttachmentChip } from './AttachmentChip' -import { AutoAcceptToggle } from './AutoAcceptToggle' import { LexicalShell } from './LexicalShell' import { ModelPicker } from './ModelPicker' import { ModePicker } from './ModePicker' @@ -21,15 +22,15 @@ interface ComposerProps { modelOptions: ModelOption[] catalogLoading?: boolean /** - * Per-conversation auto-accept-all-approvals flag. When true, the - * chat client auto-resolves every pending approval that surfaces - * for this conversation. Rendered as a sibling pill of the mode - * picker. + * Per-conversation permission mode (manual / auto / full). Owned by + * the backend `approval_settings` scope; ChatView passes the loaded + * value here. While loading, the picker disables. */ - autoAccept: boolean + permissionMode: PermissionMode + permissionModeLoading?: boolean onModeChange: (next: Mode) => void onModelChange: (next: ModelId) => void - onAutoAcceptChange: (next: boolean) => void + onPermissionModeChange: (next: PermissionMode) => void onSubmit: (payload: ComposerSubmitPayload) => void onStop?: () => void isStreaming?: boolean @@ -45,10 +46,11 @@ export function Composer({ model, modelOptions, catalogLoading, - autoAccept, + permissionMode, + permissionModeLoading, onModeChange, onModelChange, - onAutoAcceptChange, + onPermissionModeChange, onSubmit, onStop, isStreaming, @@ -111,10 +113,10 @@ export function Composer({
-
Promise + onAlwaysAllow?: ( + sessionId: string, + functionCallId: string, + functionId: string, + ) => Promise } /** @@ -132,6 +137,7 @@ export function FunctionCallGroup({ messages, defaultOpen, onResolveApproval, + onAlwaysAllow, }: FunctionCallGroupProps) { const status = deriveStatus(messages) const concerning = hasConcerningChild(messages) @@ -190,18 +196,24 @@ export function FunctionCallGroup({ const functionCallId = m.functionCallId let onApprove: (() => Promise) | undefined let onDeny: (() => Promise) | undefined + let onAlwaysAllowHandler: (() => Promise) | undefined if (onResolveApproval && sessionId && functionCallId) { onApprove = () => onResolveApproval(sessionId, functionCallId, 'allow') onDeny = () => onResolveApproval(sessionId, functionCallId, 'deny') } + if (onAlwaysAllow && sessionId && functionCallId) { + onAlwaysAllowHandler = () => + onAlwaysAllow(sessionId, functionCallId, m.functionId) + } return ( ) diff --git a/console/web/src/components/chat/FunctionCallMessage.tsx b/console/web/src/components/chat/FunctionCallMessage.tsx index 6199b608..4b564e4b 100644 --- a/console/web/src/components/chat/FunctionCallMessage.tsx +++ b/console/web/src/components/chat/FunctionCallMessage.tsx @@ -3,6 +3,7 @@ import { SandboxFunctionIdLabel, SandboxToolView, } from '@/components/chat/sandbox' +import { AlwaysAllowButton } from '@/components/permissions/AlwaysAllowButton' import { Button } from '@/components/ui/Button' import { StatusDot } from '@/components/ui/StatusDot' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/Tabs' @@ -20,6 +21,12 @@ interface FunctionCallMessageProps { */ onApprove?: () => void | Promise onDeny?: () => void | Promise + /** + * Approve + add to per-conversation always-allow list. When provided, + * an "always allow" button renders next to approve/deny. Destructive + * function ids gate on a confirmation modal inside the button. + */ + onAlwaysAllow?: () => void | Promise /** * When true, render without the outer `border border-rule bg-bg` chrome * so the parent (typically a `FunctionCallGroup`) can frame the stack. @@ -84,13 +91,16 @@ export function FunctionCallMessage({ defaultOpen, onApprove, onDeny, + onAlwaysAllow, embedded, }: FunctionCallMessageProps) { const pending = !!message.pendingApproval const running = !!message.running const [open, setOpen] = useState(!!defaultOpen || pending) const [tab, setTab] = useState<'terminal' | 'json'>('terminal') - const [submitting, setSubmitting] = useState<'approve' | 'deny' | null>(null) + const [submitting, setSubmitting] = useState< + 'approve' | 'deny' | 'always_allow' | null + >(null) const [submitError, setSubmitError] = useState(null) const sandboxPreview = SandboxToolView.tryRenderPreview(message) @@ -101,8 +111,9 @@ export function FunctionCallMessage({ !(running && hasSandboxTerminal) && !(!pending && !running && hasSandboxTerminal) - const runResolve = async (kind: 'approve' | 'deny') => { - const handler = kind === 'approve' ? onApprove : onDeny + const runResolve = async (kind: 'approve' | 'deny' | 'always_allow') => { + const handler = + kind === 'approve' ? onApprove : kind === 'deny' ? onDeny : onAlwaysAllow if (!handler || submitting) return setSubmitError(null) setSubmitting(kind) @@ -235,6 +246,14 @@ export function FunctionCallMessage({ > {submitting === 'deny' ? 'denying…' : 'deny'} + {onAlwaysAllow ? ( + void runResolve('always_allow')} + disabled={!!submitting} + submitting={submitting === 'always_allow'} + /> + ) : null} {submitting ? ( waiting for the agent to resume… diff --git a/console/web/src/components/chat/Message.tsx b/console/web/src/components/chat/Message.tsx index c49fd878..3bd39dc8 100644 --- a/console/web/src/components/chat/Message.tsx +++ b/console/web/src/components/chat/Message.tsx @@ -19,9 +19,18 @@ interface MessageProps { functionCallId: string, decision: 'allow' | 'deny', ) => Promise + onAlwaysAllow?: ( + sessionId: string, + functionCallId: string, + functionId: string, + ) => Promise } -export function Message({ message, onResolveApproval }: MessageProps) { +export function Message({ + message, + onResolveApproval, + onAlwaysAllow, +}: MessageProps) { switch (message.role) { case 'user': return @@ -34,15 +43,21 @@ export function Message({ message, onResolveApproval }: MessageProps) { const functionCallId = message.functionCallId let onApprove: (() => Promise) | undefined let onDeny: (() => Promise) | undefined + let onAlwaysAllowHandler: (() => Promise) | undefined if (onResolveApproval && sessionId && functionCallId) { onApprove = () => onResolveApproval(sessionId, functionCallId, 'allow') onDeny = () => onResolveApproval(sessionId, functionCallId, 'deny') } + if (onAlwaysAllow && sessionId && functionCallId) { + onAlwaysAllowHandler = () => + onAlwaysAllow(sessionId, functionCallId, message.functionId) + } return ( ) } diff --git a/console/web/src/components/chat/MessageList.tsx b/console/web/src/components/chat/MessageList.tsx index a4ed2597..408f9eb2 100644 --- a/console/web/src/components/chat/MessageList.tsx +++ b/console/web/src/components/chat/MessageList.tsx @@ -20,6 +20,11 @@ interface MessageListProps { functionCallId: string, decision: 'allow' | 'deny', ) => Promise + onAlwaysAllow?: ( + sessionId: string, + functionCallId: string, + functionId: string, + ) => Promise } type RenderItem = @@ -72,6 +77,7 @@ export function MessageList({ isThinking, density = 'route', onResolveApproval, + onAlwaysAllow, }: MessageListProps) { const bottomRef = useRef(null) const containerRef = useRef(null) @@ -146,12 +152,14 @@ export function MessageList({ key={item.key} message={item.message} onResolveApproval={onResolveApproval} + onAlwaysAllow={onAlwaysAllow} /> ) : ( ), )} diff --git a/console/web/src/components/permissions/AlwaysAllowButton.tsx b/console/web/src/components/permissions/AlwaysAllowButton.tsx new file mode 100644 index 00000000..878bdbd5 --- /dev/null +++ b/console/web/src/components/permissions/AlwaysAllowButton.tsx @@ -0,0 +1,104 @@ +import { useState } from 'react' +import { Button } from '@/components/ui/Button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from '@/components/ui/Dialog' +import { isAutoAcceptable } from '@/lib/backend/auto-accept-policy' + +const isDestructiveFunction = (id: string) => !isAutoAcceptable(id) + +interface AlwaysAllowButtonProps { + functionId: string + onConfirm: () => void | Promise + disabled?: boolean + submitting?: boolean +} + +/** + * Third button on a pending approval prompt: "approve always". Approves + * the current call AND remembers the decision for the rest of this + * session (per-conversation `approved_always`, honored in every mode), + * so the same function stops prompting. For functions whose id matches + * the potentially-destructive verb policy (write / delete / exec / send / + * credential / …) we gate on a confirmation dialog that shows the + * canonical `function_id` so the user can't misclick into a standing + * grant. + */ +export function AlwaysAllowButton({ + functionId, + onConfirm, + disabled, + submitting, +}: AlwaysAllowButtonProps) { + const [confirmOpen, setConfirmOpen] = useState(false) + const destructive = isDestructiveFunction(functionId) + + function handleClick() { + if (destructive) { + setConfirmOpen(true) + return + } + void onConfirm() + } + + return ( + <> + + + + + approve always for this potentially destructive function? + + + you're about to let the agent call{' '} + {functionId}{' '} + for the rest of this conversation with no further prompts. + + + this function matched the potentially-destructive verb policy (write + / delete / exec / send / credential / …). approve once if you only + want it this time. + +
+ + +
+
+
+ + ) +} diff --git a/console/web/src/components/permissions/DefaultPermissionModePicker.tsx b/console/web/src/components/permissions/DefaultPermissionModePicker.tsx new file mode 100644 index 00000000..20df2391 --- /dev/null +++ b/console/web/src/components/permissions/DefaultPermissionModePicker.tsx @@ -0,0 +1,69 @@ +import { useState } from 'react' +import { ModeToggle } from '@/components/ui/ModeToggle' +import { + loadDefaultPermissionMode, + type PermissionMode, + saveDefaultPermissionMode, +} from '@/lib/storage' +import { FullModeConfirmDialog } from './FullModeConfirmDialog' + +interface DefaultPermissionModePickerProps { + /** Lifted state so a parent can react (banner, telemetry) without re-reading localStorage. */ + value?: PermissionMode + onChange?: (next: PermissionMode) => void +} + +/** + * User-level default mode applied only to NEW conversations. Existing + * conversations own their own mode independently after creation. + * + * Selecting Full opens a confirmation dialog before localStorage is + * written. Cancel keeps the previous value. + */ +export function DefaultPermissionModePicker({ + value, + onChange, +}: DefaultPermissionModePickerProps) { + const [internal, setInternal] = useState(() => + value ?? loadDefaultPermissionMode(), + ) + const [pendingFull, setPendingFull] = useState(false) + const current = value ?? internal + + function commit(next: PermissionMode) { + saveDefaultPermissionMode(next) + setInternal(next) + onChange?.(next) + } + + function handleSelect(next: PermissionMode) { + if (next === current) return + if (next === 'full') { + setPendingFull(true) + return + } + commit(next) + } + + return ( + <> + + value={current} + onChange={handleSelect} + variant="radio" + aria-label="default permission mode" + options={[ + { value: 'manual', label: 'manual' }, + { value: 'auto', label: 'auto' }, + { value: 'full', label: 'full' }, + ]} + /> + commit('full')} + scope="default" + /> + + ) +} diff --git a/console/web/src/components/permissions/FullModeConfirmDialog.tsx b/console/web/src/components/permissions/FullModeConfirmDialog.tsx new file mode 100644 index 00000000..dfc6c781 --- /dev/null +++ b/console/web/src/components/permissions/FullModeConfirmDialog.tsx @@ -0,0 +1,64 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from '@/components/ui/Dialog' + +interface FullModeConfirmDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + onConfirm: () => void + /** + * Context-aware copy. The Configuration screen flow phrases it as + * "for every new conversation"; the in-chat picker phrases it as + * "for this conversation". The default works for the in-chat case. + */ + scope?: 'conversation' | 'default' +} + +export function FullModeConfirmDialog({ + open, + onOpenChange, + onConfirm, + scope = 'conversation', +}: FullModeConfirmDialogProps) { + const target = + scope === 'default' ? 'every new conversation' : 'this conversation' + return ( + + + + enable full permissions + + + full permissions let the agent run any function in {target} without + asking — including writing files, executing shell commands, sending + messages, and reading secrets. + + + you can revert from the banner at the top of the chat at any time. + +
+ + +
+
+
+ ) +} diff --git a/console/web/src/components/permissions/FullPermissionsBanner.tsx b/console/web/src/components/permissions/FullPermissionsBanner.tsx new file mode 100644 index 00000000..ae3af923 --- /dev/null +++ b/console/web/src/components/permissions/FullPermissionsBanner.tsx @@ -0,0 +1,38 @@ +interface FullPermissionsBannerProps { + onDisable: () => void +} + +/** + * Persistent banner shown while a conversation is in `mode === 'full'`. + * Always visible; the only way to dismiss is by reverting to manual. + * Tone is intentionally loud — Full mode bypasses every safety prompt, + * including destructive verbs like `shell::exec` and `fs::write`. + */ +export function FullPermissionsBanner({ + onDisable, +}: FullPermissionsBannerProps) { + return ( +
+

+ + full permissions active + + + the agent runs every function without asking — including writing + files, executing shells, and sending messages. + +

+ +
+ ) +} diff --git a/console/web/src/components/permissions/FunctionAllowlistTree.tsx b/console/web/src/components/permissions/FunctionAllowlistTree.tsx new file mode 100644 index 00000000..832d1e52 --- /dev/null +++ b/console/web/src/components/permissions/FunctionAllowlistTree.tsx @@ -0,0 +1,378 @@ +import { useMemo, useState } from 'react' +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from '@/components/ui/Dialog' +import { isAutoAcceptable } from '@/lib/backend/auto-accept-policy' +import type { FunctionEntry } from '@/lib/functions' +import { cn } from '@/lib/utils' + +interface FunctionAllowlistTreeProps { + functions: FunctionEntry[] + /** Current allowlist (function ids). */ + allowlist: ReadonlySet + onAdd: (functionId: string) => void + onRemove: (functionId: string) => void + /** Empty-state hint when the catalog has no entries (still loading, etc). */ + emptyHint?: string +} + +interface TreeNode { + segment: string + path: string + entry?: FunctionEntry + children: TreeNode[] +} + +function buildTree(entries: ReadonlyArray): TreeNode[] { + const root: TreeNode = { segment: '', path: '', children: [] } + const sorted = [...entries].sort((a, b) => a.id.localeCompare(b.id)) + for (const entry of sorted) { + const segments = entry.id.split('::').filter(Boolean) + if (segments.length === 0) continue + let cursor = root + let path = '' + for (let i = 0; i < segments.length; i++) { + const seg = segments[i] + path = path ? `${path}::${seg}` : seg + let child = cursor.children.find((c) => c.segment === seg) + if (!child) { + child = { segment: seg, path, children: [] } + cursor.children.push(child) + } + if (i === segments.length - 1) child.entry = entry + cursor = child + } + } + return root.children +} + +interface NodeCounts { + total: number + allowed: number + destructive: number + leafIds: string[] +} + +function collectCounts( + node: TreeNode, + allowlist: ReadonlySet, +): NodeCounts { + let total = 0 + let allowed = 0 + let destructive = 0 + const leafIds: string[] = [] + if (node.entry) { + total += 1 + leafIds.push(node.entry.id) + if (allowlist.has(node.entry.id)) allowed += 1 + if (!isAutoAcceptable(node.entry.id)) destructive += 1 + } + for (const child of node.children) { + const c = collectCounts(child, allowlist) + total += c.total + allowed += c.allowed + destructive += c.destructive + leafIds.push(...c.leafIds) + } + return { total, allowed, destructive, leafIds } +} + +/** + * Recursive function-id tree grouped by `::`. Branch checkboxes are + * tri-state and toggle their subtree as a unit; destructive leaves and + * destructive bulk-adds gate on a confirmation dialog. Pure UI — the + * parent owns the allowlist state and add/remove callbacks. + */ +export function FunctionAllowlistTree({ + functions, + allowlist, + onAdd, + onRemove, + emptyHint = 'no functions in the catalog yet.', +}: FunctionAllowlistTreeProps) { + const tree = useMemo(() => buildTree(functions), [functions]) + const [expanded, setExpanded] = useState>({}) + /** + * FIFO of destructive function ids waiting on user confirmation. The + * modal renders queue[0]; confirm dequeues + adds, cancel clears the + * whole queue (so a single "cancel" stops the chain rather than the + * user having to dismiss N modals). + */ + const [destructiveQueue, setDestructiveQueue] = useState([]) + const pendingDestructive = destructiveQueue[0] ?? null + const remainingDestructive = Math.max(destructiveQueue.length - 1, 0) + + function toggleBranch(path: string) { + setExpanded((prev) => ({ ...prev, [path]: !prev[path] })) + } + + function setSubtreeAllowed(node: TreeNode, value: boolean) { + const { leafIds } = collectCounts(node, allowlist) + if (value) { + const nonDestructive = leafIds.filter((id) => isAutoAcceptable(id)) + const destructive = leafIds.filter( + (id) => !isAutoAcceptable(id) && !allowlist.has(id), + ) + for (const id of nonDestructive) { + if (!allowlist.has(id)) onAdd(id) + } + if (destructive.length > 0) { + setDestructiveQueue((prev) => [...prev, ...destructive]) + } + } else { + for (const id of leafIds) { + if (allowlist.has(id)) onRemove(id) + } + } + } + + function toggleLeaf(id: string) { + if (allowlist.has(id)) { + onRemove(id) + return + } + if (!isAutoAcceptable(id)) { + setDestructiveQueue((prev) => [...prev, id]) + return + } + onAdd(id) + } + + if (tree.length === 0) { + return ( +

+ {emptyHint} +

+ ) + } + + return ( + <> +
    + {tree.map((node) => ( + + ))} +
+ setDestructiveQueue([])} + onConfirm={(id) => { + onAdd(id) + setDestructiveQueue((prev) => prev.slice(1)) + }} + /> + + ) +} + +interface TreeBranchProps { + node: TreeNode + depth: number + allowlist: ReadonlySet + expanded: Record + onToggleBranch: (path: string) => void + onToggleLeaf: (id: string) => void + onToggleSubtree: (node: TreeNode, value: boolean) => void +} + +function TreeBranch({ + node, + depth, + allowlist, + expanded, + onToggleBranch, + onToggleLeaf, + onToggleSubtree, +}: TreeBranchProps) { + const counts = collectCounts(node, allowlist) + const hasChildren = node.children.length > 0 + const isOpen = expanded[node.path] !== false && hasChildren + const branchState: 'unchecked' | 'mixed' | 'checked' = + counts.allowed === 0 + ? 'unchecked' + : counts.allowed === counts.total + ? 'checked' + : 'mixed' + + const indent = { paddingLeft: `${depth * 14 + 4}px` } + + return ( +
  • +
    + {hasChildren ? ( + + ) : ( + + )} + + {hasChildren ? ( + onToggleSubtree(node, branchState !== 'checked')} + aria-label={`toggle all in ${node.path}`} + /> + ) : node.entry ? ( + onToggleLeaf(node.entry!.id)} + aria-label={`toggle ${node.entry.id}`} + /> + ) : null} + + + {node.segment} + {node.entry && !isAutoAcceptable(node.entry.id) ? ( + + · potentially destructive + + ) : null} + + + {hasChildren ? ( + + {counts.allowed}/{counts.total} + + ) : null} +
    + + {hasChildren && isOpen ? ( +
      + {node.children.map((child) => ( + + ))} +
    + ) : null} +
  • + ) +} + +interface TristateProps { + state: 'checked' | 'unchecked' | 'mixed' + onClick: () => void + 'aria-label': string +} + +function Tristate({ state, onClick, 'aria-label': label }: TristateProps) { + const ariaChecked = + state === 'checked' ? true : state === 'mixed' ? 'mixed' : false + return ( + + ) +} + +interface DestructiveConfirmProps { + functionId: string | null + /** Count of additional destructive ids still in the queue behind this one. */ + remaining: number + onCancel: () => void + onConfirm: (id: string) => void +} + +function DestructiveConfirm({ + functionId, + remaining, + onCancel, + onConfirm, +}: DestructiveConfirmProps) { + const open = functionId !== null + const total = remaining + (functionId ? 1 : 0) + return ( + !v && onCancel()}> + + + allowlist a potentially destructive function? + {total > 1 ? ( + + {total - remaining}/{total} + + ) : null} + + + {functionId}{' '} + matches the potentially-destructive verb policy (write / delete / exec + / send / credential / …). adding it means the agent will run it + without asking while auto mode is on. + + {remaining > 0 ? ( + + {remaining} more potentially destructive function + {remaining === 1 ? '' : 's'} queued from your select-all. cancel to + stop the chain. + + ) : null} +
    + + +
    +
    +
    + ) +} diff --git a/console/web/src/components/permissions/PermissionModePicker.tsx b/console/web/src/components/permissions/PermissionModePicker.tsx new file mode 100644 index 00000000..7b37dccb --- /dev/null +++ b/console/web/src/components/permissions/PermissionModePicker.tsx @@ -0,0 +1,58 @@ +import { useState } from 'react' +import { ModeToggle } from '@/components/ui/ModeToggle' +import type { PermissionMode } from '@/lib/backend/approval-settings' +import { FullModeConfirmDialog } from './FullModeConfirmDialog' + +interface PermissionModePickerProps { + /** Backend-owned mode for THIS conversation. */ + value: PermissionMode + /** Sets mode for THIS conversation; selecting full goes through a confirm. */ + onChange: (next: PermissionMode) => void + /** Disabled while the backend is still loading the initial value. */ + disabled?: boolean +} + +/** + * In-chat permission mode picker. Lives next to the composer; commits + * each change directly to the backend through `onChange`. Selecting Full + * gates on the shared confirmation dialog. Allowlist management lives + * on the Configuration screen — keep this control narrow. + */ +export function PermissionModePicker({ + value, + onChange, + disabled, +}: PermissionModePickerProps) { + const [pendingFull, setPendingFull] = useState(false) + + function handleSelect(next: PermissionMode) { + if (disabled || next === value) return + if (next === 'full') { + setPendingFull(true) + return + } + onChange(next) + } + + return ( + <> + + value={value} + onChange={handleSelect} + variant="radio" + aria-label="permission mode" + options={[ + { value: 'manual', label: 'manual' }, + { value: 'auto', label: 'auto' }, + { value: 'full', label: 'full' }, + ]} + /> + onChange('full')} + scope="conversation" + /> + + ) +} diff --git a/console/web/src/hooks/use-approval-settings.ts b/console/web/src/hooks/use-approval-settings.ts new file mode 100644 index 00000000..5ab6cc2d --- /dev/null +++ b/console/web/src/hooks/use-approval-settings.ts @@ -0,0 +1,206 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { + addAlwaysAllow as rpcAddAlwaysAllow, + type ApprovalSettings, + approveAlways as rpcApproveAlways, + DEFAULT_APPROVAL_SETTINGS, + getApprovalSettings, + type PermissionMode, + removeAlwaysAllow as rpcRemoveAlwaysAllow, + setApprovalMode, +} from '@/lib/backend/approval-settings' +import { loadDefaultAllowlist, loadDefaultPermissionMode } from '@/lib/storage' + +interface UseApprovalSettingsResult { + settings: ApprovalSettings + loaded: boolean + setMode(mode: PermissionMode): Promise + addAlwaysAllow(functionId: string): Promise + removeAlwaysAllow(functionId: string): Promise + approveAlways(functionId: string): Promise +} + +/** + * Per-conversation approval settings hook. Loads on mount and on session + * change; mutators write through to the backend and refresh local state. + * + * Each mutator returns a Promise so callers can chain UI feedback. Errors + * are swallowed and logged in dev — the user-facing fallback is the prompt + * still appearing (Manual mode default). + */ +export function useApprovalSettings( + sessionId: string, +): UseApprovalSettingsResult { + const [settings, setSettings] = useState( + DEFAULT_APPROVAL_SETTINGS, + ) + const [loaded, setLoaded] = useState(false) + const activeRef = useRef(sessionId) + + useEffect(() => { + activeRef.current = sessionId + setLoaded(false) + let cancelled = false + void (async () => { + try { + const fetched = await getApprovalSettings(sessionId) + if (cancelled || activeRef.current !== sessionId) return + // First-touch initialization: when the backend has no persisted + // settings for this session (mode_set_at === 0), seed both the + // user-level default mode AND the user-level allowlist from + // localStorage. Applies to NEW conversations only — existing + // sessions return a non-zero mode_set_at and skip this branch + // entirely so the user's later edits don't retroactively touch + // conversations already in flight. + const isFirstTouch = fetched.mode_set_at === 0 + if (isFirstTouch) { + const userDefault = loadDefaultPermissionMode() + const defaultAllowlist = loadDefaultAllowlist() + let next = fetched + if (userDefault !== 'manual') { + next = await setApprovalMode(sessionId, userDefault) + if (cancelled || activeRef.current !== sessionId) return + } + for (const functionId of defaultAllowlist) { + next = await rpcAddAlwaysAllow(sessionId, functionId) + if (cancelled || activeRef.current !== sessionId) return + } + setSettings(next) + } else { + setSettings(fetched) + } + setLoaded(true) + } catch (err) { + if (cancelled) return + console.error( + '[approval-settings] load failed — is approval-gate running with the new handlers? mode picker will default to "manual" and writes will fail.', + err, + ) + setSettings(DEFAULT_APPROVAL_SETTINGS) + setLoaded(true) + } + })() + return () => { + cancelled = true + } + }, [sessionId]) + + const setMode = useCallback( + async (mode: PermissionMode) => { + // Optimistic — flip the UI now so the click feels responsive even + // when the backend round-trip is slow. Revert on failure. + let previous: ApprovalSettings | null = null + setSettings((s) => { + previous = s + return { ...s, mode, mode_set_at: Date.now() } + }) + try { + const next = await setApprovalMode(sessionId, mode) + if (activeRef.current === sessionId) setSettings(next) + } catch (err) { + console.error('[approval-settings] set_mode failed', err) + if (activeRef.current === sessionId && previous) { + setSettings(previous) + } + } + }, + [sessionId], + ) + + const addAlwaysAllow = useCallback( + async (functionId: string) => { + let previous: ApprovalSettings | null = null + setSettings((s) => { + previous = s + if (s.always_allow.some((e) => e.function_id === functionId)) return s + return { + ...s, + always_allow: [ + ...s.always_allow, + { + function_id: functionId, + granted_at: Date.now(), + granted_by: 'user_click', + }, + ], + } + }) + try { + const next = await rpcAddAlwaysAllow(sessionId, functionId) + if (activeRef.current === sessionId) setSettings(next) + } catch (err) { + console.error('[approval-settings] add_always_allow failed', err) + if (activeRef.current === sessionId && previous) { + setSettings(previous) + } + } + }, + [sessionId], + ) + + const removeAlwaysAllow = useCallback( + async (functionId: string) => { + let previous: ApprovalSettings | null = null + setSettings((s) => { + previous = s + return { + ...s, + always_allow: s.always_allow.filter( + (e) => e.function_id !== functionId, + ), + } + }) + try { + const next = await rpcRemoveAlwaysAllow(sessionId, functionId) + if (activeRef.current === sessionId) setSettings(next) + } catch (err) { + console.error('[approval-settings] remove_always_allow failed', err) + if (activeRef.current === sessionId && previous) { + setSettings(previous) + } + } + }, + [sessionId], + ) + + const approveAlways = useCallback( + async (functionId: string) => { + let previous: ApprovalSettings | null = null + setSettings((s) => { + previous = s + if (s.approved_always.some((e) => e.function_id === functionId)) + return s + return { + ...s, + approved_always: [ + ...s.approved_always, + { + function_id: functionId, + granted_at: Date.now(), + granted_by: 'user_click', + }, + ], + } + }) + try { + const next = await rpcApproveAlways(sessionId, functionId) + if (activeRef.current === sessionId) setSettings(next) + } catch (err) { + console.error('[approval-settings] approve_always failed', err) + if (activeRef.current === sessionId && previous) { + setSettings(previous) + } + } + }, + [sessionId], + ) + + return { + settings, + loaded, + setMode, + addAlwaysAllow, + removeAlwaysAllow, + approveAlways, + } +} diff --git a/console/web/src/hooks/use-auto-accept-approvals.test.ts b/console/web/src/hooks/use-auto-accept-approvals.test.ts deleted file mode 100644 index d2d99f33..00000000 --- a/console/web/src/hooks/use-auto-accept-approvals.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * The hook is a thin React shell. All of its decision-making lives - * in `selectAutoAcceptCandidates` + `nextApprovalsToAutoResolve` + - * `isAutoAcceptable`, each with dedicated test suites - * (`auto-accept.test.ts`, `auto-accept-policy.test.ts`). - * - * @testing-library/react isn't installed here, so we don't exercise - * the hook against a real React renderer. The smoke assertions below - * just pin the export shape; the typechecker catches drift in the - * imports (if the hook stops calling the policy-aware selector, - * compilation fails because the selection shape changes). - */ -import { describe, expect, it } from 'vitest' -import { useAutoAcceptApprovals } from './use-auto-accept-approvals' - -describe('useAutoAcceptApprovals (wiring guard)', () => { - it('exports a function', () => { - expect(typeof useAutoAcceptApprovals).toBe('function') - }) -}) diff --git a/console/web/src/hooks/use-auto-accept-approvals.ts b/console/web/src/hooks/use-auto-accept-approvals.ts deleted file mode 100644 index 342d1c5a..00000000 --- a/console/web/src/hooks/use-auto-accept-approvals.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { useEffect, useRef } from 'react' -import { - nextApprovalsToAutoResolve, - selectAutoAcceptCandidates, -} from '@/lib/backend/auto-accept' -import { - type AutoAcceptPolicy, - DEFAULT_POLICY, -} from '@/lib/backend/auto-accept-policy' -import type { Message } from '@/types/chat' - -export type ResolveApproval = ( - sessionId: string, - functionCallId: string, - decision: 'allow' | 'deny', -) => Promise - -export interface UseAutoAcceptApprovalsArgs { - conversationId: string - /** Whether auto-accept is currently enabled on this conversation. */ - enabled: boolean - messages: ReadonlyArray - /** Backend approval resolver. When undefined, the hook is a no-op. */ - resolveApproval: ResolveApproval | undefined - /** Optional safety policy. Defaults to {@link DEFAULT_POLICY}. */ - policy?: AutoAcceptPolicy - /** Optional callback fired after each successful auto-accept (e.g. for live-region announcement). */ - onAccepted?: (functionId: string, functionCallId: string) => void - /** Optional callback fired when the policy refuses a pending approval. */ - onDenied?: (functionId: string, functionCallId: string) => void -} - -/** - * Hook that watches the conversation for pending function-call - * approvals and auto-resolves them with `decision: "allow"` IF - * `enabled` is true AND the call's function id passes the safety - * policy. Calls that the policy refuses are left for the user to - * resolve manually. - * - * Behaviour notes: - * - Dedupe-by-ref: every auto-resolved id is recorded so a re-render - * of the same pending entry doesn't double-fire. The dedup set - * resets on `conversationId` change. - * - Optimistic dedup: the id is added to the set BEFORE the await, - * then removed on rejection so a retry can fire. - * - Cheap path: the effect bails immediately when `enabled` is false, - * when there's no resolver, or when the last message isn't a - * function-call (the only role that can introduce a pending - * approval). This is the hot path during token streaming. - * - * The selection / policy logic lives in - * `lib/backend/auto-accept.ts` as a pure function; the hook is the - * React-shaped wrapper over it. - */ -export function useAutoAcceptApprovals({ - conversationId, - enabled, - messages, - resolveApproval, - policy = DEFAULT_POLICY, - onAccepted, - onDenied, -}: UseAutoAcceptApprovalsArgs): void { - const autoResolvedRef = useRef>(new Set()) - const onAcceptedRef = useRef(onAccepted) - const onDeniedRef = useRef(onDenied) - - // Sync refs so the effect below never needs them in its deps. - onAcceptedRef.current = onAccepted - onDeniedRef.current = onDenied - - useEffect(() => { - autoResolvedRef.current = new Set() - }, [conversationId]) - - useEffect(() => { - if (!enabled) return - if (!resolveApproval) return - const selection = selectAutoAcceptCandidates(messages, policy) - if (selection === null) return // hot-path early exit - if (onDeniedRef.current) { - for (const d of selection.deniedByPolicy) { - onDeniedRef.current(d.functionId, d.functionCallId) - } - } - const todo = nextApprovalsToAutoResolve( - selection.candidates, - autoResolvedRef.current, - ) - if (todo.length === 0) return - - for (const p of todo) { - autoResolvedRef.current.add(p.functionCallId) - void resolveApproval(p.sessionId, p.functionCallId, 'allow') - .then(() => onAcceptedRef.current?.(p.functionId, p.functionCallId)) - .catch((err) => { - /* Warn-and-continue posture matches the per-card resolve - * handler — a transient failure shouldn't break the loop - * for the next pending entry. Remove from dedup set so a - * retry on the next render can fire. */ - console.warn('[chat] auto-accept: approval::resolve failed', { - functionCallId: p.functionCallId, - sessionId: p.sessionId, - err, - }) - autoResolvedRef.current.delete(p.functionCallId) - }) - } - }, [enabled, messages, resolveApproval, policy]) -} diff --git a/console/web/src/hooks/use-conversations.ts b/console/web/src/hooks/use-conversations.ts index fc7e58e5..d2eab90b 100644 --- a/console/web/src/hooks/use-conversations.ts +++ b/console/web/src/hooks/use-conversations.ts @@ -36,7 +36,6 @@ function emptyConversation( title: 'new chat', model: defaultModel, mode: DEFAULT_MODE, - autoAccept: false, messages: [], createdAt: now, updatedAt: now, @@ -53,7 +52,6 @@ export interface ConversationsApi { remove: (id: string) => void setModel: (id: string, model: ModelId) => void setMode: (id: string, mode: Mode) => void - setAutoAccept: (id: string, autoAccept: boolean) => void appendMessage: (id: string, message: Message) => void updateMessage: (id: string, messageId: string, patch: MessagePatch) => void compactConversation: (id: string, marker: Message) => void @@ -183,16 +181,6 @@ export function useConversations( [patchConversation], ) - const setAutoAccept = useCallback( - (id: string, autoAccept: boolean) => - patchConversation(id, (c) => ({ - ...c, - autoAccept, - updatedAt: Date.now(), - })), - [patchConversation], - ) - const appendMessage = useCallback( (id: string, message: Message) => patchConversation(id, (c) => { @@ -246,7 +234,6 @@ export function useConversations( remove, setModel, setMode, - setAutoAccept, appendMessage, updateMessage, compactConversation, diff --git a/console/web/src/lib/backend/approval-settings.ts b/console/web/src/lib/backend/approval-settings.ts new file mode 100644 index 00000000..7eacd00a --- /dev/null +++ b/console/web/src/lib/backend/approval-settings.ts @@ -0,0 +1,127 @@ +/** + * RPC adapters for the per-session approval settings (`approval::*` + * handlers in harness/src/approval-gate/settings/). These are + * user-initiated RPCs only — agent function calls cannot reach them + * (the turn-orchestrator hook hard-denies these function ids). + */ + +import { getIiiClient } from '@/lib/iii-client' + +export type PermissionMode = 'manual' | 'auto' | 'full' + +export interface AlwaysAllowEntry { + function_id: string + granted_at: number + granted_by: 'user_click' +} + +export interface ApprovalSettings { + mode: PermissionMode + always_allow: AlwaysAllowEntry[] + approved_always: AlwaysAllowEntry[] + mode_set_at: number +} + +export const DEFAULT_APPROVAL_SETTINGS: ApprovalSettings = { + mode: 'manual', + always_allow: [], + approved_always: [], + mode_set_at: 0, +} + +function coerceEntries(raw: unknown): AlwaysAllowEntry[] { + const list = Array.isArray(raw) ? raw : [] + return list + .filter( + (entry): entry is Record => + !!entry && typeof entry === 'object', + ) + .map( + (entry): AlwaysAllowEntry => ({ + function_id: String(entry.function_id ?? ''), + granted_at: Number(entry.granted_at ?? 0), + granted_by: 'user_click', + }), + ) + .filter((entry) => entry.function_id.length > 0) +} + +function coerceSettings(raw: unknown): ApprovalSettings { + if (!raw || typeof raw !== 'object') return DEFAULT_APPROVAL_SETTINGS + const r = raw as Record + const mode: PermissionMode = + r.mode === 'auto' || r.mode === 'full' ? r.mode : 'manual' + return { + mode, + always_allow: coerceEntries(r.always_allow), + approved_always: coerceEntries(r.approved_always), + mode_set_at: typeof r.mode_set_at === 'number' ? r.mode_set_at : 0, + } +} + +export async function getApprovalSettings( + sessionId: string, +): Promise { + const client = await getIiiClient() + const raw = await client.call('approval::get_settings', { + session_id: sessionId, + }) + return coerceSettings(raw) +} + +export async function setApprovalMode( + sessionId: string, + mode: PermissionMode, +): Promise { + const client = await getIiiClient() + const raw = await client.call('approval::set_mode', { + session_id: sessionId, + mode, + }) + return coerceSettings(raw) +} + +export async function addAlwaysAllow( + sessionId: string, + functionId: string, +): Promise { + const client = await getIiiClient() + const raw = await client.call('approval::add_always_allow', { + session_id: sessionId, + function_id: functionId, + }) + return coerceSettings(raw) +} + +export async function removeAlwaysAllow( + sessionId: string, + functionId: string, +): Promise { + const client = await getIiiClient() + const raw = await client.call('approval::remove_always_allow', { + session_id: sessionId, + function_id: functionId, + }) + return coerceSettings(raw) +} + +export async function approveAlways( + sessionId: string, + functionId: string, +): Promise { + const client = await getIiiClient() + const raw = await client.call('approval::approve_always', { + session_id: sessionId, + function_id: functionId, + }) + return coerceSettings(raw) +} + +export async function clearApprovalSettings(sessionId: string): Promise { + const client = await getIiiClient() + await client + .call('approval::clear_settings', { session_id: sessionId }) + .catch(() => { + /* best-effort cleanup on conversation deletion */ + }) +} diff --git a/console/web/src/lib/backend/auto-accept.test.ts b/console/web/src/lib/backend/auto-accept.test.ts deleted file mode 100644 index 8340922d..00000000 --- a/console/web/src/lib/backend/auto-accept.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { FunctionCallMessage, Message, UserMessage } from '@/types/chat' -import { - nextApprovalsToAutoResolve, - type PendingApprovalCandidate, - selectAutoAcceptCandidates, -} from './auto-accept' - -function pending( - ...ids: Array<[functionCallId: string, sessionId: string]> -): PendingApprovalCandidate[] { - return ids.map(([functionCallId, sessionId]) => ({ - functionCallId, - sessionId, - functionId: 'directory::skills::list', - })) -} - -function userMsg(id: string, content = 'hi'): UserMessage { - return { id, role: 'user', content, createdAt: 0 } -} - -function pendingFcall( - id: string, - functionCallId: string, - functionId = 'directory::skills::list', - sessionId = 'sess-1', -): FunctionCallMessage { - return { - id, - role: 'function-call', - functionId, - input: {}, - pendingApproval: true, - running: false, - functionCallId, - sessionId, - createdAt: 0, - } -} - -describe('nextApprovalsToAutoResolve', () => { - it('returns the pending entries minus the ones already resolved', () => { - const input = pending(['fc-1', 's-1'], ['fc-2', 's-1'], ['fc-3', 's-1']) - const seen = new Set(['fc-2']) - expect( - nextApprovalsToAutoResolve(input, seen).map((p) => p.functionCallId), - ).toEqual(['fc-1', 'fc-3']) - }) - - it('returns an empty array when every pending entry is already resolved', () => { - const input = pending(['fc-1', 's-1'], ['fc-2', 's-1']) - const seen = new Set(['fc-1', 'fc-2']) - expect(nextApprovalsToAutoResolve(input, seen)).toEqual([]) - }) - - it('returns an empty array on empty input (the no-op happy path)', () => { - expect(nextApprovalsToAutoResolve([], new Set())).toEqual([]) - expect(nextApprovalsToAutoResolve([], new Set(['fc-99']))).toEqual([]) - }) - - it('preserves input order when filtering', () => { - const input = pending(['fc-a', 's'], ['fc-b', 's'], ['fc-c', 's']) - const seen = new Set(['fc-b']) - expect( - nextApprovalsToAutoResolve(input, seen).map((p) => p.functionCallId), - ).toEqual(['fc-a', 'fc-c']) - }) - - it('does not mutate the input arrays or set', () => { - const input = pending(['fc-1', 's-1'], ['fc-2', 's-1']) - const inputBefore = JSON.stringify(input) - const seen = new Set(['fc-1']) - const seenBefore = [...seen] - nextApprovalsToAutoResolve(input, seen) - expect(JSON.stringify(input)).toBe(inputBefore) - expect([...seen]).toEqual(seenBefore) - }) -}) - -describe('selectAutoAcceptCandidates', () => { - it('returns null when the message list is empty (hot-path early exit)', () => { - expect(selectAutoAcceptCandidates([])).toBeNull() - }) - - it('returns null when the tail message is not a function-call', () => { - const messages: Message[] = [pendingFcall('m-1', 'fc-1'), userMsg('m-2')] - expect(selectAutoAcceptCandidates(messages)).toBeNull() - }) - - it('returns candidates when the tail IS a function-call', () => { - const messages: Message[] = [pendingFcall('m-1', 'fc-1')] - const out = selectAutoAcceptCandidates(messages) - expect(out).not.toBeNull() - expect(out!.candidates).toHaveLength(1) - expect(out!.candidates[0]).toEqual({ - functionCallId: 'fc-1', - sessionId: 'sess-1', - functionId: 'directory::skills::list', - }) - }) - - it('separates policy-denied entries from acceptable candidates', () => { - const messages: Message[] = [ - pendingFcall('m-1', 'fc-1', 'directory::skills::list'), - pendingFcall('m-2', 'fc-2', 'fs::write'), - pendingFcall('m-3', 'fc-3', 'agent::trigger'), - pendingFcall('m-4', 'fc-4', 'fs::ls'), - ] - const out = selectAutoAcceptCandidates(messages)! - expect(out.candidates.map((c) => c.functionCallId)).toEqual([ - 'fc-1', - 'fc-4', - ]) - expect(out.deniedByPolicy.map((c) => c.functionCallId)).toEqual([ - 'fc-2', - 'fc-3', - ]) - }) - - it('skips function-call messages that lack functionCallId or sessionId', () => { - const incomplete: FunctionCallMessage = { - id: 'm-x', - role: 'function-call', - functionId: 'fs::ls', - input: {}, - pendingApproval: true, - createdAt: 0, - } - const messages: Message[] = [incomplete, pendingFcall('m-1', 'fc-1')] - const out = selectAutoAcceptCandidates(messages)! - expect(out.candidates.map((c) => c.functionCallId)).toEqual(['fc-1']) - }) - - it('skips function-call messages without pendingApproval=true', () => { - const settled: FunctionCallMessage = { - ...pendingFcall('m-1', 'fc-1'), - pendingApproval: false, - running: false, - } - const messages: Message[] = [settled, pendingFcall('m-2', 'fc-2')] - const out = selectAutoAcceptCandidates(messages)! - expect(out.candidates.map((c) => c.functionCallId)).toEqual(['fc-2']) - }) -}) diff --git a/console/web/src/lib/backend/auto-accept.ts b/console/web/src/lib/backend/auto-accept.ts deleted file mode 100644 index 24f5caa5..00000000 --- a/console/web/src/lib/backend/auto-accept.ts +++ /dev/null @@ -1,88 +0,0 @@ -/** - * Pure helpers for the per-conversation "auto-accept all approvals" - * feature. Live next to `pending-approvals-store.ts` because both are - * framework-free helpers consumed by `useAutoAcceptApprovals`. - * - * Why these are pure: they encode the policy decisions (which - * approvals are safe, which have already fired, what to skip on the - * streaming hot path). Keeping them React-free means they can be - * tested directly with vitest, no DOM, no testing-library. - */ - -import type { Message } from '@/types/chat' -import { - type AutoAcceptPolicy, - DEFAULT_POLICY, - isAutoAcceptable, -} from './auto-accept-policy' - -export interface PendingApprovalCandidate { - /** iii `function_call_id` — the dedup key. */ - functionCallId: string - /** iii `session_id` — needed for the `approval::resolve` payload. */ - sessionId: string - /** iii function id — exposed so callers can announce / log which call resolved. */ - functionId: string -} - -/** - * Identify which messages are auto-acceptable pending approvals. - * - * Walks `messages` once and applies the safety policy. Returns - * EITHER: - * - `{ candidates: [...], deniedByPolicy: [...] }` when the tail is - * a function-call message (the only role that can introduce a - * pending approval); or - * - `null` when the tail isn't a function-call (the hot-path early - * exit during token streaming). - * - * The dedup-against-already-resolved step is left to the caller so - * the policy filter and the dedup set can live in separate concerns. - */ -export function selectAutoAcceptCandidates( - messages: ReadonlyArray, - policy: AutoAcceptPolicy = DEFAULT_POLICY, -): { - candidates: PendingApprovalCandidate[] - deniedByPolicy: PendingApprovalCandidate[] -} | null { - const last = messages.length > 0 ? messages[messages.length - 1] : null - if (!last || last.role !== 'function-call') return null - - const candidates: PendingApprovalCandidate[] = [] - const deniedByPolicy: PendingApprovalCandidate[] = [] - for (const m of messages) { - if ( - m.role !== 'function-call' || - m.pendingApproval !== true || - typeof m.functionCallId !== 'string' || - typeof m.sessionId !== 'string' - ) { - continue - } - const entry: PendingApprovalCandidate = { - functionCallId: m.functionCallId, - sessionId: m.sessionId, - functionId: m.functionId, - } - if (!isAutoAcceptable(m.functionId, policy)) { - deniedByPolicy.push(entry) - continue - } - candidates.push(entry) - } - return { candidates, deniedByPolicy } -} - -/** - * Return the subset of `pending` whose `functionCallId` is NOT in - * `alreadyResolved`. The caller is expected to add each returned id - * to its dedup set BEFORE awaiting the resolve, so a re-render of - * the same pending entry doesn't fire a second resolve. - */ -export function nextApprovalsToAutoResolve( - pending: ReadonlyArray, - alreadyResolved: ReadonlySet, -): PendingApprovalCandidate[] { - return pending.filter((p) => !alreadyResolved.has(p.functionCallId)) -} diff --git a/console/web/src/lib/chat-activity.test.ts b/console/web/src/lib/chat-activity.test.ts index 34439f3a..591c945d 100644 --- a/console/web/src/lib/chat-activity.test.ts +++ b/console/web/src/lib/chat-activity.test.ts @@ -8,7 +8,6 @@ function conv(messages: Message[]): Conversation { title: 't', model: 'openai::gpt-5', mode: 'agent', - autoAccept: false, messages, createdAt: 0, updatedAt: 0, diff --git a/console/web/src/lib/permissions/allowlist-filter.test.ts b/console/web/src/lib/permissions/allowlist-filter.test.ts new file mode 100644 index 00000000..155d5a2b --- /dev/null +++ b/console/web/src/lib/permissions/allowlist-filter.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' +import type { FunctionEntry } from '@/lib/functions' +import { filterAllowlistCandidates } from './allowlist-filter' + +function entry(id: string): FunctionEntry { + return { id, description: '' } +} + +describe('filterAllowlistCandidates', () => { + it('keeps tool-like functions (Tier 0)', () => { + const input = [ + entry('shell::fs::ls'), + entry('shell::fs::write'), + entry('shell::exec'), + entry('sandbox::create'), + entry('fs::read'), + entry('mytool::do_thing'), + ] + const out = filterAllowlistCandidates(input).map((e) => e.id) + expect(out).toEqual(input.map((e) => e.id)) + }) + + it('hides Tier 1 orchestration plumbing', () => { + const input = [ + entry('state::set'), + entry('state::get'), + entry('policy::check_permissions'), + entry('turn::function_execute'), + entry('session::ensure'), + entry('session-tree::compactions'), + entry('session-inbox::push'), + entry('approval::resolve'), + entry('approval::set_mode'), + entry('hook-fanout::publish_collect'), + entry('iii::durable::publish'), + entry('ui::subscribe'), + entry('ui::session::event'), + ] + expect(filterAllowlistCandidates(input)).toEqual([]) + }) + + it('hides Tier 2 runtime services + provider workers', () => { + const input = [ + entry('models-catalog::list'), + entry('llm-budget::record'), + entry('context-compaction::compact_session'), + entry('engine::echo'), + entry('functions::list'), + entry('directory::index'), + entry('trigger::run'), + entry('auth::status'), + entry('provider-anthropic::stream'), + entry('provider-openai::config'), + ] + expect(filterAllowlistCandidates(input)).toEqual([]) + }) + + it('hides agent lifecycle events but keeps agent::trigger visible', () => { + const input = [ + entry('agent::trigger'), + entry('agent::before_function_call'), + entry('agent::after_function_call'), + entry('agent::turn_end'), + entry('agent::run_start'), + ] + const out = filterAllowlistCandidates(input).map((e) => e.id) + expect(out).toEqual(['agent::trigger']) + }) + + it('does not partial-match a prefix (e.g. authorize-* is NOT auth::)', () => { + // sanity check the boundary: prefix match is on `auth::`, not `auth`, + // so an unrelated namespace starting with the same word survives. + const input = [entry('authorize::do_thing')] + expect(filterAllowlistCandidates(input)).toHaveLength(1) + }) +}) diff --git a/console/web/src/lib/permissions/allowlist-filter.ts b/console/web/src/lib/permissions/allowlist-filter.ts new file mode 100644 index 00000000..e5f52153 --- /dev/null +++ b/console/web/src/lib/permissions/allowlist-filter.ts @@ -0,0 +1,74 @@ +/** + * Visibility filter for the auto-mode allowlist tree. The functions + * catalog includes the entire iii bus, including orchestration plumbing + * (state::*, turn::*, approval::*, …) and runtime services + * (models-catalog::*, provider-*::*, …) the agent never calls via + * `agent_trigger`. Showing them in the allowlist is noise and + * actively misleading — checking `state::set` does nothing useful since + * agents don't dispatch it. + * + * Tier 3 (`agent::trigger`, the recursive-dispatch foot-gun) is + * intentionally NOT hidden here so operators can still allowlist it if + * they really want — the destructive-confirm modal protects against + * accidental clicks. + */ + +import type { FunctionEntry } from '@/lib/functions' + +/** Namespace prefixes (including `::`) whose entries are always hidden. */ +const HIDDEN_PREFIXES: ReadonlyArray = [ + // Tier 1 — orchestration plumbing. + 'state::', + 'policy::', + 'turn::', + 'session::', + 'session-tree::', + 'session-inbox::', + 'approval::', + 'hook-fanout::', + 'iii::durable::', + 'ui::', + // Tier 2 — runtime services / introspection. + 'models-catalog::', + 'llm-budget::', + 'context-compaction::', + 'engine::', + 'functions::', + 'directory::', + 'trigger::', + 'auth::', + // Provider workers — any registered ids under a provider-* worker + // are wiring (config, status, capability probes), not agent tools. + 'provider-', +] + +/** + * Exact function ids hidden in addition to the prefix filter. Used for + * specific lifecycle/event ids that share a namespace with legitimately + * agent-callable entries (e.g. `agent::trigger` stays visible while the + * lifecycle events under `agent::` do not). + */ +const HIDDEN_EXACT: ReadonlySet = new Set([ + 'agent::before_function_call', + 'agent::after_function_call', + 'agent::turn_end', + 'agent::run_start', +]) + +function isHidden(id: string): boolean { + if (HIDDEN_EXACT.has(id)) return true + for (const prefix of HIDDEN_PREFIXES) { + if (id.startsWith(prefix)) return true + } + return false +} + +/** + * Returns the subset of `entries` that should appear in the allowlist + * tree. Pure; safe to memoize at the call site. + */ +export function filterAllowlistCandidates( + entries: ReadonlyArray, +): FunctionEntry[] { + return entries.filter((e) => !isHidden(e.id)) +} diff --git a/console/web/src/lib/storage.ts b/console/web/src/lib/storage.ts index 7c73e73a..65175ef4 100644 --- a/console/web/src/lib/storage.ts +++ b/console/web/src/lib/storage.ts @@ -3,6 +3,64 @@ import { type Conversation, isKnownRole, type Message } from '@/types/chat' const CONVERSATIONS_KEY = 'iii-chat-conversations' const ACTIVE_KEY = 'iii-chat-active' const LAST_MODEL_KEY = 'iii-chat-last-model' +const DEFAULT_PERMISSION_MODE_KEY = 'iii-default-permission-mode' + +export type PermissionMode = 'manual' | 'auto' | 'full' +const PERMISSION_MODES: ReadonlySet = new Set([ + 'manual', + 'auto', + 'full', +]) + +function isPermissionMode(v: unknown): v is PermissionMode { + return typeof v === 'string' && PERMISSION_MODES.has(v as PermissionMode) +} + +/** User-level default mode applied to NEW conversations only. */ +export function loadDefaultPermissionMode(): PermissionMode { + try { + const raw = localStorage.getItem(DEFAULT_PERMISSION_MODE_KEY) + return isPermissionMode(raw) ? raw : 'manual' + } catch { + return 'manual' + } +} + +export function saveDefaultPermissionMode(mode: PermissionMode): void { + try { + localStorage.setItem(DEFAULT_PERMISSION_MODE_KEY, mode) + } catch { + /* best-effort */ + } +} + +const DEFAULT_ALLOWLIST_KEY = 'iii-default-allowlist' + +/** User-level allowlist used to seed new conversations' backend state. */ +export function loadDefaultAllowlist(): string[] { + try { + const raw = localStorage.getItem(DEFAULT_ALLOWLIST_KEY) + if (!raw) return [] + const parsed = JSON.parse(raw) + if (!Array.isArray(parsed)) return [] + return parsed.filter( + (v): v is string => typeof v === 'string' && v.length > 0, + ) + } catch { + return [] + } +} + +export function saveDefaultAllowlist(list: string[]): void { + try { + /* Stable insertion order matters for human review; sort once on write + * so the list reads consistently across sessions. */ + const unique = Array.from(new Set(list)).sort() + localStorage.setItem(DEFAULT_ALLOWLIST_KEY, JSON.stringify(unique)) + } catch { + /* best-effort */ + } +} export function loadConversations(): Conversation[] { try { @@ -12,10 +70,6 @@ export function loadConversations(): Conversation[] { if (!Array.isArray(parsed)) return [] return parsed.filter(isConversation).map((c) => ({ ...c, - /* `autoAccept` was added later; legacy localStorage payloads - * don't carry it. Default to false so old conversations don't - * silently inherit auto-approve. */ - autoAccept: typeof c.autoAccept === 'boolean' ? c.autoAccept : false, messages: c.messages.filter(isValidMessage), })) } catch { diff --git a/console/web/src/pages/Configuration/index.tsx b/console/web/src/pages/Configuration/index.tsx index baf5de35..ec8a9ab9 100644 --- a/console/web/src/pages/Configuration/index.tsx +++ b/console/web/src/pages/Configuration/index.tsx @@ -1,12 +1,29 @@ -import { useEffect } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { DefaultPermissionModePicker } from '@/components/permissions/DefaultPermissionModePicker' +import { FunctionAllowlistTree } from '@/components/permissions/FunctionAllowlistTree' import { ProviderRow } from '@/components/providers/ProviderRow' import { ACTIVE_PROVIDERS, ENV_VAR_MAP, } from '@/components/providers/provider-registry' +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from '@/components/ui/Dialog' import { ModeToggle } from '@/components/ui/ModeToggle' +import { useFunctionsCatalog } from '@/hooks/use-functions-catalog' import { useProviderStatuses } from '@/hooks/use-providers' import type { Theme } from '@/hooks/use-theme' +import { getDefaultBackend } from '@/lib/backend' +import type { PermissionMode } from '@/lib/backend/approval-settings' +import { filterAllowlistCandidates } from '@/lib/permissions/allowlist-filter' +import { + loadDefaultAllowlist, + loadDefaultPermissionMode, + saveDefaultAllowlist, +} from '@/lib/storage' const ENV_VAR_BY_ID = new Map(ENV_VAR_MAP) @@ -24,6 +41,42 @@ export function Configuration({ theme, onThemeChange }: ConfigurationProps) { const configuredCount = statuses.filter((s) => s.data?.configured).length const showZeroConfig = !isStatusLoading && configuredCount === 0 + // Controlled default-permission-mode + per-user allowlist. Both back to + // localStorage. The allowlist section only renders while mode === 'auto' + // (it has no effect under manual/full, so showing it would mislead). + const [defaultMode, setDefaultMode] = useState(() => + loadDefaultPermissionMode(), + ) + const [allowlist, setAllowlist] = useState(() => + loadDefaultAllowlist(), + ) + const allowlistSet = useMemo(() => new Set(allowlist), [allowlist]) + + const addAllow = useCallback((functionId: string) => { + setAllowlist((prev) => { + if (prev.includes(functionId)) return prev + const next = [...prev, functionId] + saveDefaultAllowlist(next) + return next + }) + }, []) + + const removeAllow = useCallback((functionId: string) => { + setAllowlist((prev) => { + if (!prev.includes(functionId)) return prev + const next = prev.filter((id) => id !== functionId) + saveDefaultAllowlist(next) + return next + }) + }, []) + + const { functionEntries } = useFunctionsCatalog(getDefaultBackend().id) + const allowlistCandidates = useMemo( + () => filterAllowlistCandidates(functionEntries), + [functionEntries], + ) + const [allowlistOpen, setAllowlistOpen] = useState(false) + // H7 — keyboard navigation for the provider list: // 1–N : open provider N's dialog directly // ↑ / ↓ : move focus between provider rows (when one is focused) @@ -101,6 +154,68 @@ export function Configuration({ theme, onThemeChange }: ConfigurationProps) { /> +
    + + } + meta="manual prompts for everything · auto skips functions on your allowlist · full skips everything" + /> + {defaultMode === 'auto' ? ( + setAllowlistOpen(true)} + className="font-mono text-[12px] px-3 py-1 border border-rule text-ink hover:border-ink transition-colors" + > + manage + {allowlist.length > 0 ? ` (${allowlist.length})` : ''} + + } + meta="functions that auto-approve while a new conversation is in auto mode. edits apply to NEW conversations only." + /> + ) : null} +
    + + + + + auto-mode allowlist + + + checked functions auto-approve while a new conversation is in auto + mode. existing conversations keep their own snapshot. + +
    + +
    +
    + +
    +
    +
    +
    @@ -66,10 +66,10 @@ export function ComposerVariantsSection() { model="anthropic::claude-opus-4-7" modelOptions={STATIC_MODEL_OPTIONS} functionEntries={STATIC_FUNCTIONS} - autoAccept={false} + permissionMode="manual" onModeChange={noop} onModelChange={noop} - onAutoAcceptChange={noop} + onPermissionModeChange={noop} onSubmit={noop} initialContent={seedWithText} /> @@ -84,10 +84,10 @@ export function ComposerVariantsSection() { model="openai::gpt-5" modelOptions={STATIC_MODEL_OPTIONS} functionEntries={STATIC_FUNCTIONS} - autoAccept={false} + permissionMode="manual" onModeChange={noop} onModelChange={noop} - onAutoAcceptChange={noop} + onPermissionModeChange={noop} onSubmit={noop} initialContent={seedWithMention} /> @@ -99,10 +99,10 @@ export function ComposerVariantsSection() { model="openai::gpt-5" modelOptions={STATIC_MODEL_OPTIONS} functionEntries={STATIC_FUNCTIONS} - autoAccept={false} + permissionMode="manual" onModeChange={noop} onModelChange={noop} - onAutoAcceptChange={noop} + onPermissionModeChange={noop} onSubmit={noop} initialAttachments={sampleAttachments} /> @@ -114,10 +114,10 @@ export function ComposerVariantsSection() { model="openai::gpt-5-mini" modelOptions={STATIC_MODEL_OPTIONS} functionEntries={STATIC_FUNCTIONS} - autoAccept={false} + permissionMode="manual" onModeChange={noop} onModelChange={noop} - onAutoAcceptChange={noop} + onPermissionModeChange={noop} onSubmit={noop} onStop={noop} isStreaming diff --git a/console/web/src/pages/Playground/index.tsx b/console/web/src/pages/Playground/index.tsx index 58d57056..2168581a 100644 --- a/console/web/src/pages/Playground/index.tsx +++ b/console/web/src/pages/Playground/index.tsx @@ -22,7 +22,6 @@ function makeConvo(mode: Mode): Conversation { title: 'playground', model: DEFAULT_MODEL, mode, - autoAccept: false, messages: [], createdAt: now, updatedAt: now, @@ -79,10 +78,6 @@ export function Playground() { setConvo((c) => ({ ...c, mode, updatedAt: Date.now() })) }, []) - const setAutoAccept = useCallback((_id: string, autoAccept: boolean) => { - setConvo((c) => ({ ...c, autoAccept, updatedAt: Date.now() })) - }, []) - const setModel = useCallback((_id: string, model: ModelId) => { setConvo((c) => ({ ...c, model, updatedAt: Date.now() })) }, []) @@ -144,7 +139,6 @@ export function Playground() { modelOptions={STATIC_MODEL_OPTIONS} onUpdateModel={setModel} onUpdateMode={setMode} - onUpdateAutoAccept={setAutoAccept} onAppendMessage={appendMessage} onPatchMessage={updateMessage} onCompactConversation={compactConversation} diff --git a/console/web/src/types/chat.ts b/console/web/src/types/chat.ts index 4f77926f..340ff31b 100644 --- a/console/web/src/types/chat.ts +++ b/console/web/src/types/chat.ts @@ -153,17 +153,6 @@ export interface Conversation { titleManual?: boolean model: ModelId mode: Mode - /** - * When true, the chat client auto-fires `approval::resolve { - * decision: "allow" }` for every new pending approval that surfaces - * in this conversation's state, instead of waiting for the user to - * click "approve". Persists per-conversation; a fresh conversation - * defaults to false so "yolo" doesn't carry across sessions. - * - * The approval card still renders briefly in the transcript for - * audit — we just click the button for the user. - */ - autoAccept: boolean messages: Message[] createdAt: number updatedAt: number diff --git a/harness/docs/workers/approval-gate.md b/harness/docs/workers/approval-gate.md index 6f4e6a54..3af4a634 100644 --- a/harness/docs/workers/approval-gate.md +++ b/harness/docs/workers/approval-gate.md @@ -1,15 +1,55 @@ # approval-gate -Registers `approval::resolve` and shared wire schemas for the approval path. -The turn-orchestrator reacts via the reactive `turn::on_approval` state trigger. +Registers `approval::resolve`, the per-session approval-settings handlers +(`approval::set_mode`, `approval::add_always_allow`, …), and shared wire +schemas for the approval path. The turn-orchestrator reacts via the reactive +`turn::on_approval` state trigger. ## Purpose The approval gate is the bus entry point for human decisions on parked tool calls. It does **not** intercept function calls on the bus — the turn-orchestrator consults `policy::check_permissions` directly inside -`consultBefore`. The gate's job is to accept operator input from the console -and persist the decision where the orchestrator can read it. +`consultBefore`, then applies per-session mode + always-allow before falling +back to the yaml policy. The gate's job is to accept operator input from the +console and persist the decisions (resolutions, mode changes, allow-list +mutations) where the orchestrator can read them. + +## Permission modes (per-session) + +Each session has a permission mode stored at +`approval_settings/`. The turn-orchestrator's `consultBefore` +snapshots this record at the start of each call, then evaluates in order: + +1. **human-only block.** If the agent tries to call any of + `approval::set_mode`, `approval::add_always_allow`, + `approval::remove_always_allow`, `approval::approve_always`, + `approval::get_settings`, `approval::clear_settings`, the call is + denied with rule `human_only_function`. Those handlers are only + reachable from the user-initiated RPC path; the agent's + `dispatchWithHook` route can never self-escalate. +2. **`mode === 'full'`** → allow. No safety floor; the agent may call any + function. The console renders a persistent banner while this mode is + active. +3. **`function_id ∈ approved_always`** → allow, **in every mode**. These + are per-session "approve always" grants made from an approval prompt + ("approve always" button). They are remembered human decisions, not an + auto-policy, so they hold under Manual as well as Auto. +4. **`mode === 'auto'` AND `function_id ∈ always_allow`** → allow. The + `always_allow` list is a user-curated trust profile, seeded from the + Configuration screen's default allowlist and consulted **only in + Auto**. Dormant under Manual. +5. **Fallback** → `policy::check_permissions` against `iii-permissions.yaml`. + Manual mode never short-circuits past this step; everything except + yaml `allow` rules and `approved_always` grants prompts. + +### Mode summary + +| Mode | yaml `allow` | approved_always | always_allow | yaml `deny` | otherwise | +|---|---|---|---|---|---| +| `manual` | allow | allow | dormant | deny | prompt | +| `auto` | allow | allow | allow | deny | prompt | +| `full` | allow | allow | allow | allow | allow | | Policy outcome (in orchestrator) | Orchestrator effect | |---|---| @@ -28,6 +68,12 @@ and persist the decision where the orchestrator can read it. ## Registered functions - `approval::resolve` — Validates the payload and persists the decision to scope `approvals`. Returns `{ ok: true }` or `{ ok: false, error: 'invalid_payload' | 'resume_failed' }`. +- `approval::set_mode` — Persists `{ mode }` to scope `approval_settings`. **Human-only**: the orchestrator hook denies this id when called by the agent. +- `approval::add_always_allow` — Idempotent append to the auto-mode allow-list. **Human-only**. +- `approval::remove_always_allow` — Remove an entry from the auto-mode allow-list. **Human-only**. +- `approval::approve_always` — Idempotent append to the per-session `approved_always` grants (honored in every mode). **Human-only**. +- `approval::get_settings` — Read current settings, returning defaults if none persisted. **Human-only**. +- `approval::clear_settings` — Drop the session's record on conversation delete. **Human-only**. Reactive wake is owned by the turn-orchestrator: @@ -35,12 +81,15 @@ Reactive wake is owned by the turn-orchestrator: ## State keys -All decision records use scope `approvals` (constant `STATE_SCOPE` in -[src/approval-gate/schemas.ts](harness/src/approval-gate/schemas.ts)): +Decision records use scope `approvals` (constant `STATE_SCOPE` in +[src/approval-gate/schemas.ts](harness/src/approval-gate/schemas.ts)); +per-session permission settings live in scope `approval_settings` +(constant `SETTINGS_STATE_SCOPE`): -| Key shape | Value | Purpose | -|---|---|---| -| `/` | `{ decision: 'allow' \| 'deny' \| 'aborted', reason: string \| null }` | Written by `approval::resolve`. `function_awaiting_approval` reads these keys while the turn is in `function_awaiting_approval`. | +| Scope | Key | Value | Writer | Purpose | +|---|---|---|---|---| +| `approvals` | `/` | `{ decision: 'allow' \| 'deny' \| 'aborted', reason: string \| null }` | `approval::resolve` | Decision pickup for parked calls. | +| `approval_settings` | `` | `{ mode, always_allow: AlwaysAllowEntry[], approved_always: AlwaysAllowEntry[], mode_set_at }` | `approval::set_mode`, `approval::add_always_allow`, `approval::remove_always_allow`, `approval::approve_always` | Snapshot read by `consultBefore` before consulting the yaml policy. | Pending calls are tracked on the turn record (`awaiting_approval[]`), not as separate rows under `approvals` until a decision lands. @@ -81,7 +130,8 @@ no explicit dependency block. |---|---| | [src/approval-gate/main.ts](harness/src/approval-gate/main.ts) | Binary entry point (`iii-approval-gate`). | | [src/approval-gate/resolve.ts](harness/src/approval-gate/resolve.ts) | Registers `approval::resolve`; persists decisions to scope `approvals`. | -| [src/approval-gate/schemas.ts](harness/src/approval-gate/schemas.ts) | `STATE_SCOPE`, wire schemas, `parsePolicyReply`, `pendingKey`, `ApprovalDecisionSchema`, `ResolvePayloadSchema`. | +| [src/approval-gate/settings/](harness/src/approval-gate/settings/) | Per-session mode/allow-list store, mutations, and handler registration (`readSettings`, `isHumanOnlyApprovalFunction`, `registerSettingsHandlers`). | +| [src/approval-gate/schemas.ts](harness/src/approval-gate/schemas.ts) | `STATE_SCOPE`, `SETTINGS_STATE_SCOPE`, wire schemas, `ApprovalSettingsSchema`, `parsePolicyReply`, `pendingKey`, `ApprovalDecisionSchema`, `ResolvePayloadSchema`. | | [src/approval-gate/denial.ts](harness/src/approval-gate/denial.ts) | `permissionsDenyEnvelope` and related helpers. | | [src/approval-gate/redact.ts](harness/src/approval-gate/redact.ts) | `redact` / `clip` for safe `args_excerpt` on denials. | | [src/approval-gate/iii.worker.yaml](harness/src/approval-gate/iii.worker.yaml) | Worker manifest. | diff --git a/harness/src/approval-gate/main.ts b/harness/src/approval-gate/main.ts index 87c54bf2..cb97b9fb 100644 --- a/harness/src/approval-gate/main.ts +++ b/harness/src/approval-gate/main.ts @@ -1,9 +1,14 @@ #!/usr/bin/env node import { bootstrapWorker } from '../runtime/worker.js'; import { register } from './resolve.js'; +import { registerSettingsHandlers } from './settings/register.js'; await bootstrapWorker({ name: 'approval-gate', - description: 'Registers approval::resolve and routes decisions to per-call resume functions.', - register: (iii) => register(iii), + description: + 'Registers approval::resolve, per-session settings handlers, and routes decisions to the orchestrator.', + register: async (iii) => { + await register(iii); + registerSettingsHandlers(iii); + }, }); diff --git a/harness/src/approval-gate/schemas.ts b/harness/src/approval-gate/schemas.ts index 13baebe1..5e5fabbb 100644 --- a/harness/src/approval-gate/schemas.ts +++ b/harness/src/approval-gate/schemas.ts @@ -9,8 +9,40 @@ import { z } from 'zod'; import { zodToJsonSchema } from 'zod-to-json-schema'; export const STATE_SCOPE = 'approvals'; +export const SETTINGS_STATE_SCOPE = 'approval_settings'; export const DENIAL_SCHEMA_VERSION = 1; +export const PermissionModeSchema = z.enum(['manual', 'auto', 'full']); +export type PermissionMode = z.infer; + +const allowEntrySchema = z.object({ + function_id: z.string().min(1), + granted_at: z.number().int().nonnegative(), + granted_by: z.literal('user_click'), +}); +export type AlwaysAllowEntry = z.infer; + +export const ApprovalSettingsSchema = z.object({ + mode: PermissionModeSchema, + // Auto-mode allowlist: curated on the Configuration screen, consulted + // ONLY in auto mode. + always_allow: z.array(allowEntrySchema), + // Per-session "approve always" grants made from an approval prompt. + // Consulted in EVERY mode (it's a remembered human decision, not an + // auto-policy). `.default([])` keeps records written before this field + // existed parseable. + approved_always: z.array(allowEntrySchema).default([]), + mode_set_at: z.number().int().nonnegative(), +}); +export type ApprovalSettings = z.infer; + +export const DEFAULT_APPROVAL_SETTINGS: ApprovalSettings = { + mode: 'manual', + always_allow: [], + approved_always: [], + mode_set_at: 0, +}; + const wireDecisionSchema = z.enum(['allow', 'deny']); const deniedBySchema = z.enum(['permissions', 'user', 'gate_unavailable']); diff --git a/harness/src/approval-gate/settings/add-always-allow.ts b/harness/src/approval-gate/settings/add-always-allow.ts new file mode 100644 index 00000000..7d0083ec --- /dev/null +++ b/harness/src/approval-gate/settings/add-always-allow.ts @@ -0,0 +1,54 @@ +import type { RegisterFunctionOptions, RemoteFunctionHandler } from 'iii-sdk'; +import { z } from 'zod'; +import { zodToJsonSchema } from 'zod-to-json-schema'; +import type { ApprovalSettings } from '../schemas.js'; +import type { ISdk } from '../../runtime/iii.js'; +import { type MutationReply, functionIdField, sessionIdField } from './types.js'; +import { mutationError, ok } from './reply.js'; +import { readSettings, writeSettings } from './store.js'; + +const PayloadSchema = z.object({ + session_id: sessionIdField, + function_id: functionIdField, +}); + +const options = { + description: 'Append a function id to the per-session always-allow list (idempotent).', + request_format: zodToJsonSchema(PayloadSchema, { name: 'AlwaysAllowPayload' }), +} as RegisterFunctionOptions; + +export async function addAlwaysAllow( + iii: ISdk, + session_id: string, + function_id: string, +): Promise { + const current = await readSettings(iii, session_id); + if (current.always_allow.some((entry) => entry.function_id === function_id)) { + return current; + } + const next: ApprovalSettings = { + ...current, + always_allow: [ + ...current.always_allow, + { function_id, granted_at: Date.now(), granted_by: 'user_click' }, + ], + }; + await writeSettings(iii, session_id, next); + return next; +} + +export function registerAddAlwaysAllow(iii: ISdk): void { + const handler: RemoteFunctionHandler = async ( + payload, + ) => { + const parsed = PayloadSchema.safeParse(payload); + if (!parsed.success) return mutationError(parsed.error.message); + try { + return ok(await addAlwaysAllow(iii, parsed.data.session_id, parsed.data.function_id)); + } catch (err) { + return mutationError(err); + } + }; + + iii.registerFunction('approval::add_always_allow', handler, options); +} diff --git a/harness/src/approval-gate/settings/approve-always.ts b/harness/src/approval-gate/settings/approve-always.ts new file mode 100644 index 00000000..1b0219ee --- /dev/null +++ b/harness/src/approval-gate/settings/approve-always.ts @@ -0,0 +1,56 @@ +import type { RegisterFunctionOptions, RemoteFunctionHandler } from 'iii-sdk'; +import { z } from 'zod'; +import { zodToJsonSchema } from 'zod-to-json-schema'; +import type { ApprovalSettings } from '../schemas.js'; +import type { ISdk } from '../../runtime/iii.js'; +import { type MutationReply, functionIdField, sessionIdField } from './types.js'; +import { mutationError, ok } from './reply.js'; +import { readSettings, writeSettings } from './store.js'; + +const PayloadSchema = z.object({ + session_id: sessionIdField, + function_id: functionIdField, +}); + +const options = { + description: + 'Remember an "approve always" decision for this session — the function id stops prompting in every mode. Idempotent.', + request_format: zodToJsonSchema(PayloadSchema, { name: 'ApproveAlwaysPayload' }), +} as RegisterFunctionOptions; + +/** Idempotent on `function_id`; separate from auto-mode `always_allow`. */ +export async function approveAlways( + iii: ISdk, + session_id: string, + function_id: string, +): Promise { + const current = await readSettings(iii, session_id); + if (current.approved_always.some((entry) => entry.function_id === function_id)) { + return current; + } + const next: ApprovalSettings = { + ...current, + approved_always: [ + ...current.approved_always, + { function_id, granted_at: Date.now(), granted_by: 'user_click' }, + ], + }; + await writeSettings(iii, session_id, next); + return next; +} + +export function registerApproveAlways(iii: ISdk): void { + const handler: RemoteFunctionHandler = async ( + payload, + ) => { + const parsed = PayloadSchema.safeParse(payload); + if (!parsed.success) return mutationError(parsed.error.message); + try { + return ok(await approveAlways(iii, parsed.data.session_id, parsed.data.function_id)); + } catch (err) { + return mutationError(err); + } + }; + + iii.registerFunction('approval::approve_always', handler, options); +} diff --git a/harness/src/approval-gate/settings/clear-settings.ts b/harness/src/approval-gate/settings/clear-settings.ts new file mode 100644 index 00000000..e6f1fa60 --- /dev/null +++ b/harness/src/approval-gate/settings/clear-settings.ts @@ -0,0 +1,31 @@ +import type { RegisterFunctionOptions, RemoteFunctionHandler } from 'iii-sdk'; +import { z } from 'zod'; +import { zodToJsonSchema } from 'zod-to-json-schema'; +import type { ISdk } from '../../runtime/iii.js'; +import { mutationError } from './reply.js'; +import { clearSettings } from './store.js'; +import { type MutationReply, sessionIdField } from './types.js'; + +const PayloadSchema = z.object({ + session_id: sessionIdField, +}); + +const options = { + description: 'Clear the per-session approval settings on conversation deletion.', + request_format: zodToJsonSchema(PayloadSchema, { name: 'ClearSettingsPayload' }), +} as RegisterFunctionOptions; + +export function registerClearSettings(iii: ISdk): void { + const handler: RemoteFunctionHandler = async (payload) => { + const parsed = PayloadSchema.safeParse(payload); + if (!parsed.success) return mutationError(parsed.error.message); + try { + await clearSettings(iii, parsed.data.session_id); + return { ok: true }; + } catch (err) { + return mutationError(err); + } + }; + + iii.registerFunction('approval::clear_settings', handler, options); +} diff --git a/harness/src/approval-gate/settings/get-settings.ts b/harness/src/approval-gate/settings/get-settings.ts new file mode 100644 index 00000000..46952b98 --- /dev/null +++ b/harness/src/approval-gate/settings/get-settings.ts @@ -0,0 +1,24 @@ +import type { RegisterFunctionOptions, RemoteFunctionHandler } from 'iii-sdk'; +import { z } from 'zod'; +import { zodToJsonSchema } from 'zod-to-json-schema'; +import type { ISdk } from '../../runtime/iii.js'; +import { readSettings } from './store.js'; +import { sessionIdField, type GetSettingsReply } from './types.js'; + +const PayloadSchema = z.object({ + session_id: sessionIdField, +}); + +const options = { + description: 'Read the per-session approval settings; returns defaults when none persisted.', + request_format: zodToJsonSchema(PayloadSchema, { name: 'GetSettingsPayload' }), +} as RegisterFunctionOptions; + +export function registerGetSettings(iii: ISdk): void { + const handler: RemoteFunctionHandler = async (payload) => { + const parsed = PayloadSchema.parse(payload); + return readSettings(iii, parsed.session_id); + }; + + iii.registerFunction('approval::get_settings', handler, options); +} diff --git a/harness/src/approval-gate/settings/human-only.ts b/harness/src/approval-gate/settings/human-only.ts new file mode 100644 index 00000000..8deb9004 --- /dev/null +++ b/harness/src/approval-gate/settings/human-only.ts @@ -0,0 +1,14 @@ +const HUMAN_ONLY_FUNCTION_IDS = [ + 'approval::set_mode', + 'approval::add_always_allow', + 'approval::remove_always_allow', + 'approval::approve_always', + 'approval::get_settings', + 'approval::clear_settings', +] as const; + +export type HumanOnlyFunctionId = (typeof HUMAN_ONLY_FUNCTION_IDS)[number]; + +export function isHumanOnlyApprovalFunction(function_id: string): boolean { + return (HUMAN_ONLY_FUNCTION_IDS as readonly string[]).includes(function_id); +} diff --git a/harness/src/approval-gate/settings/register.ts b/harness/src/approval-gate/settings/register.ts new file mode 100644 index 00000000..26cc7026 --- /dev/null +++ b/harness/src/approval-gate/settings/register.ts @@ -0,0 +1,36 @@ +/** + * Per-session approval settings: permission mode (manual / auto / full), + * the auto-mode `always_allow` list, and the per-session + * `approved_always` grants ("approve always" decisions made from a + * prompt, honored in every mode). + * + * Settings live in shared state under scope `approval_settings`, keyed by + * ``. `policy::check_permissions` reads a snapshot at the + * start of each check; mutations made during a check do not affect that + * check. The handlers registered here are the only writers — every entry + * carries `granted_by: 'user_click'` so audit can distinguish user grants + * from any future programmatic source. + * + * The handlers are intentionally usable only from the frontend RPC path. + * Agent-issued calls funnel through `consultBefore`, which carries a + * hardcoded deny for these function ids; user-initiated SDK calls + * (e.g. `approval::set_mode` from `console/web`) bypass the hook and + * land here directly. + */ + +import type { ISdk } from '../../runtime/iii.js'; +import { registerAddAlwaysAllow } from './add-always-allow.js'; +import { registerApproveAlways } from './approve-always.js'; +import { registerClearSettings } from './clear-settings.js'; +import { registerGetSettings } from './get-settings.js'; +import { registerRemoveAlwaysAllow } from './remove-always-allow.js'; +import { registerSetMode } from './set-mode.js'; + +export function registerSettingsHandlers(iii: ISdk): void { + registerSetMode(iii); + registerAddAlwaysAllow(iii); + registerRemoveAlwaysAllow(iii); + registerApproveAlways(iii); + registerGetSettings(iii); + registerClearSettings(iii); +} diff --git a/harness/src/approval-gate/settings/remove-always-allow.ts b/harness/src/approval-gate/settings/remove-always-allow.ts new file mode 100644 index 00000000..2a6d3bff --- /dev/null +++ b/harness/src/approval-gate/settings/remove-always-allow.ts @@ -0,0 +1,48 @@ +import type { RegisterFunctionOptions, RemoteFunctionHandler } from 'iii-sdk'; +import { z } from 'zod'; +import { zodToJsonSchema } from 'zod-to-json-schema'; +import type { ApprovalSettings } from '../schemas.js'; +import type { ISdk } from '../../runtime/iii.js'; +import { type MutationReply, functionIdField, sessionIdField } from './types.js'; +import { mutationError, ok } from './reply.js'; +import { readSettings, writeSettings } from './store.js'; + +const PayloadSchema = z.object({ + session_id: sessionIdField, + function_id: functionIdField, +}); + +const options = { + description: 'Remove a function id from the per-session always-allow list.', + request_format: zodToJsonSchema(PayloadSchema, { name: 'RemoveAlwaysAllowPayload' }), +} as RegisterFunctionOptions; + +export async function removeAlwaysAllow( + iii: ISdk, + session_id: string, + function_id: string, +): Promise { + const current = await readSettings(iii, session_id); + const next: ApprovalSettings = { + ...current, + always_allow: current.always_allow.filter((entry) => entry.function_id !== function_id), + }; + await writeSettings(iii, session_id, next); + return next; +} + +export function registerRemoveAlwaysAllow(iii: ISdk): void { + const handler: RemoteFunctionHandler = async ( + payload, + ) => { + const parsed = PayloadSchema.safeParse(payload); + if (!parsed.success) return mutationError(parsed.error.message); + try { + return ok(await removeAlwaysAllow(iii, parsed.data.session_id, parsed.data.function_id)); + } catch (err) { + return mutationError(err); + } + }; + + iii.registerFunction('approval::remove_always_allow', handler, options); +} diff --git a/harness/src/approval-gate/settings/reply.ts b/harness/src/approval-gate/settings/reply.ts new file mode 100644 index 00000000..45aac581 --- /dev/null +++ b/harness/src/approval-gate/settings/reply.ts @@ -0,0 +1,9 @@ +import type { MutationReply } from './types.js'; + +export function ok(value: T): T { + return value; +} + +export function mutationError(err: unknown): MutationReply { + return { ok: false, error: String(err) }; +} diff --git a/harness/src/approval-gate/settings/set-mode.ts b/harness/src/approval-gate/settings/set-mode.ts new file mode 100644 index 00000000..604b1b17 --- /dev/null +++ b/harness/src/approval-gate/settings/set-mode.ts @@ -0,0 +1,45 @@ +import type { RegisterFunctionOptions, RemoteFunctionHandler } from 'iii-sdk'; +import { z } from 'zod'; +import { zodToJsonSchema } from 'zod-to-json-schema'; +import { PermissionModeSchema, type ApprovalSettings, type PermissionMode } from '../schemas.js'; +import type { ISdk } from '../../runtime/iii.js'; +import { mutationError, ok } from './reply.js'; +import { readSettings, writeSettings } from './store.js'; +import { type MutationReply, sessionIdField } from './types.js'; + +const PayloadSchema = z.object({ + session_id: sessionIdField, + mode: PermissionModeSchema, +}); + +const options = { + description: 'Set the permission mode (manual | auto | full) for a session.', + request_format: zodToJsonSchema(PayloadSchema, { name: 'SetModePayload' }), +} as RegisterFunctionOptions; + +export async function setMode( + iii: ISdk, + session_id: string, + mode: PermissionMode, +): Promise { + const current = await readSettings(iii, session_id); + const next: ApprovalSettings = { ...current, mode, mode_set_at: Date.now() }; + await writeSettings(iii, session_id, next); + return next; +} + +export function registerSetMode(iii: ISdk): void { + const handler: RemoteFunctionHandler = async ( + payload, + ) => { + const parsed = PayloadSchema.safeParse(payload); + if (!parsed.success) return mutationError(parsed.error.message); + try { + return ok(await setMode(iii, parsed.data.session_id, parsed.data.mode)); + } catch (err) { + return mutationError(err); + } + }; + + iii.registerFunction('approval::set_mode', handler, options); +} diff --git a/harness/src/approval-gate/settings/store.ts b/harness/src/approval-gate/settings/store.ts new file mode 100644 index 00000000..577030ec --- /dev/null +++ b/harness/src/approval-gate/settings/store.ts @@ -0,0 +1,43 @@ +import { + ApprovalSettingsSchema, + DEFAULT_APPROVAL_SETTINGS, + SETTINGS_STATE_SCOPE, + type ApprovalSettings, +} from '../schemas.js'; +import type { ISdk } from '../../runtime/iii.js'; +import { logger } from '../../runtime/otel.js'; + +export async function readSettings(iii: ISdk, session_id: string): Promise { + try { + const raw = await iii.trigger({ + function_id: 'state::get', + payload: { scope: SETTINGS_STATE_SCOPE, key: session_id }, + }); + const parsed = ApprovalSettingsSchema.safeParse(raw); + return parsed.success ? parsed.data : DEFAULT_APPROVAL_SETTINGS; + } catch (err) { + logger.warn('approval-settings read failed; using defaults', { + session_id, + err: String(err), + }); + return DEFAULT_APPROVAL_SETTINGS; + } +} + +export async function writeSettings( + iii: ISdk, + session_id: string, + settings: ApprovalSettings, +): Promise { + await iii.trigger({ + function_id: 'state::set', + payload: { scope: SETTINGS_STATE_SCOPE, key: session_id, value: settings }, + }); +} + +export async function clearSettings(iii: ISdk, session_id: string): Promise { + await iii.trigger({ + function_id: 'state::set', + payload: { scope: SETTINGS_STATE_SCOPE, key: session_id, value: null }, + }); +} diff --git a/harness/src/approval-gate/settings/types.ts b/harness/src/approval-gate/settings/types.ts new file mode 100644 index 00000000..063faa1f --- /dev/null +++ b/harness/src/approval-gate/settings/types.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; +import type { ApprovalSettings } from '../schemas.js'; + +export type { ApprovalSettings, PermissionMode } from '../schemas.js'; + +export type GetSettingsReply = ApprovalSettings; +export type MutationReply = { ok: true } | { ok: false; error: string }; + + +export const sessionIdField = z + .string() + .min(1) + .refine((v) => !v.includes('/'), 'session_id must not contain "/"'); + +export const functionIdField = z.string().min(1); diff --git a/harness/src/approval-gate/settings/verdict.ts b/harness/src/approval-gate/settings/verdict.ts new file mode 100644 index 00000000..64d08dd0 --- /dev/null +++ b/harness/src/approval-gate/settings/verdict.ts @@ -0,0 +1,36 @@ +/** + * Pure approval verdict derived from a per-session settings snapshot. + * + * Shared by the dispatch-time gate (`consultBefore`) and the parked-call + * re-evaluation in `function_awaiting_approval`, so both honor the same + * grants. Returns `'allow'` when the settings alone authorize the call, + * or `null` when they say nothing (caller falls through to policy / a + * human decision). + * + * - `mode === 'full'` → allow everything. + * - `approved_always` match → allow in EVERY mode (a remembered + * human "approve always" decision). + * - `mode === 'auto'` + allowlist match → allow (auto-mode allowlist is + * dormant outside Auto). + */ + +import type { ApprovalSettings } from './types.js'; + +function listed( + entries: ApprovalSettings['always_allow'], + function_id: string, +): boolean { + return entries.some((entry) => entry.function_id === function_id); +} + +export function settingsVerdict( + settings: ApprovalSettings, + function_id: string, +): 'allow' | null { + if (settings.mode === 'full') return 'allow'; + if (listed(settings.approved_always, function_id)) return 'allow'; + if (settings.mode === 'auto' && listed(settings.always_allow, function_id)) { + return 'allow'; + } + return null; +} diff --git a/harness/src/index.ts b/harness/src/index.ts index fed2b395..b14cd79e 100644 --- a/harness/src/index.ts +++ b/harness/src/index.ts @@ -9,6 +9,7 @@ import { Command } from 'commander'; import { register as registerApprovalGate } from './approval-gate/resolve.js'; +import { registerSettingsHandlers as registerApprovalSettings } from './approval-gate/settings/register.js'; import { register as registerAuthCredentials } from './auth-credentials/register.js'; import { register as registerContextCompaction } from './context-compaction/register.js'; import { register as registerHarness } from './harness/register.js'; @@ -49,8 +50,11 @@ const WORKERS: readonly WorkerDefinition[] = [ { name: 'approval-gate', description: - 'Registers approval::resolve; persists human decisions to the approvals scope and enqueues turn::function_awaiting_approval.', - register: (iii) => registerApprovalGate(iii), + 'Registers approval::resolve and the per-session mode/allow-list settings handlers; persists human decisions to the approvals and approval_settings scopes.', + register: async (iii) => { + await registerApprovalGate(iii); + registerApprovalSettings(iii); + }, }, { name: 'session', diff --git a/harness/src/turn-orchestrator/function-awaiting-approval/ports.ts b/harness/src/turn-orchestrator/function-awaiting-approval/ports.ts index 350d3877..aa26cd07 100644 --- a/harness/src/turn-orchestrator/function-awaiting-approval/ports.ts +++ b/harness/src/turn-orchestrator/function-awaiting-approval/ports.ts @@ -3,6 +3,8 @@ */ import { ApprovalDecisionSchema, STATE_SCOPE } from '../../approval-gate/schemas.js'; +import { readSettings } from '../../approval-gate/settings/store.js'; +import type { ApprovalSettings } from '../../approval-gate/settings/types.js'; import type { ISdk } from '../../runtime/iii.js'; import type { z } from 'zod'; export type ApprovalDecision = z.infer; @@ -15,6 +17,7 @@ export function parseApprovalDecision(value: unknown): ApprovalDecision | null { export type AwaitingApprovalPorts = { readDecision(session_id: string, function_call_id: string): Promise; + readSettings(session_id: string): Promise; }; export function createAwaitingApprovalPorts(iii: ISdk): AwaitingApprovalPorts { @@ -27,5 +30,8 @@ export function createAwaitingApprovalPorts(iii: ISdk): AwaitingApprovalPorts { }); return parseApprovalDecision(raw); }, + readSettings(session_id) { + return readSettings(iii, session_id); + }, }; } diff --git a/harness/src/turn-orchestrator/function-awaiting-approval/run.ts b/harness/src/turn-orchestrator/function-awaiting-approval/run.ts index ebd5aabc..f1646cf8 100644 --- a/harness/src/turn-orchestrator/function-awaiting-approval/run.ts +++ b/harness/src/turn-orchestrator/function-awaiting-approval/run.ts @@ -2,6 +2,7 @@ * Resolve approval decisions and route the batch after each decision. */ +import { settingsVerdict } from '../../approval-gate/settings/verdict.js'; import { text } from '../../types/content.js'; import type { FunctionResult } from '../../types/function.js'; import { finalizeBatch, runOneCall } from '../function-execute/run.js'; @@ -51,6 +52,11 @@ export async function processResolvedApprovals( const work = rec.work; let awaiting = [...rec.awaiting_approval]; const executed = { ...work.executed }; + // Lazily snapshotted once per wake: a grant made AFTER a call parked + // (e.g. "approve always" on a sibling of the same function id, or a + // switch to auto/full mode) must release the still-parked calls it now + // covers — otherwise the batch never finalizes and the turn hangs. + let settings: Awaited> | null = null; for (const entry of [...awaiting]) { const callId = entry.function_call_id; @@ -60,7 +66,13 @@ export async function processResolvedApprovals( continue; } - const decision = await readPorts.readDecision(rec.session_id, callId); + let decision = await readPorts.readDecision(rec.session_id, callId); + if (!decision) { + if (settings === null) settings = await readPorts.readSettings(rec.session_id); + if (settingsVerdict(settings, entry.function_id) === 'allow') { + decision = { decision: 'allow', reason: null }; + } + } if (!decision) continue; const current = work.prepared.find((p) => p.call.id === callId)!; diff --git a/harness/src/turn-orchestrator/hook.ts b/harness/src/turn-orchestrator/hook.ts index 416d14fb..9421c563 100644 --- a/harness/src/turn-orchestrator/hook.ts +++ b/harness/src/turn-orchestrator/hook.ts @@ -1,11 +1,29 @@ /** - * Approval consultation. Calls `policy::check_permissions` directly and maps - * the reply to allow / deny / pending. Fail-closed on transport errors: - * unreachable policy → deny with `gate_unavailable`. + * Approval consultation. Resolves the approval verdict for one agent + * function call. Evaluation order (race-safe via a single settings + * snapshot per call): + * + * 1. Deny if the agent is trying to invoke a human-only approval + * function (`approval::set_mode`, `approval::add_always_allow`, + * etc.) — self-escalation defense. + * 2. Snapshot per-session approval settings. + * 3. `mode === 'full'` → allow. + * 4. `mode === 'auto'` and `function_id` is in the user's curated + * `always_allow` list → allow. In any other mode the list is + * dormant — the user can build it up but it only takes effect + * under Auto. + * 5. Fall through to `policy::check_permissions` (yaml rules) — the + * pre-existing behavior. + * + * Fail-closed on transport errors: unreachable policy → deny with + * `gate_unavailable`. */ import { permissionsDenyEnvelope } from '../approval-gate/denial.js'; import { DENIAL_SCHEMA_VERSION, type DenialEnvelope } from '../approval-gate/schemas.js'; +import { isHumanOnlyApprovalFunction } from '../approval-gate/settings/human-only.js'; +import { readSettings } from '../approval-gate/settings/store.js'; +import { settingsVerdict } from '../approval-gate/settings/verdict.js'; import type { CheckPermissionsPayload, PolicyCheckReply, @@ -33,7 +51,33 @@ export function gateUnavailableEnvelope(function_id: string, reason: string): De }; } +function humanOnlyDenial(function_id: string, args: unknown): DenialEnvelope { + return permissionsDenyEnvelope(function_id, 'human_only_function', null, args); +} + +function extractSessionId(args: unknown): string | null { + if (args && typeof args === 'object' && !Array.isArray(args)) { + const candidate = (args as Record).session_id; + if (typeof candidate === 'string' && candidate.length > 0) return candidate; + } + return null; +} + export async function consultBefore(iii: ISdk, function_call: FunctionCall): Promise { + if (isHumanOnlyApprovalFunction(function_call.function_id)) { + return { + kind: 'deny', + denial: humanOnlyDenial(function_call.function_id, function_call.arguments), + }; + } + + const session_id = extractSessionId(function_call.arguments); + const settings = session_id ? await readSettings(iii, session_id) : null; + + if (settings && settingsVerdict(settings, function_call.function_id) === 'allow') { + return { kind: 'allow' }; + } + try { const reply = await iii.trigger({ function_id: 'policy::check_permissions', diff --git a/harness/tests/approval-gate/settings.test.ts b/harness/tests/approval-gate/settings.test.ts new file mode 100644 index 00000000..a788bffa --- /dev/null +++ b/harness/tests/approval-gate/settings.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { ISdk } from '../../src/runtime/iii.js'; +import { isHumanOnlyApprovalFunction } from '../../src/approval-gate/settings/human-only.js'; +import { addAlwaysAllow } from '../../src/approval-gate/settings/add-always-allow.js'; +import { approveAlways } from '../../src/approval-gate/settings/approve-always.js'; +import { removeAlwaysAllow } from '../../src/approval-gate/settings/remove-always-allow.js'; +import { setMode } from '../../src/approval-gate/settings/set-mode.js'; +import { readSettings } from '../../src/approval-gate/settings/store.js'; +import { SETTINGS_STATE_SCOPE } from '../../src/approval-gate/schemas.js'; + +interface TriggerCall { + function_id: string; + payload: { scope: string; key: string; value?: unknown }; +} + +function makeIii(initial: unknown = null) { + const store = new Map(); + if (initial !== null) store.set('sess-1', initial); + const calls: TriggerCall[] = []; + const trigger = vi.fn(async (req: TriggerCall) => { + calls.push(req); + if (req.function_id === 'state::get') { + return store.get(req.payload.key) ?? null; + } + if (req.function_id === 'state::set') { + if (req.payload.value === null) store.delete(req.payload.key); + else store.set(req.payload.key, req.payload.value); + return null; + } + throw new Error(`unexpected trigger ${req.function_id}`); + }); + return { iii: { trigger } as unknown as ISdk, calls, store }; +} + +describe('approval-gate settings', () => { + it('readSettings returns defaults when nothing persisted', async () => { + const { iii } = makeIii(); + const s = await readSettings(iii, 'sess-1'); + expect(s.mode).toBe('manual'); + expect(s.always_allow).toEqual([]); + expect(s.mode_set_at).toBe(0); + }); + + it('setMode persists with mode_set_at > 0', async () => { + const { iii, store } = makeIii(); + const result = await setMode(iii, 'sess-1', 'auto'); + expect(result.mode).toBe('auto'); + expect(result.mode_set_at).toBeGreaterThan(0); + expect(store.get('sess-1')).toMatchObject({ mode: 'auto' }); + }); + + it('addAlwaysAllow is idempotent on function_id', async () => { + const { iii } = makeIii(); + const once = await addAlwaysAllow(iii, 'sess-1', 'shell::fs::ls'); + expect(once.always_allow).toHaveLength(1); + const twice = await addAlwaysAllow(iii, 'sess-1', 'shell::fs::ls'); + expect(twice.always_allow).toHaveLength(1); + expect(twice.always_allow[0].granted_by).toBe('user_click'); + }); + + it('removeAlwaysAllow strips matching entries', async () => { + const { iii } = makeIii(); + await addAlwaysAllow(iii, 'sess-1', 'shell::fs::ls'); + await addAlwaysAllow(iii, 'sess-1', 'fs::read'); + const next = await removeAlwaysAllow(iii, 'sess-1', 'shell::fs::ls'); + expect(next.always_allow.map((e) => e.function_id)).toEqual(['fs::read']); + }); + + it('approveAlways appends to approved_always (idempotent), separate from always_allow', async () => { + const { iii } = makeIii(); + const once = await approveAlways(iii, 'sess-1', 'shell::exec'); + expect(once.approved_always.map((e) => e.function_id)).toEqual([ + 'shell::exec', + ]); + expect(once.always_allow).toEqual([]); + const twice = await approveAlways(iii, 'sess-1', 'shell::exec'); + expect(twice.approved_always).toHaveLength(1); + expect(twice.approved_always[0].granted_by).toBe('user_click'); + }); + + it('approveAlways and addAlwaysAllow write to independent lists', async () => { + const { iii } = makeIii(); + await addAlwaysAllow(iii, 'sess-1', 'shell::fs::ls'); + const after = await approveAlways(iii, 'sess-1', 'shell::exec'); + expect(after.always_allow.map((e) => e.function_id)).toEqual([ + 'shell::fs::ls', + ]); + expect(after.approved_always.map((e) => e.function_id)).toEqual([ + 'shell::exec', + ]); + }); + + it('writes go to the SETTINGS_STATE_SCOPE keyed by session_id', async () => { + const { iii, calls } = makeIii(); + await setMode(iii, 'sess-1', 'full'); + const write = calls.find((c) => c.function_id === 'state::set'); + expect(write?.payload.scope).toBe(SETTINGS_STATE_SCOPE); + expect(write?.payload.key).toBe('sess-1'); + }); + + it('isHumanOnlyApprovalFunction catches every settings handler id', () => { + expect(isHumanOnlyApprovalFunction('approval::set_mode')).toBe(true); + expect(isHumanOnlyApprovalFunction('approval::add_always_allow')).toBe(true); + expect(isHumanOnlyApprovalFunction('approval::remove_always_allow')).toBe( + true, + ); + expect(isHumanOnlyApprovalFunction('approval::approve_always')).toBe(true); + expect(isHumanOnlyApprovalFunction('approval::get_settings')).toBe(true); + expect(isHumanOnlyApprovalFunction('approval::clear_settings')).toBe(true); + }); + + it('isHumanOnlyApprovalFunction does NOT block approval::resolve', () => { + expect(isHumanOnlyApprovalFunction('approval::resolve')).toBe(false); + expect(isHumanOnlyApprovalFunction('shell::exec')).toBe(false); + }); +}); diff --git a/harness/tests/integration/parallel-approval.e2e.test.ts b/harness/tests/integration/parallel-approval.e2e.test.ts index a13c5435..ab5e2084 100644 --- a/harness/tests/integration/parallel-approval.e2e.test.ts +++ b/harness/tests/integration/parallel-approval.e2e.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; +import { approveAlways } from '../../src/approval-gate/settings/approve-always.js'; import * as agentTriggerModule from '../../src/turn-orchestrator/agent-trigger.js'; import { createParallelApprovalHarness, @@ -154,6 +155,42 @@ describe('parallel approval e2e', () => { ); }); + it('releases a parked sibling of the same function when "approve always" is granted', async () => { + const h = createParallelApprovalHarness(); + vi.spyOn(agentTriggerModule, 'dispatchWithHook') + .mockResolvedValueOnce({ kind: 'pending' }) + .mockResolvedValueOnce({ kind: 'pending' }); + + h.seedExecute( + 'sess-grant', + makeAssistantWithCalls([ + { id: 'fc-1', functionId: 'shell::run' }, + { id: 'fc-2', functionId: 'shell::run' }, + ]), + ); + await h.runExecute('sess-grant'); + + expect(h.loadTurnRecord('sess-grant')?.awaiting_approval?.map((e) => e.function_call_id)).toEqual( + ['fc-1', 'fc-2'], + ); + + // "Approve always" on fc-1: persist the per-session grant for the + // function id, then resolve only the clicked call (mirrors the UI's + // handleAlwaysAllow). The grant must release the still-parked sibling + // fc-2, which shares the function id, on the same wake. + await approveAlways(h.iii, 'sess-grant', 'shell::run'); + await h.resolveApproval('sess-grant', 'fc-1', 'allow'); + + const rec = h.loadTurnRecord('sess-grant'); + expect(rec?.awaiting_approval).toEqual([]); + expect(rec?.state).toBe('steering_check'); + // Batch finalized: work cleared, both calls produced results, and the + // sibling fc-2 ran without its own explicit approval::resolve. + expect(rec?.work).toBeUndefined(); + expect(rec?.function_results?.map((r) => r.function_call_id).sort()).toEqual(['fc-1', 'fc-2']); + expect(executionEvents(h.emitted, 'function_execution_end', 'fc-2')).toHaveLength(1); + }); + it('persists the decision and wakes function_awaiting_approval via approval::resolve', async () => { const h = createParallelApprovalHarness(); vi.spyOn(agentTriggerModule, 'dispatchWithHook').mockResolvedValueOnce({ kind: 'pending' }); diff --git a/harness/tests/turn-orchestrator/hook.test.ts b/harness/tests/turn-orchestrator/hook.test.ts index 9829dd5d..dd913977 100644 --- a/harness/tests/turn-orchestrator/hook.test.ts +++ b/harness/tests/turn-orchestrator/hook.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; +import type { ApprovalSettings } from '../../src/approval-gate/schemas.js'; import type { CheckPermissionsPayload, PolicyCheckReply, @@ -19,6 +20,39 @@ function fakeIii( } as unknown as ISdk; } +/** Build an ApprovalSettings with sane defaults; override per test. */ +function mkSettings(overrides: Partial = {}): ApprovalSettings { + return { + mode: 'manual', + always_allow: [], + approved_always: [], + mode_set_at: 1, + ...overrides, + }; +} + +function allowEntry(function_id: string) { + return { function_id, granted_at: 1, granted_by: 'user_click' as const }; +} + +/** Build an iii fake that returns approval_settings for `state::get` calls and routes policy checks through the supplied impl. */ +function modedIii( + settings: ApprovalSettings | null, + policyImpl?: () => PolicyCheckReply | Promise, +) { + return { + trigger: vi.fn(async (req: { function_id: string; payload: unknown }) => { + if (req.function_id === 'state::get') { + return settings; + } + if (req.function_id === 'policy::check_permissions') { + return policyImpl ? await policyImpl() : { decision: 'needs_approval' }; + } + throw new Error(`unexpected trigger ${req.function_id}`); + }), + } as unknown as ISdk; +} + describe('consultBefore (direct policy call)', () => { const fc = { id: 'fc-1', function_id: 'shell::fs::write', arguments: { path: '/tmp/x' } }; @@ -71,3 +105,137 @@ describe('consultBefore (direct policy call)', () => { expect(trigger.mock.calls[0][0].function_id).toBe('policy::check_permissions'); }); }); + +describe('consultBefore (mode + always_allow)', () => { + const fcWithSession = { + id: 'fc-1', + function_id: 'shell::fs::write', + arguments: { path: '/tmp/x', session_id: 'sess-abc' }, + }; + const fcSafeWithSession = { + id: 'fc-2', + function_id: 'shell::fs::read', + arguments: { path: '/tmp/x', session_id: 'sess-abc' }, + }; + + it('full mode short-circuits to allow without calling policy', async () => { + const iii = modedIii(mkSettings({ mode: 'full' })); + const outcome = await consultBefore(iii, fcWithSession); + expect(outcome.kind).toBe('allow'); + const calls = (iii.trigger as ReturnType).mock.calls; + expect(calls.map((c) => c[0].function_id)).not.toContain( + 'policy::check_permissions', + ); + }); + + it('auto mode + allowlist hit short-circuits to allow even when policy would deny', async () => { + const iii = modedIii( + mkSettings({ + mode: 'auto', + always_allow: [allowEntry('shell::fs::write')], + }), + () => ({ + decision: 'deny', + rule_id: 'would-deny', + rule_action: 'deny', + }), + ); + const outcome = await consultBefore(iii, fcWithSession); + expect(outcome.kind).toBe('allow'); + const calls = (iii.trigger as ReturnType).mock.calls; + expect(calls.map((c) => c[0].function_id)).not.toContain( + 'policy::check_permissions', + ); + }); + + it('manual mode treats the allowlist as dormant (does NOT short-circuit)', async () => { + const iii = modedIii( + mkSettings({ + mode: 'manual', + always_allow: [allowEntry('shell::fs::write')], + }), + () => ({ decision: 'needs_approval' }), + ); + const outcome = await consultBefore(iii, fcWithSession); + expect(outcome.kind).toBe('pending'); + const calls = (iii.trigger as ReturnType).mock.calls; + expect(calls.map((c) => c[0].function_id)).toContain( + 'policy::check_permissions', + ); + }); + + it('auto mode falls through to policy when function id is not on the allowlist', async () => { + const iii = modedIii(mkSettings({ mode: 'auto' }), () => ({ + decision: 'needs_approval', + })); + const outcome = await consultBefore(iii, fcSafeWithSession); + expect(outcome.kind).toBe('pending'); + }); + + it('approved_always short-circuits in MANUAL mode (per-session grant honored everywhere)', async () => { + const iii = modedIii( + mkSettings({ + mode: 'manual', + approved_always: [allowEntry('shell::fs::write')], + }), + () => ({ decision: 'needs_approval' }), + ); + const outcome = await consultBefore(iii, fcWithSession); + expect(outcome.kind).toBe('allow'); + const calls = (iii.trigger as ReturnType).mock.calls; + expect(calls.map((c) => c[0].function_id)).not.toContain( + 'policy::check_permissions', + ); + }); + + it('approved_always short-circuits in AUTO mode too', async () => { + const iii = modedIii( + mkSettings({ + mode: 'auto', + approved_always: [allowEntry('shell::fs::write')], + }), + () => ({ decision: 'deny', rule_id: 'would-deny', rule_action: 'deny' }), + ); + const outcome = await consultBefore(iii, fcWithSession); + expect(outcome.kind).toBe('allow'); + }); + + it('approved_always does not affect a different function id', async () => { + const iii = modedIii( + mkSettings({ + mode: 'manual', + approved_always: [allowEntry('shell::fs::read')], + }), + () => ({ decision: 'needs_approval' }), + ); + const outcome = await consultBefore(iii, fcWithSession); + expect(outcome.kind).toBe('pending'); + }); + + it('denies human-only approval functions (self-escalation defense)', async () => { + const iii = modedIii(null); + const outcome = await consultBefore(iii, { + id: 'fc-escalate', + function_id: 'approval::set_mode', + arguments: { session_id: 'sess-abc', mode: 'full' }, + }); + expect(outcome.kind).toBe('deny'); + if (outcome.kind !== 'deny') return; + expect(outcome.denial.denied_by).toBe('permissions'); + expect(outcome.denial.rule_id).toBe('human_only_function'); + // No state::get and no policy::check_permissions — denial is upfront. + const calls = (iii.trigger as ReturnType).mock.calls; + expect(calls.length).toBe(0); + }); + + it('falls back to policy when settings are absent (legacy session)', async () => { + const iii = modedIii(null, () => ({ + decision: 'allow', + rule_id: 'r', + })); + const outcome = await consultBefore(iii, fcWithSession); + expect(outcome.kind).toBe('allow'); + const calls = (iii.trigger as ReturnType).mock.calls; + expect(calls.map((c) => c[0].function_id)).toContain('policy::check_permissions'); + }); +}); From 35cf826728f93564b976767a9603cd1c7967b68a Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Thu, 28 May 2026 07:26:21 -0300 Subject: [PATCH 2/3] test(approval): e2e coverage for mode-driven approval through the orchestrator Adds an integration suite that runs the real consultBefore (not mocked) against seeded per-session approval_settings, exercising the full ordering end-to-end via function_execute: - full mode executes immediately, no parking, no approvals write - approved_always honored in manual mode (executes without parking) - auto + always_allow hit executes without parking - manual with no grant parks (falls through to policy needs_approval) - always_allow dormant in manual (still parks) - auto parks a call not on the allowlist - agent attempt to call a human-only approval fn is denied (self-escalation) Harness gains a realistic policy::check_permissions default (needs_approval) so the fall-through path is exercised; existing parallel-approval tests are unaffected (they mock dispatchWithHook). --- .../integration/mode-approval.e2e.test.ts | 178 ++++++++++++++++++ .../integration/parallel-approval-harness.ts | 8 + 2 files changed, 186 insertions(+) create mode 100644 harness/tests/integration/mode-approval.e2e.test.ts diff --git a/harness/tests/integration/mode-approval.e2e.test.ts b/harness/tests/integration/mode-approval.e2e.test.ts new file mode 100644 index 00000000..2eae2426 --- /dev/null +++ b/harness/tests/integration/mode-approval.e2e.test.ts @@ -0,0 +1,178 @@ +/** + * Mode-driven approval e2e. Unlike the parallel-approval suite, these + * tests do NOT mock `dispatchWithHook` — they run the real `consultBefore` + * against per-session `approval_settings` seeded into the harness store, + * exercising the full ordering (full > approved_always > always_allow > + * yaml policy) through the orchestrator's function_execute path. + */ + +import { describe, expect, it } from 'vitest'; +import { SETTINGS_STATE_SCOPE } from '../../src/approval-gate/schemas.js'; +import { + createParallelApprovalHarness, + executionEvents, + makeAssistantWithCalls, + type ParallelApprovalHarness, +} from './parallel-approval-harness.js'; + +type PermissionMode = 'manual' | 'auto' | 'full'; + +interface SeedSettings { + mode?: PermissionMode; + always_allow?: string[]; + approved_always?: string[]; +} + +function entry(function_id: string) { + return { function_id, granted_at: 1, granted_by: 'user_click' as const }; +} + +/** Persist approval_settings into the harness store before runExecute. */ +function seedSettings( + h: ParallelApprovalHarness, + session_id: string, + s: SeedSettings, +): void { + h.stateStore.set(`${SETTINGS_STATE_SCOPE}/${session_id}`, { + mode: s.mode ?? 'manual', + always_allow: (s.always_allow ?? []).map(entry), + approved_always: (s.approved_always ?? []).map(entry), + mode_set_at: s.mode ? 1 : 0, + }); +} + +function awaitingIds(h: ParallelApprovalHarness, session_id: string): string[] { + return ( + h.loadTurnRecord(session_id)?.awaiting_approval?.map((e) => e.function_call_id) ?? [] + ); +} + +describe('mode-driven approval e2e (real consultBefore)', () => { + it('full mode executes the call immediately without parking or writing an approval', async () => { + const h = createParallelApprovalHarness(); + seedSettings(h, 'sess-full', { mode: 'full' }); + h.seedExecute( + 'sess-full', + makeAssistantWithCalls([{ id: 'fc-1', functionId: 'shell::run' }]), + ); + + await h.runExecute('sess-full'); + + const rec = h.loadTurnRecord('sess-full'); + expect(rec?.state).toBe('steering_check'); + expect(rec?.awaiting_approval).toEqual([]); + expect(rec?.work).toBeUndefined(); + expect(h.stateStore.has('approvals/sess-full/fc-1')).toBe(false); + expect(executionEvents(h.emitted, 'function_execution_end', 'fc-1')).toHaveLength(1); + }); + + it('approved_always honors the grant in MANUAL mode (executes without parking)', async () => { + const h = createParallelApprovalHarness(); + seedSettings(h, 'sess-grant', { + mode: 'manual', + approved_always: ['shell::run'], + }); + h.seedExecute( + 'sess-grant', + makeAssistantWithCalls([{ id: 'fc-1', functionId: 'shell::run' }]), + ); + + await h.runExecute('sess-grant'); + + const rec = h.loadTurnRecord('sess-grant'); + expect(rec?.state).toBe('steering_check'); + expect(rec?.awaiting_approval).toEqual([]); + expect(executionEvents(h.emitted, 'function_execution_end', 'fc-1')).toHaveLength(1); + }); + + it('auto mode + always_allow hit executes without parking', async () => { + const h = createParallelApprovalHarness(); + seedSettings(h, 'sess-auto', { + mode: 'auto', + always_allow: ['shell::run'], + }); + h.seedExecute( + 'sess-auto', + makeAssistantWithCalls([{ id: 'fc-1', functionId: 'shell::run' }]), + ); + + await h.runExecute('sess-auto'); + + expect(awaitingIds(h, 'sess-auto')).toEqual([]); + expect(h.loadTurnRecord('sess-auto')?.state).toBe('steering_check'); + }); + + it('manual mode with no grant parks the call (falls through to policy → needs_approval)', async () => { + const h = createParallelApprovalHarness(); + seedSettings(h, 'sess-manual', { mode: 'manual' }); + h.seedExecute( + 'sess-manual', + makeAssistantWithCalls([{ id: 'fc-1', functionId: 'shell::run' }]), + ); + + await h.runExecute('sess-manual'); + + expect(h.loadTurnRecord('sess-manual')?.state).toBe('function_awaiting_approval'); + expect(awaitingIds(h, 'sess-manual')).toEqual(['fc-1']); + }); + + it('always_allow is DORMANT in manual mode — the call still parks', async () => { + const h = createParallelApprovalHarness(); + seedSettings(h, 'sess-dormant', { + mode: 'manual', + always_allow: ['shell::run'], + }); + h.seedExecute( + 'sess-dormant', + makeAssistantWithCalls([{ id: 'fc-1', functionId: 'shell::run' }]), + ); + + await h.runExecute('sess-dormant'); + + expect(h.loadTurnRecord('sess-dormant')?.state).toBe('function_awaiting_approval'); + expect(awaitingIds(h, 'sess-dormant')).toEqual(['fc-1']); + }); + + it('auto mode parks a call whose id is not on the allowlist', async () => { + const h = createParallelApprovalHarness(); + seedSettings(h, 'sess-miss', { + mode: 'auto', + always_allow: ['other::fn'], + }); + h.seedExecute( + 'sess-miss', + makeAssistantWithCalls([{ id: 'fc-1', functionId: 'shell::run' }]), + ); + + await h.runExecute('sess-miss'); + + expect(awaitingIds(h, 'sess-miss')).toEqual(['fc-1']); + }); + + it('denies an agent attempt to call a human-only approval function (self-escalation)', async () => { + const h = createParallelApprovalHarness(); + seedSettings(h, 'sess-escalate', { mode: 'manual' }); + h.seedExecute( + 'sess-escalate', + makeAssistantWithCalls([ + { id: 'fc-1', functionId: 'approval::set_mode', payload: { mode: 'full' } }, + ]), + ); + + await h.runExecute('sess-escalate'); + + const rec = h.loadTurnRecord('sess-escalate'); + // Denied up front: not parked, not executed, batch finalizes with an + // error result carrying the human_only_function denial. + expect(rec?.awaiting_approval).toEqual([]); + expect(rec?.state).toBe('steering_check'); + const denied = rec?.function_results?.find((r) => r.function_call_id === 'fc-1'); + expect(denied?.is_error).toBe(true); + expect(JSON.stringify(denied?.details)).toContain('human_only_function'); + // No real mode change leaked into settings. + const settings = h.stateStore.get(`${SETTINGS_STATE_SCOPE}/sess-escalate`) as { + mode: string; + }; + expect(settings.mode).toBe('manual'); + }); +}); diff --git a/harness/tests/integration/parallel-approval-harness.ts b/harness/tests/integration/parallel-approval-harness.ts index 2fa5e5cc..75c91ade 100644 --- a/harness/tests/integration/parallel-approval-harness.ts +++ b/harness/tests/integration/parallel-approval-harness.ts @@ -144,6 +144,14 @@ export function createParallelApprovalHarness(): ParallelApprovalHarness { }; } + // Realistic default for the real `consultBefore` fall-through: no + // yaml rule matches → needs_approval. Mode/allowlist short-circuits + // happen before this in consultBefore, so tests that seed + // approval_settings exercise those paths without reaching here. + if (function_id === 'policy::check_permissions') { + return { decision: 'needs_approval' }; + } + if (function_id.startsWith('turn::') && action != null) { const p = payload as { session_id: string }; await runTurnStep(iii as unknown as ISdk, function_id, p.session_id); From 3ad73e415963c2077790c5f6eed051f2fe4591f2 Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Thu, 28 May 2026 09:54:25 -0300 Subject: [PATCH 3/3] style(approval): apply biome 2.4.10 formatting to new files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the harness node lint CI step — the new settings/test files were hand-written without biome 2.4.10 and had formatting diffs. No behavior change. --- harness/src/approval-gate/settings/types.ts | 1 - harness/src/approval-gate/settings/verdict.ts | 10 ++----- harness/tests/approval-gate/settings.test.ts | 16 +++------- .../integration/mode-approval.e2e.test.ts | 30 ++++--------------- .../integration/parallel-approval.e2e.test.ts | 6 ++-- harness/tests/turn-orchestrator/hook.test.ts | 16 +++------- 6 files changed, 19 insertions(+), 60 deletions(-) diff --git a/harness/src/approval-gate/settings/types.ts b/harness/src/approval-gate/settings/types.ts index 063faa1f..f0cc442c 100644 --- a/harness/src/approval-gate/settings/types.ts +++ b/harness/src/approval-gate/settings/types.ts @@ -6,7 +6,6 @@ export type { ApprovalSettings, PermissionMode } from '../schemas.js'; export type GetSettingsReply = ApprovalSettings; export type MutationReply = { ok: true } | { ok: false; error: string }; - export const sessionIdField = z .string() .min(1) diff --git a/harness/src/approval-gate/settings/verdict.ts b/harness/src/approval-gate/settings/verdict.ts index 64d08dd0..b3f7ca44 100644 --- a/harness/src/approval-gate/settings/verdict.ts +++ b/harness/src/approval-gate/settings/verdict.ts @@ -16,17 +16,11 @@ import type { ApprovalSettings } from './types.js'; -function listed( - entries: ApprovalSettings['always_allow'], - function_id: string, -): boolean { +function listed(entries: ApprovalSettings['always_allow'], function_id: string): boolean { return entries.some((entry) => entry.function_id === function_id); } -export function settingsVerdict( - settings: ApprovalSettings, - function_id: string, -): 'allow' | null { +export function settingsVerdict(settings: ApprovalSettings, function_id: string): 'allow' | null { if (settings.mode === 'full') return 'allow'; if (listed(settings.approved_always, function_id)) return 'allow'; if (settings.mode === 'auto' && listed(settings.always_allow, function_id)) { diff --git a/harness/tests/approval-gate/settings.test.ts b/harness/tests/approval-gate/settings.test.ts index a788bffa..e8b55c61 100644 --- a/harness/tests/approval-gate/settings.test.ts +++ b/harness/tests/approval-gate/settings.test.ts @@ -69,9 +69,7 @@ describe('approval-gate settings', () => { it('approveAlways appends to approved_always (idempotent), separate from always_allow', async () => { const { iii } = makeIii(); const once = await approveAlways(iii, 'sess-1', 'shell::exec'); - expect(once.approved_always.map((e) => e.function_id)).toEqual([ - 'shell::exec', - ]); + expect(once.approved_always.map((e) => e.function_id)).toEqual(['shell::exec']); expect(once.always_allow).toEqual([]); const twice = await approveAlways(iii, 'sess-1', 'shell::exec'); expect(twice.approved_always).toHaveLength(1); @@ -82,12 +80,8 @@ describe('approval-gate settings', () => { const { iii } = makeIii(); await addAlwaysAllow(iii, 'sess-1', 'shell::fs::ls'); const after = await approveAlways(iii, 'sess-1', 'shell::exec'); - expect(after.always_allow.map((e) => e.function_id)).toEqual([ - 'shell::fs::ls', - ]); - expect(after.approved_always.map((e) => e.function_id)).toEqual([ - 'shell::exec', - ]); + expect(after.always_allow.map((e) => e.function_id)).toEqual(['shell::fs::ls']); + expect(after.approved_always.map((e) => e.function_id)).toEqual(['shell::exec']); }); it('writes go to the SETTINGS_STATE_SCOPE keyed by session_id', async () => { @@ -101,9 +95,7 @@ describe('approval-gate settings', () => { it('isHumanOnlyApprovalFunction catches every settings handler id', () => { expect(isHumanOnlyApprovalFunction('approval::set_mode')).toBe(true); expect(isHumanOnlyApprovalFunction('approval::add_always_allow')).toBe(true); - expect(isHumanOnlyApprovalFunction('approval::remove_always_allow')).toBe( - true, - ); + expect(isHumanOnlyApprovalFunction('approval::remove_always_allow')).toBe(true); expect(isHumanOnlyApprovalFunction('approval::approve_always')).toBe(true); expect(isHumanOnlyApprovalFunction('approval::get_settings')).toBe(true); expect(isHumanOnlyApprovalFunction('approval::clear_settings')).toBe(true); diff --git a/harness/tests/integration/mode-approval.e2e.test.ts b/harness/tests/integration/mode-approval.e2e.test.ts index 2eae2426..3a4801bd 100644 --- a/harness/tests/integration/mode-approval.e2e.test.ts +++ b/harness/tests/integration/mode-approval.e2e.test.ts @@ -28,11 +28,7 @@ function entry(function_id: string) { } /** Persist approval_settings into the harness store before runExecute. */ -function seedSettings( - h: ParallelApprovalHarness, - session_id: string, - s: SeedSettings, -): void { +function seedSettings(h: ParallelApprovalHarness, session_id: string, s: SeedSettings): void { h.stateStore.set(`${SETTINGS_STATE_SCOPE}/${session_id}`, { mode: s.mode ?? 'manual', always_allow: (s.always_allow ?? []).map(entry), @@ -42,19 +38,14 @@ function seedSettings( } function awaitingIds(h: ParallelApprovalHarness, session_id: string): string[] { - return ( - h.loadTurnRecord(session_id)?.awaiting_approval?.map((e) => e.function_call_id) ?? [] - ); + return h.loadTurnRecord(session_id)?.awaiting_approval?.map((e) => e.function_call_id) ?? []; } describe('mode-driven approval e2e (real consultBefore)', () => { it('full mode executes the call immediately without parking or writing an approval', async () => { const h = createParallelApprovalHarness(); seedSettings(h, 'sess-full', { mode: 'full' }); - h.seedExecute( - 'sess-full', - makeAssistantWithCalls([{ id: 'fc-1', functionId: 'shell::run' }]), - ); + h.seedExecute('sess-full', makeAssistantWithCalls([{ id: 'fc-1', functionId: 'shell::run' }])); await h.runExecute('sess-full'); @@ -72,10 +63,7 @@ describe('mode-driven approval e2e (real consultBefore)', () => { mode: 'manual', approved_always: ['shell::run'], }); - h.seedExecute( - 'sess-grant', - makeAssistantWithCalls([{ id: 'fc-1', functionId: 'shell::run' }]), - ); + h.seedExecute('sess-grant', makeAssistantWithCalls([{ id: 'fc-1', functionId: 'shell::run' }])); await h.runExecute('sess-grant'); @@ -91,10 +79,7 @@ describe('mode-driven approval e2e (real consultBefore)', () => { mode: 'auto', always_allow: ['shell::run'], }); - h.seedExecute( - 'sess-auto', - makeAssistantWithCalls([{ id: 'fc-1', functionId: 'shell::run' }]), - ); + h.seedExecute('sess-auto', makeAssistantWithCalls([{ id: 'fc-1', functionId: 'shell::run' }])); await h.runExecute('sess-auto'); @@ -139,10 +124,7 @@ describe('mode-driven approval e2e (real consultBefore)', () => { mode: 'auto', always_allow: ['other::fn'], }); - h.seedExecute( - 'sess-miss', - makeAssistantWithCalls([{ id: 'fc-1', functionId: 'shell::run' }]), - ); + h.seedExecute('sess-miss', makeAssistantWithCalls([{ id: 'fc-1', functionId: 'shell::run' }])); await h.runExecute('sess-miss'); diff --git a/harness/tests/integration/parallel-approval.e2e.test.ts b/harness/tests/integration/parallel-approval.e2e.test.ts index ab5e2084..47150670 100644 --- a/harness/tests/integration/parallel-approval.e2e.test.ts +++ b/harness/tests/integration/parallel-approval.e2e.test.ts @@ -170,9 +170,9 @@ describe('parallel approval e2e', () => { ); await h.runExecute('sess-grant'); - expect(h.loadTurnRecord('sess-grant')?.awaiting_approval?.map((e) => e.function_call_id)).toEqual( - ['fc-1', 'fc-2'], - ); + expect( + h.loadTurnRecord('sess-grant')?.awaiting_approval?.map((e) => e.function_call_id), + ).toEqual(['fc-1', 'fc-2']); // "Approve always" on fc-1: persist the per-session grant for the // function id, then resolve only the clicked call (mirrors the UI's diff --git a/harness/tests/turn-orchestrator/hook.test.ts b/harness/tests/turn-orchestrator/hook.test.ts index dd913977..c02052ca 100644 --- a/harness/tests/turn-orchestrator/hook.test.ts +++ b/harness/tests/turn-orchestrator/hook.test.ts @@ -123,9 +123,7 @@ describe('consultBefore (mode + always_allow)', () => { const outcome = await consultBefore(iii, fcWithSession); expect(outcome.kind).toBe('allow'); const calls = (iii.trigger as ReturnType).mock.calls; - expect(calls.map((c) => c[0].function_id)).not.toContain( - 'policy::check_permissions', - ); + expect(calls.map((c) => c[0].function_id)).not.toContain('policy::check_permissions'); }); it('auto mode + allowlist hit short-circuits to allow even when policy would deny', async () => { @@ -143,9 +141,7 @@ describe('consultBefore (mode + always_allow)', () => { const outcome = await consultBefore(iii, fcWithSession); expect(outcome.kind).toBe('allow'); const calls = (iii.trigger as ReturnType).mock.calls; - expect(calls.map((c) => c[0].function_id)).not.toContain( - 'policy::check_permissions', - ); + expect(calls.map((c) => c[0].function_id)).not.toContain('policy::check_permissions'); }); it('manual mode treats the allowlist as dormant (does NOT short-circuit)', async () => { @@ -159,9 +155,7 @@ describe('consultBefore (mode + always_allow)', () => { const outcome = await consultBefore(iii, fcWithSession); expect(outcome.kind).toBe('pending'); const calls = (iii.trigger as ReturnType).mock.calls; - expect(calls.map((c) => c[0].function_id)).toContain( - 'policy::check_permissions', - ); + expect(calls.map((c) => c[0].function_id)).toContain('policy::check_permissions'); }); it('auto mode falls through to policy when function id is not on the allowlist', async () => { @@ -183,9 +177,7 @@ describe('consultBefore (mode + always_allow)', () => { const outcome = await consultBefore(iii, fcWithSession); expect(outcome.kind).toBe('allow'); const calls = (iii.trigger as ReturnType).mock.calls; - expect(calls.map((c) => c[0].function_id)).not.toContain( - 'policy::check_permissions', - ); + expect(calls.map((c) => c[0].function_id)).not.toContain('policy::check_permissions'); }); it('approved_always short-circuits in AUTO mode too', async () => {