Skip to content
tnsaiDocs
GitHub

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.

OriginMeans
SELFThe executing agent acts on its own initiative
USERA person asked, including work an agent plans on that person's behalf
OTHER_AGENTA different agent asked

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:

  • execution on @ActionSpec or @Tool;
  • @ExecutionPolicy on 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

  1. Wire the executor once, at host startup: its principal, its default authority scope and the agent that really runs its actions.
  2. For each request, create an InvocationContext in your own host code, with the factory that matches the origin.
  3. Run the dispatch inside invocation.call(...) or invocation.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()));
}
FactoryOrigin
InvocationContext.user(human, executingAgent, executorId, authority)USER
InvocationContext.autonomous(executingAgent, executorId, authority)SELF
InvocationContext.otherAgent(callerAgent, executingAgent, executorId, authority)OTHER_AGENT; the caller must be a different agent
InvocationContext.unknown(executorId)None, so every protected action is refused

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.

TransitionOrigins at the destination
A person's request, nested work in the same agentUSER
A person's request, agent A delegates to agent BUSER and OTHER_AGENT
Agent A's own work, delegated to agent BOTHER_AGENT
Forwarded from A to B and back to AOTHER_AGENT remains
Agent B's own independent workSELF

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 bundled PlannerHandle, and Agent.executePlan() when it uses it) refuses a plan with SecurityException(UNAUTHORIZED) before its first step if any step declares a policy or @Security;
  • a role whose @OnMessageReceived callback 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

  • @Security on a composed annotation is not detected on any path: dispatch, plans or message callbacks. @ExecutionPolicy is. Put @Security on 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.