Assistants, identity and authority / Field essay 04

Build the tool broker around the authority that survives alternate paths.

A model proposes a typed action; an independently authorized executor controls credentials, object access and effectful routes.

Published

ARCHITECTURE
FIELD ESSAY 04 / ASSISTANTS, IDENTITY AND AUTHORITY

Build the tool broker around the authority that survives alternate paths

The useful security test for a tool broker is the alternate path.

In our assistant, a named MCP tool can call a product route. So can the generic call_api tool. If removing a named tool from the model's menu were our authorization boundary, the second path would undo the protection. Instead, both paths need to arrive at the same independently enforced product decision.

That is why I built the remote MCP surface around a deliberately repetitive operation: carry the caller's bearer credential into an in-process request through the real REST router. The outer MCP request authenticates. The product request authenticates again. The route still decides whether this actor may touch this object in this organization.

This costs work. I have not measured that overhead here, and I will not turn a design tradeoff into a latency claim. The benefit is architectural: a new presentation of an action does not need a second interpretation of the permission model. Here is how I separate tool selection, authorization, credential handling and effect recovery, and where the existing implementation stops short of the stronger gateway design.

The tool menu is an interface to authority

A model needs a useful vocabulary. Named tools provide descriptions, argument schemas and predictable results. They help the model choose the right operation and reduce avoidable argument mistakes. None of those properties proves that the operation is authorized.

A syntactically valid issue identifier can belong to another organization. A valid destination can be outside the approved audience. An argument called approved can be a string the model invented. A tool description can be copied into untrusted content. The executor needs evidence that exists independently of those assertions.

Our curated MCP tools are wrappers around product routes. The server registers the handlers. The model supplies arguments to one of those handlers; it does not supply a new executable function. The transport constructs a context containing the original authorization header and remote address. The handler maps its arguments onto a route and calls the shared dispatcher.

The generic API tool is useful precisely because a curated list never covers every product operation. Its description promises the credential's existing permissions. Its handler normalizes a path under the API root and uses the same dispatcher. Its schema allows five methods: GET, POST, PATCH, PUT and DELETE. That is an interface choice, not a substitute for the route's authorization.

The important review question is therefore: what prevents the generic path, another transport or a future wrapper from exceeding the actor's ceiling? If the answer lives only in the curated tool's description, the system has duplicated a policy in the least reliable place.

Carry identity through the real request pipeline

Here is the essential request construction from our dispatcher, reduced to the fields that matter for this discussion:

const res = await ctx.app.inject({
  method,
  url,
  headers: {
    ...(opts?.headers ?? {}),
    ...(runKey ? { 'idempotency-key': runKey } : {}),
    authorization: ctx.authorizationHeader,
    ...inProcessDispatchHeaders(),
    ...(hasBody ? { 'content-type': 'application/json' } : {}),
  },
  remoteAddress: ctx.remoteAddress,
  payload: hasBody ? JSON.stringify(opts.body) : undefined,
});

The header order is part of the mechanism. An optional supplied header cannot replace authorization because the original caller header is assigned afterwards. A server-derived run key, when present, replaces a supplied idempotency key. The process dispatch marker is supplied by the process's own helper. These are different ownership decisions, so I test them separately.

The in-process request is dispatched to the application router rather than sent as an outbound fetch. That keeps this helper within the registered application's request surface. A route may itself perform network work, which needs its own destination and data controls. Keeping dispatch local does not make every downstream effect safe.

The original bearer credential is reused, rather than exchanged for an all-powerful service identity. That matters when a model has access to a generic call facility. A malicious prompt can choose an unfortunate route or identifier; it should still meet the permissions attached to the actual caller and the object checks in that route.

I treat this as a design invariant to verify across wrappers. A comment saying all tools use the shared helper is not a complete route-coverage result. New tool modules, direct database operations and background dispatchers can introduce other paths. The review needs to follow those paths to the actual effect.

An internal marker must have a narrow meaning

Our process also marks its own injected requests. This is easy to misunderstand because internal headers often accumulate authority over time.

Here, the marker solves a bookkeeping problem. The outer request carries the real client's address and user agent. An injected request is another pass through the same authentication pipeline, but it is not another network client. Without a distinction, the synthetic request's metadata could overwrite useful evidence about where the credential was actually used.

The implementation generates a 32-byte random secret per process boot and compares the presented marker in constant time after checking its type and length. Its documented job is to suppress that one bookkeeping update. It authorizes nothing.

That last sentence is a property worth preserving in code review. If someone later interprets the marker as permission to skip an object check, a harmless internal-transport detail has become an authorization bypass. The right question is not whether the header looks difficult to forge. It is what the receiver does when the header is present.

There is also a separate one-use marker in the outbound approval path. That mechanism has a different purpose and binding. I do not collapse every internal header into one category called trusted. Each needs its own producer, consumer, lifetime and permitted effect.

Put permission ceilings above convenience

Our user-token route policy classifies the router's matched route pattern. It does not try to infer permissions from arbitrary model text or from a guessed product action name.

The policy has several layers. Some actions are non-delegable to these tokens. Other routes require base read or write ability plus the abilities associated with their action family. Sensitive operations can require a human approval. Those token abilities are a ceiling on the owner's live permissions; they do not manufacture permissions the owner lacks.

The matched-pattern detail matters. A route such as /v1/orgs/:orgId/... has a stable policy identity even though actual object identifiers vary. The route still needs to evaluate the concrete organization and object. Pattern classification and object authorization solve separate problems.

The outer /v1/mcp transport is classified as a transport, not as the final product action. Admitting an MCP connection should not authorize every operation that can be described inside it. That would put the broadest decision at the earliest point, before the server knows which object or effect is actually requested.

Likewise, a token with an expansive ability selection still meets the non-delegable rules and its owner's current permissions. The mechanism is an intersection. The user-token feature is documented as default off; source implementation is not proof that a particular deployment has enabled it. I would rather make those intersections visible than compress them into one boolean named trustedAgent.

For a broker review, I write down the full conjunction: authenticated actor, live credential, current membership, action ability, object permission, destination policy, material approval where required, and any execution lease. A failure in any required term should reach a concrete protocol result before the effect happens.

Schema validation protects a different contract

A schema is still valuable. It can reject unknown enum values, require identifiers, constrain sizes and stop accidental shape drift. It gives the model a smaller and more useful action space.

But an identifier's format says nothing about who owns the object. A valid UUID is not an authorization token. A schema accepting an approval identifier does not establish that the approval belongs to this requester, covers this payload or remains unexpired.

Our generic tool exposes an optional server-issued approval identifier, not a model approval boolean. The approval path must consume that identifier against its actual binding terms. For outbound operations those terms can include material audience and attachment facts. Replaying an old identifier after those facts change should not silently preserve authority.

I keep argument normalization equally narrow. If the model fills an optional field with a known empty placeholder, the interface can make a documented normalization. It should not quietly repair a real recipient, choose a broader organization, or replace a failed identifier with another object that happens to exist. Convenience should preserve the requested meaning, not select a more powerful meaning.

Separate credential transport from credential disclosure

There are two different credential questions in this architecture.

The first is the bearer credential authorizing the assistant's product request. The transport receives it and the dispatcher reuses it. The model's tool arguments do not choose that header. That is identity continuity across the wrapper.

The second is a credential used to reach an external service. A stronger executor design injects that secret only inside a fixed adapter, after the adapter has checked the effect and destination. The model proposes an operation; the executor chooses the registered implementation and supplies the secret at the final boundary.

Our researched effect gateway specifies that stronger inject-only pattern for consequential operations such as pushing a branch, publishing and deploying. It also specifies execution-time identity, lease/fence and owner-permit checks. Those are design requirements in the source used for this article, not evidence that every current assistant action already flows through that gateway.

The distinction is especially important because the existing product also has vault tools that can return a revealed secret through authorized routes. A vault reveal permission and an inject-only executor have different exposure properties. I cannot describe the entire tool surface as keeping every secret out of model context while a permitted tool explicitly returns secret material.

When designing a new integration, I prefer a fixed operation with a narrow credential injection path over handing the model a credential and a general shell. The acceptance test should confirm what bytes enter model-visible results, exceptions, logs and previews, including failure paths.

The destination is part of the effect

A credentialed adapter can still be unsafe if the model chooses an arbitrary destination. A token intended for one service must not be attached to a request merely because the URL has a plausible shape.

The effect-gateway design makes this concrete for Git. It binds a push to a resolved URL, branch and commit, rather than treating a mutable remote name as the destination. It describes checks for configuration that can redirect a remote or invoke helper code. It constructs a branch refspec itself instead of accepting an arbitrary push command.

Those are useful design patterns because they expose hidden parameters. A command's visible arguments are only part of its meaning; repository configuration, credential helpers, hooks and environment can change what executes and where credentials travel. An effect broker has to own the parameters that matter to its claim.

For a chatbot sending a message, the analogous hidden parameters are the final audience, the material attachment version and the actual delivery route. For a browser, they include resource requests and redirects. For a deployment, they include the target account and release artifact. One generic phrase such as allowlisted tool cannot substitute for those different bindings.

Give the model an outcome it can safely reason about

A broker also owns the protocol after execution. The model needs to know whether a request was refused, needs approval, is unavailable, completed, or has an unknown outcome. Those states should not be inferred from the emotional tone of a string.

Our result helper preserves the route's body and sets isError for status codes at or above 400. When the route returns no body, it constructs a text response containing the status and success classification. That avoids an important protocol ambiguity: an operation can complete with HTTP204 and still fail to produce a valid tool-result text block if undefined is passed through unhandled.

This small helper does not implement a durable effect ledger. It is one layer of result encoding. A network exception, process crash or downstream timeout can still leave the actual effect unresolved. The broader executor needs an intent identity and an independent way to read the destination state.

I distinguish three records: what we intended, what dispatch accepted, and what the destination proves. A successful local function return is not always the third record. A thrown exception is not always evidence that nothing happened. Retry policy belongs with the effect's idempotency and reconciliation contract.

For a proposed gateway result, I would use a tagged union with separate refused, approval_required, unavailable, accepted, unknown and verified states. That is a design recommendation here. I do not relabel the current HTTP/MCP wrapper as already implementing the entire union.

What I actually exercised

For this article I copied three actual helper functions without changing their bodies: query encoding, internal request construction and tool-result encoding. I ran 34 checks under Node v22.23.2.

The request checks cover the original authorization header winning over a supplied alternative, server run-key precedence, internal-marker precedence, retained remote address, query encoding and the difference between an absent body and defined values such as null, false, zero and an empty string. Five methods are checked for absent-body behavior.

The result checks cover JSON, plain text and empty responses. Eight status examples span 200, 204, 399, 400, 403, 409, 500 and 502. An injected transport failure must reject rather than fabricate a success. These checks are deliberately small enough to understand as contracts.

The dispatcher is a fake request capture. The run-key and internal-header providers are explicit fixture adapters. No database, model or network call is made. Therefore these results prove behavior of the copied helper construction and encoding; they do not prove that real Fastify authentication, object authorization, MCP schema validation or concurrent approval consumption succeeds.

The next acceptance layer is a transport-by-action matrix against an isolated product fixture: named tool and generic API call, each with allowed, wrong-organization, revoked, non-delegable and changed-approval cases. Observe both the returned result and the actual object or destination. A refusal response accompanied by a real send is a failed boundary.

Review the broker where authority becomes effect

My practical review starts with one legitimate task and two ways to request it. I trace the caller identity through both paths, find the last independently enforced object decision, and identify who owns credentials, destination selection and outcome readback.

Then I change one authority-bearing fact at a time. Use a different organization. Revoke the credential. Change an attachment generation. Replace a permitted destination. Interrupt after dispatch acceptance. Ask the model to assert that approval already exists. The system should have a specific owner for each decision and a bounded recovery path for uncertainty.

This is how a broker becomes more than a collection of typed functions. The interface helps the model propose useful work. The executor preserves the actor's actual authority while the system changes around it. The result protocol tells the model what happened without asking it to invent certainty.

Pick one effectful tool in your assistant and follow its alternate path. That exercise will tell you more about the security boundary than another page of instructions telling the model to be careful.

How this was verified

Implementation references are pinned to bugtracker commit18e32f8a4df07108978ddf81c47edd20b84c3f78 with file hashes and exact helper line ranges in the source packet. The effect-gateway specification is separate design evidence. The 34-check offline exercise uses actual helper bodies with explicit fixture dependencies; it is not an integration or penetration-test result. No throughput, cost saving, full route coverage or live gateway activation is claimed.

CONNECTED READINGForward and back
Keep reading

The answer deserves
another experiment.

Back to the journal