TnsAI
Server

Tool Approval

The server implements a risk-based tool approval system that classifies tool calls by danger level, checks them against the session's autonomy setting, and either auto-approves or blocks the agent's virtual thread until the user responds via WebSocket.

ToolRisk Enum

Every tool call is classified into a risk level before the autonomy matrix is consulted. Risk levels range from safe read-only operations to dangerous destructive commands, and this classification determines whether the user needs to approve the action.

RiskLabelrequiresApproval()isRejected()Examples
READ_ONLYlowfalsefalsefile_read, git (status, log, diff)
BUILD_TESTlowfalsefalsemvn test, npm run build
WRITEmediumtruefalsefile_write
DESTRUCTIVEhightruefalsegit_write (reset, force push), rm -rf
NETWORKmediumtruefalsecurl, wget
ESCALATIONcriticaltruetruesudo, su -- always rejected
UNKNOWNhightruefalseUnrecognized commands

Shell commands are classified by ShellTool.classifyCommand(), which inspects the command string for destructive patterns, network tools, and privilege escalation keywords.

AutonomyLevel Enum

The autonomy level controls how much freedom the agent has to execute tools without asking. At FULL_AUTO, the agent handles most things itself; at MANUAL, every tool call requires explicit user approval. Choose based on how much you trust the agent and the sensitivity of the environment.

LevelLOW riskMEDIUM riskHIGH riskCRITICAL risk
FULL_AUTOAutoAutoAutoAsk
SUPERVISEDAutoAutoAskAsk
CAUTIOUSAutoAskAskAsk
MANUALAskAskAskAsk

ESCALATION risk is always rejected outright -- it never reaches the approval matrix.

Change the autonomy level at runtime:

{"v": 1, "type": "set_autonomy", "sessionId": "s1", "level": "CAUTIOUS"}

The server broadcasts an autonomy_changed event and updates all existing WsToolApprovalFilter instances for that session.

Approval Flow

This diagram shows the decision path for every tool call, from initial risk classification through to the final approve/reject decision. Understanding this flow is essential for building a client that handles tool approval correctly.

Agent calls tool
       |
       v
WsToolApprovalFilter.isAllowed(toolName, args)
       |
       +-- ESCALATION? --> REJECT (broadcast error, audit "rejected")
       |
       +-- autonomyLevel.requiresApproval(risk) == false? --> AUTO-APPROVE (audit "auto_approved")
       |
       +-- trustedTools.contains(toolName)? --> AUTO-APPROVE (audit "trusted")
       |
       v
Send tool_approve_request to client
       |
       v
Block virtual thread (CompletableFuture.get, 120s timeout)
       |
       +-- User sends "approve"  --> ALLOW (audit "approved")
       +-- User sends "reject"   --> DENY  (audit "rejected")
       +-- User sends "always"   --> ALLOW + add to trustedTools (audit "approved")
       +-- Timeout (120s)        --> DENY  (audit "timeout", broadcast APPROVAL_TIMEOUT error)

WsToolApprovalFilter

The WsToolApprovalFilter is the server-side component that bridges the tool approval system with the WebSocket protocol. It implements ToolCallFilter from tnsai-core and is created automatically for each session+agent pair.

// Created automatically when a chat event is processed
WsToolApprovalFilter filter = new WsToolApprovalFilter(
    sessionId, agentId, connectionManager, auditService);
filter.setAutonomyLevel(session.getAutonomyLevel());
agent.setToolCallFilter(filter);

Key behaviors:

  • CompletableFuture blocking: The filter creates a CompletableFuture<Boolean> per pending approval. The agent's virtual thread calls future.get(120, SECONDS). This blocks the virtual thread without pinning OS threads.
  • 120-second timeout: If the user does not respond within 2 minutes, the tool call is rejected and an APPROVAL_TIMEOUT error is broadcast.
  • Always-trust: When the user sends decision: "always", the tool name is added to a Set<String> trustedTools. Future calls to that tool in this session skip the approval dialog.
  • Cancellation: cancelAll() completes all pending futures with false, unblocking any waiting agents. Called on cancel events and server shutdown.

AuditService

Every tool call decision -- whether auto-approved, manually approved, rejected, or timed out -- is recorded in a per-session audit trail. This provides full accountability for what the agent did and why, which is essential for debugging and compliance.

// AuditService holds up to 500 entries (bounded ConcurrentLinkedDeque)
AuditService audit = session.getAuditService();

// Record an entry
audit.record(new AuditEntry(
    toolCallId, agentId, toolName,
    risk.label(),   // "low", "medium", "high", "critical"
    decision,       // "auto_approved", "approved", "rejected", "trusted", "timeout"
    timestamp,
    summary
));

// Query
List<AuditEntry> all = audit.getEntries();
List<AuditEntry> recent = audit.getEntries(10);  // last 10

When the trail exceeds 500 entries, the oldest entry is dropped. Each audit entry is also broadcast as an audit_entry WebSocket event for real-time UI display.

The audit trail is accessible via the REST API:

GET /api/audit?sessionId=my-session

Tool Risk Classification

The filter uses a combination of tool name matching and command string analysis to determine the risk level. Shell commands get the most detailed classification because they can execute arbitrary system commands.

private ToolRisk classifyToolRisk(String toolName, Map<String, Object> arguments) {
    if ("shell".equals(toolName)) {
        // Delegates to ShellTool.classifyCommand() for fine-grained analysis
        return ShellTool.classifyCommand(arguments.get("input").toString());
    }
    if ("file_write".equals(toolName)) return ToolRisk.WRITE;
    if ("file_read".equals(toolName)) return ToolRisk.READ_ONLY;
    if ("git".equals(toolName))       return ToolRisk.READ_ONLY;
    if ("git_write".equals(toolName)) return ToolRisk.DESTRUCTIVE;
    return ToolRisk.UNKNOWN;  // Unknown tools treated as high risk
}

Shell commands get the most detailed classification, examining the command string for patterns like rm -rf, sudo, curl, git push --force, etc.

Declarative YAML Lifecycle Hooks

Lifecycle hooks let you run custom actions (like pre-deploy checks or notification scripts) in response to server events, without writing any Java code. You define them in YAML, and the server executes them automatically when the matching event occurs.

HookConfig

Each hook has a name, the event it listens for, a glob matcher to filter which tools or agents trigger it, and an action to execute. Here is a complete example configuration.

hooks:
  - name: "pre-deploy-check"
    event: "tool.execute"
    matcher: "deploy*"
    action:
      type: "command"
      command: "scripts/pre-deploy-check.sh"
    enabled: true

  - name: "audit-shell"
    event: "tool.execute"
    matcher: "shell"
    action:
      type: "log"
      message: "Shell command executed: {{toolName}} with args: {{args}}"
    enabled: true

  - name: "notify-on-error"
    event: "agent.error"
    matcher: "*"
    action:
      type: "command"
      command: "scripts/notify-error.sh {{agentId}} {{error}}"
    enabled: true

HookMatcher

Hooks use glob patterns to match against tool names or event types. This lets you target specific tools, groups of tools, or all events with a single rule.

matcher: "file_*"        # matches file_read, file_write
matcher: "deploy*"       # matches deploy, deploy-staging
matcher: "*"             # matches everything
matcher: "shell"         # exact match

Hook Action Types

There are two action types. Both support {{variable}} placeholders that are replaced with actual values at execution time.

TypeDescription
commandExecute a shell command. Supports {{variable}} placeholders for tool name, arguments, agent ID, etc.
logWrite a structured log entry. Supports the same placeholders.

DeclarativeHookExecutor

The DeclarativeHookExecutor runs matched hooks when events fire. Command hooks run asynchronously and have a configurable timeout (default: 30 seconds). Failed hooks are logged but never block agent execution, so a broken hook script will not take down your agent.

DeclarativeHookRegistry

The DeclarativeHookRegistry manages all registered hooks and matches them against incoming events. You can also enable or disable individual hooks at runtime without restarting the server.

DeclarativeHookRegistry registry = DeclarativeHookRegistry.fromConfig(config);

// Match hooks for an event
List<HookConfig> matched = registry.match("tool.execute", "shell");

// Enable/disable hooks at runtime
registry.setEnabled("pre-deploy-check", false);

// List all hooks
List<HookConfig> all = registry.getAll();

Security Fixes (v0.2.4)

These security issues were identified and fixed in version 0.2.4. If you are upgrading from an earlier version, review these changes to understand what was patched.

  • FileReadTool path traversal: Fixed directory traversal via ../ sequences in file paths. Paths are now resolved and validated against the allowed directory.
  • WsToolApprovalFilter always-trust: Fixed an issue where the "always trust" decision was not properly scoped to the current session.
  • IdleShutdownManager: Shutdown timeout is now configurable via TNSAI_IDLE_TIMEOUT_SECONDS environment variable.
  • Version skew: Fixed version mismatch between client and server protocol negotiation.

On this page