Skip to content

Give each agent its own token, and stop trusting the body - #34

Open
davidmckayv wants to merge 3 commits into
feat/langchain-defaultfrom
feat/agent-callback-tokens
Open

Give each agent its own token, and stop trusting the body#34
davidmckayv wants to merge 3 commits into
feat/langchain-defaultfrom
feat/agent-callback-tokens

Conversation

@davidmckayv

Copy link
Copy Markdown
Contributor

Stacked on #33, which is stacked on #31. Review those first; this targets #33's branch.

What this changes

Closes the weakness #33 disclosed. /api/agent-tools/call authenticated with one deployment-wide secret and then read the Bot and the actor out of the request body. Anything holding that secret could spend any Bot's grants and write any person's name into the audit trail.

The trail is the product. A forgeable trail is worse than no trail, because it is believed.

Two credentials now, answering two different questions:

  • Which agent is calling. Its own token, issued per agent, stored here only as a SHA-256 hash. We issue it and only ever need to check one, so keeping the token itself would make a database dump a working credential for every registered agent.
  • Which Bot and which person the run is for. A short-lived assertion this deployment signs and hands to the agent at the start of a run. A token cannot carry this: it is minted once and reused for months, while the answer changes every run.

They are checked against each other. An assertion names the Bot it was issued for, and a call is refused unless that Bot is the one the presented token belongs to, so an agent cannot replay an assertion it happened to see and act as somebody else's Bot.

Taken from CopilotKit/openkai (a9b3722), renamed, with the demo and visitor pieces dropped.

Notes on the port

  • OpenKai added the columns to its base schema. We cannot: 0000 is already applied everywhere, so editing it means the columns never appear. They arrive as 0001_swift_morph.sql, and a test asserts that rather than asserting they are in the base.
  • sign/verify lived in OpenKai's visitor module, which is demo-only here. They are lifted into server/src/auth/signed-value.ts, keyed under a label derived from the deployment's encryption key so a signature here can never be confused with a credential ciphertext there.
  • The old shared secret still works as a fallback, so this is not a breaking change for a deployment mid-upgrade. It no longer carries the Bot or the actor.

Where it runs

  • New state that outlives a request? One column pair on agent_profiles, in Postgres.
  • What happens on the second replica? Correct on all of them. The token is looked up from the database, and the assertion is verified from the deployment's own key with an expiry inside it, so any replica can check a call served by any other. Nothing is held in memory.
  • Anything serialised? Nothing needs to be.
  • Anything fanned out to a browser? No.
  • New listener, port, or schedule? None.

Boundary and audit

  • Same path: grant, policy, audit, vendor.
  • The actor on the audit row now comes from the signed assertion, never from the body.
  • Fails closed. A deployment that cannot sign hands the agent nothing, and its tool calls are refused: a Bot that cannot prove whose run it is should not be spending anybody's grants.

A defect this also fixes

A run is offered two kinds of tool and no naming rule separates them: the Bot's grants, which execute through the policy, and the components the surface draws. The agent was running both. That invented results, so the Bot apologised for a chart the person was looking at, and an approval card answered on its behalf waited for a click that could never land. A call the surface owns now ends the run, which is how a browser tool is meant to work.

Proof

Driven in Chrome.

  • The old forgery path is closed. POST /api/agent-tools/call holding the deployment-wide token, naming a tool, with no run assertion: 401 Not authorised. Before this PR that call succeeded.
  • The honest path works. Risk Analyst, on LangGraph, answered "Week one: shadow two customer calls" from the notes server, with the tool line drawn before the answer.
  • Attribution is real. Audit row: mcp.call_succeeded, notes/search_notes, bot risk-analyst, actor dev-local-user. On Make LangChain the default harness, and let a framework Bot call tools back #33 that same row read actor agent.

17 unit tests on the token and the assertion, including that another agent presenting a valid assertion it did not earn is refused, that an edited Bot fails verification, and that an expired assertion is refused.

Migration verified on a clean database through the full chain, and db:generate reports no drift.

format:check, lint, typecheck clean. 683 pass, 5 skip, 0 fail across 78 files.

Calling a tool back used to need one deployment-wide secret, with the Bot
and the person read out of the request body. Anything holding that secret
could spend any Bot's grants and write any name into the audit trail. The
trail is the product, and a forgeable trail is worse than none because it
is believed.

Two credentials now, because they answer different questions and one cannot
answer both.

The token says which agent is calling. Issued per agent, held by whoever
runs it, and stored here only as a hash, so a database dump is not a set of
working credentials. Issuing again rotates; revoking leaves the agent able
to talk and unable to reach anything outside a conversation. Null is the
default, which is what a URL somebody pasted should get.

The run assertion says which Bot and which person. Signed by this
deployment, ten minutes long, sent in forwardedProps and handed straight
back. An agent is in no position to assert who it acts for, so it carries
our note instead of making a claim.

They are checked against each other: an agent may only act as the Bot its
token was issued for, so an assertion seen once cannot be replayed by
another credentialled agent. The deployment-wide token still authenticates
the Bots that ship in the box, but it no longer asserts anything.

Driven end to end, not just unit tested. A registered outside agent with its
own token called a granted tool and the audit row named the visitor rather
than "agent". The same assertion presented with a second agent's token was
refused 403, no assertion at all 401, an unknown token 401.

Also fixes a latent migration bug this work uncovered. drizzle.config.ts
still named computer.ts, deleted when computer use went, and did not name
the policy.ts that replaced it, so action_policy looked absent and the next
generated migration opened with DROP TABLE "action_policy" CASCADE. It
destroys the deployment's boundary. The config now names the right file, the
new columns are folded into the single baseline this repo keeps rather than a
second migration, and the baseline was verified by rebuilding the database
from nothing. The demo seed also refuses with an explanation when the roster
has not been written yet, instead of a foreign-key error naming a constraint.
Two things the merge dropped, both found by driving it rather than by any gate.

The set naming which tools this deployment runs was not built, so the filter that reads it threw
`ours is not defined` and the run died before it said anything. Green typecheck, green suite, dead
Bot: the variable is only reached once a model actually asks for a tool.

With it back, the reason it exists matters more than the crash. A run is offered two kinds of tool
that no naming rule separates: the Bot's grants, which execute through the policy and the audit
trail, and the components the surface draws. A call the surface owns now ends the run, which is how
a browser tool is supposed to work, the surface draws it or puts the question to a person and starts
the next run with the answer. Running the loop through it instead invents a result: the Bot
apologises for a chart the person is looking at, and an approval card answered on its behalf waits
for a click that can never land.
The token this deployment issues an agent was minted as `okai_agt_...`, which is the other product's
prefix. The prefix exists so a leaked credential can be recognised on sight, in a log or by a secret
scanner, and one naming a product this is not defeats the only job it has. It is `obot_agt_` now.

The panel that issues it, and the code around it, still called a Bot a teammate. That rename
happened everywhere else already.

Found by opening the screen. The security work these sit on was proven through the API, which is the
right place to prove a refusal, and it meant nobody had looked at the thing a person actually uses to
mint one.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants