Skip to content

PATCH /v1/agents/:name lets any workspace-key holder write identity_key onto any agent, satisfying relay's admission gate with a planted proof #310

Description

@khaliqgant

Summary

PATCH /v1/agents/:name accepts requireWorkspaceKey alone and shallow-merges caller-supplied metadata onto any agent record in the workspace. Nothing restricts a caller to its own record, and nothing reserves the metadata keys the platform relies on for identity.

// packages/engine/src/routes/agent.ts:291
agentRoutes.patch(
  '/agents/:name',
  requireWorkspaceKey,
  rateLimit,
  async (c) => {
    ...
    const nextMetadata = body.metadata !== undefined || body.skills !== undefined
      ? {
        ...(existing.metadata || {}),
        ...(body.metadata || {}),
        ...
      }
      : undefined;

    const updated = await agentEngine.updateAgent(db, workspace.id, name, {
      status: body.status,
      persona: body.persona,
      metadata: nextMetadata,
      capabilities: body.capabilities,
    });

The :name parameter is whatever the caller sends. The merge is unfiltered.

Why this matters now

relay's v11.4.2 spawn-admission gate (AgentWorkforce/relay#1438) decides whether a name collision is an honest reclaim by comparing a caller-supplied identity key against the hash stored at metadata.identity_key on the incumbent's record:

// relay crates/broker/src/relaycast/auth.rs:990
let reclaims_same_work_unit = matches!(
    (identity_key, existing_identity),
    (Some(ours), Some(theirs)) if hash_identity_key(ours) == theirs
);

That check reads the very field this endpoint lets any workspace-key holder write. The sequence is:

  1. PATCH /v1/agents/<victim> with {"metadata": {"identity_key": "<sha256 of a key you chose>"}}
  2. Register under <victim> presenting that key
  3. The gate compares the stored hash to yours, they match, and it hands over the incumbent's id with a freshly rotated token

The gate is not bypassed — it is satisfied, with a proof the caller planted. Its own doc comment explains that the raw key is hashed precisely because "metadata is readable by any caller holding the same workspace key… storing the raw key there would let any workspace member replay it verbatim." Hashing stops replay of a read value. It does not stop a write.

POST /agents/:name/rotate-token (packages/engine/src/routes/workspace.ts:415) is also workspace-key-only, and reaches the same outcome in one step. Both need the same decision.

The threat model this actually sits in

On at least one production host the workspace key is readable by any local process: it is passed in broker pty argv and is visible through a process listing. That was independently reconfirmed twice on 2026-08-07 and is flagged for rotation.

I did not reproduce it — doing so requires exactly the full-argv process listing that leaks the key, and running it would surface the key into a transcript. Reported here on the strength of those two independent confirmations, and it should be verified by whoever owns rotation, in a way that does not persist the output.

So this is not a theoretical privilege boundary. On such a host the chain is: read the key from the process table → PATCH an identity_key onto any agent → reclaim that agent's identity through the gate.

Rotating the key does not close this while argv exposure stands. The new key lands in argv the same way the old one did. Rotation and this issue are independent fixes and neither substitutes for the other.

The conclusion worth stating plainly

relay#1438 is a coordination gate against honest collision, not a security boundary against a workspace-key holder. It reliably stops two well-behaved work units from silently stealing each other's name, which is the AR-448 failure it was built for and it does that job. It does not survive an adversary who can write the field it reads. Anything that describes it as an authentication or authorization boundary is describing it wrongly, and decisions resting on that description should be revisited.

Suggested direction

Not prescribing the fix, but the shape of the decision:

  • Reserve platform-owned metadata keys. identity_key, and the fleet block written by registerAgentViaNode, are platform state that happens to live in a caller-writable bag. Reject or strip them on the PATCH merge path. This is the smallest change that breaks the chain and needs no new auth model.
  • Decide whether workspace-key auth should be able to mutate an arbitrary agent at all, or whether identity-affecting mutations need the target agent's own token. That is the larger question and it also governs rotate-token.
  • If the metadata bag is to stay open, then the gate cannot read its proof from it, and relay#1438 needs a store the caller cannot write.

Whichever way it goes, metadata being simultaneously a caller-writable bag and the store of record for an identity proof is the thing to resolve.

Verification

Route and merge logic read from main at 08ddec7. The relay gate reference read from origin/main. The attack chain is derived from those two code paths and has not been executed against a live workspace — writing an identity_key onto a real agent record is a destructive act against a running fleet and was not an appropriate way to confirm a finding that is legible from the source.

Related

Context

Filed from an incident investigation on 2026-08-07. Khaliq owns the merge gate — no agent merges.

Activity

  1. khaliqgant commented on Aug 7, 2026

    @khaliqgant
    MemberAuthor

    Provenance note. Source references in this issue were read from main at 08ddec7. The deployed engine is not this checkout — it demonstrably carries code no local tree has: stored status = 'active' serializes as "unknown" through a mapping that exists nowhere in this source (#312). Treat every file:line here as provisional until checked against the deployed build; if you cannot find a reference, suspect a build difference before assuming the reference is wrong.

    Measurements taken against the live workspace — record shapes, status counts, staleness ages, and CLI probe results — do not depend on this and stand on their own.

  2. khaliqgant commented on Aug 7, 2026

    @khaliqgant
    MemberAuthor

    The payoff for a planted proof is now demonstrated, not inferred

    Not adding evidence to this issue's mechanism — the PATCH path is well argued and I did not exercise it. One corroborating data point, because it sets what a satisfied gate is actually worth.

    Probing the engine's registerAgentViaNode path directly (a different path from this one — see #311 for the transcript), a permitted name reclaim returns:

    {"ok":true,"data":{"agent_id":"<the incumbent's existing row id>",
                       "name":"contested","token":"at_live_<redacted>"}}
    

    Same row id, working bearer token, incumbent's token evicted. So once an admission gate is satisfied — whether legitimately, or with a proof planted through the write this issue describes — the outcome is full credential handover on an existing identity, not the creation of a lookalike record.

    That is worth pinning down here because this issue's own note that POST /agents/:name/rotate-token is workspace-key-only reaches the same outcome in one step, without needing the two-step plant. Both endpoints hand over the same thing.

    Nothing in #306 changes this. That PR moves the engine's reclaim guard off the status column onto last_seen; it does not touch metadata, so the planted-identity_key path described here is unaffected either way. Stating that explicitly since several of us have now been caught out by which field governs what.

  3. willwashburn commented on Oct 10, 2026

    @willwashburn
    Member

    PATCH /v1/agents/:name rejects identity_key, and token rotation is limited to the authenticated agent. Credential revocation is a separate operation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions