Restrict who can trigger an action
An execution policy limits an action by where the request came from: the agent's own initiative, a person, or another agent. It answers a narrower question than @Security, which decides which callers and roles may call an action. Execution policy says nothing about reads, writes or data ownership.
The check fails closed. tnsai-quality provides the enforcer; without it on the runtime classpath, every action that declares a policy is refused.
Planning or picking a tool never turns a USER or forwarded request into SELF. The origin records who asked, not which code path ran.
Declare a policy
public static class FrontDesk {
@ActionSpec(type = ActionType.LOCAL, description = "Answer a visitor's question",
execution = @ExecutionPolicy(execute = {USER, OTHER_AGENT}))
public String answer(String question) {
return "Ready";
}
}A policy can come from:
executionon@ActionSpecor@Tool;@ExecutionPolicyon the method or the class;- a composed annotation that carries
@ExecutionPolicy, at any depth; - a dynamically registered tool:
DynamicToolMethod.builder(...).execution(new ExecutionRule(Set.of(USER))).
An action with no policy gets no execution restriction; @Security, principal, authority and approval checks still apply. execute = {} denies every origin, and so does an ExecutionRule with an empty set. A null rule means no policy.
All the policies that apply to an action intersect: the class, the method, superclasses, interfaces, generic ancestors, composed annotations and the dynamic rule. An override can narrow an inherited policy but never widen it, and leaving the annotation off an override keeps the inherited one.
Call a protected action
- Wire the executor once, at host startup: its principal, its default authority scope and the agent that really runs its actions.
- For each request, create an
InvocationContextin your own host code, with the factory that matches the origin. - Run the dispatch inside
invocation.call(...)orinvocation.run(...).
static final AccessSubject AGENT = new AccessSubject(AccessSubject.Kind.AGENT, "front-desk", Set.of());
static void wire(ActionExecutor executor, AgentPrincipal principal, AuthorityScope authority) {
executor.setPrincipal(principal);
executor.setDefaultScope(authority);
executor.configureExecution(AGENT); // the agent that really runs the actions
}
static Object answerForVisitor(ActionExecutor executor, AgentPrincipal principal, AuthorityScope authority,
Object desk, String question) throws Exception {
AccessSubject visitor = new AccessSubject(AccessSubject.Kind.HUMAN, "local-chat", Set.of());
InvocationContext invocation = InvocationContext.user(visitor, AGENT, principal.id(), authority);
ActionMetadata action = ActionDiscovery.discoverActions(desk.getClass()).findAction("answer");
return invocation.call(() -> executor.execute(action, desk, Map.of("question", question), Map.of()));
}executorId is the executor principal's id(). call declares throws Exception; run takes a Runnable.
These factories are trusted Java APIs, not an authentication protocol. Never take the origin from a request body, header, query parameter or model-generated argument. A local chat endpoint may use one fixed anonymous HUMAN subject: it marks the kind of interaction, not a verified account. An active invocation cannot be replaced by an unrelated one mid-call.
A protected dispatch is refused unless:
- there is a current invocation, and the executor has a principal, an authority scope and a configured agent;
- the invocation's executing agent is that configured agent, and its executor id is the principal's id;
- no subject is
UNKNOWN; - every policy allows every origin the invocation carries;
- every authority scope, the executor's and the invocation's, is unexpired, permits the action type and, if it lists target systems, permits the action's target.
To check target systems, pass a lookup as the second argument: configureExecution(agent, target -> "billing").
Attribute an invocation to its own principal
user, autonomous and otherAgent each have an overload with a trailing AgentPrincipal. That invocation's dispatches are attributed to it instead of the executor's principal, so one executor can serve several principals. null keeps the executor's. principal() returns the invocation's principal, and InvocationContext.dispatchPrincipal(wired) returns the one a dispatch running now is attributed to. For what a principal is, see Principal and authority.
SCOPBridge has no principal of its own. A bridge dispatch whose invocation carries none is refused with principal=missing.
Delegate and run work in the background
delegateTo(nextAgent, nextExecutorId, nextAuthority) derives the next invocation from the current one; it never accepts a claimed sender. The new authority scope is added to the existing ones, so a delegation can narrow authority but never widen it.
Because a policy must allow every origin at once, a person's request that reaches an agent through another agent needs {USER, OTHER_AGENT}, not either alone.
A hop to the same agent keeps the invocation's principal. A hop to another agent drops it, so the recipient's dispatches are attributed to the recipient executor's own principal; on an executor without one, such as SCOPBridge, they are refused.
The origin follows planning and nested tool calls. For work on another thread, wrap the task with InvocationContext.wrap or AgentContext.wrap; a plain thread inherits no invocation.
Check before you call
ActionExecutor.authorizeAction(action, target, parameters) and SCOPBridge.authorizeAction(target, actionName, params) run the same check without side effects. They return the checked AccessRequest, or null for an action that declares nothing, and throw SecurityException when the call would be refused.
On SCOPBridge, executeAction(target, actionName, params, resolvedPaths, invocation) dispatches under the given invocation. The three- and four-argument forms use the invocation current on the thread, so outside one they are refused with principal=missing.
Where the check runs
ActionExecutor, SCOPBridge and tool dispatch check the policy before hooks, retrieval, the cache lookup, approval and the action body. Approval is consumed only after this check passes. Filtering the tool list offered to the model is advisory; the dispatch check runs regardless.
Plan steps and message callbacks call action methods directly, so the check cannot run for them. Instead:
- the bundled planner (
PlanExecutor, the bundledPlannerHandle, andAgent.executePlan()when it uses it) refuses a plan withSecurityException(UNAUTHORIZED)before its first step if any step declares a policy or@Security; - a role whose
@OnMessageReceivedcallback declares either is refused at registration.
Dispatch such actions through ActionExecutor.
A custom PlannerHandle is not covered. Agent.executePlan() runs it without this refusal, and PlanExecutor.refuseProtectedSteps lives in tnsai-intelligence, so a handle built on tnsai-core alone cannot call it. A custom planner has to refuse protected steps itself.
Cached results are kept per caller
Stored @Idempotent results, including those with explicit keys, are partitioned by caller: the executor id, the executing agent, the origins, every subject with its roles, the invocation's principal and, for a protected action, the checked target. A retry by the same caller in a fresh invocation still hits the cache. Another caller never reads the entry, and every hit is checked again against the current policy. InvocationContext.unknown invocations get a random partition and share nothing.
The Idempotency-Key header sent to a web service is the plain key. An upstream service that deduplicates on that header alone treats the same key from different callers as one request.
Limits
@Securityon a composed annotation is not detected on any path: dispatch, plans or message callbacks.@ExecutionPolicyis. Put@Securityon the method, an overridden declaration, the class or one of its ancestors.- Native SCOP ingress and replay, and HTTP requests from people, are classified by your host code. The guarantees assume it does so correctly.
- Remote MCP permissions and direct Java calls to action methods are outside this check.
- A discovered MCP alias of a protected endpoint needs its own dynamic registration; discovery does not carry the policy over.