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.
| Risk | Label | requiresApproval() | isRejected() | Examples |
|---|---|---|---|---|
READ_ONLY | low | false | false | file_read, git (status, log, diff) |
BUILD_TEST | low | false | false | mvn test, npm run build |
WRITE | medium | true | false | file_write |
DESTRUCTIVE | high | true | false | git_write (reset, force push), rm -rf |
NETWORK | medium | true | false | curl, wget |
ESCALATION | critical | true | true | sudo, su -- always rejected |
UNKNOWN | high | true | false | Unrecognized 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.
| Level | LOW risk | MEDIUM risk | HIGH risk | CRITICAL risk |
|---|---|---|---|---|
FULL_AUTO | Auto | Auto | Auto | Ask |
SUPERVISED | Auto | Auto | Ask | Ask |
CAUTIOUS | Auto | Ask | Ask | Ask |
MANUAL | Ask | Ask | Ask | Ask |
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 callsfuture.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_TIMEOUTerror is broadcast. - Always-trust: When the user sends
decision: "always", the tool name is added to aSet<String> trustedTools. Future calls to that tool in this session skip the approval dialog. - Cancellation:
cancelAll()completes all pending futures withfalse, unblocking any waiting agents. Called oncancelevents 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 10When 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-sessionTool 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: trueHookMatcher
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 matchHook Action Types
There are two action types. Both support {{variable}} placeholders that are replaced with actual values at execution time.
| Type | Description |
|---|---|
command | Execute a shell command. Supports {{variable}} placeholders for tool name, arguments, agent ID, etc. |
log | Write 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_SECONDSenvironment variable. - Version skew: Fixed version mismatch between client and server protocol negotiation.
RAG Pipeline
The server provides a per-session Retrieval-Augmented Generation pipeline that indexes local codebases, chunks source files by language boundaries, and retrieves relevant context using hybrid BM25 + vector search with Reciprocal Rank Fusion.
WebSocket Protocol
The Server module is a Javalin-based backend that bridges frontends (CLI, IDE, web) to the TnsAI agent framework via WebSocket. It provides multi-agent sessions, real-time streaming, risk-based tool approval, hybrid RAG search, and an audit trail.