Skip to content
tnsaiDocs
GitHub

Enforce permissions and content rules

Use @Security to decide who may call an action and how its data is handled. The other classes on this page check content on its way in and out: moderation, input validation, secret scrubbing, tool allow lists and an audit trail.

Enforcement lives in the tnsai-quality module. Add it next to tnsai-core; the executor finds it through ServiceLoader.

Protect an action with @Security

public static class BillingActions {
    @ActionSpec(type = ActionType.LOCAL, description = "Issue a refund")
    @Security(requiredPermissions = "billing:refund", allowedRoles = "support",
              audit = Security.AuditLevel.STANDARD, maskFields = "cardNumber",
              securityTimeout = 10_000)
    public String refund(String orderId, String cardNumber) {
        return "refunded " + orderId;
    }
}

Put @Security on the method or on its class. The executor collects it from the class, its superclasses and interfaces, the method and the methods it overrides, and applies all of them. Use the annotation directly: a @Security placed on a custom annotation is not detected.

FieldEnforcedEffect
allowedCallersYesCaller ids that may run the action. A call with no caller is refused.
allowedRolesYesThe caller needs at least one of these roles.
requiredPermissionsYesThe caller needs every permission, looked up in the PermissionProvider.
encryptParamsYesString arguments are encrypted before the action body runs, so the body receives ciphertext.
encryptResultYesA String result is encrypted before it is returned.
securityTimeoutYesMilliseconds per attempt. A slower call fails with a SecurityException and the body is interrupted. 0 means no limit.
auditYesNONE, BASIC, STANDARD, DETAILED or FULL. Writes an audit line to the log.
maskFields, sensitiveYesMasks the named arguments, or all of them, in audit lines.
approvalRequired, approvalReasonNoMetadata only. Use @ApprovalRequired; see Approve risky actions.
idempotentNoMetadata only.

When several @Security annotations apply, the access rules of each one must pass. For data handling, the encryption flags and sensitive apply if any annotation sets them, the shortest timeout wins, the highest audit level wins and the masked fields are combined.

Any @Security or @ExecutionPolicy makes the executor run an access check. Without tnsai-quality on the classpath that check refuses the call, so a protected action never runs unprotected.

The agent also needs a principal and an authority scope. AgentBuilder requires both. A call is refused if the scope has expired, does not permit the action's type or does not cover its target systems.

Pass the caller

The access check reads the caller from the current InvocationContext. Each subject in it (the person, and anyone the call was made on behalf of) must pass allowedCallers, allowedRoles and requiredPermissions. A subject of kind UNKNOWN has no id and no roles.

static Object refundAsAlice(Agent agent, AccessSubject agentSubject, String executorId,
                            AuthorityScope authority) throws Exception {
    // Grant on the provider the enforcer uses: discover() returns the same cached instance.
    ((DefaultPermissionProvider) PermissionProvider.discover()).grant("alice", "billing:refund");

    AccessSubject alice = new AccessSubject(AccessSubject.Kind.HUMAN, "alice", Set.of("support"));
    InvocationContext invocation = InvocationContext.user(alice, agentSubject, executorId, authority);
    return invocation.call(() -> agent.executeAction("refund",
        Map.of("orderId", "o-42", "cardNumber", "4111111111111111")));
}

Outside an invocation there is no caller, so an action with allowedCallers or requiredPermissions is refused. Execution policy covers the InvocationContext factories, delegation and how the origin carries across threads.

Grant permissions

requiredPermissions asks the PermissionProvider returned by PermissionProvider.discover(). tnsai-quality registers DefaultPermissionProvider, an in-memory map with grant, revoke, grantAll, revokeAll, hasPermission and getPermissions. discover() caches its result per class loader, so the instance it returns is the one the enforcer checks.

To read permissions from your own system, implement PermissionProvider and register it in META-INF/services/com.tnsai.security.PermissionProvider. The first registered provider wins.

Encryption and audit lines

The enforcer an agent uses generates its own AES-256 key at startup. encryptParams and encryptResult use that key, and you cannot supply it through AgentBuilder. To encrypt with a key you manage, build an EncryptionConfig and call a SecurityEnforcer yourself:

  • EncryptionConfig.generateKey(), fromBase64Key(String) or fromKeyBytes(byte[]); builder() also sets the IV length and the GCM tag length.
  • new SecurityEnforcer(config) or new SecurityEnforcer(config, permissionProvider), then encrypt, decrypt and maskForLogging(action, role, params).

Audit lines from @Security(audit = ...) and @AuditLog go to the SLF4J logger com.tnsai.quality.security.AuditLogger, with masking applied. Route that logger in your logging configuration. These lines are not written to an AuditStore.

Moderate content

PatternBasedModerator scores text against regex patterns per category. It blocks at or above the block threshold and warns at or above the warn threshold.

static String moderate(String userInput) {
    PatternBasedModerator moderator = PatternBasedModerator.builder()
        .addPattern(ModerationResult.Category.OFF_TOPIC, "(?i)\\bcrypto tips\\b", 0.9)
        .blockThreshold(0.8)
        .warnThreshold(0.5)
        .build();

    ModerationResult result = moderator.checkInput(userInput);
    if (result.isBlocked()) {
        return result.getSafeResponse();
    }
    if (result.hasWarning()) {
        System.out.println("Flagged: " + result.getFlaggedCategories() + " scores " + result.getCategoryScores());
    }
    return userInput;
}

Every moderator starts from the built-in patterns; addPattern adds to them. PatternBasedModerator.withDefaults() blocks at 0.8 and warns at 0.5. strict() blocks at 0.6 and warns at 0.3. The builder also takes checkPII(boolean) and enabledCategories(Set<Category>).

The ContentModerator interface has checkInput, checkOutput and checkToolResult(toolName, result). Implement it to call an external moderation service.

Category
HATEHARASSMENTVIOLENCE
SELF_HARMSEXUALILLEGAL
TOXICSPAMPROMPT_INJECTION
PIIOFF_TOPIC

ModerationResult.Action is ALLOW, WARN or BLOCK. The result also offers isAllowed, isFlagged(category), getScore(category), getReason, getFlaggedContent and getDetailedSafeResponse.

For prompt injection, use the dedicated detector in Detect prompt injection.

Validate input

ValidationService checks sizes, nesting and known attack patterns before you use input.

static String validate(Map<String, Object> params) {
    ValidationService validator = ValidationService.builder()
        .maxStringLength(5_000)
        .allowedFields(Set.of("orderId", "note"))
        .build();

    ValidationResult result = validator.validateParameters(params);
    return result.isValid() ? "ok" : String.join("; ", result.getErrors());
}
LimitwithDefaults()strict()
Max string length100,00010,000
Max parameter count10020
Max nesting depth105
Max array size1,000100

Both block dangerous patterns: SQL injection, command injection, path traversal and script injection such as <script, javascript: or inline event handlers. Add your own with addPattern(name, Pattern), or turn the built-in ones off with blockDangerousPatterns(false).

Other methods: validateString(value, field), validateNumber(value, field, min, max), sanitize(input) and containsDangerousPatterns(value). validateOrThrow and validateParametersOrThrow throw a SecurityException of type VALIDATION_FAILURE.

Scrub secrets

SecretFilter replaces API keys, tokens, private keys and connection strings with [REDACTED:type] placeholders before text reaches a model or a log.

static String scrubSecrets(String text) {
    SecretFilter filter = SecretFilter.builder()
        .addPattern("internal_token", "itk_[A-Za-z0-9]{32}", "internal_token")
        .build();

    SecretFilter.ScrubResult result = filter.scrubWithDetails(text);
    if (result.hasDetections()) {
        System.out.println("Redacted " + result.totalRedactions() + ": " + result.detections());
    }
    return result.scrubbedText();       // e.g. "key=[REDACTED:openai_api_key]"
}

SecretFilter.standard() uses the built-in patterns only. The builder can disablePattern(name), enablePattern(name) or start empty with noBuiltIn(). scrub(text) returns the cleaned text, and containsSecrets(text) only checks. For personal data such as emails and phone numbers, see Redaction.

Limit tools

To block or steer tool calls on an agent, set a ToolCallFilter; see Tool call filters. ToolFilter and ToolPermissionManager are building blocks for the decision inside such a filter.

static String decide(String toolName) {
    ToolFilter filter = ToolFilter.builder()
        .allow("read_*", "search_*")
        .deny("*_delete")
        .build();

    ToolPermissionManager permissions = ToolPermissionManager.defaultDeny();
    permissions.setToolPermission("read_file", PermissionLevel.ALWAYS);
    permissions.setToolPermission("search_web", PermissionLevel.ASK);

    if (!filter.isAllowed(toolName) || !permissions.isAllowed(toolName)) {
        return "deny";
    }
    return permissions.requiresConfirmation(toolName) ? "ask" : "run";   // ASK still allows execution
}

ToolFilter matches tool names case-insensitively against globs (* and ?). A deny match always wins. With no allow globs, everything not denied is allowed. filterAllowed(names) keeps the allowed names from a collection, and ToolFilter.allowAll() allows everything.

ToolPermissionManager gives each tool a PermissionLevel: ALWAYS runs it, ASK runs it after confirmation, and DENY refuses it. Tools without their own level use the default: ASK for new ToolPermissionManager(), DENY for defaultDeny(). Asking the person is up to your code.

Keep an audit trail

AuditStore records security decisions as AuditEvents for later queries. Your code records the events.

static List<AuditEvent> auditTrail() {
    AuditStore store = new FileAuditStore(Path.of("/var/lib/myapp/audit"));   // a directory
    store.record(AuditEvent.builder()
        .agentId("billing-agent")
        .action("refund")
        .decision("APPROVED")
        .approver("alice")
        .context(Map.of("orderId", "o-42"))
        .build());

    return store.query(AuditQuery.builder().agentId("billing-agent").limit(50).build());
}

agentId, action, decision and approver are required. The id defaults to a random UUID and the timestamp to now. AuditQuery also filters by decision, sessionId, from and to, and AuditQuery.all() matches everything. The store also has count(query) and deleteOlderThan(instant).

FileAuditStore() writes under .tnsai/audit in the working directory. With tnsai-quality on the classpath, AuditStore.discover() returns an InMemoryAuditStore. For a database, implement AuditStore and register it in META-INF/services/com.tnsai.security.audit.AuditStore.

To run untrusted code in isolation, see Run code in a sandbox.