Permission System¶
Overview¶
The permission system (io.agentscope.core.permission) intercepts every tool call the agent makes and produces one of three decisions: ALLOW, DENY, or ASK (request user confirmation).
It combines static configuration with dynamic runtime analysis. Three components together decide the outcome:
Rules — explicit allow / deny / ask patterns per tool and command, with the highest priority. Rules come from two sources: static configuration in
PermissionContextState, or suggested rules added dynamically when the user accepts them at an ASK prompt. Suggested rules are auto-generated from the current invocation — once accepted, identical future calls are auto-handled without prompting.Mode — a global static policy set at configuration time; decides the default behaviour for calls that match no rule (e.g.
EXPLOREmakes the agent read-only,DONT_ASKsilently denies anything not matching a rule).Built-in Checks — runtime analysis performed by the tool itself based on the actual input (implemented in
ToolBase#checkPermissions). These are runtime checks rather than preconfigured patterns, so they are non-bypassable — they are not subject to mode or rules.
sequenceDiagram
participant LLM
participant PS as Permission System
participant Tool
participant User
LLM->>PS: Tool Call
Note over PS: Built-in Checks · Rules · Mode
alt ALLOW
PS->>Tool: execute
Tool->>LLM: result
else DENY
PS->>LLM: denied
else ASK + Suggestions
PS->>User: ASK + Suggestions
alt User approves
User->>Tool: allow
Tool->>LLM: result
User-->>PS: accept suggested rule
else User denies
User->>PS: deny
PS->>LLM: denied
end
end
Detailed decision flow
flowchart TD
A([Tool Call]) --> B{Deny Rules?}
B -->|Match| DENY([DENY])
B -->|No Match| C{Ask Rules?}
C -->|Match| ASK1([ASK])
C -->|No Match| D{Tool-Specific Checks}
D -->|EXPLORE + write op| DENY
D -->|Dangerous path| ASK2([ASK])
D -->|Pass| E{Allow Rules?}
E -->|Match| ALLOW([ALLOW])
E -->|No Match| F{"ACCEPT_EDITS + safe file op?"}
F -->|Yes| ALLOW
F -->|No| G{"Read-only Bash command?"}
G -->|Yes| ALLOW
G -->|No| H{BYPASS mode?}
H -->|Yes| ALLOW
H -->|No| I{DONT_ASK mode?}
I -->|Yes| DENY
I -->|No| ASK3([ASK])
ASK1 --> S[Generate Suggestions]
ASK2 --> S
ASK3 --> S
S --> U{User Confirms?}
U -->|Approve| ALLOW
U -->|Deny| DENY
U -->|Apply Rule| R[Update Context] --> ALLOW
style DENY fill:#ff6b6b,color:#fff
style ALLOW fill:#51cf66,color:#fff
style ASK1 fill:#ffd43b,color:#333
style ASK2 fill:#ffd43b,color:#333
style ASK3 fill:#ffd43b,color:#333
Note
Deny rules and dangerous-path checks are non-bypassable — they apply even in BYPASS mode.
Permission Mode¶
The PermissionMode enum (io.agentscope.core.permission.PermissionMode) supports the following modes:
Mode |
Behaviour |
Use case |
|---|---|---|
|
All operations require explicit rules or user confirmation |
Safest default, recommended |
|
Auto-allow file ops inside the working directory |
Active development with the user present |
|
Read-only: allow reads, deny all writes and commands |
Code exploration, planning |
|
Allow everything (deny / ask rules still apply) |
Fully trusted sandbox |
|
Demote ASK to DENY |
Unattended / scheduled runs |
Set the mode on the agent builder via permissionContext(...):
import io.agentscope.core.ReActAgent;
import io.agentscope.core.permission.PermissionContextState;
import io.agentscope.core.permission.PermissionMode;
PermissionContextState permCtx =
PermissionContextState.builder()
.mode(PermissionMode.DEFAULT)
.build();
ReActAgent agent =
ReActAgent.builder()
.name("my_agent")
.sysPrompt("...")
.model(model)
.permissionContext(permCtx)
.build();
import io.agentscope.core.permission.AdditionalWorkingDirectory;
import io.agentscope.core.permission.PermissionContextState;
import io.agentscope.core.permission.PermissionMode;
PermissionContextState permCtx =
PermissionContextState.builder()
.mode(PermissionMode.ACCEPT_EDITS)
.addWorkingDirectory(
"/my/project",
new AdditionalWorkingDirectory("/my/project", "userSettings"))
.build();
Permission Rule¶
PermissionRule (a record) maps a tool plus a specific call pattern to one of three behaviours: ALLOW, DENY, ASK.
Each rule has the fields below. When the engine evaluates a rule, it calls the tool’s matchRule() with the ruleContent and the actual input to decide whether the rule fires.
toolName·String· required — The tool name the rule applies to:todo_write(built-in) or any custom tool name.ruleContent·String | null· required — Match pattern — semantics depend on the tool, interpreted by the tool’smatchRule().nullmeans the rule matches every invocation of that tool.behavior·PermissionBehavior· required —ALLOW,DENY,ASK, orPASSTHROUGHsource·String· required — Origin of the rule:"userSettings","projectSettings","session","suggested", …
Configuring rules¶
At init time — pass rules through PermissionContextState.builder():
import io.agentscope.core.permission.PermissionBehavior;
import io.agentscope.core.permission.PermissionContextState;
import io.agentscope.core.permission.PermissionMode;
import io.agentscope.core.permission.PermissionRule;
PermissionContextState permCtx =
PermissionContextState.builder()
.mode(PermissionMode.DEFAULT)
.addAllowRule(
"safe_read",
new PermissionRule(
"safe_read", null, PermissionBehavior.ALLOW, "userSettings"))
.addAskRule(
"dangerous_delete",
new PermissionRule(
"dangerous_delete",
null,
PermissionBehavior.ASK,
"userSettings"))
.addDenyRule(
"drop_table",
new PermissionRule(
"drop_table", null, PermissionBehavior.DENY, "userSettings"))
.build();
At runtime via suggested rules — when the permission system returns ASK, it auto-generates suggested rules based on the current invocation. Pass the accepted rules in ConfirmResult and the agent will write them into the engine:
import io.agentscope.core.event.ConfirmResult;
// ASK decisions carry suggestedRules on the ToolUseBlock.
// Accept them by attaching to the result:
ConfirmResult result =
new ConfirmResult(
/* confirmed = */ true,
/* toolCall = */ toolCall,
/* rules = */ toolCall.getSuggestedRules());
Runnable examples: agentscope-examples/documentation/.../tool/PermissionContextExample.java, hitl/PermissionHITLExample.java.
Built-in checks¶
Every tool implements checkPermissions(toolInput, context) (on ToolBase) — a runtime check on the actual input that returns Mono<PermissionDecision>. These checks cannot be bypassed: they apply regardless of mode or rules.
PermissionDecision provides four static factories: allow(message) / deny(message) / ask(message) / passthrough(message). Returning PASSTHROUGH means “I’m not deciding — let the engine evaluate rules and mode.”
A custom tool can override checkPermissions() for its own logic:
import io.agentscope.core.permission.PermissionDecision;
import io.agentscope.core.tool.ToolBase;
import io.agentscope.core.tool.ToolExecutionContext;
import java.util.Map;
import reactor.core.publisher.Mono;
public class MyTool extends ToolBase {
public MyTool() {
super(
ToolBase.builder()
.name("MyTool")
.description("...")
.readOnly(false));
}
@Override
public Mono<PermissionDecision> checkPermissions(
Map<String, Object> toolInput, ToolExecutionContext context) {
Object target = toolInput.get("target");
// Custom safety check: block production resources.
if (target instanceof String s && s.startsWith("prod-")) {
return Mono.just(
PermissionDecision.ask("Operation targets production resource: " + s));
}
// Return PASSTHROUGH to let the engine continue evaluating rules / mode.
return Mono.just(PermissionDecision.passthrough("default"));
}
}
Dangerous-path protection¶
The ToolBase dangerous-path list is maintained in ToolDangerousPathConstants. A custom tool can append more paths via the dangerousFiles / dangerousDirectories attributes on @Tool. Once matched, the path triggers ASK even in BYPASS mode.
Category |
Examples |
|---|---|
Shell config |
|
Git config |
|
SSH |
|
Credentials |
|
Directories |
|
HITL integration¶
When the permission engine returns an ASK decision for a tool call, the agent pauses instead of executing and returns a response with GenerateReason.PERMISSION_ASKING. The returned Msg contains the ToolUseBlocks in ASKING state. The caller extracts them, presents the pending operation to the user, and resumes the agent with ConfirmResult objects.
Interaction flow¶
Configure ASK rules for tools that require human confirmation
Agent pauses on ASK tools, returning
PERMISSION_ASKINGExtract
ToolUseBlocks (withASKINGstate) from the returnedMsgand show them to the userBuild
ConfirmResultobjects and attach them to the resume message via metadata
import io.agentscope.core.event.ConfirmResult;
import io.agentscope.core.message.GenerateReason;
import io.agentscope.core.message.Msg;
import io.agentscope.core.message.MsgRole;
import io.agentscope.core.message.ToolCallState;
import io.agentscope.core.message.ToolUseBlock;
import io.agentscope.core.message.UserMessage;
import io.agentscope.core.permission.PermissionBehavior;
import io.agentscope.core.permission.PermissionContextState;
import io.agentscope.core.permission.PermissionMode;
import io.agentscope.core.permission.PermissionRule;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
// 1. Configure permissions: safe_read auto-allowed, dangerous_delete requires confirmation
PermissionContextState permCtx =
PermissionContextState.builder()
.mode(PermissionMode.DEFAULT)
.addAllowRule(
"safe_read",
new PermissionRule(
"safe_read", null, PermissionBehavior.ALLOW, "policy"))
.addAskRule(
"dangerous_delete",
new PermissionRule(
"dangerous_delete", null, PermissionBehavior.ASK, "policy"))
.build();
ReActAgent agent =
ReActAgent.builder()
.name("GuardedAgent")
.sysPrompt("...")
.model(model)
.toolkit(toolkit)
.permissionContext(permCtx)
.build();
// 2. Call the agent
Msg result = agent.call(new UserMessage("Delete /tmp/important.txt")).block();
// 3. Check whether user confirmation is needed
if (result != null && result.getGenerateReason() == GenerateReason.PERMISSION_ASKING) {
// Extract the ASKING ToolUseBlocks from the returned Msg
List<ToolUseBlock> askingTools =
result.getContent().stream()
.filter(b -> b instanceof ToolUseBlock)
.map(ToolUseBlock.class::cast)
.filter(t -> t.getState() == ToolCallState.ASKING)
.toList();
// Show pending operations to the user
askingTools.forEach(t -> System.out.println("Pending: " + t.getName() + " " + t.getInput()));
// 4. Collect the user's decision, build ConfirmResult, and resume
boolean approved = askUser();
List<ConfirmResult> confirmResults =
askingTools.stream()
.map(t -> new ConfirmResult(approved, t))
.toList();
Map<String, Object> meta = new HashMap<>();
meta.put(Msg.METADATA_CONFIRM_RESULTS, confirmResults);
Msg resumeMsg =
Msg.builder()
.name("user")
.role(MsgRole.USER)
.textContent(approved ? "approved" : "denied")
.metadata(meta)
.build();
Msg finalResult = agent.call(List.of(resumeMsg)).block();
}
All tools denied¶
When the user denies all tool calls from a reasoning step in the confirmation UI, the agent continues to the next reasoning iteration by default — the model only sees “Permission denied by user” tool results, which often leads to unhelpful reasoning.
To stop the agent in this scenario, wire up an onActing middleware that observes AllToolsDeniedEvent and emits a RequestStopEvent. After stopping, Msg.getGenerateReason() returns ALL_TOOLS_DENIED.
See Middleware — Stop agent when all tools are denied for the implementation.
Streaming mode¶
When using streamEvents(), you don’t need to extract ToolUseBlocks from the returned Msg — the event stream delivers a RequireUserConfirmEvent that carries the pending tool calls directly:
import io.agentscope.core.event.AgentEvent;
import io.agentscope.core.event.ConfirmResult;
import io.agentscope.core.event.RequireUserConfirmEvent;
import io.agentscope.core.message.Msg;
import io.agentscope.core.message.MsgRole;
import io.agentscope.core.message.ToolUseBlock;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
// Subscribe to the event stream
agent.streamEvents(List.of(new UserMessage("Delete /tmp/important.txt")))
.doOnNext(event -> {
if (event instanceof RequireUserConfirmEvent confirmEvent) {
// Get pending ToolUseBlocks directly from the event
List<ToolUseBlock> pending = confirmEvent.getToolCalls();
pending.forEach(t ->
System.out.println("Pending: " + t.getName() + " " + t.getInput()));
// Collect user decision, store pending list for the resume call
}
})
.blockLast();
// Resume is the same as with the blocking API: build ConfirmResult in metadata
List<ConfirmResult> confirmResults =
pendingTools.stream()
.map(t -> new ConfirmResult(true, t))
.toList();
Map<String, Object> meta = new HashMap<>();
meta.put(Msg.METADATA_CONFIRM_RESULTS, confirmResults);
Msg resumeMsg =
Msg.builder()
.name("user")
.role(MsgRole.USER)
.textContent("approved")
.metadata(meta)
.build();
agent.call(List.of(resumeMsg)).block();
Comparison of the two modes:
Blocking |
Streaming |
|
|---|---|---|
Getting pending tools |
Filter |
Get directly from |
Resuming |
Same: build |
Same |
Use case |
REST APIs, simple synchronous services |
WebSocket, SSE, real-time UIs |
Unattended mode¶
In CI or cron-job scenarios with no human operator, set the mode to DONT_ASK so that all ASK decisions degrade to DENY automatically:
PermissionContextState headless =
PermissionContextState.builder()
.mode(PermissionMode.DONT_ASK)
.addAllowRule(
"safe_read",
new PermissionRule(
"safe_read", null, PermissionBehavior.ALLOW, "policy"))
.build();
// ASK-rule hits are auto-denied — no blocking wait
Full runnable example: agentscope-examples/documentation/.../hitl/PermissionHITLExample.java.
Common recipes¶
The examples below show how to configure permissionContext for typical deployment scenarios. Each recipe combines a mode with a rule set tuned for one use case.
// EXPLORE mode: agent freely calls read-only tools; all writes are auto-denied.
PermissionContextState explore =
PermissionContextState.builder()
.mode(PermissionMode.EXPLORE)
.build();
ReActAgent explorer =
ReActAgent.builder()
.name("explorer")
.sysPrompt("...")
.model(model)
.permissionContext(explore)
.build();
import io.agentscope.core.permission.PermissionBehavior;
import io.agentscope.core.permission.PermissionRule;
PermissionContextState ci =
PermissionContextState.builder()
.mode(PermissionMode.DONT_ASK)
.addAllowRule(
"deploy",
new PermissionRule(
"deploy", "staging", PermissionBehavior.ALLOW, "project"))
.addAllowRule(
"git_commit",
new PermissionRule(
"git_commit", null, PermissionBehavior.ALLOW, "project"))
.build();
ReActAgent ciAgent =
ReActAgent.builder()
.name("ci_agent")
.sysPrompt("...")
.model(model)
.permissionContext(ci)
.build();
// Only explicitly allowed commands run; everything else is silently denied.
PermissionContextState bypassWithDeny =
PermissionContextState.builder()
.mode(PermissionMode.BYPASS)
.addDenyRule(
"drop_table",
new PermissionRule(
"drop_table", null, PermissionBehavior.DENY, "userSettings"))
.addDenyRule(
"force_push",
new PermissionRule(
"force_push", null, PermissionBehavior.DENY, "userSettings"))
.build();
// Everything except the explicitly denied tools runs (deny rules can't be bypassed).