feat(permission): model-requested escalation with approval-gated widening - #3012
feat(permission): model-requested escalation with approval-gated widening#3012birdie7761 wants to merge 1 commit into
Conversation
…ning Adds an opt-in mechanism for the model to request a strictly wider permission mode for a single tool call, with the user approving the request before anything executes. Motivated by the caller-side-only permission model: integrators pick a mode once, then choose between confirmation fatigue (DEFAULT/ASK everything) and dropping the safety story (BYPASS). Escalation is the third path: the agent starts locked down and requests exactly the widening it needs, with a human-auditable reason. Design follows the choreography DeepSeek's open-source harness ships in production (deepseek-harness, packages/sandbox/sandbox/src/ escalation.ts). Core pieces: - PermissionEscalation (one home shared by tool families): the closed target vocabulary read-only / workspace-write / danger-full-access, the strictly-wider ladder, argument-pairing validation (sandbox_permissions requires a non-empty string justification; either alone is a malformed ask; non-string values are rejected rather than coerced; the justification is trimmed and capped at 500 characters with a visible ellipsis before entering the approval prompt), and fail-closed resolution. Validation happens at EXECUTION time from the raw tool-call input, never baked into a tool schema - schemas are registry-global while the effective mode is per-call truth. A key explicitly mapped to null carries no escalation intent. - PermissionEngine: escalation requests resolve after deny rules (a hard integrator ceiling escalation can never override) and before everything else. Rejections deny fail-closed with model-facing reasons (the not-wider denial talks about the ladder, not about mode coverage); a valid strictly-wider request becomes ASK carrying the target and justification, riding the existing user-confirmation loop (RequireUserConfirmEvent -> confirm -> ALLOWED -> execute). The ASK decision message serves logs and tests only - the confirmation UI renders the request from the tool-call input. DONT_ASK denies: no approver, no escalation. The escalation branch evaluates every tool call that carries the arguments, not just the shell tool - only shell advertises them today (legacy non-ToolBase tools bypass permission evaluation entirely, as before). - ReActAgent: denial REASONS now reach the model. PermissionVerdict carries the decision message and the auto-denied gate is a call-id -> message map, so writeAutoDeniedResults and runToolBatch write the engine's (or tool self-check's) own denial text into the DENIED ToolResultBlock and its delta event instead of a generic "Permission denied by rules" - a model facing a malformed escalation ask learns exactly what to fix instead of retrying blindly. This equally benefits deny-rule and tool-self-check denials. Calls carrying escalation arguments engage the permission engine per call (useEngine || hasEscalationArgs(input)); every other call keeps exactly its previous evaluation path. - PermissionContextState: new escalationEnabled flag, serialized as escalation_enabled with a default of false - pre-feature JSON deserializes unchanged. The flag deliberately does NOT affect isTrivial(): enabling escalation must not flip an otherwise default-configured agent from auto-execution to ask-per-call. - HarnessAgent.Builder.permissionEscalation(boolean) (default false): merges the flag onto the effective permission context (preserving user-supplied modes and rules, call-order independent) and registers EscalatingShellExecuteTool in place of the plain shell tool - same tool name, execution body shared via ShellExecuteTool.executeAndFormat (the working-directory validation is a security check and must not drift between variants), plus the two optional escalation arguments in the schema. permissionContext(...) javadoc documents the recommended pairing with the dedicated flag. Default builds observe no change: no schema, state, or evaluation difference. Tests (TDD, compile-level red on main): 14 PermissionEscalation spec cases (ladder, pairing, type rejection, capped truncation with marker, null-valued keys, normalization, DONT_ASK, ladder-phrased denials), 8 engine integration cases (deny-rule supremacy, malformed pairing, disabled agent, no-args-unchanged evaluation, serialization round-trip + legacy JSON, posture-preserving triviality, withMode preservation), 2 end-to-end confirmation-loop cases on a trivial-context ReActAgent (plain call auto-executes after the flag is enabled; escalation call pauses, confirms, executes; hallucinated args on a disabled agent deny closed WITH the specific reason asserted in the DENIED tool result), 3 harness wiring cases (default build unchanged, flag travels onto context + schema, user context preserved on merge). Full core suite 2343/0, full harness suite 926/0.
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
|
I reviewed the proposal and the opt-in posture-preserving design is a good fit. Two details are worth making explicit before merge:\n\n1. Please add a test for approval-to-execution drift: after an approval is recorded, mutate or replay the same toolCallId with different canonical arguments and assert it cannot reuse the approval. The approval must be bound to the exact call snapshot, not only the id.\n2. Please add a concurrent duplicate-resume test asserting that only one decision wins and the tool executes at most once. This is especially important because the approval path is asynchronous and the same callback can be retried by a transport.\n\nThe current tests cover malformed pairing, disabled escalation, deny precedence and ordinary calls, but these two cases protect the runtime security boundary described in the issue. I will avoid a competing implementation and am happy to review the follow-up. |
Fixes #3011
Description
Adds an opt-in mechanism for the model to request a strictly wider permission mode for a single tool call, with the user approving before anything executes — the third path between confirmation fatigue (
DEFAULTasks for everything) and dropping the safety story (BYPASS). Design follows the choreography DeepSeek's open-source harness ships in production (deepseek-harness,escalation.ts).Core pieces:
PermissionEscalation(one home shared by tool families): the closed target vocabularyread-only/workspace-write/danger-full-access, the strictly-wider ladder, argument-pairing validation (sandbox_permissionsrequires a non-empty stringjustification; either alone is a malformed ask; non-string values are rejected rather than coerced; the justification is trimmed and capped at 500 characters with a visible ellipsis), and fail-closed resolution. Validation happens at execution time from the raw tool-call input, never baked into a tool schema — schemas are registry-global while the effective mode is per-call truth. A key explicitly mapped tonullcarries no escalation intent.PermissionEngine: escalation requests resolve after deny rules (a hard integrator ceiling escalation can never override) and before everything else. A valid strictly-wider request becomes ASK carrying the target and justification, riding the existing user-confirmation loop (RequireUserConfirmEvent→ confirm →ALLOWED→ execute).DONT_ASKdenies: no approver, no escalation. The branch evaluates every call that carries the arguments (not just the shell tool — only shell advertises them today; legacy non-ToolBasetools bypass permission evaluation entirely, as before).ReActAgent: denial reasons now reach the model —PermissionVerdictcarries the decision message and the auto-denied gate is a call-id → message map, sowriteAutoDeniedResults/runToolBatchwrite the engine's (or tool self-check's) own denial text into the DENIEDToolResultBlockand its delta event instead of a generic placeholder. A model facing a malformed escalation ask learns exactly what to fix instead of blindly retrying. Note this also improves plain deny-rule and tool-self-check denials — an intentional, deliberate behavior improvement beyond the escalation scope. Calls carrying escalation arguments engage the engine per call (useEngine || hasEscalationArgs(input)); every other call keeps exactly its previous evaluation path.PermissionContextState: newescalationEnabledflag, serialized asescalation_enabled, defaultfalse— pre-feature JSON deserializes unchanged. The flag deliberately does NOT affectisTrivial(): enabling escalation must not flip an otherwise default-configured agent from auto-execution to ask-per-call.HarnessAgent.Builder.permissionEscalation(boolean)(defaultfalse): merges the flag onto the effective permission context (preserving user-supplied modes and rules, call-order independent) and registersEscalatingShellExecuteToolin place of the plain shell tool — same tool name, execution body shared viaShellExecuteTool.executeAndFormat(the working-directory validation is a security check and must not drift between variants), plus the two optional escalation arguments in the schema.Compatibility: default builds observe no change at all — no schema, state, or evaluation difference; enabling the flag changes only calls that actually carry the escalation arguments.
Test evidence (TDD, compile-level red on main)
PermissionEscalationTest(14): ladder, pairing, type rejection, capped truncation with marker, null-valued keys, normalization,DONT_ASK, ladder-phrased denialsPermissionEngineEscalationTest(8): deny-rule supremacy, malformed pairing, disabled agent, no-args-unchanged evaluation, serialization round-trip + legacy JSON, posture-preserving triviality,withModepreservationReActAgentEscalationHitlTest(3, end-to-end on a trivial-context agent): plain call auto-executes after the flag is enabled; escalation call pauses → confirms → executes; hallucinated args on a disabled agent deny closed with the specific reason asserted in the DENIED tool result; a message-less tool self-check DENY still denies (fail-closed regression)HarnessAgentPermissionEscalationWiringTest(3): default build unchanged, flag travels onto context + schema, user context preserved on mergeFull core suite 2344/0, full harness suite 926/0.
Checklist
mvn spotless:applymvn test)