Overview
One-line positioning: SkillFilter decides what the agent is designed to have; grant decides what the current user is authorized to use. The effective capability set is the intersection. Why grant exists: in enterprise deployments, one agent serves many users with different permissions (multi-tenant, multi-role). The grant layer cuts out every capability the current user is not authorized for — a cut capability does not exist for the model: it never enters tool schemas, cannot be enumerated, cannot be activated viareset_equipped_tools, cannot be loaded via load_skill_through_path, and cannot be executed. The harness skill chain (HarnessSkillMiddleware’s prompt and catalog, plus the harness load_skill_through_path) is equally governed by the grant surface. The staged cache is clamped the same way: sandbox workspace projection hydrates only the granted skills’ .skills-cache subtrees for the current call — residue staged under an earlier, wider grant stays on disk (the grace-window GC owns deletion), no longer enters the sandbox, and is revoked out of a reused or snapshot-restored workspace when the current call no longer authorizes it.
It is orthogonal to the existing layers — each layer answers exactly one question:
One-line composition: a capability actually executes only if it passes, in order — configuration intent (SkillFilter) → user grant → current activation → per-call Permission approval. Each layer answers exactly one question; they intersect, they never overlap.
Quick start
Two things: implement aCapabilityResolver, register it on the agent.
- When it resolves: once per
call(), server-side, at the call entry; anullreturn means no trimming. - Lifecycle: the resolved surface rides the
RuntimeContextthrough the whole call chain — frozen per request, never persisted. - Sub-agents: sub-agents without their own resolver reuse the parent’s surface instance (zero re-computation).
- Harness:
HarnessAgent.builder().capabilityResolver(...)is supported as well; resolution happens before the sandbox acquire and skill pre-staging, so the staged set, the prompt catalog and every gate observe the same surface. - Caching: the resolver runs at every call entry (exactly once per call). If its backend is an expensive lookup (DB / RPC), cache it yourself to match your business needs (e.g. per
userIdwith a TTL), accepting the corresponding permission-propagation delay; the framework does not cache resolver results. - Supersets are safe: surface contents may exceed any agent’s actual capabilities — names the agent does not have are simply never matched.
The three surface shapes (read this)
ACapabilitySurface has exactly three shapes with clear meanings:
null (not set) and Set.of() (explicitly empty) are two different things: the latter declares “zero grants via this dimension”, the former “not participating”. Once the surface is not all-null, the two have the same grant outcome (nothing passes via that dimension); the accessors (allowedSkills() etc.) still preserve the distinction.
Grant decision rules
Whitelists only — matching any set dimension grants:- Granting a skill implicitly grants its manual, scripts and associated tools (the skill tool group, convention-named
skillId + "_skill_tools"), and automatically enables custom tool groups bound to it viaactivateOnSkill(the call entry merges bound groups into the grant set); - Granting a ToolGroup implicitly grants every tool in it;
- Granting a tool by name grants exactly that tool (grouping is ignored, and it does not amplify to sibling tools in the same group).
name + "_" + source) remains the framework-internal registration and addressing key: loading takes a skillId, and because the standard naming rule forbids underscores in names, the first underscore in a skillId is always the separator — the split is unambiguous.
Only whitelists are provided; there is no deny (reverse-ban) capability. Default is deny — anything not reachable from the enumeration does not exist.
System capability markers (system)
Which capabilities skip trimming? Basic core capabilities that keep the agent running (the framework meta tools are exactly this). The marker is not a control axis for business-shaped capability trimming — do not use it as a business capability switch.
The framework’s own meta tools (reset_equipped_tools, load_skill_through_path) are marked system and stay usable under any surface (including DENY_ALL). Assemblers may also mark their own tools/groups/skills system = true (@Tool(system = true) or the corresponding builder) — an explicit declaration that the capability is exempt from trimming; registration logs an INFO trace for auditability.
The marker applies only to the object it marks — it does not propagate:
- a tool’s
systemexempts that tool’s visibility and execution; - a group’s
systemexempts only the group’s own enumeration and activation — member tools stay governed by the grant surface (mark each membersystemif it needs the exemption); - a skill’s
systemexempts its prompt visibility and loading.
Trust boundary: the system marker is open to and trusted from the assembler (the agent developer). The threat model is “end user vs. the agent’s capability surface”, not defense against the assembler.
Fail-closed behavior
Principle: when the authorization infrastructure fails, fail the call — never open up.- A throwing resolver aborts the call: the exception propagates to the caller and ERROR is logged — an authorization-infrastructure failure is not “no grants”. Catch inside your resolver and return
DENY_ALLif graceful degradation is wanted. - Direct execution: paths that execute tools directly without going through an agent
call()entry (e.g.Toolkit.callTool/callTools) bypass grant resolution — a missing surface means no trimming; invokingDynamicSkillMiddlewaredirectly behaves the same (no surface on the rc = trusted assembler code, skills stay visible). Place a surface on theRuntimeContext(rc.put(CapabilitySurface.class, surface)) to have grants applied.
Static skillBox path limitation
skillBox(...) is deprecated since 2.0.0 (retiring with the hook system); do not use it in new code — register skills with skillRepositories(...) (the default dynamic path) instead.
Authorization compatibility: skillBox(...) combines with a capability resolver. The static prompt is trimmed to the current grant surface right before injection — the SkillHook reads the surface from the call’s runtime context and filters through the same rendering pipeline. When authorization is not engaged (or the surface is ALLOW_ALL) the prompt stays byte-identical to previous versions; when no skill is granted the whole block is omitted; sub-agents inheriting a parent surface are covered the same way.
One combination is still rejected: skillRepositories(...) + dynamicSkillsEnabled(false) + resolver — that shape renders no per-call skill prompt at all, so the configuration would be a silently ineffective authorization; build() fails with an IllegalStateException (an authorization setting must never silently degrade to an untrimmed agent).
Persistence and downgrade: authorization state (the surface, the managed marker, revoke scopes) is never persisted; the revocation manifest (projectedWorkspacePaths) is written only once authorization is engaged — deployments that never enable it keep a persisted JSON shape identical to older versions and can roll back safely. A grant-enabled deployment rolled back to an older version loses the unrecognized field: the old code discards the resumed sandbox state and recreates the workspace (WARN, calls unaffected). Multi-instance deployments should configure a real execution guard (DistributedStore) — under the default Noop guard, concurrent calls on one scope resolve projection state last-write-wins.