Security & Approvals
TnsAI provides a layered security model for agent actions: approval tokens for human-in-the-loop gating, AG-UI interrupts for blocking agent execution until a user responds, input/output guardrails for validation and sanitization, and a declarative `@Security` annotation that combines audit, access control, and encryption policies in one place.
@ApprovalRequired and ApprovalToken
Some actions are too dangerous to run automatically -- deleting data, sending emails, or making payments. The @ApprovalRequired annotation marks these actions so the framework blocks execution until a human explicitly approves it through an ApprovalToken. This is the foundation of human-in-the-loop safety in TnsAI.
Annotation
Place @ApprovalRequired on any action method that should not run without human consent. The reason field is shown to the approver so they understand why approval is needed.
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ApprovalRequired {
String reason() default "Sensitive operation";
}Usage:
@ActionSpec(type = ActionType.LOCAL, description = "Delete user account permanently")
@ApprovalRequired(reason = "Permanent data deletion")
public void deleteUser(String userId) {
// Only runs if an approved token is presented
}ApprovalToken
An ApprovalToken is a time-limited, agent-and-action-scoped credential that tracks the approval state. It is created when a protected action is invoked, sent to a human for review, and then approved or rejected. Tokens are bound to a specific agent and action, time-limited, and optionally single-use. The lifecycle is:
- Action annotated with
@ApprovalRequiredis invoked. - Token created with
agentId,actionName, and expiry. - Token sent to an approval system (UI, CLI, API).
- Human approves or rejects.
- If approved, the action executes.
- If
singleUse, the token transitions toCONSUMED.
Status Enum
A token moves through these states during its lifecycle. Once it leaves PENDING, it cannot go back.
| Status | Description |
|---|---|
PENDING | Awaiting human decision |
APPROVED | Approved by human |
REJECTED | Rejected by human |
EXPIRED | TTL elapsed before decision |
CONSUMED | Single-use token already used |
Builder
Create tokens with the builder for full control over expiration, single-use behavior, and metadata. The agentId and actionName fields scope the token so it cannot be reused for a different action.
ApprovalToken token = ApprovalToken.builder()
.agentId("agent-1")
.actionName("delete-user")
.parameters(Map.of("userId", "123"))
.timeToLive(Duration.ofMinutes(5)) // default: 5 minutes
.singleUse(true) // default: true
.requestedBy("system")
.reason("User requested account deletion")
.tokenId("custom-id") // optional, auto-generated UUID if omitted
.build();Factory shorthand:
ApprovalToken token = ApprovalToken.create("agent-1", "delete-user", Duration.ofMinutes(5));State Mutations
These methods transition the token between states. Each returns true if the transition was valid, false if it was not (for example, trying to approve an already-expired token).
// Approve -- only valid (not expired/consumed/rejected) tokens can be approved
boolean ok = token.approve("admin@example.com");
// Reject -- only PENDING, non-expired tokens can be rejected
boolean ok = token.reject("admin@example.com", "Too risky");
// Consume -- marks single-use tokens as CONSUMED after execution
boolean ok = token.consume();Query Methods
Use these to check the current state of a token. The isValid() method is a convenient shorthand that checks the token is not expired, consumed, or rejected.
token.isApproved(); // status == APPROVED
token.isPending(); // status == PENDING (auto-checks expiry)
token.isRejected(); // status == REJECTED
token.isExpired(); // Instant.now().isAfter(expiresAt)
token.isConsumed(); // status == CONSUMED
token.isValid(); // !expired && !consumed && !rejected
token.getRemainingTime(); // Duration until expiry (Duration.ZERO if past)
token.matches("agent-1", "delete-user"); // checks agentId + actionNameApprovalTokenStore
In a real application, tokens need to be stored so they survive between requests and can be looked up when the human responds. ApprovalTokenStore is the persistence interface, and three implementations ship with TnsAI for different deployment scenarios.
| Implementation | Use case |
|---|---|
InMemoryApprovalTokenStore | Development and testing |
RedisApprovalTokenStore | Distributed systems |
DatabaseApprovalTokenStore | SQL-backed persistent storage |
Store API
The store manages the full token lifecycle: creating, saving, looking up, approving, rejecting, validating, and cleaning up tokens.
ApprovalTokenStore store = new InMemoryApprovalTokenStore();
// Create tokens
ApprovalToken token = store.createToken("agent-1", "delete-data", Duration.ofMinutes(5));
ApprovalToken token = store.createToken("agent-1", "delete-data",
Map.of("userId", "123"), Duration.ofMinutes(5));
// Or save a manually built token
store.save(token);
// Lookup
Optional<ApprovalToken> found = store.getToken(tokenId);
// Approve / Reject via store
store.approve(tokenId, "admin@example.com");
store.reject(tokenId, "admin@example.com", "Not authorized");
// Validate and consume atomically -- checks approved + not expired + agent/action match
boolean valid = store.validateAndConsume(tokenId, "agent-1", "delete-data");
// List pending tokens
List<ApprovalToken> pending = store.listPending();
List<ApprovalToken> agentPending = store.listPendingForAgent("agent-1");
// Cleanup
int cleaned = store.cleanupExpired();
boolean deleted = store.delete(tokenId);
int total = store.count();Full Workflow Example
This example shows the complete approval flow from token creation to action execution, as it would work in a web application with an approval UI.
// 1. Create and store token
ApprovalTokenStore store = new InMemoryApprovalTokenStore();
ApprovalToken token = store.createToken("agent-1", "delete-user", Duration.ofMinutes(5));
// 2. Send token ID to approval UI (REST endpoint, WebSocket, etc.)
sendToApprovalUI(token.getTokenId(), token.getActionName(), token.getReason());
// 3. Human approves via UI -> callback hits server
store.approve(token.getTokenId(), "admin@example.com");
// 4. Action executor validates before running
boolean canExecute = store.validateAndConsume(
token.getTokenId(), "agent-1", "delete-user"
);
if (canExecute) {
executeAction();
}InterruptService (AG-UI Pattern)
When your agent runs in a UI-connected environment, you may want to pause execution mid-task and ask the user a question before continuing. InterruptService implements this pattern: it blocks the agent thread, emits an event to the frontend, waits for the user's response, and then resumes the agent with the result.
Flow
Here is the step-by-step sequence of how an interrupt works, from the agent requesting it to the user responding and the agent resuming.
- Agent calls
requestInterrupt()before a critical action. - Service creates an interrupt; the agent thread blocks.
- AG-UI emits
RUN_FINISHED { outcome: "interrupt", interrupt: {...} }. - Frontend shows an approval dialog.
- User approves or rejects.
- Frontend POSTs to
/v1/runs/{runId}/resume. - Service completes the future; the agent thread unblocks.
- Agent continues or aborts based on the result.
Constructors
Create an InterruptService with an optional listener (which emits events to the frontend) and an optional default timeout for how long to wait for a human response.
// Default: no listener, 5-minute timeout
InterruptService service = new InterruptService();
// With listener for AG-UI event emission
InterruptService service = new InterruptService(request -> {
agUiAdapter.emitInterrupt(request);
});
// With listener and custom default timeout
InterruptService service = new InterruptService(listener, Duration.ofMinutes(10));The listener receives an InterruptRequest record:
public record InterruptRequest(
String interruptId,
InterruptReason reason,
Map<String, Object> payload,
Duration timeout,
Instant createdAt
) {}InterruptReason Enum
The reason tells the frontend what kind of interrupt this is, so it can show the appropriate UI (an approval dialog, a file upload prompt, an error recovery form, etc.).
| Value | AG-UI wire value | Use case |
|---|---|---|
HUMAN_APPROVAL | "human_approval" | Sensitive actions (delete, send, pay) |
UPLOAD_REQUIRED | "upload_required" | Missing files or parameters |
POLICY_VIOLATION | "policy_violation" | Rate limit, budget, access control override |
ERROR_RECOVERY | "error_recovery" | API errors needing human guidance |
CONFIRMATION | "confirmation" | Multi-step wizard confirmation |
CUSTOM | "custom" | Anything else |
Parsing: InterruptReason.fromValue("human_approval") returns HUMAN_APPROVAL. Unknown strings return CUSTOM.
Requesting Interrupts
There are two ways to request an interrupt: blocking (the agent thread waits) and non-blocking (you get a CompletableFuture).
Blocking (synchronous) -- blocks the calling thread until resolved or timed out:
InterruptResult result = service.requestInterrupt(
InterruptReason.HUMAN_APPROVAL,
Map.of("action", "delete_user", "userId", "123")
);
// Thread blocks here until user responds or timeout
if (result.shouldProceed()) {
deleteUser("123");
} else {
throw new ActionRejectedException(result.getRejectionReason());
}With custom timeout:
InterruptResult result = service.requestInterrupt(
InterruptReason.HUMAN_APPROVAL,
Map.of("action", "delete_user", "userId", "123"),
Duration.ofMinutes(10)
);Non-blocking (async) -- returns a CompletableFuture:
CompletableFuture<InterruptResult> future = service.requestInterruptAsync(
InterruptReason.CONFIRMATION,
Map.of("summary", orderSummary),
Duration.ofMinutes(5)
);
future.thenAccept(result -> {
if (result.isApproved()) {
submitOrder();
}
});Resuming and Cancelling
These methods are called from your backend endpoint when the user responds in the frontend. The resume call unblocks the waiting agent thread with the user's decision.
// Approve
service.resume(interruptId, true, Map.of("approver", "admin"));
// Reject
service.resume(interruptId, false, Map.of("reason", "Too risky"));
// Cancel programmatically
service.cancel(interruptId);Query Methods
Check whether an interrupt is still waiting for a response, retrieve its details, or count all active interrupts.
service.isPending(interruptId); // true if active and not expired
Optional<InterruptRequest> req =
service.getPendingInterrupt(interruptId); // details or empty
int count = service.getPendingCount(); // auto-cleans expiredInterruptResult
When an interrupt resolves, the agent receives an InterruptResult that tells it the outcome: approved, rejected, timed out, cancelled, or error. Use shouldProceed() as the primary check for whether to continue with the action.
Outcome Enum
These are the possible outcomes of an interrupt. Only APPROVED results in shouldProceed() returning true.
| Outcome | Description |
|---|---|
APPROVED | User approved the action |
REJECTED | User rejected the action |
TIMED_OUT | No response within timeout |
CANCELLED | Cancelled programmatically |
ERROR | An exception occurred |
Factory Methods
Create InterruptResult instances for each outcome. These are typically called by the InterruptService internally, but you can use them in tests.
InterruptResult.approved(interruptId, Map.of("approver", "admin"));
InterruptResult.rejected(interruptId, Map.of("reason", "Not safe"));
InterruptResult.timedOut(interruptId);
InterruptResult.cancelled(interruptId);
InterruptResult.error(interruptId, "Connection failed");Query Methods
These methods let you inspect the result and extract relevant details like the approver's identity or rejection reason.
result.isApproved(); // outcome == APPROVED
result.isRejected(); // outcome == REJECTED
result.isTimedOut(); // outcome == TIMED_OUT
result.isCancelled(); // outcome == CANCELLED
result.isError(); // outcome == ERROR
result.shouldProceed(); // true only if APPROVED
result.getInterruptId(); // the interrupt ID
result.getOutcome(); // Outcome enum value
result.getPayload(); // Map<String, Object>, never null
result.getErrorMessage(); // error message (null unless ERROR)
result.getRejectionReason(); // payload.get("reason") as String, null if not rejected
result.getApprover(); // payload.get("approver") as String@InputGuardrail
Before an action runs, you want to make sure the input is safe and well-formed. @InputGuardrail lets you declare validation rules (length limits, required patterns, injection detection) and sanitization steps (trimming whitespace, escaping HTML) that are applied automatically before the action method is called.
Fields
These fields control what validation and sanitization is applied to the input. Most fields are optional -- only configure what you need.
| Field | Type | Default | Description |
|---|---|---|---|
maxLength | int | 0 | Maximum input length in characters. 0 = no limit. |
minLength | int | 0 | Minimum input length in characters. |
validators | Class<?>[] | {} | Validator classes (must implement InputValidator). |
sanitizers | Class<?>[] | {} | Sanitizer classes applied in order (must implement InputSanitizer). |
blockPatterns | String[] | {} | Regex patterns to reject (prompt injection protection). |
allowPatterns | String[] | {} | Regex patterns input must match. Non-matching input is rejected. |
jsonSchema | String | "" | JSON schema path or inline schema for JSON input validation. |
detectInjection | boolean | true | Enable prompt injection detection. |
injectionThreshold | double | 0.7 | Injection detection sensitivity (0.0--1.0). Higher = stricter. |
moderateContent | boolean | false | Enable content moderation. |
blockCategories | ContentCategory[] | {} | Content categories to block. |
onFailure | FailureAction | REJECT | What to do when validation fails. |
errorMessage | String | "" | Custom error message on failure. |
logFailures | boolean | true | Log validation failures. |
ContentCategory Enum
When content moderation is enabled, you can block specific categories of harmful content. These match standard content moderation taxonomies.
HATE, VIOLENCE, SEXUAL, SELF_HARM, HARASSMENT, DANGEROUS, ILLEGAL
FailureAction Enum
When validation fails, this determines what happens next. Choose based on how strict your use case requires.
| Value | Behavior |
|---|---|
REJECT | Reject input and throw exception |
SANITIZE | Sanitize the input and continue |
WARN | Log a warning and continue |
REVIEW | Request human review |
Built-in Validators
TnsAI ships with these validators out of the box. You can also implement your own by implementing the InputValidator interface.
NotEmpty, MaxLength, NoInjection, SafeContent, JsonSchema
Built-in Sanitizers
These sanitizers transform the input to remove or escape potentially harmful content. They are applied in the order you list them.
TrimWhitespace, HtmlEscape, NormalizeUnicode, RemoveControlChars
Example
@ActionSpec(type = ActionType.LOCAL, description = "Process user message")
@InputGuardrail(
maxLength = 10000,
validators = {NotEmpty.class, NoInjection.class},
sanitizers = {TrimWhitespace.class, HtmlEscape.class},
blockPatterns = {"ignore previous", "system prompt"},
injectionThreshold = 0.8,
onFailure = FailureAction.REJECT
)
public String processMessage(String message) {
// message is validated and sanitized before this runs
}Role-level default:
@InputGuardrail(
maxLength = 5000,
validators = {NotEmpty.class},
detectInjection = true
)
public class UserInputRole extends Role {
// All actions in this role inherit these guardrails
}@OutputGuardrail
Just as you validate input before processing, you should validate output before returning it to the user. @OutputGuardrail lets you enforce format requirements, mask personally identifiable information (PII), check for hallucinations, and moderate content -- all declaratively through an annotation.
Fields
These fields control output validation, PII masking, hallucination detection, and what to do when validation fails.
| Field | Type | Default | Description |
|---|---|---|---|
format | OutputFormat | TEXT | Expected output format. |
schema | String | "" | JSON schema path or inline schema. |
maxTokens | int | 0 | Maximum output length in tokens. 0 = no limit. |
maxChars | int | 0 | Maximum output length in characters. |
minChars | int | 0 | Minimum output length in characters. |
validators | Class<?>[] | {} | Validator classes (must implement OutputValidator). |
postProcessors | Class<?>[] | {} | Post-processor classes (must implement OutputParser). |
maskPII | boolean | false | Enable PII detection and masking. |
piiTypes | PIIType[] | {} | PII types to mask. |
moderateContent | boolean | false | Enable content moderation on output. |
blockCategories | ContentCategory[] | {} | Content categories to filter (reuses InputGuardrail.ContentCategory). |
checkHallucination | boolean | false | Check for hallucinations (requires context). |
hallucinationThreshold | double | 0.8 | Hallucination confidence threshold (0.0--1.0). |
onFailure | FailureAction | RETRY | What to do when validation fails. |
maxRetries | int | 3 | Maximum retry attempts on failure. |
fallback | String | "" | Fallback value if all retries fail. |
logFailures | boolean | true | Log validation failures. |
includeSpec | boolean | false | Include validation metadata in response. |
OutputFormat Enum
The expected format of the action's output. When set, the framework validates that the output conforms to this format.
TEXT, JSON, MARKDOWN, HTML, XML, YAML, CSV
PIIType Enum
When PII masking is enabled, specify which types of personal information to detect and redact from the output.
EMAIL, PHONE, SSN, CREDIT_CARD, ADDRESS, NAME, DOB, IP_ADDRESS, PASSPORT, DRIVER_LICENSE
FailureAction Enum
When output validation fails, this determines the recovery behavior. RETRY is the default because LLMs often produce valid output on the second attempt.
| Value | Behavior |
|---|---|
RETRY | Retry generation |
FALLBACK | Return fallback value |
REJECT | Throw exception |
TRUNCATE | Return partial/truncated output |
REVIEW | Request human review |
Built-in Validators
TnsAI ships with these output validators. You can also implement the OutputValidator interface for custom checks.
NotEmpty, JsonValid, SchemaCompliant, NoHallucination, NoPII, SafeContent
Example
@ActionSpec(type = ActionType.LLM_GENERATION, description = "Generate response")
@OutputGuardrail(
format = OutputFormat.JSON,
schema = "response-schema.json",
maxTokens = 1000,
validators = {NoHallucination.class, NoPII.class},
maskPII = true,
piiTypes = {PIIType.EMAIL, PIIType.PHONE, PIIType.SSN},
onFailure = FailureAction.RETRY,
maxRetries = 3,
fallback = "{\"error\": \"Unable to generate response\"}"
)
public String generateResponse(String query) {
// Response is validated before returning to the caller
}Role-level default:
@OutputGuardrail(
format = OutputFormat.MARKDOWN,
maxTokens = 2000,
checkHallucination = true
)
public class ContentRole extends Role {
// All actions inherit these output guardrails
}@Security
For actions that need multiple security controls at once, @Security bundles everything into a single annotation: approval gating, audit logging, access control (who can call this action), encryption of parameters and results, and idempotency protection. This avoids the need to stack multiple separate annotations on the same method.
Fields
Each field controls one aspect of the security policy. All fields are optional with safe defaults -- only enable what your action needs.
| Field | Type | Default | Description |
|---|---|---|---|
approvalRequired | boolean | false | Require human approval before execution. |
approvalReason | String | "" | Reason shown to the approver. |
audit | AuditLevel | NONE | Audit logging level. |
sensitive | boolean | false | Marks action as handling sensitive data (masked in logs/traces). |
maskFields | String[] | {} | Field names to mask in logs for sensitive data. |
allowedCallers | String[] | {} | Agent IDs allowed to call this action. Empty = all allowed. |
allowedRoles | String[] | {} | Role names allowed to call this action. Empty = all allowed. |
requiredPermissions | String[] | {} | Required permission names. |
encryptParams | boolean | false | Encrypt action parameters in transit. |
encryptResult | boolean | false | Encrypt action result in transit. |
securityTimeout | int | 0 | Max execution time (ms) before automatic abort. 0 = no timeout. |
idempotent | boolean | false | Whether the action can be safely replayed. Non-idempotent actions get replay protection. |
AuditLevel Enum
Choose how much detail to record in audit logs. Higher levels capture more information but generate more log volume.
| Level | What is logged |
|---|---|
NONE | Nothing |
BASIC | Action name and caller only |
STANDARD | Action name, caller, and parameters |
DETAILED | Everything including results |
FULL | Full audit with timing and stack traces |
Example -- Method Level
This example shows a high-security action that requires approval, detailed audit logging, restricted access, and a timeout.
@ActionSpec(type = ActionType.LOCAL, description = "Delete user account")
@Security(
approvalRequired = true,
approvalReason = "Permanent data deletion",
audit = AuditLevel.DETAILED,
sensitive = true,
maskFields = {"password", "ssn"},
allowedCallers = {"admin-agent"},
requiredPermissions = {"user:delete"},
securityTimeout = 30000
)
public void deleteUser(String userId) {
// Requires approval, full audit trail, 30s security timeout
}Example -- Type Level
Apply @Security to a role class to set default policies for all its actions. Individual methods can still override these defaults.
@Security(
audit = AuditLevel.BASIC,
allowedCallers = {"admin-agent", "supervisor-agent"},
sensitive = true,
encryptParams = true,
encryptResult = true
)
public class AdminRole extends Role {
// All actions inherit these security policies
}Combining with Other Annotations
For maximum protection, layer multiple security annotations. This example shows a financial transaction protected by approval, full audit, input validation, and output PII masking -- defense in depth.
@ActionSpec(type = ActionType.WEB_SERVICE, description = "Transfer funds")
@Security(
approvalRequired = true,
approvalReason = "Financial transaction",
audit = AuditLevel.FULL,
sensitive = true,
maskFields = {"accountNumber"},
requiredPermissions = {"finance:transfer"},
idempotent = false
)
@InputGuardrail(
validators = {NotEmpty.class},
detectInjection = true,
onFailure = FailureAction.REJECT
)
@OutputGuardrail(
maskPII = true,
piiTypes = {PIIType.CREDIT_CARD, PIIType.SSN},
onFailure = FailureAction.REJECT
)
public String transferFunds(String fromAccount, String toAccount, double amount) {
// Protected by approval, input validation, output masking, and full audit
}Roles
A `Role` defines what an agent can do. Each role has an identity (name, goal, domain), a set of responsibilities, and discoverable actions. Roles generate the system prompt that instructs the LLM. Actions are methods annotated with `@ActionSpec` — they are discovered at runtime via reflection and routed to one of four executor types.
Streaming
TnsAI supports three streaming modes for real-time token delivery from LLM providers.