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")); + } +}