Skip to content
tnsaijava agent framework

Execution Policy

0.17.0. Every dispatched action that declares a policy now runs behind an access-control layer (see Known limits for the one gap in that "every"), and execution policy is the half of it that answers a narrower question than @Security does: not who may call an action, but what kind of trusted origin is asking. Evaluation goes through the security SPI's SecurityEnforcerHandle.enforceAccess(AccessRequest), not a separate resource-permission engine. It fails closed by default: include tnsai-quality at runtime for declared policies to be enforced, or a missing provider denies every declaring action.

import com.tnsai.annotations.ActionSpec;
import com.tnsai.annotations.ExecutionPolicy;
import com.tnsai.enums.ActionType;
import static com.tnsai.enums.ExecutionCaller.*;

@ActionSpec(type = ActionType.LOCAL,
    execution = @ExecutionPolicy(execute = {USER, OTHER_AGENT}))
public String answer() { return "Ready"; }

ExecutionCaller has three values. SELF is the executing agent's own autonomous initiative. USER is a human interaction, including work an agent plans on the human's behalf. OTHER_AGENT is work requested by a different agent. Planning or selecting a tool never turns a USER or forwarded request into SELF -- origin is about who asked, not which code path ran. This API does not classify read/write effects, resource ownership, or data-access permissions; that stays @Security's job.

Declaring a policy

ActionSpec.execution() and Tool.execution() default to an empty annotation array, and absence adds no execution restriction -- existing @Security, principal, authority and approval checks still apply on their own. An explicit execution = @ExecutionPolicy(execute = {}) denies every origin. There is no permissive override marker and no separate "policy mode" setting.

A standalone @ExecutionPolicy also works directly on methods and types, or through a composed annotation at any depth. Restrictions from the class, the method, superclasses, interfaces, generic ancestors (including a T[] or T... parameter), composed annotations and dynamic registration all intersect -- an override, including a method that omits the annotation entirely, can narrow an ancestor's restriction but never widen it. A dynamically registered tool takes an ExecutionRule: .execution(new ExecutionRule(Set.of(USER))), where null means absent and an empty set denies. No annotation, tool name, HTTP verb or model argument implies SELF on its own.

Establishing the caller

AccessSubject agent = new AccessSubject(AccessSubject.Kind.AGENT, "agent-a", Set.of());
AccessSubject human = new AccessSubject(AccessSubject.Kind.HUMAN, "local-chat", Set.of());
executor.setPrincipal(principal);
executor.setDefaultScope(authority);
executor.configureExecution(agent);
InvocationContext invocation = InvocationContext.user(human, agent, principal.id(), authority);
// call(Callable<T>) declares throws Exception -- the enclosing method needs
// its own throws clause or a try/catch around this call.
Object result = invocation.call(() -> executor.execute(action, target, parameters, Map.of()));

The host picks the ingress factory: InvocationContext.user(human, executingAgent, executorId, authority), .autonomous(executingAgent, executorId, authority), or .otherAgent(callerAgent, executingAgent, executorId, authority). .unknown(executorId) grants no execution category at all. These factories are trusted Java APIs you call from your own host code -- not an authentication protocol and not a sandbox against a hostile JVM. Never read the origin, category or SELF claim from a request body, header, query parameter or model-generated argument; an active invocation cannot be replaced by an unrelated caller to change its origin mid-flight.

A local human-chat endpoint may mint a fixed anonymous HUMAN subject and USER category on the backend. That denotes the endpoint's interaction category, not a verified account, login, owner or tenant.

The executor's principal and the actual executing agent are distinct concepts: two agents can share one principal and remain different agents. Configure the real executing agent once, on its ActionExecutor or SCOPBridge, with configureExecution(AccessSubject). A protected invocation has to match both that agent binding and the current principal. AuthorityScope expiry, action types and target systems are additional bounds layered on top; root and delegated authority scopes intersect, so a later, narrower scope can never widen an earlier one.

The origin survives planning, nested tool calls, asynchronous work (InvocationContext.wrap, AgentContext.wrap) and delegation (delegateTo). Ordinary Java threads that aren't wrapped this way do not inherit identity.

Delegation and asynchronous work

delegateTo(nextAgent, nextExecutorId, nextAuthority) derives the departing agent from the existing context -- it never accepts an arbitrary claimed sender. What the destination requires depends on the transition:

TransitionRequired origins at destination
USER, local nested workUSER
USER, agent A delegates to BUSER and OTHER_AGENT
Autonomous A delegates to BOTHER_AGENT
Forwarded A → B → AOTHER_AGENT remains
B's own independent autonomous initiativeSELF

Every effective policy has to allow all the required categories at once, so a USER request that arrives through another agent needs {USER, OTHER_AGENT} declared, not either alone. Nesting within the same actual agent preserves its origin regardless of executor-principal equality.

Where the check runs -- and where it deliberately doesn't

Managed Role, Object-target (SCOP), and static/dynamic tool dispatch all check the policy before hooks, retrieval, cache lookup, approval consumption and the action body run. Inputs and post-hook maps are copied for the check; a policy or host change between two checks denies rather than reusing a stale verdict. Approval is an additional condition on top, consumed only after this authorization succeeds. No action can bypass the real dispatch gate just because it appeared in a tool list offered to the model -- that list filtering is advisory only.

Plan steps and message callbacks are the two exceptions, because both invoke action methods directly rather than going through ActionExecutor: PlanExecutor, the bundled PlannerHandle.execute() (and Agent.executePlan() when it runs the bundled planner), and MessageCallbackDispatcher never ran the gate for them before 0.17.0. So that no declared action can run ungated, a plan containing a step whose action declares @ExecutionPolicy or @Security declared directly (not through a composed annotation -- see Known limits) is refused with SecurityException(UNAUTHORIZED) before its first step runs, and a role whose @OnMessageReceived callback declares either, directly, is refused at registration. Dispatch an action like that through ActionExecutor instead.

A custom PlannerHandle is not covered by this refusal. Agent.executePlan() calls a PlannerHandle supplied through AgentBuilder.plannerHandle, Agent.setPlannerHandle or another PlannerHandle.Factory without checking it first -- only the bundled planner refuses protected steps. PlanExecutor.refuseProtectedSteps lives in tnsai-intelligence, so a tnsai-core-only custom handle cannot call it even if it wanted to; a custom PlannerHandle has to refuse its own protected steps.

ActionExecutor.authorizeAction(...) and SCOPBridge.authorizeAction(target, actionName, params) run the same check without side effects and return the checked AccessRequest -- null for an action that declares nothing -- for a host that wants to preflight a call. SCOPBridge.executeAction(target, actionName, params, resolvedPaths, sourceOverrides, invocation) dispatches under a given InvocationContext directly.

Idempotency is partitioned by caller

Stored idempotency results, including explicit keys, are partitioned by the caller: the executor id, the executing agent, the origin categories, and every subject with its roles, plus -- for an action that declares a policy or @Security -- the checked target binding. A retry by the same caller under a fresh context still deduplicates; a different caller, role set, origin or agent never reads another caller's entry, and each hit is reauthorized against current policy and security state regardless. The Idempotency-Key header sent upstream stays the plain key and carries no caller identity of its own, so an upstream service that deduplicates on that header alone still treats identical keys from different callers as one request. InvocationContext.unknown contexts get a random partition and never share entries with anything.

Known limits

This API doesn't cover everything that touches security in the framework:

  • Native SCOP ingress and replay, and real HTTP USER integration, are the host's responsibility to wire correctly -- this contract assumes the host did.
  • Remote MCP permissions and arbitrary direct Java calls are outside this guarantee entirely.
  • A discovered MCP alias of an already-declared endpoint still needs explicit dynamic registration; discovery alone does not carry a policy over.
  • @Security placed only on a composed (meta-)annotation is not detected on any dispatch path -- ActionExecutor, plans and message callbacks included, whether or not tnsai-quality is present. @ExecutionPolicy resolves through composed annotations correctly; the equivalent lookup for @Security does not exist. This is a known, open gap, not something 0.17.0 fixed. Declare @Security directly -- on the method, on an overridden declaration, or on the role class or one of its ancestors; see the @Security section of Approvals and Annotations.

Protect every sensitive action explicitly -- an absent policy intentionally adds no restriction of its own.