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. EXPLORE makes the agent read-only, DONT_ASK silently 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

DEFAULT

All operations require explicit rules or user confirmation

Safest default, recommended

ACCEPT_EDITS

Auto-allow file ops inside the working directory

Active development with the user present

EXPLORE

Read-only: allow reads, deny all writes and commands

Code exploration, planning

BYPASS

Allow everything (deny / ask rules still apply)

Fully trusted sandbox

DONT_ASK

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’s matchRule(). null means the rule matches every invocation of that tool.

  • behavior · PermissionBehavior · requiredALLOW, DENY, ASK, or PASSTHROUGH

  • source · 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

.bashrc, .zshrc, .bash_profile, .profile

Git config

.gitconfig, .gitmodules

SSH

.ssh/config, .ssh/authorized_keys, id_rsa, id_ed25519

Credentials

.env, .env.local, .npmrc, .pypirc, .aws/credentials

Directories

.git/, .ssh/, .aws/, .kube/

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

  1. Configure ASK rules for tools that require human confirmation

  2. Agent pauses on ASK tools, returning PERMISSION_ASKING

  3. Extract ToolUseBlocks (with ASKING state) from the returned Msg and show them to the user

  4. Build ConfirmResult objects 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 call()

Streaming streamEvents()

Getting pending tools

Filter ToolUseBlocks (state ASKING) from Msg.getContent()

Get directly from RequireUserConfirmEvent.getToolCalls()

Resuming

Same: build ConfirmResult in metadata and issue a new call()

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).