From f258df79403bfe0c270009c06d6bd907d747c6cc Mon Sep 17 00:00:00 2001 From: webmin Date: Sun, 6 Sep 2026 11:53:03 +0800 Subject: [PATCH] feat(permission): model-requested escalation with approval-gated widening 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. Approval-boundary tests (requested in review, protecting the runtime security boundary the escalation rides on): - ReActAgentApprovalBoundaryTest: a replayed confirmation after its call executed is processed as a normal turn and never re-executes the tool; a tool-call id REUSED by the model with different arguments never inherits the earlier approval (the duplicate id is silently dropped by the id-based tool-result correlation - fail-closed; the correlation quirk itself is pre-existing core behavior worth a follow-up); a confirm referencing a non-ASKING id is rejected outright; and a transport-retried concurrent duplicate resume executes the tool at most once (same-session serialization + no ASKING remains for the duplicate). Note applyConfirmResults intentionally lets the approver EDIT the confirmed call's arguments - the approval binds to the id of a currently-ASKING call, and the confirm channel is the trusted human channel. Tests (TDD, compile-level red on main): 14 PermissionEscalation spec cases, 8 engine integration cases, 3 end-to-end confirmation-loop cases (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 approval-boundary cases, 3 harness wiring cases. Full core suite 2347/0, full harness suite 926/0. --- .../java/io/agentscope/core/ReActAgent.java | 75 +++- .../java/io/agentscope/core/message/Msg.java | 5 + .../permission/PermissionContextState.java | 61 +++- .../core/permission/PermissionEngine.java | 55 ++- .../core/permission/PermissionEscalation.java | 221 ++++++++++++ .../agent/ReActAgentApprovalBoundaryTest.java | 316 +++++++++++++++++ .../agent/ReActAgentEscalationHitlTest.java | 320 ++++++++++++++++++ .../PermissionEngineEscalationTest.java | 252 ++++++++++++++ .../permission/PermissionEscalationTest.java | 239 +++++++++++++ .../harness/agent/HarnessAgent.java | 49 ++- .../tool/EscalatingShellExecuteTool.java | 92 +++++ .../harness/agent/tool/ShellExecuteTool.java | 14 + ...ssAgentPermissionEscalationWiringTest.java | 124 +++++++ 13 files changed, 1793 insertions(+), 30 deletions(-) create mode 100644 agentscope-core/src/main/java/io/agentscope/core/permission/PermissionEscalation.java create mode 100644 agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentApprovalBoundaryTest.java create mode 100644 agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentEscalationHitlTest.java create mode 100644 agentscope-core/src/test/java/io/agentscope/core/permission/PermissionEngineEscalationTest.java create mode 100644 agentscope-core/src/test/java/io/agentscope/core/permission/PermissionEscalationTest.java create mode 100644 agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/EscalatingShellExecuteTool.java create mode 100644 agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentPermissionEscalationWiringTest.java diff --git a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java index 1138ff8042..92f3edbe31 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java +++ b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java @@ -97,6 +97,7 @@ import io.agentscope.core.permission.PermissionBehavior; import io.agentscope.core.permission.PermissionContextState; import io.agentscope.core.permission.PermissionEngine; +import io.agentscope.core.permission.PermissionEscalation; import io.agentscope.core.permission.PermissionMode; import io.agentscope.core.permission.PermissionRule; import io.agentscope.core.rag.GenericRAGHook; @@ -136,6 +137,7 @@ import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; @@ -2832,14 +2834,14 @@ Flux actingStream( .flatMapMany( gate -> { List pending = gate.pendingAsk(); - Set autoDenied = gate.autoDeniedIds(); + Map autoDenied = gate.autoDenied(); // Mark ToolUseBlock.state in context for every gated tool. ALLOWED // calls run immediately; ASKING calls cause the agent to pause and // return; DENIED calls get DENIED ToolResultBlocks written below. Map stateUpdates = new HashMap<>(); for (ToolUseBlock tc : toolCalls) { - if (autoDenied.contains(tc.getId())) { + if (autoDenied.containsKey(tc.getId())) { // DENIED tools don't need a state change — they'll get a // DENIED ToolResultBlock and won't reappear in pending. continue; @@ -2893,13 +2895,15 @@ Flux actingStream( * them to context so the conversation reflects the rejection (and resume doesn't see them * as pending). */ - private void writeAutoDeniedResults(List toolCalls, Set deniedIds) { + private void writeAutoDeniedResults( + List toolCalls, Map deniedMessages) { for (ToolUseBlock tc : toolCalls) { - if (!deniedIds.contains(tc.getId())) { + String message = deniedMessages.get(tc.getId()); + if (message == null) { continue; } ToolResultBlock denied = - ToolResultBlock.text("Permission denied by rules") + ToolResultBlock.text(message) .withIdAndName(tc.getId(), tc.getName()) .withState(ToolResultState.DENIED); Msg deniedMsg = ToolResultMessageBuilder.buildToolResultMsg(denied, tc, getName()); @@ -2915,16 +2919,17 @@ private void writeAutoDeniedResults(List toolCalls, Set de */ private Flux runToolBatch( List toolCalls, - Set deniedIds, + Map deniedMessages, String replyId, AtomicReference>> resultHolder) { List> deniedEntries = new ArrayList<>(); List approved = new ArrayList<>(); for (ToolUseBlock tc : toolCalls) { - if (deniedIds.contains(tc.getId())) { + String deniedMessage = deniedMessages.get(tc.getId()); + if (deniedMessage != null) { ToolResultBlock denied = - ToolResultBlock.text("Permission denied by rules") + ToolResultBlock.text(deniedMessage) .withIdAndName(tc.getId(), tc.getName()) .withState(ToolResultState.DENIED); deniedEntries.add(Map.entry(tc, denied)); @@ -2945,7 +2950,8 @@ private Flux runToolBatch( replyId, use.getId(), use.getName(), - "Permission denied by rules"), + deniedMessages.getOrDefault( + use.getId(), "Permission denied")), new ToolResultEndEvent( replyId, use.getId(), @@ -3135,7 +3141,12 @@ private Flux runToolBatch( * @param autoDeniedIds ids of tool calls whose decision was {@code DENY}; the agent loop * synthesises denied results for them without invoking the tool. */ - private record PermissionGate(List pendingAsk, Set autoDeniedIds) {} + /** + * Calls gated to ASK, plus a map of auto-denied call id -> denial message (the engine's or + * the tool self-check's own text; never a generic placeholder when one exists). + */ + private record PermissionGate( + List pendingAsk, Map autoDenied) {} /** * Run every tool call through the permission gate. @@ -3151,7 +3162,7 @@ private record PermissionGate(List pendingAsk, Set autoDen */ private Mono evaluatePermissions(List toolCalls) { if (toolCalls == null || toolCalls.isEmpty()) { - return Mono.just(new PermissionGate(List.of(), Set.of())); + return Mono.just(new PermissionGate(List.of(), Map.of())); } boolean useEngine = !state.getPermissionContext().isTrivial(); return Flux.fromIterable(toolCalls) @@ -3160,10 +3171,15 @@ private Mono evaluatePermissions(List toolCalls) { .map( verdicts -> { List pending = new ArrayList<>(); - Set denied = new HashSet<>(); + Map denied = new LinkedHashMap<>(); for (PermissionVerdict v : verdicts) { switch (v.behavior()) { - case DENY -> denied.add(v.use().getId()); + case DENY -> + denied.put( + v.use().getId(), + v.message() != null + ? v.message() + : "Permission denied"); case ASK -> pending.add(v.use()); case ALLOW, PASSTHROUGH -> { // auto-approved; falls through to execution @@ -3184,7 +3200,10 @@ private Mono evaluateOne(ToolUseBlock use, boolean useEngine) return Mono.just(new PermissionVerdict(use, PermissionBehavior.ALLOW)); } Map input = use.getInput() == null ? Map.of() : use.getInput(); - if (useEngine) { + // Calls carrying escalation arguments always engage the engine — enabling the + // escalation feature must not flip the evaluation path of calls that did not opt in + // (an otherwise-trivial context keeps its lightweight pre-2.0 path). + if (useEngine || PermissionEscalation.hasEscalationArgs(input)) { return permissionEngine .checkPermission(tb, input) .map( @@ -3193,7 +3212,8 @@ private Mono evaluateOne(ToolUseBlock use, boolean useEngine) use, decision == null ? PermissionBehavior.ASK - : decision.getBehavior())); + : decision.getBehavior(), + decision == null ? null : decision.getMessage())); } return tb.checkPermissions(input, state.getPermissionContext()) .map( @@ -3205,15 +3225,34 @@ private Mono evaluateOne(ToolUseBlock use, boolean useEngine) // gates execution; PASSTHROUGH and ALLOW both run, DENY is // honoured. return switch (decision.getBehavior()) { - case ASK -> new PermissionVerdict(use, PermissionBehavior.ASK); + case ASK -> + new PermissionVerdict( + use, + PermissionBehavior.ASK, + decision.getMessage()); case DENY -> - new PermissionVerdict(use, PermissionBehavior.DENY); + new PermissionVerdict( + use, + PermissionBehavior.DENY, + decision.getMessage()); default -> new PermissionVerdict(use, PermissionBehavior.ALLOW); }; }); } - private record PermissionVerdict(ToolUseBlock use, PermissionBehavior behavior) {} + /** + * One tool call's permission verdict. {@code message} carries the engine's (or the tool + * self-check's) decision text so DENIED results can tell the model WHY it was denied — + * a generic "denied" gives the model nothing to correct against. + */ + private record PermissionVerdict( + ToolUseBlock use, PermissionBehavior behavior, String message) { + + /** Convenience for ALLOW/PASSTHROUGH verdicts, which never enter the denied map. */ + PermissionVerdict(ToolUseBlock use, PermissionBehavior behavior) { + this(use, behavior, null); + } + } private List getSuspendedToolCalls( List> results) { diff --git a/agentscope-core/src/main/java/io/agentscope/core/message/Msg.java b/agentscope-core/src/main/java/io/agentscope/core/message/Msg.java index 82df1e01cf..a394da2c88 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/message/Msg.java +++ b/agentscope-core/src/main/java/io/agentscope/core/message/Msg.java @@ -73,6 +73,11 @@ public class Msg implements State { * Metadata key for carrying a {@code List} when resuming a Permission HITL * pause. The receiving {@code ReActAgent.call(msgs)} extracts and applies these results to * the ASKING tool calls in context. + * + *

Idempotency contract: when no tool call is currently ASKING (e.g. a transport retried + * an already-applied confirmation), the payload is processed as a normal turn and silently + * ignored — "no error" therefore does NOT mean "the confirmation took effect". A result + * referencing an id that is not among the ASKING calls, by contrast, is rejected outright. */ public static final String METADATA_CONFIRM_RESULTS = "agentscope_confirm_results"; diff --git a/agentscope-core/src/main/java/io/agentscope/core/permission/PermissionContextState.java b/agentscope-core/src/main/java/io/agentscope/core/permission/PermissionContextState.java index ecf5b95821..02bc61aa33 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/permission/PermissionContextState.java +++ b/agentscope-core/src/main/java/io/agentscope/core/permission/PermissionContextState.java @@ -33,7 +33,14 @@ * that tool. The engine evaluates {@code denyRules} first, then {@code askRules}, then tool * self-check, then {@code allowRules}; see the {@code PermissionEngine} javadoc for full ordering. */ -@JsonPropertyOrder({"mode", "working_directories", "allow_rules", "deny_rules", "ask_rules"}) +@JsonPropertyOrder({ + "mode", + "working_directories", + "allow_rules", + "deny_rules", + "ask_rules", + "escalation_enabled" +}) public final class PermissionContextState { private final PermissionMode mode; @@ -41,6 +48,7 @@ public final class PermissionContextState { private final Map> allowRules; private final Map> denyRules; private final Map> askRules; + private final boolean escalationEnabled; private PermissionContextState(Builder builder) { this.mode = builder.mode == null ? PermissionMode.DEFAULT : builder.mode; @@ -49,6 +57,7 @@ private PermissionContextState(Builder builder) { this.allowRules = freeze(builder.allowRules); this.denyRules = freeze(builder.denyRules); this.askRules = freeze(builder.askRules); + this.escalationEnabled = builder.escalationEnabled; } @JsonCreator @@ -58,7 +67,8 @@ static PermissionContextState fromJson( Map workingDirectories, @JsonProperty("allow_rules") Map> allowRules, @JsonProperty("deny_rules") Map> denyRules, - @JsonProperty("ask_rules") Map> askRules) { + @JsonProperty("ask_rules") Map> askRules, + @JsonProperty("escalation_enabled") Boolean escalationEnabled) { Builder b = builder(); if (mode != null) { b.mode(mode); @@ -69,6 +79,9 @@ static PermissionContextState fromJson( copyInto(allowRules, b::addAllowRule); copyInto(denyRules, b::addDenyRule); copyInto(askRules, b::addAskRule); + if (escalationEnabled != null) { + b.escalationEnabled(escalationEnabled); + } return b.build(); } @@ -112,6 +125,19 @@ public boolean isTrivial() { && askRules.isEmpty(); } + /** + * Whether model-requested permission escalation is enabled on this agent. When {@code true}, + * tools may declare optional {@code sandbox_permissions} + {@code justification} arguments; + * a valid, strictly-wider request is routed through the user-confirmation flow before + * anything executes. The flag deliberately does NOT affect {@link #isTrivial()}: enabling + * escalation leaves calls without escalation arguments on exactly their previous evaluation + * path — only calls that actually carry the arguments engage the permission engine. + */ + @JsonProperty("escalation_enabled") + public boolean isEscalationEnabled() { + return escalationEnabled; + } + @JsonProperty("working_directories") public Map getWorkingDirectories() { return workingDirectories; @@ -150,7 +176,23 @@ public PermissionContextState withMode(PermissionMode newMode) { if (newMode == this.mode) { return this; } - Builder b = builder().mode(newMode); + Builder b = builder().mode(newMode).escalationEnabled(escalationEnabled); + workingDirectories.forEach(b::addWorkingDirectory); + copyInto(allowRules, b::addAllowRule); + copyInto(denyRules, b::addDenyRule); + copyInto(askRules, b::addAskRule); + return b.build(); + } + + /** + * Returns a copy of this context with the escalation flag replaced and every mode, working + * directory, and rule preserved (returns {@code this} when the flag is unchanged). + */ + public PermissionContextState withEscalationEnabled(boolean newEscalationEnabled) { + if (newEscalationEnabled == this.escalationEnabled) { + return this; + } + Builder b = builder().mode(mode).escalationEnabled(newEscalationEnabled); workingDirectories.forEach(b::addWorkingDirectory); copyInto(allowRules, b::addAllowRule); copyInto(denyRules, b::addDenyRule); @@ -167,6 +209,7 @@ public boolean equals(Object o) { return false; } return mode == other.mode + && escalationEnabled == other.escalationEnabled && Objects.equals(workingDirectories, other.workingDirectories) && Objects.equals(allowRules, other.allowRules) && Objects.equals(denyRules, other.denyRules) @@ -175,7 +218,8 @@ public boolean equals(Object o) { @Override public int hashCode() { - return Objects.hash(mode, workingDirectories, allowRules, denyRules, askRules); + return Objects.hash( + mode, workingDirectories, allowRules, denyRules, askRules, escalationEnabled); } @Override @@ -190,6 +234,8 @@ public String toString() { + denyRules + ", askRules=" + askRules + + ", escalationEnabled=" + + escalationEnabled + '}'; } @@ -200,6 +246,7 @@ private interface RuleAdder { public static final class Builder { private PermissionMode mode = PermissionMode.DEFAULT; + private boolean escalationEnabled = false; private final Map workingDirectories = new LinkedHashMap<>(); private final Map> allowRules = new LinkedHashMap<>(); @@ -213,6 +260,12 @@ public Builder mode(PermissionMode mode) { return this; } + /** Enables model-requested permission escalation on the built context. */ + public Builder escalationEnabled(boolean escalationEnabled) { + this.escalationEnabled = escalationEnabled; + return this; + } + public Builder addWorkingDirectory(String key, AdditionalWorkingDirectory directory) { Objects.requireNonNull(key, "key must not be null"); Objects.requireNonNull(directory, "directory must not be null"); diff --git a/agentscope-core/src/main/java/io/agentscope/core/permission/PermissionEngine.java b/agentscope-core/src/main/java/io/agentscope/core/permission/PermissionEngine.java index 00cb97f2e1..d8fb30ff0c 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/permission/PermissionEngine.java +++ b/agentscope-core/src/main/java/io/agentscope/core/permission/PermissionEngine.java @@ -31,7 +31,11 @@ *

Evaluation order: * *

    - *
  1. Tool-level deny rules (highest priority). + *
  2. Tool-level deny rules (highest priority — an escalation request can never override + * them). + *
  3. Model-requested escalation, when the call carries {@code sandbox_permissions} / + * {@code justification} arguments (see {@link PermissionEscalation}); rejected requests + * deny fail-closed, valid strictly-wider requests become ASK with the justification. *
  4. Tool-level ask rules. *
  5. Tool-specific checks (bypass-immune): EXPLORE/ACCEPT_EDITS read-only handling, dangerous * path checks, plus whatever the tool's own {@link ToolBase#checkPermissions} returns. @@ -132,6 +136,10 @@ private static Map> unmodifiableSnapshot( /** * Resolves a permission decision for the given tool invocation. * + *

    When the call carries escalation arguments ({@code sandbox_permissions} / {@code + * justification}), they are resolved after deny rules (a hard integrator ceiling that + * escalation cannot override) and before everything else; see {@link PermissionEscalation}. + * * @param tool the tool being called * @param toolInput the input map the tool will receive * @return a Mono emitting the resolved {@link PermissionDecision} @@ -140,19 +148,52 @@ public Mono checkPermission(ToolBase tool, Map input = toolInput == null ? Map.of() : toolInput; - // 1. Deny rules (highest priority) + // 1. Deny rules (highest priority — escalation can never override a deny rule) PermissionDecision denyDecision = checkDenyRules(tool, input); if (denyDecision != null) { return Mono.just(denyDecision); } - // 2. Ask rules + // 2. Model-requested escalation (when the call carries escalation arguments) + PermissionEscalation.Outcome escalation = + PermissionEscalation.resolve( + input, context.getMode(), context.isEscalationEnabled()); + if (escalation.type() == PermissionEscalation.Outcome.Type.DENY) { + return Mono.just( + PermissionDecision.builder() + .behavior(PermissionBehavior.DENY) + .message(escalation.denialReason()) + .decisionReason("Escalation request rejected") + .build()); + } + if (escalation.type() == PermissionEscalation.Outcome.Type.ASK) { + // Note: this message does NOT travel into RequireUserConfirmEvent — the event + // carries the pending ToolUseBlock, and the confirmation UI renders the request + // (target + justification) from the tool-call input. The decision message serves + // logs and tests; do not "fix" it into the event. + return Mono.just( + PermissionDecision.builder() + .behavior(PermissionBehavior.ASK) + .message( + "Escalation to '" + + escalation.target() + + "' requested for " + + tool.getName() + + " — justification: " + + escalation.justification()) + .decisionReason( + "Model-requested escalation from mode " + + context.getMode().name().toLowerCase(Locale.ROOT)) + .build()); + } + + // 3. Ask rules PermissionDecision askDecision = checkAskRules(tool, input); if (askDecision != null) { return Mono.just(askDecision.withSuggestedRules(tool.generateSuggestions(input))); } - // 3. Tool-specific check (bypass-immune) + // 4. Tool-specific check (bypass-immune) return toolCheckPermissions(tool, input) .flatMap( toolDecision -> { @@ -179,13 +220,13 @@ public Mono checkPermission(ToolBase tool, Map continueAfterToolCheck( ToolBase tool, Map input) { - // 4. Allow rules + // 5. Allow rules PermissionDecision allowDecision = checkAllowRules(tool, input); if (allowDecision != null) { return Mono.just(allowDecision); } - // 5. BYPASS fallback + // 6. BYPASS fallback if (context.getMode() == PermissionMode.BYPASS) { return Mono.just( PermissionDecision.builder() @@ -195,7 +236,7 @@ private Mono continueAfterToolCheck( .build()); } - // 6. Default (ASK, or DENY under DONT_ASK) + // 7. Default (ASK, or DENY under DONT_ASK) return Mono.just( defaultDecisionAsk(tool.getName()) .withSuggestedRules(tool.generateSuggestions(input))); diff --git a/agentscope-core/src/main/java/io/agentscope/core/permission/PermissionEscalation.java b/agentscope-core/src/main/java/io/agentscope/core/permission/PermissionEscalation.java new file mode 100644 index 0000000000..1dd5661828 --- /dev/null +++ b/agentscope-core/src/main/java/io/agentscope/core/permission/PermissionEscalation.java @@ -0,0 +1,221 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.core.permission; + +import java.util.Locale; +import java.util.Map; + +/** + * The model-requested permission escalation vocabulary and validation shared by every + * escalation-aware tool family: the strictly-wider ladder, the argument-pairing validation, and + * the fail-closed resolution of a {@code sandbox_permissions} request. One home keeps the tool + * families' validation ordering and model-facing denial texts from drifting apart. + * + *

    Ladder (strictly-wider table — a request may only name a mode strictly wider than the call's + * effective mode; nothing escalates down, {@code read-only} is the floor): + * + *

      + *
    • {@code read-only} — read operations only (floor) + *
    • {@code workspace-write} — reads plus workspace modifications + *
    • {@code danger-full-access} — unrestricted (ceiling) + *
    + * + *

    Validation is checked 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. The + * schema only advertises the closed target vocabulary. + * + *

    All rejections are fail-closed: a malformed pairing, an unknown target, a non-wider + * target, or an unavailable approver deny the call with a model-facing reason instead of + * falling back to normal evaluation. + */ +public final class PermissionEscalation { + + /** Tool argument naming the requested target on the ladder. */ + public static final String ARG_PERMISSIONS = "sandbox_permissions"; + + /** Tool argument carrying the model's justification for the request. */ + public static final String ARG_JUSTIFICATION = "justification"; + + /** Closed escalation-target vocabulary, ordered from floor to ceiling. */ + public static final String TARGET_READ_ONLY = "read-only"; + + public static final String TARGET_WORKSPACE_WRITE = "workspace-write"; + public static final String TARGET_DANGER_FULL_ACCESS = "danger-full-access"; + + /** Upper bound on the justification text carried into the approval prompt. */ + static final int MAX_JUSTIFICATION_LENGTH = 500; + + private PermissionEscalation() {} + + /** + * Whether a tool-call input carries escalation arguments at all. Callers use this to engage + * the permission engine for calls that opted in, without changing the evaluation path of + * calls that did not. A key explicitly mapped to {@code null} carries no request and is + * ignored, so such a call keeps its previous evaluation path. + */ + public static boolean hasEscalationArgs(Map input) { + return input != null + && (input.get(ARG_PERMISSIONS) != null || input.get(ARG_JUSTIFICATION) != null); + } + + /** + * Resolves an escalation request embedded in a tool call's input. + * + * @param input the raw tool-call input map + * @param mode the call's effective permission mode + * @param escalationEnabled whether escalation is enabled on this agent + * @return the outcome — {@link Outcome.Type#NOT_PRESENT} when the call carries no escalation + * arguments, {@link Outcome.Type#ASK} for a valid strictly-wider request (the caller + * routes it through the user-confirmation flow), or {@link Outcome.Type#DENY} with a + * model-facing reason for every rejection + */ + public static Outcome resolve( + Map input, PermissionMode mode, boolean escalationEnabled) { + Object permissions = input == null ? null : input.get(ARG_PERMISSIONS); + Object justification = input == null ? null : input.get(ARG_JUSTIFICATION); + + if (permissions == null && justification == null) { + return Outcome.notPresent(); + } + if (!escalationEnabled) { + return Outcome.deny( + "Permission escalation is not enabled on this agent; remove the " + + ARG_PERMISSIONS + + " and " + + ARG_JUSTIFICATION + + " arguments and call the tool without them."); + } + // Argument pairing: an approval prompt without a reason, or a reason driving nothing, + // is a malformed ask. + if (permissions == null) { + return Outcome.deny( + "Invalid escalation: " + + ARG_JUSTIFICATION + + " is only valid together with " + + ARG_PERMISSIONS + + "."); + } + if (justification == null) { + return Outcome.deny( + "Invalid escalation: " + + ARG_PERMISSIONS + + " requires a " + + ARG_JUSTIFICATION + + "."); + } + // Non-string values are rejected rather than coerced: a coerced object or array would + // produce a nonsense prompt the user is asked to approve. + if (!(permissions instanceof String)) { + return Outcome.deny("Invalid escalation: " + ARG_PERMISSIONS + " must be a string."); + } + if (!(justification instanceof String)) { + return Outcome.deny( + "Invalid justification: " + ARG_JUSTIFICATION + " must be a string."); + } + String reason = ((String) justification).trim(); + if (reason.isEmpty()) { + return Outcome.deny( + "Invalid justification: expected a non-empty sentence explaining why this " + + "call needs wider permissions."); + } + if (reason.length() > MAX_JUSTIFICATION_LENGTH) { + // Keep the approved prompt honest: the approver must see that the text was cut. + reason = reason.substring(0, MAX_JUSTIFICATION_LENGTH - 1) + "…"; + } + String target = ((String) permissions).trim().toLowerCase(Locale.ROOT); + Integer targetRank = rankOfTarget(target); + if (targetRank == null) { + return Outcome.deny( + "Invalid escalation target '" + + target + + "': expected one of " + + TARGET_READ_ONLY + + ", " + + TARGET_WORKSPACE_WRITE + + ", " + + TARGET_DANGER_FULL_ACCESS + + "."); + } + if (mode == PermissionMode.DONT_ASK) { + return Outcome.deny( + "Permission escalation is unavailable: no user is available to approve it."); + } + int modeRank = rankOfMode(mode); + if (targetRank <= modeRank) { + return Outcome.deny( + "Invalid escalation target '" + + target + + "': it is not strictly wider than the current mode (" + + mode.name().toLowerCase(Locale.ROOT) + + "); call the tool without escalation arguments."); + } + return Outcome.ask(target, reason); + } + + /** + * Ladder rank of an escalation target, {@code null} when unknown. Ranks are ordered + * floor-to-ceiling and comparable with {@link #rankOfMode}. + */ + private static Integer rankOfTarget(String target) { + return switch (target) { + case TARGET_READ_ONLY -> 0; + case TARGET_WORKSPACE_WRITE -> 2; + case TARGET_DANGER_FULL_ACCESS -> 3; + default -> null; + }; + } + + /** + * Width rank of a permission mode on the same scale as {@link #rankOfTarget}. {@code + * DONT_ASK} maps to the ceiling so no target is strictly wider (escalation requires an + * approver that mode explicitly declares unavailable); callers may also short-circuit it + * earlier for a clearer denial message. + */ + private static int rankOfMode(PermissionMode mode) { + return switch (mode) { + case EXPLORE -> 0; + case DEFAULT -> 1; + case ACCEPT_EDITS -> 2; + case BYPASS -> 3; + case DONT_ASK -> 3; + }; + } + + /** Resolution of one escalation request. */ + public record Outcome(Type type, String target, String justification, String denialReason) { + + public enum Type { + /** The tool call carries no escalation arguments; normal evaluation proceeds. */ + NOT_PRESENT, + /** A valid strictly-wider request; route through the user-confirmation flow. */ + ASK, + /** Rejected — always fail-closed, with a model-facing reason. */ + DENY + } + + static Outcome notPresent() { + return new Outcome(Type.NOT_PRESENT, null, null, null); + } + + static Outcome ask(String target, String justification) { + return new Outcome(Type.ASK, target, justification, null); + } + + static Outcome deny(String reason) { + return new Outcome(Type.DENY, null, null, reason); + } + } +} diff --git a/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentApprovalBoundaryTest.java b/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentApprovalBoundaryTest.java new file mode 100644 index 0000000000..9559170139 --- /dev/null +++ b/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentApprovalBoundaryTest.java @@ -0,0 +1,316 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.core.agent; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.agentscope.core.ReActAgent; +import io.agentscope.core.event.ConfirmResult; +import io.agentscope.core.message.ContentBlock; +import io.agentscope.core.message.GenerateReason; +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.MsgRole; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.ToolResultBlock; +import io.agentscope.core.message.ToolUseBlock; +import io.agentscope.core.model.ChatModelBase; +import io.agentscope.core.model.ChatResponse; +import io.agentscope.core.model.GenerateOptions; +import io.agentscope.core.model.ToolSchema; +import io.agentscope.core.permission.PermissionContextState; +import io.agentscope.core.permission.PermissionDecision; +import io.agentscope.core.tool.ToolBase; +import io.agentscope.core.tool.ToolCallParam; +import io.agentscope.core.tool.Toolkit; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * Runtime security-boundary tests for the permission approval loop that escalation rides on + * (requested in review of the escalation PR): an approval cannot be replayed after its call + * executed, a tool-call id reused by the model never inherits an earlier approval, a confirm + * referencing a non-ASKING call is rejected, and a transport-retried duplicate resume executes + * the tool at most once. + * + *

    Note on scope: {@code applyConfirmResults} intentionally replaces the ASKING block with the + * (possibly edited) block from the ConfirmResult — the approver editing arguments before + * approving is a supported capability, so the approval binds to the id of a currently-ASKING + * call; what must NOT happen is execution without a fresh approval for that id. + */ +class ReActAgentApprovalBoundaryTest { + + private static final class ScriptedModel extends ChatModelBase { + private final List>> scripts; + private final AtomicInteger idx = new AtomicInteger(0); + + ScriptedModel(List>> scripts) { + this.scripts = scripts; + } + + @Override + public String getModelName() { + return "scripted"; + } + + @Override + protected Flux doStream( + List messages, List tools, GenerateOptions options) { + int i = idx.getAndIncrement(); + if (i >= scripts.size()) { + return Flux.just(textResponse("")); + } + return scripts.get(i).get(); + } + } + + private static ChatResponse textResponse(String text) { + return ChatResponse.builder() + .content(List.of(TextBlock.builder().text(text).build())) + .build(); + } + + private static ChatResponse toolUseResponse(String id, Map input) { + Map typed = new HashMap<>(input); + return ChatResponse.builder() + .content( + List.of( + ToolUseBlock.builder().id(id).name("work").input(typed).build())) + .build(); + } + + private static final class CountingTool extends ToolBase { + final AtomicInteger executions = new AtomicInteger(); + final AtomicReference lastArgs = new AtomicReference<>(); + + CountingTool() { + super("work", "counting", schemaFor(), false, true, false, null, false, false); + } + + private static Map schemaFor() { + Map schema = new HashMap<>(); + schema.put("type", "object"); + Map props = new HashMap<>(); + Map q = new HashMap<>(); + q.put("type", "string"); + props.put("query", q); + schema.put("properties", props); + return schema; + } + + @Override + public Mono checkPermissions( + Map toolInput, PermissionContextState context) { + return Mono.just(PermissionDecision.passthrough("no opinion")); + } + + @Override + public Mono callAsync(ToolCallParam param) { + executions.incrementAndGet(); + Map input = param.getInput() == null ? Map.of() : param.getInput(); + lastArgs.set(String.valueOf(input.get("query"))); + return Mono.just(ToolResultBlock.text("executed:" + input.get("query"))); + } + } + + private static ReActAgent escalationAgent(ChatModelBase model, Toolkit toolkit) { + return ReActAgent.builder() + .name("approval-boundary") + .model(model) + .toolkit(toolkit) + .permissionContext(PermissionContextState.builder().escalationEnabled(true).build()) + .build(); + } + + private static Msg confirmMsg(ToolUseBlock toolCall) { + Map meta = new HashMap<>(); + meta.put(Msg.METADATA_CONFIRM_RESULTS, List.of(new ConfirmResult(true, toolCall, null))); + return Msg.builder() + .name("user") + .role(MsgRole.USER) + .textContent("[confirm]") + .metadata(meta) + .build(); + } + + private static Map escalationArgs(String query) { + Map input = new HashMap<>(); + input.put("query", query); + input.put("sandbox_permissions", "workspace-write"); + input.put("justification", "must run the build"); + return input; + } + + /** Drives the agent into the paused (PERMISSION_ASKING) state and returns the pending call. */ + private static ToolUseBlock driveToPause(ScriptedModel model, Toolkit toolkit) { + ReActAgent agent = escalationAgent(model, toolkit); + Msg paused = agent.call(List.of()).block(); + assertNotNull(paused); + assertEquals(GenerateReason.PERMISSION_ASKING, paused.getGenerateReason()); + List pending = paused.getContentBlocks(ToolUseBlock.class); + assertEquals(1, pending.size()); + return pending.get(0); + } + + @Test + void approvalReplay_andModelIdReuse_cannotReuseApproval() { + CountingTool tool = new CountingTool(); + Toolkit toolkit = new Toolkit(); + toolkit.registerAgentTool(tool); + ScriptedModel model = + new ScriptedModel( + List.of( + // Pause on the escalation call. + () -> Flux.just(toolUseResponse("t9", escalationArgs("build"))), + // After confirm: tool executes, turn ends. + () -> Flux.just(textResponse("done")), + // Turn 2a: replayed confirm arrives (no ASKING) -> normal turn. + () -> Flux.just(textResponse("ignored")), + // Turn 2b: the model REUSES the same id with different args — + // must re-request approval instead of inheriting it. + () -> + Flux.just( + toolUseResponse( + "t9", escalationArgs("dangerous-args"))), + () -> Flux.just(textResponse("done-2")))); + ReActAgent agent = escalationAgent(model, toolkit); + + // Turn 1: pause and capture the pending block. + Msg paused = agent.call(List.of()).block(); + assertEquals(GenerateReason.PERMISSION_ASKING, paused.getGenerateReason()); + ToolUseBlock pending = paused.getContentBlocks(ToolUseBlock.class).get(0); + assertEquals("t9", pending.getId()); + + // Confirm: executes once. + agent.call(List.of(confirmMsg(pending))).block(); + assertEquals(1, tool.executions.get()); + + // Replay the SAME confirm after execution: no ASKING remains, so it is processed as a + // normal turn — the tool must NOT execute again on the stale approval. + agent.call(List.of(confirmMsg(pending))).block(); + assertEquals(1, tool.executions.get(), "a replayed approval must not re-execute"); + + // The model reuses the approved id with DIFFERENT arguments. SECURITY property: the + // new call never inherits the earlier approval — it is a fresh ToolUseBlock instance + // (PENDING, not ALLOWED) and cannot execute. Current core semantics for the duplicate + // id: the id-based tool-result correlation sees the OLD call's result as answering the + // new one, so the call is silently dropped (fail-closed, no execution, no re-ask) — + // a pre-existing correlation behavior worth its own follow-up, noted in the review + // doc; the assertion below pins the boundary that matters for approval safety. + agent.call(List.of()).block(); + assertEquals( + 1, + tool.executions.get(), + "an id-reused call must never inherit the earlier approval"); + assertEquals("build", tool.lastArgs.get(), "only the originally approved args ran"); + } + + @Test + void confirmReferencingNonAskingId_rejected() { + CountingTool tool = new CountingTool(); + Toolkit toolkit = new Toolkit(); + toolkit.registerAgentTool(tool); + ScriptedModel model = + new ScriptedModel( + List.of(() -> Flux.just(toolUseResponse("t1", escalationArgs("build"))))); + ReActAgent agent = escalationAgent(model, toolkit); + + Msg paused = agent.call(List.of()).block(); + assertEquals(GenerateReason.PERMISSION_ASKING, paused.getGenerateReason()); + ToolUseBlock asking = paused.getContentBlocks(ToolUseBlock.class).get(0); + + // A confirm for an id that is NOT among the ASKING calls must be rejected outright + // (stale/replayed id from an earlier turn, foreign id, etc.). + ToolUseBlock foreign = + ToolUseBlock.builder() + .id("not-asking") + .name("work") + .input(new HashMap<>(escalationArgs("x"))) + .build(); + IllegalStateException ex = + assertThrows( + IllegalStateException.class, + () -> agent.call(List.of(confirmMsg(foreign))).block()); + assertTrue( + ex.getMessage().contains("non-ASKING"), + "rejection must name the non-ASKING reference, got: " + ex.getMessage()); + // Rejection is side-effect free: the genuinely ASKING call was untouched, and a + // CORRECT confirm for it still executes after the rejected payload. + assertEquals(0, tool.executions.get()); + assertNotNull(asking); + agent.call(List.of(confirmMsg(asking))).block(); + assertEquals(1, tool.executions.get(), "a correct confirm must still work after rejection"); + } + + @Test + void concurrentDuplicateResume_executesAtMostOnce() throws Exception { + CountingTool tool = new CountingTool(); + Toolkit toolkit = new Toolkit(); + toolkit.registerAgentTool(tool); + CountDownLatch bothSubmitted = new CountDownLatch(2); + ScriptedModel model = + new ScriptedModel( + List.of(() -> Flux.just(toolUseResponse("t1", escalationArgs("build"))))); + ReActAgent agent = escalationAgent(model, toolkit); + + Msg paused = agent.call(List.of()).block(); + assertEquals(GenerateReason.PERMISSION_ASKING, paused.getGenerateReason()); + ToolUseBlock pending = paused.getContentBlocks(ToolUseBlock.class).get(0); + Msg confirm = confirmMsg(pending); + + // A transport retries the SAME confirmation concurrently: same-session calls are + // serialised, and after the first resume executes there is no ASKING call left, so the + // duplicate is processed as a normal turn — the tool runs exactly once. + ExecutorService pool = Executors.newFixedThreadPool(2); + try { + Future f1 = + pool.submit( + () -> { + bothSubmitted.countDown(); + agent.call(List.of(confirm)).block(); + }); + Future f2 = + pool.submit( + () -> { + bothSubmitted.countDown(); + agent.call(List.of(confirm)).block(); + }); + assertTrue(bothSubmitted.await(5, TimeUnit.SECONDS)); + f1.get(30, TimeUnit.SECONDS); + f2.get(30, TimeUnit.SECONDS); + } finally { + pool.shutdownNow(); + } + assertEquals( + 1, + tool.executions.get(), + "a transport-retried duplicate confirmation must not execute the tool twice"); + } +} diff --git a/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentEscalationHitlTest.java b/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentEscalationHitlTest.java new file mode 100644 index 0000000000..cbcc18f7bd --- /dev/null +++ b/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentEscalationHitlTest.java @@ -0,0 +1,320 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.core.agent; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.agentscope.core.ReActAgent; +import io.agentscope.core.event.ConfirmResult; +import io.agentscope.core.message.ContentBlock; +import io.agentscope.core.message.GenerateReason; +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.MsgRole; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.ToolResultBlock; +import io.agentscope.core.message.ToolUseBlock; +import io.agentscope.core.model.ChatModelBase; +import io.agentscope.core.model.ChatResponse; +import io.agentscope.core.model.GenerateOptions; +import io.agentscope.core.model.ToolSchema; +import io.agentscope.core.permission.PermissionContextState; +import io.agentscope.core.permission.PermissionDecision; +import io.agentscope.core.tool.ToolBase; +import io.agentscope.core.tool.ToolCallParam; +import io.agentscope.core.tool.Toolkit; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * End-to-end confirmation loop for model-requested escalation on an otherwise default-configured + * (trivial-context) agent: a call carrying escalation arguments pauses with {@code + * PERMISSION_ASKING}, the user's confirmation executes it, and — the key posture invariant — + * plain calls on the same agent keep auto-executing exactly as before the flag was enabled. + */ +class ReActAgentEscalationHitlTest { + + private static final class ScriptedModel extends ChatModelBase { + private final List>> scripts; + private final AtomicInteger idx = new AtomicInteger(0); + + ScriptedModel(List>> scripts) { + this.scripts = scripts; + } + + @Override + public String getModelName() { + return "scripted"; + } + + @Override + protected Flux doStream( + List messages, List tools, GenerateOptions options) { + int i = idx.getAndIncrement(); + if (i >= scripts.size()) { + return Flux.just(textResponse("")); + } + return scripts.get(i).get(); + } + } + + private static ChatResponse textResponse(String text) { + return ChatResponse.builder() + .content(List.of(TextBlock.builder().text(text).build())) + .build(); + } + + private static ChatResponse toolUseResponse( + String toolId, String toolName, Map input) { + Map typed = new HashMap<>(input); + return ChatResponse.builder() + .content( + List.of( + ToolUseBlock.builder() + .id(toolId) + .name(toolName) + .input(typed) + .build())) + .build(); + } + + /** PASSTHROUGH tool: in the lightweight path it auto-executes without asking. */ + private static final class PassthroughTool extends ToolBase { + final AtomicReference lastResult = new AtomicReference<>("never-run"); + + PassthroughTool(String name) { + super(name, "passthrough", schemaFor(), false, true, false, null, false, false); + } + + @Override + public Mono checkPermissions( + Map toolInput, PermissionContextState context) { + return Mono.just(PermissionDecision.passthrough("no opinion")); + } + + @Override + public Mono callAsync(ToolCallParam param) { + Object q = param.getInput() == null ? "" : param.getInput().get("query"); + lastResult.set("executed:" + q); + return Mono.just(ToolResultBlock.text("executed:" + q)); + } + } + + private static Map schemaFor() { + Map schema = new HashMap<>(); + schema.put("type", "object"); + Map props = new HashMap<>(); + Map q = new HashMap<>(); + q.put("type", "string"); + props.put("query", q); + schema.put("properties", props); + return schema; + } + + /** Denies via the tool self-check WITHOUT a message — the denial must still hold. */ + private static final class SilentDenyTool extends ToolBase { + final AtomicReference lastResult = new AtomicReference<>("never-run"); + + SilentDenyTool() { + super( + "deny-silently", + "denies without a message", + schemaFor(), + false, + true, + false, + null, + false, + false); + } + + @Override + public Mono checkPermissions( + Map toolInput, PermissionContextState context) { + // PermissionDecision requires a non-null message at construction; an EMPTY + // message is the weakest text a real custom tool can produce. + return Mono.just( + PermissionDecision.builder() + .behavior(io.agentscope.core.permission.PermissionBehavior.DENY) + .message("") + .build()); + } + + @Override + public Mono callAsync(ToolCallParam param) { + lastResult.set("SHOULD-NOT-RUN"); + return Mono.just(ToolResultBlock.text("SHOULD-NOT-RUN")); + } + } + + private static ReActAgent agentWith( + ChatModelBase model, Toolkit toolkit, boolean escalationEnabled) { + return ReActAgent.builder() + .name("esc-hitl") + .model(model) + .toolkit(toolkit) + .permissionContext( + PermissionContextState.builder() + .escalationEnabled(escalationEnabled) + .build()) + .build(); + } + + private static Msg confirmMsg(ToolUseBlock toolCall) { + Map meta = new HashMap<>(); + meta.put(Msg.METADATA_CONFIRM_RESULTS, List.of(new ConfirmResult(true, toolCall, null))); + return Msg.builder() + .name("user") + .role(MsgRole.USER) + .textContent("[confirm]") + .metadata(meta) + .build(); + } + + @Test + void escalationCallPausesThenExecutesOnConfirm_plainCallStillAutoRuns() { + PassthroughTool tool = new PassthroughTool("work"); + Toolkit toolkit = new Toolkit(); + toolkit.registerAgentTool(tool); + // Trivial context (DEFAULT mode, no rules) with only the escalation flag enabled. + // The ReAct loop keeps calling the model until a text response ends the turn, so the + // script alternates: plain tool call -> text (ends turn 1) -> escalation call (pauses) + // -> text (ends the resumed turn after the confirmed call executes). + ScriptedModel model = + new ScriptedModel( + List.of( + () -> + Flux.just( + toolUseResponse( + "t1", "work", Map.of("query", "plain"))), + () -> Flux.just(textResponse("turn-1-done")), + () -> + Flux.just( + toolUseResponse( + "t2", + "work", + Map.of( + "query", + "build", + "sandbox_permissions", + "workspace-write", + "justification", + "must run the build"))), + () -> Flux.just(textResponse("done")))); + ReActAgent agent = agentWith(model, toolkit, true); + + // Turn 1: plain call on the trivial+escalation context — previous behavior, no pause. + Msg first = agent.call(List.of()).block(); + assertNotNull(first); + assertNotEquals( + GenerateReason.PERMISSION_ASKING, + first.getGenerateReason(), + "plain calls must keep auto-executing after the flag is enabled"); + assertEquals("executed:plain", tool.lastResult.get()); + + // Turn 2: escalation request — pauses with PERMISSION_ASKING carrying the tool call. + Msg second = agent.call(List.of()).block(); + assertNotNull(second); + assertEquals(GenerateReason.PERMISSION_ASKING, second.getGenerateReason()); + List pending = second.getContentBlocks(ToolUseBlock.class); + assertEquals(1, pending.size()); + assertEquals("t2", pending.get(0).getId()); + + // Turn 3: confirm — the escalated call executes. + Msg third = agent.call(List.of(confirmMsg(pending.get(0)))).block(); + assertNotNull(third); + assertNotEquals(GenerateReason.PERMISSION_ASKING, third.getGenerateReason()); + assertEquals("executed:build", tool.lastResult.get()); + assertTrue( + agent.getAgentState().getContext().stream() + .anyMatch(m -> m.getRole() == MsgRole.TOOL || m instanceof Msg), + "context advanced"); + } + + @Test + void hallucinatedEscalationArgsOnDisabledAgent_deniesClosed() { + PassthroughTool tool = new PassthroughTool("work"); + Toolkit toolkit = new Toolkit(); + toolkit.registerAgentTool(tool); + // Escalation NOT enabled; the model hallucinated the arguments anyway. + ScriptedModel model = + new ScriptedModel( + List.of( + () -> + Flux.just( + toolUseResponse( + "t1", + "work", + Map.of( + "query", + "build", + "sandbox_permissions", + "workspace-write", + "justification", + "please"))), + () -> Flux.just(textResponse("done")))); + ReActAgent agent = agentWith(model, toolkit, false); + + Msg first = agent.call(List.of()).block(); + assertNotNull(first); + // Fail-closed: the call is denied with the not-enabled reason instead of executing. + assertEquals("never-run", tool.lastResult.get()); + // And the denial REASON reaches the model in the DENIED tool result — a generic + // "denied" would give it nothing to correct against. + String deniedText = null; + for (Msg m : agent.getAgentState().getContext()) { + for (ToolResultBlock tr : m.getContentBlocks(ToolResultBlock.class)) { + if (tr.getState() == io.agentscope.core.message.ToolResultState.DENIED) { + deniedText = String.valueOf(tr.getOutput()); + } + } + } + assertNotNull(deniedText, "a DENIED tool result must be present"); + assertTrue(deniedText.contains("not enabled"), deniedText); + } + + @Test + void emptyMessageSelfCheckDeny_stillDenies() { + // A custom tool's self-check may return DENY with an empty message; the denial + // must hold (fail-closed) rather than silently falling out of the denied map. + SilentDenyTool tool = new SilentDenyTool(); + Toolkit toolkit = new Toolkit(); + toolkit.registerAgentTool(tool); + ScriptedModel model = + new ScriptedModel( + List.of( + () -> + Flux.just( + toolUseResponse( + "t1", + "deny-silently", + Map.of("query", "x"))), + () -> Flux.just(textResponse("done")))); + ReActAgent agent = agentWith(model, toolkit, false); + + agent.call(List.of()).block(); + assertEquals("never-run", tool.lastResult.get(), "a message-less DENY must not execute"); + } +} diff --git a/agentscope-core/src/test/java/io/agentscope/core/permission/PermissionEngineEscalationTest.java b/agentscope-core/src/test/java/io/agentscope/core/permission/PermissionEngineEscalationTest.java new file mode 100644 index 0000000000..333df5cf38 --- /dev/null +++ b/agentscope-core/src/test/java/io/agentscope/core/permission/PermissionEngineEscalationTest.java @@ -0,0 +1,252 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.core.permission; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.agentscope.core.tool.ToolBase; +import io.agentscope.core.tool.ToolCallParam; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +/** + * Engine integration of model-requested escalation: deny rules stay supreme, a valid + * strictly-wider request becomes an ASK carrying the justification, and every rejection fails + * closed — while calls without escalation arguments keep byte-for-byte the previous evaluation. + */ +class PermissionEngineEscalationTest { + + private static final class FakeShellTool extends ToolBase { + + FakeShellTool() { + super( + "execute", + "shell", + Map.of("type", "object", "properties", Map.of()), + /* isReadOnly */ false, + /* isConcurrencySafe */ true, + /* isMcp */ false, + /* mcpName */ null, + /* isExternalTool */ false, + /* isStateInjected */ false); + } + + @Override + public Mono checkPermissions( + Map toolInput, PermissionContextState context) { + return Mono.just(PermissionDecision.passthrough("no tool-specific opinion")); + } + + @Override + public Mono callAsync(ToolCallParam param) { + return Mono.error(new UnsupportedOperationException("not executed in engine tests")); + } + } + + private static Map escalationArgs(String target, String justification) { + Map input = new HashMap<>(); + input.put("command", "npm test"); + if (target != null) { + input.put(PermissionEscalation.ARG_PERMISSIONS, target); + } + if (justification != null) { + input.put(PermissionEscalation.ARG_JUSTIFICATION, justification); + } + return input; + } + + @Test + @DisplayName("valid strictly-wider request -> ASK carrying target and justification") + void validRequest_asksWithJustification() { + PermissionContextState ctx = + PermissionContextState.builder() + .mode(PermissionMode.DEFAULT) + .escalationEnabled(true) + .build(); + PermissionEngine engine = new PermissionEngine(ctx); + + StepVerifier.create( + engine.checkPermission( + new FakeShellTool(), + escalationArgs("workspace-write", "must run the build"))) + .assertNext( + decision -> { + assertEquals(PermissionBehavior.ASK, decision.getBehavior()); + assertTrue( + decision.getMessage().contains("workspace-write"), + decision.getMessage()); + assertTrue( + decision.getMessage().contains("must run the build"), + decision.getMessage()); + }) + .verifyComplete(); + } + + @Test + @DisplayName("deny rules stay supreme — escalation can never override them") + void denyRulesSupreme() { + PermissionContextState ctx = + PermissionContextState.builder() + .mode(PermissionMode.DEFAULT) + .escalationEnabled(true) + .addDenyRule( + "execute", + new PermissionRule( + "execute", null, PermissionBehavior.DENY, "test")) + .build(); + PermissionEngine engine = new PermissionEngine(ctx); + + StepVerifier.create( + engine.checkPermission( + new FakeShellTool(), + escalationArgs("danger-full-access", "please"))) + .assertNext( + decision -> { + assertEquals(PermissionBehavior.DENY, decision.getBehavior()); + assertTrue( + decision.getDecisionReason().contains("Rule"), + decision.getDecisionReason()); + }) + .verifyComplete(); + } + + @Test + @DisplayName("malformed pairing -> fail-closed DENY with model-facing reason") + void malformedPairing_denies() { + PermissionContextState ctx = + PermissionContextState.builder() + .mode(PermissionMode.DEFAULT) + .escalationEnabled(true) + .build(); + PermissionEngine engine = new PermissionEngine(ctx); + + StepVerifier.create( + engine.checkPermission( + new FakeShellTool(), escalationArgs("workspace-write", null))) + .assertNext( + decision -> { + assertEquals(PermissionBehavior.DENY, decision.getBehavior()); + assertTrue( + decision.getMessage().contains("justification"), + decision.getMessage()); + }) + .verifyComplete(); + } + + @Test + @DisplayName("escalation arguments on a disabled agent -> fail-closed DENY") + void disabledAgent_denies() { + PermissionEngine engine = + new PermissionEngine( + PermissionContextState.builder().mode(PermissionMode.DEFAULT).build()); + + StepVerifier.create( + engine.checkPermission( + new FakeShellTool(), + escalationArgs("workspace-write", "must run the build"))) + .assertNext( + decision -> { + assertEquals(PermissionBehavior.DENY, decision.getBehavior()); + assertTrue( + decision.getMessage().contains("not enabled"), + decision.getMessage()); + }) + .verifyComplete(); + } + + @Test + @DisplayName("call without escalation arguments keeps the previous evaluation") + void noEscalationArgs_previousBehavior() { + // DEFAULT mode, no rules: previous default is ASK. + PermissionEngine disabledEngine = + new PermissionEngine( + PermissionContextState.builder().mode(PermissionMode.DEFAULT).build()); + StepVerifier.create( + disabledEngine.checkPermission( + new FakeShellTool(), Map.of("command", "ls"))) + .assertNext(d -> assertEquals(PermissionBehavior.ASK, d.getBehavior())) + .verifyComplete(); + + // Enabled agent, plain call: still the default ASK — enabling the flag changes nothing + // for calls that do not opt in. + PermissionEngine enabledEngine = + new PermissionEngine( + PermissionContextState.builder() + .mode(PermissionMode.DEFAULT) + .escalationEnabled(true) + .build()); + StepVerifier.create( + enabledEngine.checkPermission(new FakeShellTool(), Map.of("command", "ls"))) + .assertNext(d -> assertEquals(PermissionBehavior.ASK, d.getBehavior())) + .verifyComplete(); + } + + @Test + @DisplayName("escalation flag survives serialization; old JSON without it stays disabled") + void serializationCompatibility() throws Exception { + PermissionContextState enabled = + PermissionContextState.builder() + .mode(PermissionMode.EXPLORE) + .escalationEnabled(true) + .build(); + String json = io.agentscope.core.util.JsonUtils.getJsonCodec().toJson(enabled); + assertTrue(json.contains("escalation_enabled"), json); + PermissionContextState roundTripped = + io.agentscope.core.util.JsonUtils.getJsonCodec() + .fromJson(json, PermissionContextState.class); + assertTrue(roundTripped.isEscalationEnabled()); + assertEquals(PermissionMode.EXPLORE, roundTripped.getMode()); + + // Pre-feature JSON has no escalation_enabled field — must deserialize to false. + String legacyJson = + "{\"mode\":\"default\",\"working_directories\":{},\"allow_rules\":{}," + + "\"deny_rules\":{},\"ask_rules\":{}}"; + PermissionContextState legacy = + io.agentscope.core.util.JsonUtils.getJsonCodec() + .fromJson(legacyJson, PermissionContextState.class); + assertFalse(legacy.isEscalationEnabled()); + assertTrue(legacy.isTrivial()); + } + + @Test + @DisplayName("the escalation flag alone does NOT flip the agent's permission posture") + void escalationFlagAloneKeepsContextTrivial() { + // Enabling escalation must not switch an otherwise default-configured agent from + // auto-execution to ask-per-call: only calls that actually carry escalation arguments + // engage the engine (ReActAgent routes per call via hasEscalationArgs). + assertTrue(PermissionContextState.builder().build().isTrivial()); + assertTrue(PermissionContextState.builder().escalationEnabled(true).build().isTrivial()); + } + + @Test + @DisplayName("withMode preserves the escalation flag") + void withModePreservesFlag() { + PermissionContextState enabled = + PermissionContextState.builder().escalationEnabled(true).build(); + assertTrue(enabled.withMode(PermissionMode.EXPLORE).isEscalationEnabled()); + assertTrue( + enabled.withEscalationEnabled(false) + .withMode(PermissionMode.BYPASS) + .isEscalationEnabled() + == false); + } +} diff --git a/agentscope-core/src/test/java/io/agentscope/core/permission/PermissionEscalationTest.java b/agentscope-core/src/test/java/io/agentscope/core/permission/PermissionEscalationTest.java new file mode 100644 index 0000000000..d5d2d5ed9a --- /dev/null +++ b/agentscope-core/src/test/java/io/agentscope/core/permission/PermissionEscalationTest.java @@ -0,0 +1,239 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.core.permission; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Behaviour spec for {@link PermissionEscalation}: the strictly-wider ladder, argument-pairing + * validation, and fail-closed resolution of a {@code sandbox_permissions} request. + */ +class PermissionEscalationTest { + + private static Map args(Object permissions, Object justification) { + Map input = new HashMap<>(); + if (permissions != null) { + input.put(PermissionEscalation.ARG_PERMISSIONS, permissions); + } + if (justification != null) { + input.put(PermissionEscalation.ARG_JUSTIFICATION, justification); + } + return input; + } + + @Test + @DisplayName("no escalation arguments -> NOT_PRESENT, normal evaluation proceeds") + void noArgs_notPresent() { + assertEquals( + PermissionEscalation.Outcome.Type.NOT_PRESENT, + PermissionEscalation.resolve(Map.of(), PermissionMode.DEFAULT, true).type()); + assertEquals( + PermissionEscalation.Outcome.Type.NOT_PRESENT, + PermissionEscalation.resolve(null, PermissionMode.DEFAULT, true).type()); + } + + @Test + @DisplayName("escalation arguments on a disabled agent -> fail-closed DENY") + void disabled_denyClosed() { + PermissionEscalation.Outcome out = + PermissionEscalation.resolve( + args("workspace-write", "need to run the build"), + PermissionMode.DEFAULT, + false); + assertEquals(PermissionEscalation.Outcome.Type.DENY, out.type()); + assertTrue(out.denialReason().contains("not enabled"), out.denialReason()); + } + + @Test + @DisplayName("permissions without justification -> DENY (pairing)") + void permissionsAlone_deny() { + PermissionEscalation.Outcome out = + PermissionEscalation.resolve( + args("workspace-write", null), PermissionMode.DEFAULT, true); + assertEquals(PermissionEscalation.Outcome.Type.DENY, out.type()); + assertTrue(out.denialReason().contains("requires a"), out.denialReason()); + } + + @Test + @DisplayName("justification without permissions -> DENY (pairing)") + void justificationAlone_deny() { + PermissionEscalation.Outcome out = + PermissionEscalation.resolve( + args(null, "need to run the build"), PermissionMode.DEFAULT, true); + assertEquals(PermissionEscalation.Outcome.Type.DENY, out.type()); + assertTrue(out.denialReason().contains("only valid together"), out.denialReason()); + } + + @Test + @DisplayName("blank justification -> DENY") + void blankJustification_deny() { + PermissionEscalation.Outcome out = + PermissionEscalation.resolve( + args("workspace-write", " "), PermissionMode.DEFAULT, true); + assertEquals(PermissionEscalation.Outcome.Type.DENY, out.type()); + assertTrue(out.denialReason().contains("non-empty"), out.denialReason()); + } + + @Test + @DisplayName("unknown target -> DENY with the closed vocabulary") + void unknownTarget_deny() { + PermissionEscalation.Outcome out = + PermissionEscalation.resolve( + args("root", "need root"), PermissionMode.DEFAULT, true); + assertEquals(PermissionEscalation.Outcome.Type.DENY, out.type()); + assertTrue(out.denialReason().contains("read-only"), out.denialReason()); + assertTrue(out.denialReason().contains("danger-full-access"), out.denialReason()); + } + + @Test + @DisplayName("DONT_ASK (no approver available) -> DENY") + void dontAsk_deny() { + PermissionEscalation.Outcome out = + PermissionEscalation.resolve( + args("danger-full-access", "need it"), PermissionMode.DONT_ASK, true); + assertEquals(PermissionEscalation.Outcome.Type.DENY, out.type()); + assertTrue(out.denialReason().contains("unavailable"), out.denialReason()); + } + + @Test + @DisplayName("non-wider target -> DENY: current mode already covers it") + void notWider_deny() { + // read-only is the floor: nobody can escalate DOWN to it. + assertEquals( + PermissionEscalation.Outcome.Type.DENY, + PermissionEscalation.resolve( + args("read-only", "reading only"), PermissionMode.EXPLORE, true) + .type()); + // BYPASS is the ceiling: nothing is wider. + assertEquals( + PermissionEscalation.Outcome.Type.DENY, + PermissionEscalation.resolve( + args("danger-full-access", "full speed"), + PermissionMode.BYPASS, + true) + .type()); + // Same rung, not strictly wider. + assertEquals( + PermissionEscalation.Outcome.Type.DENY, + PermissionEscalation.resolve( + args("workspace-write", "build it"), + PermissionMode.ACCEPT_EDITS, + true) + .type()); + } + + @Test + @DisplayName("strictly-wider targets -> ASK carrying target + justification") + void wider_asks() { + PermissionEscalation.Outcome explore = + PermissionEscalation.resolve( + args("workspace-write", "must run the test suite"), + PermissionMode.EXPLORE, + true); + assertEquals(PermissionEscalation.Outcome.Type.ASK, explore.type()); + assertEquals("workspace-write", explore.target()); + assertEquals("must run the test suite", explore.justification()); + assertNull(explore.denialReason()); + + assertEquals( + PermissionEscalation.Outcome.Type.ASK, + PermissionEscalation.resolve( + args("danger-full-access", "install system deps"), + PermissionMode.ACCEPT_EDITS, + true) + .type()); + // Two rungs up in one hop is allowed (read-only -> danger-full-access from EXPLORE). + assertEquals( + PermissionEscalation.Outcome.Type.ASK, + PermissionEscalation.resolve( + args("danger-full-access", "reboot the box"), + PermissionMode.EXPLORE, + true) + .type()); + } + + @Test + @DisplayName("target matching is case-insensitive and trims whitespace") + void targetNormalization() { + PermissionEscalation.Outcome out = + PermissionEscalation.resolve( + args(" Workspace-Write ", "build"), PermissionMode.DEFAULT, true); + assertEquals(PermissionEscalation.Outcome.Type.ASK, out.type()); + assertEquals("workspace-write", out.target()); + } + + @Test + @DisplayName("non-string argument values are rejected, not coerced") + void nonStringValues_deny() { + Map numericTarget = new HashMap<>(); + numericTarget.put(PermissionEscalation.ARG_PERMISSIONS, 42); + numericTarget.put(PermissionEscalation.ARG_JUSTIFICATION, "why"); + assertEquals( + PermissionEscalation.Outcome.Type.DENY, + PermissionEscalation.resolve(numericTarget, PermissionMode.DEFAULT, true).type()); + + Map objectReason = new HashMap<>(); + objectReason.put(PermissionEscalation.ARG_PERMISSIONS, "workspace-write"); + objectReason.put(PermissionEscalation.ARG_JUSTIFICATION, Map.of("why", "because")); + PermissionEscalation.Outcome out = + PermissionEscalation.resolve(objectReason, PermissionMode.DEFAULT, true); + assertEquals(PermissionEscalation.Outcome.Type.DENY, out.type()); + assertTrue(out.denialReason().contains("must be a string"), out.denialReason()); + } + + @Test + @DisplayName("justification is capped with a visible truncation marker") + void longJustification_capped() { + String longReason = "r".repeat(PermissionEscalation.MAX_JUSTIFICATION_LENGTH + 100); + PermissionEscalation.Outcome out = + PermissionEscalation.resolve( + args("workspace-write", longReason), PermissionMode.DEFAULT, true); + assertEquals(PermissionEscalation.Outcome.Type.ASK, out.type()); + assertEquals(PermissionEscalation.MAX_JUSTIFICATION_LENGTH, out.justification().length()); + assertTrue(out.justification().endsWith("…"), out.justification()); + } + + @Test + @DisplayName("keys explicitly mapped to null carry no escalation intent") + void nullValuedKeys_notEscalation() { + Map input = new HashMap<>(); + input.put(PermissionEscalation.ARG_PERMISSIONS, null); + input.put(PermissionEscalation.ARG_JUSTIFICATION, null); + input.put("command", "npm test"); + assertFalse(PermissionEscalation.hasEscalationArgs(input)); + assertEquals( + PermissionEscalation.Outcome.Type.NOT_PRESENT, + PermissionEscalation.resolve(input, PermissionMode.DEFAULT, true).type()); + } + + @Test + @DisplayName("not-wider denial talks about the ladder, not about mode coverage") + void notWiderMessage_isLadderPhrased() { + PermissionEscalation.Outcome out = + PermissionEscalation.resolve( + args("read-only", "read"), PermissionMode.DEFAULT, true); + assertEquals(PermissionEscalation.Outcome.Type.DENY, out.type()); + assertTrue(out.denialReason().contains("not strictly wider"), out.denialReason()); + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/HarnessAgent.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/HarnessAgent.java index 759263ea0d..be3a24b218 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/HarnessAgent.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/HarnessAgent.java @@ -99,6 +99,7 @@ import io.agentscope.harness.agent.subagent.SubagentDeclaration; import io.agentscope.harness.agent.subagent.task.TaskRepository; import io.agentscope.harness.agent.tool.ArtifactDeliveryTool; +import io.agentscope.harness.agent.tool.EscalatingShellExecuteTool; import io.agentscope.harness.agent.tool.FilesystemTool; import io.agentscope.harness.agent.tool.MemoryGetTool; import io.agentscope.harness.agent.tool.MemorySaveTool; @@ -1233,6 +1234,8 @@ public static class Builder { SandboxFilesystemSpec sandboxFilesystemSpec; RemoteFilesystemSpec remoteFilesystemSpec; LocalFilesystemSpec localFilesystemSpec; + boolean permissionEscalation = false; + PermissionContextState userPermissionContext; final Map filesystemRoutes = new LinkedHashMap<>(); // AgentStateStore — mirrored only to pass through to inner; the user-set AgentStateStore @@ -1648,11 +1651,39 @@ public Builder stopOnReject(boolean stopOnReject) { return this; } + /** + * Replaces the permission context wholesale. When combining with {@link + * #permissionEscalation(boolean)}, prefer setting the flag via the dedicated builder + * method (it also registers the escalation-aware shell tool); setting {@code + * escalationEnabled(true)} directly on the supplied context enables the engine-side + * handling only, without the schema advertising the arguments. + */ public Builder permissionContext(PermissionContextState permissionContext) { + this.userPermissionContext = permissionContext; inner.permissionContext(permissionContext); return this; } + /** + * Enables model-requested permission escalation. When {@code true}: (a) the shell tool's + * schema additionally advertises optional {@code sandbox_permissions} + {@code + * justification} arguments, and (b) the effective permission context carries the + * escalation flag so the permission engine resolves those requests — a valid + * strictly-wider request routes through the user-confirmation flow before anything + * executes; every rejection (malformed pairing, unknown or non-wider target, deny rules) + * fails closed. + * + *

    Enabling this flag does NOT change how any other tool call is evaluated: calls + * without escalation arguments stay on exactly their previous permission path (an + * otherwise default-configured agent keeps auto-executing them). Only calls that + * actually carry the escalation arguments engage the permission engine. Default {@code + * false}: no schema, state, or evaluation change at all. + */ + public Builder permissionEscalation(boolean permissionEscalation) { + this.permissionEscalation = permissionEscalation; + return this; + } + // ---- Harness-only setters ---- /** @@ -2266,6 +2297,17 @@ public HarnessAgent build() { // user-registered tools never bleed across builds. Toolkit agentToolkit = this.toolkit.copy(); + // The escalation flag travels on the permission context so the engine and any + // user-supplied rules observe one coherent context. Merging here keeps the call + // order of permissionContext(...) and permissionEscalation(...) irrelevant. + if (permissionEscalation) { + PermissionContextState base = + userPermissionContext != null + ? userPermissionContext + : PermissionContextState.builder().build(); + inner.permissionContext(base.withEscalationEnabled(true)); + } + // ---- Validation ---- int specCount = 0; if (sandboxFilesystemSpec != null) specCount++; @@ -2627,7 +2669,12 @@ public HarnessAgent build() { filesystem, pathNormalizer, artifactDeliveryTarget)); } if (!disableShellTool && filesystem instanceof AbstractSandboxFilesystem sandbox) { - agentToolkit.registerTool(new ShellExecuteTool(sandbox)); + // The escalation-aware variant advertises the same tool name with two additional + // optional arguments; registered only when the feature is explicitly enabled. + agentToolkit.registerTool( + permissionEscalation + ? new EscalatingShellExecuteTool(sandbox) + : new ShellExecuteTool(sandbox)); } agentToolkit.registerTool(new WebTools.WebFetchTool()); agentToolkit.registerTool(new WebTools.WebSearchTool()); diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/EscalatingShellExecuteTool.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/EscalatingShellExecuteTool.java new file mode 100644 index 0000000000..dafc855bfc --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/EscalatingShellExecuteTool.java @@ -0,0 +1,92 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.harness.agent.tool; + +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.core.permission.PermissionEscalation; +import io.agentscope.core.tool.Tool; +import io.agentscope.core.tool.ToolParam; +import io.agentscope.harness.agent.filesystem.sandbox.AbstractSandboxFilesystem; + +/** + * Escalation-aware variant of {@link ShellExecuteTool}, registered in its place when {@code + * permissionEscalation(true)} is configured: identical execution semantics (shared body in + * {@link ShellExecuteTool#executeAndFormat}), plus the optional {@code sandbox_permissions} + + * {@code justification} arguments a model may use to request a strictly-wider permission mode + * for one call. The schema advertises only the closed target vocabulary; validity is checked at + * execution time by {@link PermissionEscalation} (never baked into the schema), and an approved + * request runs through the normal user-confirmation flow before anything executes. + * + *

    The registered tool name ({@code execute}, derived from the method name) intentionally + * matches {@link ShellExecuteTool#NAME} — the model sees one tool either way. + */ +public class EscalatingShellExecuteTool { + + private final AbstractSandboxFilesystem sandbox; + + public EscalatingShellExecuteTool(AbstractSandboxFilesystem sandbox) { + this.sandbox = sandbox; + } + + /** + * @param runtimeContext per-call agent runtime injected by the framework (not an LLM argument); + * may be {@code null} when no merged context is available + */ + @Tool( + description = + "Execute a shell command. Use for git, npm, build, test, and other terminal" + + " operations. Returns combined output and exit code. If a dedicated tool" + + " exists (e.g., read_file, write_file), you MUST use it instead of shell" + + " commands. When the current permission mode blocks this command, you may" + + " request a strictly wider mode for this single call via" + + " sandbox_permissions (one of: read-only, workspace-write," + + " danger-full-access) together with a justification sentence; the user" + + " approves or denies the request before anything executes.") + public String execute( + RuntimeContext runtimeContext, + @ToolParam(name = "command", description = "Shell command to execute") String command, + @ToolParam( + name = "working_directory", + description = + "Working directory (relative to workspace root, optional)", + required = false) + String workingDirectory, + @ToolParam( + name = "timeout", + description = "Timeout in seconds (default: 30)", + required = false) + Integer timeout, + @ToolParam( + name = PermissionEscalation.ARG_PERMISSIONS, + description = + "Optional: request a strictly wider permission mode for this" + + " single call. One of: read-only, workspace-write," + + " danger-full-access. Must be paired with a" + + " justification.", + required = false) + String sandboxPermissions, + @ToolParam( + name = PermissionEscalation.ARG_JUSTIFICATION, + description = + "Optional: a non-empty sentence explaining why this call" + + " needs wider permissions. Only valid together with" + + " sandbox_permissions.", + required = false) + String justification) { + return ShellExecuteTool.executeAndFormat( + sandbox, runtimeContext, command, workingDirectory, timeout); + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/ShellExecuteTool.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/ShellExecuteTool.java index 60403fe61d..36ef212299 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/ShellExecuteTool.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/ShellExecuteTool.java @@ -61,6 +61,20 @@ public String execute( description = "Timeout in seconds (default: 30)", required = false) Integer timeout) { + return executeAndFormat(sandbox, runtimeContext, command, workingDirectory, timeout); + } + + /** + * Shared execution body for the shell tool family (the plain and escalation-aware variants + * must not drift — the working-directory validation is a security check). Package-private + * static so both tool classes run byte-for-byte identical logic. + */ + static String executeAndFormat( + AbstractSandboxFilesystem sandbox, + RuntimeContext runtimeContext, + String command, + String workingDirectory, + Integer timeout) { String effectiveCommand = command; if (workingDirectory != null && !workingDirectory.isBlank()) { String wd = workingDirectory.strip(); diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentPermissionEscalationWiringTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentPermissionEscalationWiringTest.java new file mode 100644 index 0000000000..ee618c09df --- /dev/null +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentPermissionEscalationWiringTest.java @@ -0,0 +1,124 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.harness.agent; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.model.ChatResponse; +import io.agentscope.core.model.Model; +import io.agentscope.core.permission.PermissionContextState; +import io.agentscope.core.permission.PermissionMode; +import io.agentscope.core.tool.AgentTool; +import io.agentscope.harness.agent.filesystem.local.LocalFilesystemWithShell; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import reactor.core.publisher.Flux; + +/** + * Builder wiring for {@code permissionEscalation(true)}: the flag travels onto the permission + * context (preserving user-supplied rules/modes), the shell tool's schema advertises the + * escalation arguments only when enabled, and the default build observes no change at all. + */ +class HarnessAgentPermissionEscalationWiringTest { + + @TempDir Path tmp; + + private static Model stubModel() { + Model model = mock(Model.class); + when(model.getModelName()).thenReturn("stub-model"); + ChatResponse chunk = + new ChatResponse( + "stub-id", + List.of(TextBlock.builder().text("ok").build()), + null, + Map.of(), + "stop"); + when(model.stream(anyList(), any(), any())).thenReturn(Flux.just(chunk)); + return model; + } + + private HarnessAgent.Builder baseBuilder() { + return HarnessAgent.builder() + .name("escalation-wiring-test") + .model(stubModel()) + .workspace(tmp) + .abstractFilesystem(new LocalFilesystemWithShell(tmp)); + } + + @Test + void defaultBuild_escalationDisabledAndPlainShellSchema() { + HarnessAgent agent = baseBuilder().build(); + PermissionContextState ctx = agent.getDelegate().getPermissionContext(); + assertNotNull(ctx); + assertFalse(ctx.isEscalationEnabled()); + AgentTool execute = agent.getToolkit().getTool("execute"); + assertNotNull(execute); + @SuppressWarnings("unchecked") + Map properties = + (Map) execute.getParameters().get("properties"); + assertFalse(properties.containsKey("sandbox_permissions"), String.valueOf(properties)); + assertFalse(properties.containsKey("justification"), String.valueOf(properties)); + } + + @Test + void escalationEnabled_flagTravelsOntoContextAndSchemaAdvertisesArgs() { + HarnessAgent agent = baseBuilder().permissionEscalation(true).build(); + PermissionContextState ctx = agent.getDelegate().getPermissionContext(); + assertNotNull(ctx); + assertTrue(ctx.isEscalationEnabled()); + AgentTool execute = agent.getToolkit().getTool("execute"); + assertNotNull(execute); + @SuppressWarnings("unchecked") + Map properties = + (Map) execute.getParameters().get("properties"); + assertTrue(properties.containsKey("sandbox_permissions"), String.valueOf(properties)); + assertTrue(properties.containsKey("justification"), String.valueOf(properties)); + } + + @Test + void userSuppliedContextIsPreservedWhenMergingTheFlag() { + // Call order reversed: permissionContext first, then the flag — the merge must keep the + // user's mode and rules. + PermissionContextState userCtx = + PermissionContextState.builder() + .mode(PermissionMode.EXPLORE) + .addAllowRule( + "read_file", + new io.agentscope.core.permission.PermissionRule( + "read_file", + null, + io.agentscope.core.permission.PermissionBehavior.ALLOW, + "test")) + .build(); + HarnessAgent agent = + baseBuilder().permissionContext(userCtx).permissionEscalation(true).build(); + PermissionContextState ctx = agent.getDelegate().getPermissionContext(); + assertTrue(ctx.isEscalationEnabled()); + assertEquals(PermissionMode.EXPLORE, ctx.getMode()); + assertTrue(ctx.getAllowRules().containsKey("read_file")); + } +}