Skip to content

feat(permission): model-requested escalation with approval-gated widening - #3012

Open
birdie7761 wants to merge 1 commit into
agentscope-ai:mainfrom
birdie7761:feat/permission-escalation
Open

feat(permission): model-requested escalation with approval-gated widening#3012
birdie7761 wants to merge 1 commit into
agentscope-ai:mainfrom
birdie7761:feat/permission-escalation

Conversation

@birdie7761

Copy link
Copy Markdown
Contributor

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 (DEFAULT asks 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 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), 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. A valid strictly-wider request becomes ASK carrying the target and justification, riding the existing user-confirmation loop (RequireUserConfirmEvent → confirm → ALLOWED → execute). DONT_ASK denies: 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-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 / 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 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: new escalationEnabled flag, serialized as escalation_enabled, default 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.

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 denials
  • PermissionEngineEscalationTest (8): deny-rule supremacy, malformed pairing, disabled agent, no-args-unchanged evaluation, serialization round-trip + legacy JSON, posture-preserving triviality, withMode preservation
  • ReActAgentEscalationHitlTest (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 merge

Full core suite 2344/0, full harness suite 926/0.

Checklist

  • Code has been formatted with mvn spotless:apply
  • All tests are passing (mvn test)
  • Javadoc comments are complete and follow project conventions
  • Related documentation has been updated (builder/class javadocs document the posture-preservation and pairing contracts)
  • Code is ready for review

…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

codecov Bot commented Sep 6, 2026

Copy link
Copy Markdown

@CryoThrust

Copy link
Copy Markdown

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Model-requested permission escalation — sandbox_permissions + justification, approval-gated

2 participants