Apricot Framework

Labels

Host-defined markers on a tool, for policies this library has no opinion about.

A tool carries the things every tool has — a name, a prompt, schemas, whether it changes anything. What it does not carry is whatever your organisation happens to care about: how sensitive the data is, which tier of caller may reach it, whether it has been reviewed.

Those are labels. The mechanism is here; the vocabulary is not.

[AgentToolType]
[AgentToolLabel("sensitivity", Sensitivity.Personal)]
[Authorize(Policy = "tickets.read")]
public sealed class TicketTools(TicketStore tickets)

Sensitivity is yours. Nothing in the library knows it exists, and nothing has to change here when you add a second label next year.

Reading them

if (descriptor.Declaration.TryGetLabel<Sensitivity>("sensitivity", out var sensitivity)) { … }
if (descriptor.Declaration.HasLabel("experimental")) { … }

They hang off IAgentToolDeclaration, not off the tool, so the same two lines read a class you wrote, a method you annotated, a function somebody handed you, and a tool another service owns. That is the whole reason the declaration is a separate thing from the function.

Asking for the wrong type reports absence rather than throwing, so a policy that does not recognise a value fails closed instead of falling over.

Where they earn their keep

In an authorization handler. The authorizer passes the descriptor as the authorization resource, so a handler sees the labels without anything in between:

protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, AgentAccessRequirement requirement)
{
    if (context.Resource is AgentToolDescriptor tool &&
        tool.Declaration.TryGetLabel<Sensitivity>("sensitivity", out var level) &&
        level == Sensitivity.Personal &&
        !context.User.HasClaim("scope", "personal-data"))
    {
        return Task.CompletedTask;   // abstain, and the requirement goes unmet
    }

    context.Succeed(requirement);

    return Task.CompletedTask;
}

In a validator, to make the vocabulary closed and the omission a startup failure:

public class SensitivityDeclared : IAgentToolValidator
{
    public void Validate(AgentToolDescriptor tool)
    {
        if (!tool.Declaration.TryGetLabel<Sensitivity>("sensitivity", out _))
        {
            throw new AgentToolDeclarationException($"'{tool.Name}' declares no sensitivity.");
        }
    }
}

That pairing is the point. An open vocabulary would decay — one service labelling "pii" where another labels "personal", matching neither policy and erroring nowhere. A validator is where you close it, and closing it at startup names the tool to whoever is adding it.

Typed labels

AgentToolLabelAttribute is not sealed. A host that wants its vocabulary closed at the point of declaration derives from it:

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
public sealed class SensitivityAttribute(Sensitivity level)
    : AgentToolLabelAttribute(SupportDeskLabels.Sensitivity, level)
{
    public Sensitivity Level { get; } = level;
}
[AgentToolType]
[Sensitivity(Sensitivity.Public)]
public sealed class TicketTools
{
    [Sensitivity(Sensitivity.Personal)]        // narrows it
    [AgentTool("support_tickets_get", ReadOnly = true)]
    public Task<TicketDetail> Get(long id) => …;
}

No label name to mistype, no value that is not one of the enum's, AllowMultiple = false if one per tool is what you mean, and IDE completion on both. It reads exactly as before — nothing that consumes labels knows the type exists.

Derive rather than write an unrelated attribute. A derived one is read both ways at once: a portable filter finds it as a label, and your own handler finds it in metadata as itself, with its typed property. An unrelated attribute is only ever the second.

Read it as a label, not out of the metadata. The two differ where it matters:

Rule
TryGetLabel<T>overrides by name — the method's wins over the holding class's
GetMetadata<T>()accumulates in declaration order — the holding class's comes first

So a method narrowing its family reads correctly as a label, while GetMetadata<SensitivityAttribute>().First() would hand you the family's. That asymmetry is deliberate — authorization attributes have to accumulate — but it is a trap if you read a label the metadata way.

Inheritance

Applied to a base class, a label covers a family of tools. A derived tool restating the same name overrides it rather than adding a second:

[AgentToolType]
[AgentToolLabel("sensitivity", Sensitivity.Public)]
public sealed class TicketTools
{
    [AgentToolLabel("sensitivity", Sensitivity.Personal)]     // wins for this tool only
    [AgentTool("support_tickets_get", ReadOnly = true)]
    public Task<TicketDetail> Get(long id) => …;
}

The same rule applies up a type chain, so a hand-written AgentTool deriving a shared base narrows its family the same way.

Tools nobody here declared

A tool that is a bare function, one read off an MCP server, or one another service owns carries labels it was handed rather than ones it declared — set on the AgentToolDeclaration beside it:

new AgentToolDeclaration
{
    Name = "weather_search",
    IsReadOnly = true,
    IsDestructive = false,
    Labels = new Dictionary<string, object?>
    {
        ["sensitivity"] = Sensitivity.Public,
        ["origin"] = "weather-co",
    },
}

The reading side cannot tell the difference, which is what lets one policy apply to both. The MCP source adds an mcp.origin label of its own for the same reason, and the gRPC source carries whatever labels the serving host published.

A caution

Labels are declared by whoever writes the tool. A tool mislabelling itself walks past a policy that trusts the label. That is the same trust already placed in a tool to implement its own operation correctly — but it makes these fields security-relevant, and worth reviewing as such.

For a tool from somewhere you do not control, the caution is stronger: a label it published is a claim by a third party. Attach the labels your policies read yourself, on the declaration, rather than trusting the ones that arrived.

On this page