Skip to content

feat(auth): revoke an agent credential without deleting the record - #305

Draft
khaliqgant wants to merge 6 commits into
mainfrom
feat/agent-token-revocation
Draft

feat(auth): revoke an agent credential without deleting the record#305
khaliqgant wants to merge 6 commits into
mainfrom
feat/agent-token-revocation

Conversation

@khaliqgant

@khaliqgant khaliqgant commented Aug 6, 2026

Copy link
Copy Markdown
Member

Summary

Adds a minimal, explicit way to invalidate an agent credential without deleting anything: a revoked_at column, a check at the single agent-auth lookup, an idempotent engine operation, one endpoint, and an operator runbook.

Do not merge or deploy. Review only — deploy authorization has not been given. See the deploy-ordering note below, which is a hard constraint if this ever does ship.

Why this exists — the two paths that looked like they worked

Containing a leaked at_live_ token had no usable path. Both routes operators were pointed at fail, in ways that return success.

remove_agent never touches the credential. It dispatches a release to the agent's node, which stops a process, and returns status: dispatched. Authentication never consults status, so a released, offline, roster-absent agent authenticates exactly as well as a running one.

DELETE /v1/agents/:name fails server-side on any seat with history. Four foreign keys onto agents(id) are ON DELETE NO ACTION in migration 0000:

Table Column
messages agent_id
channels created_by
files uploaded_by
webhooks created_by

A seat that has posted a single message aborts with FOREIGN KEY constraint failed. Reproduced from the real migrations with foreign_keys=ON, and pinned as a test here: on one seat with one message, deleteAgent throws while revokeAgentToken succeeds and the message survives.

Note what that means. The seats deletion can remove are the ones that never posted — it succeeds only where there is nothing to preserve and fails exactly where there is. Where it does succeed it takes history with it: dm_participants.agent_id cascades, which is how ordinary two-party DMs collapsed into one-row rosters (see the note in scripts/audit-dm-reservations.mjs). Deletion is the wrong axis for containment.

Does a supported path already exist? Partly — and it is worth being precise

Yes: POST /v1/agents/:name/rotate-token genuinely invalidates. It overwrites token_hash, so the leaked credential stops authenticating immediately. It is not a broken endpoint and this PR does not replace it.

It is unusable for containment for one structural reason: it returns the replacement token in its response body, and register_agent returns a live token in its reply too (relay#1389). Both put a working credential straight back into a transcript — trading a known-leaked token for a freshly-leaked one.

So the gap is narrow and real: there is no way to invalidate without issuing a replacement. That is what this adds. Rotation stays the right tool when a seat must keep working and you control where the new token lands.

The change

  • 0034_agent_token_revocation.sqlALTER TABLE agents ADD COLUMN revoked_at INTEGER. Additive; NULL means active, so no backfill.
  • auth/index.ts — one check in the agent branch of authenticate(). That is the only lookup resolving an at_live_ token to an identity; the realtime WS path rejects agent tokens outright (engine/wsAuth.ts), so there is no second door. Refusal reports agent_token_revoked, deliberately distinct from agent_token_invalid — a deleted row reports the latter and is indistinguishable from a token that was never issued.
  • revokeAgentToken() — idempotent, and preserves the original timestamp rather than sliding it forward, so re-running the runbook cannot rewrite when containment took effect.
  • POST /v1/agents/:name/revoke — workspace key only; an agent must not revoke itself or a peer.
  • docs/revoking-an-agent-credential.md — operator runbook and receipts path.

Deliberately not folded into status, which presence rewrites to 'active' on every touch.

Receipts

The tests assert the only thing that counts: the credential is presented and authentication is refused. They do not assert absence from a roster, a stopped process, or a successful-looking response — each of those has already been mistaken for containment on this incident, and none of them is evidence.

Current durable receipts across the seven affected seats are 0/7. Nothing here changes that; producing receipts needs a deploy, which is out of scope for this PR.

Deploy ordering — hard constraint

Migration 0034 must be applied before or with the code, never after. The drizzle schema enumerates every column on each query, so a build carrying revoked_at cannot talk to an agents table that lacks it. Verified: an ordinary insert against an unmigrated schema fails with table agents has no column named revoked_at. Code-first takes agent registration and authentication down, not just revoke.

Confirm which database you are migrating — production is the D1 instance the worker binds, resolved through the SST resource RelaycastDatabase, never by the name matching the repo, and with --remote.

Verification

  • 541/541 engine tests pass (9 new), typecheck and lint clean, in a clean install.
  • The repo's own telemetry catalog guard caught the new event being silently dropped; declaring it is included, so the one action that contains a credential is not the one action with no telemetry.

No credential value appears in this branch: the only token-shaped strings are non-hex synthetic fixtures that have never authenticated anything.

Refs relay#1389. Related: #1379, #1409, #1370, #1059.

Review in cubic

khaliqgant and others added 4 commits August 6, 2026 06:32
Containing a leaked `at_live_` token had no supported path. Both routes
operators were pointed at fail, in ways that look like success:

`remove_agent` dispatches a release to the node. It stops a process and
returns `dispatched`. It never touches the credential — `status` is not
consulted during authentication at all, so a released, offline, roster-absent
agent authenticates exactly as well as a running one.

`DELETE /v1/agents/:name` fails server-side on any seat with history. Four
foreign keys onto `agents(id)` are ON DELETE NO ACTION (`messages.agent_id`,
`channels.created_by`, `files.uploaded_by`, `webhooks.created_by`), so a seat
that has posted one message aborts with a FOREIGN KEY constraint error. The
seats it can delete are the ones that never posted — deletion succeeds only
where there is nothing to preserve and fails exactly where there is. Where it
does succeed it takes history with it: `dm_participants.agent_id` cascades,
which is how two-party DMs collapsed to one-row rosters (see the note in
scripts/audit-dm-reservations.mjs).

So revocation is a state on the row, not the absence of the row.

`revoked_at` is checked in the agent branch of authenticate(), the only lookup
that resolves an agent token to an identity — the realtime WS path rejects
agent tokens outright, so there is no second door. Refusal reports
`agent_token_revoked`, distinct from `agent_token_invalid`, so an operator can
tell a contained credential from one that never existed; a deleted row cannot
express that difference.

Deliberately not folded into `status`, which presence rewrites to 'active' on
every touch. Deliberately not a rotation: re-registering returns the live token
in its reply (relay#1389), putting a fresh credential straight back into a
transcript.

Tests assert the only thing that counts — the credential is presented and
authentication is refused — and pin the contrast: on one seat with one message,
deleteAgent throws while revokeAgentToken succeeds and the message survives.

The migration is additive (NULL means active); an unmigrated deployment accepts
the revoke call and still authenticates the token, so the runbook makes the
401 receipt, not the API response, the evidence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The repo's own catalog guard caught this: an event name not present in
SERVER_TELEMETRY_EVENTS fails the zod enum inside a floating promise, so it is
dropped before reaching PostHog with no error surfaced. The revoke endpoint
would have emitted into nothing — leaving the one action that contains a
credential as the one action with no telemetry.
The first draft claimed an unmigrated deployment would accept the revoke call
and keep authenticating the token. That is wrong, and wrong in the more
dangerous direction. The drizzle schema enumerates every column on each query,
so a build carrying `revoked_at` cannot talk to an `agents` table without it —
verified against an unmigrated schema, where an ordinary insert fails with
"table agents has no column named revoked_at". Deploying the code ahead of the
migration takes agent registration and authentication down.

Also names the instance to migrate: the SST resource, not the repo-matching
name, with --remote.
`POST /v1/agents/:name/rotate-token` does invalidate a leaked credential — it
overwrites token_hash, so the old token stops authenticating. An operator
hunting for an invalidation path will find it and use it. The reason not to is
not that it fails, it is that it returns the replacement token in its response
body (relay#1389), trading a known-leaked credential for a freshly-leaked one.
Saying only "do not rotate" does not survive contact with someone who can see
that the endpoint works.
@cursor

cursor Bot commented Aug 6, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@khaliqgant, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 25 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 9105bd8d-164c-45d9-889c-72273af52e25

📥 Commits

Reviewing files that changed from the base of the PR and between 759ceeb and ea82dc6.

📒 Files selected for processing (16)
  • CHANGELOG.md
  • README.md
  • docs/revoking-an-agent-credential.md
  • openapi.yaml
  • packages/engine/CHANGELOG.md
  • packages/engine/src/__tests__/agentRevocationReceipt.test.ts
  • packages/engine/src/auth/index.ts
  • packages/engine/src/engine/__tests__/agentRevocation.test.ts
  • packages/engine/src/engine/agent.ts
  • packages/engine/src/ports/auth.ts
  • packages/engine/src/routes/a2a.ts
  • packages/engine/src/routes/agent.ts
  • packages/sdk-typescript/CHANGELOG.md
  • packages/sdk-typescript/src/errors.ts
  • packages/types/CHANGELOG.md
  • packages/types/src/__tests__/sdk-openapi-sync.test.ts
📝 Walkthrough

Walkthrough

This change adds persistent agent-token revocation. It introduces database storage, engine logic, authentication rejection, a workspace-key-protected endpoint, telemetry, tests, and operational documentation.

Changes

Agent token revocation

Layer / File(s) Summary
Persistent revocation state and engine operation
packages/engine/src/db/migrations/0034_agent_token_revocation.sql, packages/engine/src/db/schema.ts, packages/engine/src/engine/agent.ts, packages/engine/src/engine/__tests__/agentRevocation.test.ts
Agents now store nullable revocation timestamps. revokeAgentToken preserves agent and message history, supports idempotent calls, and scopes lookups to a workspace.
Authentication, API route, and telemetry
packages/engine/src/auth/index.ts, packages/engine/src/routes/agent.ts, packages/types/src/telemetry.ts, packages/engine/src/engine/__tests__/agentRevocation.test.ts
Revoked credentials return 401 agent_token_revoked. The new endpoint returns revocation details and emits relaycast_server_agent_token_revoked telemetry.
Containment procedure and deployment verification
docs/revoking-an-agent-credential.md
The documentation describes secure token handling, workspace-key revocation, authentication verification, migration ordering, history checks, and scope limits.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Operator
  participant AgentRoute
  participant AgentEngine
  participant Database
  participant AgentAuth
  Operator->>AgentRoute: POST /v1/agents/:name/revoke with workspace key
  AgentRoute->>AgentEngine: Revoke agent token
  AgentEngine->>Database: Store revoked_at
  Database-->>AgentEngine: Return revocation timestamp
  AgentEngine-->>AgentRoute: Return revocation details
  AgentRoute-->>Operator: Return receipt and telemetry
  Operator->>AgentAuth: Authenticate with revoked token
  AgentAuth->>Database: Read agent revokedAt
  Database-->>AgentAuth: Return revoked agent
  AgentAuth-->>Operator: Return 401 agent_token_revoked
Loading

Possibly related PRs

Suggested labels: size:L

Suggested reviewers: willwashburn

Poem

I tucked the token safely away,
Marked its old credential “not today.”
The agent and messages remain,
While revoked calls meet a clear refrain.
401 tells the tale,
And workspace keys guard the trail.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: revoking an agent credential without deleting its record.
Description check ✅ Passed The description directly explains the credential revocation mechanism, implementation details, deployment constraints, and verification scope.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/agent-token-revocation

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/revoking-an-agent-credential.md`:
- Around line 104-107: Update the fenced response example in the
credential-revocation documentation to declare the JSON language, changing the
untyped fence around the response body to a json-tagged fence while preserving
its contents and status code.

In `@packages/engine/src/engine/agent.ts`:
- Around line 323-327: Update the revocation result logic after the re-read in
the agent revocation flow to return null when no persisted row or revokedAt
value is found, rather than falling back to the local revokedAt timestamp.
Preserve the existing settled.revokedAt value and alreadyRevoked behavior when
revocation data exists.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 64c5e338-b7d0-40b3-83e7-45d705c0c533

📥 Commits

Reviewing files that changed from the base of the PR and between 45beff3 and 759ceeb.

📒 Files selected for processing (8)
  • docs/revoking-an-agent-credential.md
  • packages/engine/src/auth/index.ts
  • packages/engine/src/db/migrations/0034_agent_token_revocation.sql
  • packages/engine/src/db/schema.ts
  • packages/engine/src/engine/__tests__/agentRevocation.test.ts
  • packages/engine/src/engine/agent.ts
  • packages/engine/src/routes/agent.ts
  • packages/types/src/telemetry.ts

Comment thread docs/revoking-an-agent-credential.md Outdated
Comment thread packages/engine/src/engine/agent.ts

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 759ceeb053

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// second door. Distinct code from `agent_token_invalid` so an operator can
// tell "revoked" from "never existed" — a deleted row would report the
// latter, and the difference is the whole point of keeping the record.
if (agent.revokedAt) return unauthorized('Agent token revoked', 'agent_token_revoked');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject revoked tokens on the A2A webhook

When the revoked seat is an A2A proxy, its issued relay_token remains usable: routes/a2a.ts:119-129 fetches only agents.tokenHash, and the webhook at lines 312-318 authenticates by comparing that hash directly instead of calling this provider or checking revokedAt. Consequently, revoking the proxy reports successful containment while the leaked credential can still submit webhook payloads; include the revocation state in that lookup and reject revoked agents there.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 112b31a. This was the most important finding on the PR — it falsified the claim in my own code comment that there was "no second door".

Verified before fixing: the webhook has no auth middleware and compared agents.token_hash itself, so the revocation check never ran. I did not patch the comparison in place, because patching known routes one at a time leaves the class of defect intact. The route now resolves the bearer token through the configured AuthProvider and re-checks the binding afterwards, so provider-level checks apply there by construction.

Guarded by an HTTP-level test that registers a real A2A proxy, confirms the webhook accepts the credential, revokes, and requires 401 agent_token_revoked. I verified the test fails against the pre-fix code (the revoked credential got through to payload validation and returned 400).

Comment thread packages/engine/src/routes/agent.ts Outdated
const db = c.get('db');
const workspace = c.get('workspace');
const name = c.req.param('name');
const result = await agentEngine.revokeAgentToken(db, workspace.id, name);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Route revocation through the configured auth provider

In deployments that inject a custom AuthProvider, particularly one backed by an external identity store as permitted by ports/auth.ts, this call only marks the engine database row and then returns a successful receipt; the provider has no revocation operation and may continue accepting the token indefinitely. Delegate revocation through the provider or add provider-independent enforcement so the endpoint works for both the hosted gateway and self-hosting.

AGENTS.md reference: AGENTS.md:L10-L11

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid, fixed in 112b31a. Marking the engine row while a custom provider authenticates from its own store is a clean receipt over a live credential — the exact failure mode this endpoint exists to prevent.

Revocation is now an optional revokeAgentCredential on the AuthProvider port. The built-in provider implements it; a provider with no authority to invalidate a credential it did not issue leaves it undefined and the endpoint fails closed with revocation_unsupported (501). No capability, no receipt — rather than a 200 that means nothing.

Comment on lines +359 to +360
agentRoutes.post(
'/agents/:name/revoke',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Document the new endpoint in the API sources

The new POST /agents/{name}/revoke operation and agent_token_revoked error are absent from openapi.yaml, while README.md still states that invalid or expired agent tokens always return agent_token_invalid and recommends rotation. This leaves generated clients without the endpoint and gives users contradictory recovery guidance, so update both public API sources with this behavior.

AGENTS.md reference: AGENTS.md:L34-L36

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 112b31a. Added POST /agents/{name}/revoke to openapi.yaml with its 200/404 responses, extended the Error.code description to cover agent_token_revoked, and corrected the README, which previously told users that rotation was the recovery path for a bad agent token — actively wrong advice for a deliberately revoked credential. Verified the spec still parses and the $refs resolve.

-- Additive and reversible: a NULL `revoked_at` is an active credential, so
-- existing rows keep their current behaviour with no backfill.

ALTER TABLE agents ADD COLUMN revoked_at INTEGER;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Record the revocation feature in the changelogs

This commit adds a user-facing endpoint, database migration, and exported telemetry event, but the root, engine, and types changelogs remain at empty [Unreleased] headings. A release cut would therefore omit the feature and migration requirements and lack the required SemVer level; add concise pending entries and raise the applicable headings.

AGENTS.md reference: AGENTS.md:L39-L44

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 112b31a. Pending [Unreleased - Minor] entries added to the root, engine, types, and SDK changelogs per AGENTS.md, including the migration-ordering requirement (0034 must be applied before or with the code — the schema enumerates every column per query, so the build cannot talk to an agents table without it).

// second door. Distinct code from `agent_token_invalid` so an operator can
// tell "revoked" from "never existed" — a deleted row would report the
// latter, and the difference is the whole point of keeping the record.
if (agent.revokedAt) return unauthorized('Agent token revoked', 'agent_token_revoked');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the revoked-token code in the TypeScript SDK

When a TypeScript SDK request uses a revoked token, the server now returns this distinct code, but packages/sdk-typescript/src/errors.ts neither includes nor maps agent_token_revoked; its 401 fallback converts it to generic unauthorized. That erases the distinction this change intentionally introduces and prevents typed clients from recognizing deliberate revocation, so add the code to the SDK error union and normalization map.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 112b31aagent_token_revoked added to the RelayErrorCode union and to RAW_CODE_MAP, so it survives normalization instead of collapsing to unauthorized.

Comment thread packages/engine/src/engine/agent.ts Outdated
Comment on lines +323 to +326
const [settled] = await db.select().from(agents).where(eq(agents.id, agent.id));
return {
revokedAt: settled?.revokedAt ?? revokedAt,
alreadyRevoked: false,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Report the losing concurrent revoke as already revoked

When two revoke requests select the active row before either update completes, one guarded update is a no-op, but both callers unconditionally return alreadyRevoked: false. The re-read preserves the winning timestamp but does not correct the receipt semantics, so concurrent operator requests both claim to have performed the fresh revocation; inspect the update result or use an atomic update/returning path to mark the loser as already revoked.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 112b31a. The guarded UPDATE now uses returning(), so the call that did not set the column reports already_revoked: true. Covered by a test that lands another revoke inside the read-then-write window and asserts both the flag and that the winning timestamp is the one reported.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

6 issues found across 8 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/engine/src/routes/agent.ts">

<violation number="1" location="packages/engine/src/routes/agent.ts:359">
P3: This adds a new public endpoint `POST /v1/agents/:name/revoke` without updating the API reference. The repo's docs convention (AGENTS.md "Docs Hygiene") requires README and openapi.yaml to be updated together when API behavior changes; openapi.yaml documents the sibling `/agents/{name}/rotate-token` but has no `/revoke` entry. Please add the new endpoint to openapi.yaml (and note it in README) so the documented surface stays in sync with the implemented routes before this ships.</violation>

<violation number="2" location="packages/engine/src/routes/agent.ts:360">
P2: The new HTTP containment workflow has no request-level test, so regressions in route mounting, `requireWorkspaceKey`, the unknown-agent response, or the documented idempotent response could pass the current suite. A conformance test covering workspace-key success, agent-token rejection, unknown-agent 404, and repeated revocation would make the runbook contract executable.</violation>
</file>

<file name="packages/engine/src/db/migrations/0034_agent_token_revocation.sql">

<violation number="1" location="packages/engine/src/db/migrations/0034_agent_token_revocation.sql:8">
P3: The migration rationale incorrectly implies that a successful agent delete means there is no history; cascade-only references can make the delete succeed while silently removing agent-owned records. Please explain that `NO ACTION` references block only some deletes and that cascade references can still discard history.</violation>

<violation number="2" location="packages/engine/src/db/migrations/0034_agent_token_revocation.sql:22">
P3: This change adds a new endpoint, migration, and telemetry event but doesn't add a corresponding changelog entry, so a release cut would omit the feature and its migration requirement.</violation>
</file>

<file name="packages/engine/src/auth/index.ts">

<violation number="1" location="packages/engine/src/auth/index.ts:60">
P1: Revoking an A2A relay agent does not contain its credential: `/a2a/webhook/:workspace_id/:agent_name` bypasses `SqliteApiKeyAuthProvider` and still accepts the revoked `at_live_` token via a direct hash comparison. Applying the same revocation check there or routing this authentication through the provider would close the second door.</violation>

<violation number="2" location="packages/engine/src/auth/index.ts:60">
P2: The new agent_token_revoked error code isn't added to the TypeScript SDK's error union/normalization map, so its 401 fallback collapses it into a generic unauthorized error, erasing the distinction between a revoked token and other auth failures for SDK consumers.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

// second door. Distinct code from `agent_token_invalid` so an operator can
// tell "revoked" from "never existed" — a deleted row would report the
// latter, and the difference is the whole point of keeping the record.
if (agent.revokedAt) return unauthorized('Agent token revoked', 'agent_token_revoked');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Revoking an A2A relay agent does not contain its credential: /a2a/webhook/:workspace_id/:agent_name bypasses SqliteApiKeyAuthProvider and still accepts the revoked at_live_ token via a direct hash comparison. Applying the same revocation check there or routing this authentication through the provider would close the second door.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/engine/src/auth/index.ts, line 60:

<comment>Revoking an A2A relay agent does not contain its credential: `/a2a/webhook/:workspace_id/:agent_name` bypasses `SqliteApiKeyAuthProvider` and still accepts the revoked `at_live_` token via a direct hash comparison. Applying the same revocation check there or routing this authentication through the provider would close the second door.</comment>

<file context>
@@ -51,6 +51,13 @@ export class SqliteApiKeyAuthProvider implements AuthProvider {
+      // second door. Distinct code from `agent_token_invalid` so an operator can
+      // tell "revoked" from "never existed" — a deleted row would report the
+      // latter, and the difference is the whole point of keeping the record.
+      if (agent.revokedAt) return unauthorized('Agent token revoked', 'agent_token_revoked');
       const [workspace] = await db.select().from(workspaces).where(eq(workspaces.id, agent.workspaceId));
       if (!workspace) return unauthorized('Workspace not found');
</file context>

Comment thread packages/engine/src/engine/agent.ts Outdated
// Workspace key only: an agent must not be able to revoke itself or a peer.
// Returns the revocation timestamp so the caller has a receipt to record.
agentRoutes.post(
'/agents/:name/revoke',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The new HTTP containment workflow has no request-level test, so regressions in route mounting, requireWorkspaceKey, the unknown-agent response, or the documented idempotent response could pass the current suite. A conformance test covering workspace-key success, agent-token rejection, unknown-agent 404, and repeated revocation would make the runbook contract executable.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/engine/src/routes/agent.ts, line 360:

<comment>The new HTTP containment workflow has no request-level test, so regressions in route mounting, `requireWorkspaceKey`, the unknown-agent response, or the documented idempotent response could pass the current suite. A conformance test covering workspace-key success, agent-token rejection, unknown-agent 404, and repeated revocation would make the runbook contract executable.</comment>

<file context>
@@ -347,6 +347,44 @@ agentRoutes.patch(
+// Workspace key only: an agent must not be able to revoke itself or a peer.
+// Returns the revocation timestamp so the caller has a receipt to record.
+agentRoutes.post(
+  '/agents/:name/revoke',
+  requireWorkspaceKey,
+  rateLimit,
</file context>

Comment thread packages/engine/src/routes/agent.ts Outdated
// second door. Distinct code from `agent_token_invalid` so an operator can
// tell "revoked" from "never existed" — a deleted row would report the
// latter, and the difference is the whole point of keeping the record.
if (agent.revokedAt) return unauthorized('Agent token revoked', 'agent_token_revoked');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The new agent_token_revoked error code isn't added to the TypeScript SDK's error union/normalization map, so its 401 fallback collapses it into a generic unauthorized error, erasing the distinction between a revoked token and other auth failures for SDK consumers.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/engine/src/auth/index.ts, line 60:

<comment>The new agent_token_revoked error code isn't added to the TypeScript SDK's error union/normalization map, so its 401 fallback collapses it into a generic unauthorized error, erasing the distinction between a revoked token and other auth failures for SDK consumers.</comment>

<file context>
@@ -51,6 +51,13 @@ export class SqliteApiKeyAuthProvider implements AuthProvider {
+      // second door. Distinct code from `agent_token_invalid` so an operator can
+      // tell "revoked" from "never existed" — a deleted row would report the
+      // latter, and the difference is the whole point of keeping the record.
+      if (agent.revokedAt) return unauthorized('Agent token revoked', 'agent_token_revoked');
       const [workspace] = await db.select().from(workspaces).where(eq(workspaces.id, agent.workspaceId));
       if (!workspace) return unauthorized('Workspace not found');
</file context>

-- ON DELETE NO ACTION in 0000: `messages.agent_id`, `channels.created_by`,
-- `files.uploaded_by` and `webhooks.created_by`. Any seat that has ever posted a
-- message therefore fails the delete outright with a FOREIGN KEY constraint
-- error. The delete only succeeds for a seat with no history — that is, exactly

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The migration rationale incorrectly implies that a successful agent delete means there is no history; cascade-only references can make the delete succeed while silently removing agent-owned records. Please explain that NO ACTION references block only some deletes and that cascade references can still discard history.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/engine/src/db/migrations/0034_agent_token_revocation.sql, line 8:

<comment>The migration rationale incorrectly implies that a successful agent delete means there is no history; cascade-only references can make the delete succeed while silently removing agent-owned records. Please explain that `NO ACTION` references block only some deletes and that cascade references can still discard history.</comment>

<file context>
@@ -0,0 +1,22 @@
+-- ON DELETE NO ACTION in 0000: `messages.agent_id`, `channels.created_by`,
+-- `files.uploaded_by` and `webhooks.created_by`. Any seat that has ever posted a
+-- message therefore fails the delete outright with a FOREIGN KEY constraint
+-- error. The delete only succeeds for a seat with no history — that is, exactly
+-- when there is nothing to contain and nothing worth keeping. Worse, the deletes
+-- that do land take history with them: `dm_participants.agent_id` cascades, which
</file context>

//
// Workspace key only: an agent must not be able to revoke itself or a peer.
// Returns the revocation timestamp so the caller has a receipt to record.
agentRoutes.post(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This adds a new public endpoint POST /v1/agents/:name/revoke without updating the API reference. The repo's docs convention (AGENTS.md "Docs Hygiene") requires README and openapi.yaml to be updated together when API behavior changes; openapi.yaml documents the sibling /agents/{name}/rotate-token but has no /revoke entry. Please add the new endpoint to openapi.yaml (and note it in README) so the documented surface stays in sync with the implemented routes before this ships.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/engine/src/routes/agent.ts, line 359:

<comment>This adds a new public endpoint `POST /v1/agents/:name/revoke` without updating the API reference. The repo's docs convention (AGENTS.md "Docs Hygiene") requires README and openapi.yaml to be updated together when API behavior changes; openapi.yaml documents the sibling `/agents/{name}/rotate-token` but has no `/revoke` entry. Please add the new endpoint to openapi.yaml (and note it in README) so the documented surface stays in sync with the implemented routes before this ships.</comment>

<file context>
@@ -347,6 +347,44 @@ agentRoutes.patch(
+//
+// Workspace key only: an agent must not be able to revoke itself or a peer.
+// Returns the revocation timestamp so the caller has a receipt to record.
+agentRoutes.post(
+  '/agents/:name/revoke',
+  requireWorkspaceKey,
</file context>

@@ -0,0 +1,22 @@
-- Agent token revocation.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This change adds a new endpoint, migration, and telemetry event but doesn't add a corresponding changelog entry, so a release cut would omit the feature and its migration requirement.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/engine/src/db/migrations/0034_agent_token_revocation.sql, line 22:

<comment>This change adds a new endpoint, migration, and telemetry event but doesn't add a corresponding changelog entry, so a release cut would omit the feature and its migration requirement.</comment>

<file context>
@@ -0,0 +1,22 @@
+-- Additive and reversible: a NULL `revoked_at` is an active credential, so
+-- existing rows keep their current behaviour with no backfill.
+
+ALTER TABLE agents ADD COLUMN revoked_at INTEGER;
</file context>

Comment thread docs/revoking-an-agent-credential.md
Security review found a revoked agent token still authenticating on the A2A
webhook. Patching that one route would have left the class of defect intact, so
the choke point is now structural in both directions.

Inbound: `routes/a2a.ts` no longer compares `agents.token_hash` itself. It
resolves the bearer token through the configured AuthProvider and re-checks the
binding afterwards, so every check the provider owns — revocation today,
whatever is added later — applies there automatically instead of having to be
remembered. The bypass was real: the accompanying HTTP test fails against the
previous code, where a revoked credential reached the handler and was stopped
only by payload validation.

Outbound: revocation is now an optional `revokeAgentCredential` on the
AuthProvider port. A deployment injecting a provider backed by an external
identity store has no authority to invalidate a credential it did not issue, and
previously would have written `revoked_at` into a column its authenticator never
reads — a clean receipt over a live credential. The endpoint fails closed with
`revocation_unsupported` when the capability is absent. No capability, no
receipt.

Also fixes the vanished-row false receipt from review: the post-update re-read
returned a locally-generated timestamp when the row had been deleted
concurrently or the update never landed, reporting a revocation that did not
happen. It returns null now. `returning()` separates the winner from the loser
of a concurrent revoke so the loser reports `already_revoked`.

Adds the regression test that matters most for containment durability.
`registerAgentViaNode` upserts on (workspace_id, name) and its `setWhere` fires
for any seat whose status is not 'active', rewriting `token_hash` — so a
containment marker held in that column is silently overwritten the next time any
node claims the name. `revoked_at` is deliberately absent from that set clause:
a re-registering node gets a fresh token that is still refused. Do not "fix"
that test by clearing `revoked_at` on registration.

Docs, SDK, and changelogs per AGENTS.md: openapi.yaml and README.md together,
`agent_token_revoked` in the SDK error union and normalization map, pending
Minor entries in the root, engine, types, and SDK changelogs.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

4 issues found across 15 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/engine/src/auth/index.ts">

<violation number="1" location="packages/engine/src/auth/index.ts:71">
P3: The comment misdescribes the A2A webhook and can send future maintainers toward a duplicate or conflicting revocation check; it should describe that the webhook now routes through this provider while warning only about future direct lookups.</violation>
</file>

<file name="CHANGELOG.md">

<violation number="1" location="CHANGELOG.md:23">
P3: This entry is one long bullet full of implementation backstory (why deletion fails, what rotate-token returns) rather than a short impact-first note. Per AGENTS.md CHANGELOG rules, each user-visible change should be "one short impact-first bullet" with backstory omitted.</violation>
</file>

<file name="packages/engine/src/routes/a2a.ts">

<violation number="1" location="packages/engine/src/routes/a2a.ts:329">
P2: A valid custom-provider A2A credential can now be rejected after authentication because the binding requires a local `AuthResult.agent` that the provider contract does not require. Require an agent identity for `require: 'agent'` in the provider contract, or add a provider-level binding operation that can validate this token against `relayAgentId`.</violation>
</file>

<file name="packages/engine/CHANGELOG.md">

<violation number="1" location="packages/engine/CHANGELOG.md:14">
P3: This changelog entry is far longer and more implementation-heavy than the project's documented changelog convention. `AGENTS.md` asks for "one short bullet per user-visible change" and says to "omit ... implementation backstory unless they explain shipped impact." The `Added` bullet here packs in internal mechanics (the `revoked_at` column, `SqliteApiKeyAuthProvider.authenticate`, "the schema enumerates every column per query", the ON DELETE NO ACTION foreign-key count, and DM-cascade internals). Consider trimming the paragraph to the user-facing contract: the new `POST /agents/{name}/revoke` endpoint, that it returns `agent_token_revoked` (401) distinct from `agent_token_invalid`, the migration `0034` requirement, and a one-line note on when to prefer it over `DELETE`/`rotate-token`. The migration syntax requirement and `AuthProvider.revokeAgentCredential` provider-contract detail can stay in the operator runbook (`docs/revoking-an-agent-credential.md`) rather than the changelog.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

if (!authResult.ok) {
return jsonError(c, authResult.code, authResult.message, authResult.status as ContentfulStatusCode);
}
if (authResult.agent?.id !== relayAgent.relayAgentId) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: A valid custom-provider A2A credential can now be rejected after authentication because the binding requires a local AuthResult.agent that the provider contract does not require. Require an agent identity for require: 'agent' in the provider contract, or add a provider-level binding operation that can validate this token against relayAgentId.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/engine/src/routes/a2a.ts, line 329:

<comment>A valid custom-provider A2A credential can now be rejected after authentication because the binding requires a local `AuthResult.agent` that the provider contract does not require. Require an agent identity for `require: 'agent'` in the provider contract, or add a provider-level binding operation that can validate this token against `relayAgentId`.</comment>

<file context>
@@ -312,8 +312,21 @@ a2aRoutes.post('/a2a/webhook/:workspace_id/:agent_name', async (c) => {
+    if (!authResult.ok) {
+      return jsonError(c, authResult.code, authResult.message, authResult.status as ContentfulStatusCode);
+    }
+    if (authResult.agent?.id !== relayAgent.relayAgentId) {
       return jsonError(c, 'unauthorized', 'Missing or invalid bearer token', 401);
     }
</file context>

// Refuse a revoked credential. This is the main lookup resolving an
// `at_live_` token to an identity, and the realtime WS path rejects agent
// tokens outright (see engine/wsAuth.ts) — but it is NOT the only one. The
// A2A webhook (`routes/a2a.ts`) compares the stored hash directly without

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The comment misdescribes the A2A webhook and can send future maintainers toward a duplicate or conflicting revocation check; it should describe that the webhook now routes through this provider while warning only about future direct lookups.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/engine/src/auth/index.ts, line 71:

<comment>The comment misdescribes the A2A webhook and can send future maintainers toward a duplicate or conflicting revocation check; it should describe that the webhook now routes through this provider while warning only about future direct lookups.</comment>

<file context>
@@ -51,12 +65,15 @@ export class SqliteApiKeyAuthProvider implements AuthProvider {
+      // Refuse a revoked credential. This is the main lookup resolving an
+      // `at_live_` token to an identity, and the realtime WS path rejects agent
+      // tokens outright (see engine/wsAuth.ts) — but it is NOT the only one. The
+      // A2A webhook (`routes/a2a.ts`) compares the stored hash directly without
+      // going through this provider and carries its own check; any new path that
+      // matches on `agents.token_hash` must do the same, or revocation silently
</file context>

Comment thread CHANGELOG.md

### Added

- `POST /agents/{name}/revoke` invalidates an agent's token while keeping the agent and its history on the record. Requests carrying a revoked credential are refused with `agent_token_revoked` (HTTP 401), which is distinct from `agent_token_invalid` so a deliberate revocation is not mistaken for an unknown token. Use it instead of `DELETE /agents/{name}` to contain a leaked credential — deletion fails for any agent that has posted a message and destroys audit history where it succeeds — and instead of `POST /agents/{name}/rotate-token`, which also invalidates but returns a live replacement token in its response. Requires migration `0034`, which must be applied before or with this release; see `docs/revoking-an-agent-credential.md` for the operator runbook.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This entry is one long bullet full of implementation backstory (why deletion fails, what rotate-token returns) rather than a short impact-first note. Per AGENTS.md CHANGELOG rules, each user-visible change should be "one short impact-first bullet" with backstory omitted.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At CHANGELOG.md, line 23:

<comment>This entry is one long bullet full of implementation backstory (why deletion fails, what rotate-token returns) rather than a short impact-first note. Per AGENTS.md CHANGELOG rules, each user-visible change should be "one short impact-first bullet" with backstory omitted.</comment>

<file context>
@@ -16,7 +16,11 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+### Added
+
+- `POST /agents/{name}/revoke` invalidates an agent's token while keeping the agent and its history on the record. Requests carrying a revoked credential are refused with `agent_token_revoked` (HTTP 401), which is distinct from `agent_token_invalid` so a deliberate revocation is not mistaken for an unknown token. Use it instead of `DELETE /agents/{name}` to contain a leaked credential — deletion fails for any agent that has posted a message and destroys audit history where it succeeds — and instead of `POST /agents/{name}/rotate-token`, which also invalidates but returns a live replacement token in its response. Requires migration `0034`, which must be applied before or with this release; see `docs/revoking-an-agent-credential.md` for the operator runbook.
 
 ## [6.3.2] - 2026-08-02
</file context>


### Added

- `POST /agents/{name}/revoke` invalidates an agent credential while leaving the agent row, its messages, and every record referencing it in place. Enforcement is a new `revoked_at` column checked in the agent branch of `SqliteApiKeyAuthProvider.authenticate`; refused requests return `agent_token_revoked` (401), distinct from `agent_token_invalid`. Requires migration `0034`, which must be applied **before or with** this release — the schema enumerates every column per query, so the code cannot talk to an `agents` table without it. Prefer this to `DELETE /agents/{name}`, which fails for any agent that has posted a message (four foreign keys onto `agents.id` are ON DELETE NO ACTION) and cascades away DM history where it succeeds.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This changelog entry is far longer and more implementation-heavy than the project's documented changelog convention. AGENTS.md asks for "one short bullet per user-visible change" and says to "omit ... implementation backstory unless they explain shipped impact." The Added bullet here packs in internal mechanics (the revoked_at column, SqliteApiKeyAuthProvider.authenticate, "the schema enumerates every column per query", the ON DELETE NO ACTION foreign-key count, and DM-cascade internals). Consider trimming the paragraph to the user-facing contract: the new POST /agents/{name}/revoke endpoint, that it returns agent_token_revoked (401) distinct from agent_token_invalid, the migration 0034 requirement, and a one-line note on when to prefer it over DELETE/rotate-token. The migration syntax requirement and AuthProvider.revokeAgentCredential provider-contract detail can stay in the operator runbook (docs/revoking-an-agent-credential.md) rather than the changelog.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/engine/CHANGELOG.md, line 14:

<comment>This changelog entry is far longer and more implementation-heavy than the project's documented changelog convention. `AGENTS.md` asks for "one short bullet per user-visible change" and says to "omit ... implementation backstory unless they explain shipped impact." The `Added` bullet here packs in internal mechanics (the `revoked_at` column, `SqliteApiKeyAuthProvider.authenticate`, "the schema enumerates every column per query", the ON DELETE NO ACTION foreign-key count, and DM-cascade internals). Consider trimming the paragraph to the user-facing contract: the new `POST /agents/{name}/revoke` endpoint, that it returns `agent_token_revoked` (401) distinct from `agent_token_invalid`, the migration `0034` requirement, and a one-line note on when to prefer it over `DELETE`/`rotate-token`. The migration syntax requirement and `AuthProvider.revokeAgentCredential` provider-contract detail can stay in the operator runbook (`docs/revoking-an-agent-credential.md`) rather than the changelog.</comment>

<file context>
@@ -7,7 +7,17 @@ See the [root changelog](../../CHANGELOG.md) for cross-package release highlight
+
+### Added
+
+- `POST /agents/{name}/revoke` invalidates an agent credential while leaving the agent row, its messages, and every record referencing it in place. Enforcement is a new `revoked_at` column checked in the agent branch of `SqliteApiKeyAuthProvider.authenticate`; refused requests return `agent_token_revoked` (401), distinct from `agent_token_invalid`. Requires migration `0034`, which must be applied **before or with** this release — the schema enumerates every column per query, so the code cannot talk to an `agents` table without it. Prefer this to `DELETE /agents/{name}`, which fails for any agent that has posted a message (four foreign keys onto `agents.id` are ON DELETE NO ACTION) and cascades away DM history where it succeeds.
+- `AuthProvider.revokeAgentCredential` (optional). Providers backed by an external identity store leave it undefined and the endpoint refuses with `revocation_unsupported` (501) rather than recording a revocation their authenticator never consults.
+
</file context>
Suggested change
- `POST /agents/{name}/revoke` invalidates an agent credential while leaving the agent row, its messages, and every record referencing it in place. Enforcement is a new `revoked_at` column checked in the agent branch of `SqliteApiKeyAuthProvider.authenticate`; refused requests return `agent_token_revoked` (401), distinct from `agent_token_invalid`. Requires migration `0034`, which must be applied **before or with** this release — the schema enumerates every column per query, so the code cannot talk to an `agents` table without it. Prefer this to `DELETE /agents/{name}`, which fails for any agent that has posted a message (four foreign keys onto `agents.id` are ON DELETE NO ACTION) and cascades away DM history where it succeeds.
- `POST /agents/{name}/revoke` invalidates an agent credential while leaving the agent row and its history in place. Refused requests return `agent_token_revoked` (401), distinct from `agent_token_invalid`. Requires migration `0034`. Prefer it to `DELETE /agents/{name}` to contain a leaked credential; see `docs/revoking-an-agent-credential.md` for the operator runbook.

CI failed on `@relaycast/types#test`: the sync guard requires every path in
openapi.yaml to be either referenced by an SDK or listed as intentionally
non-SDK, and `/v1/agents/{param}/revoke` was neither.

Classified as non-SDK, with the reasoning recorded next to it rather than left
implicit. `rotate-token` is in CORE_SDK_PATHS, so the parity argument for
covering revoke in the SDKs is real — but that means a method across four SDKs,
which is a product decision and not part of an engine change. Revoking is also
break-glass: an agent runtime has no reason to call it, and the operator path is
the runbook.

Caught only in CI because the previous push validated the packages that changed
and not the ones that assert across them.
@khaliqgant

Copy link
Copy Markdown
Member Author

@coderabbitai review

Full re-review requested on ea82dc6. Since the last pass the change is materially different, not just patched:

  • A2A bypass (P1, confirmed real). The webhook compared agents.token_hash outside the AuthProvider, so revocation never reached it. It now resolves through the provider and re-checks the binding. Verified the new HTTP test fails against the pre-fix code — a revoked credential previously reached payload validation.
  • Provider contract. Revocation is an optional revokeAgentCredential on the AuthProvider port; a provider that cannot enforce it gets revocation_unsupported (501) instead of a receipt it cannot honour.
  • Vanished-row false receipt + concurrency, per the earlier inline findings.
  • Durability. registerAgentViaNode upserts on (workspace_id, name) and rewrites token_hash for any non-active seat. revoked_at is deliberately outside that set clause; there is a test pinning it.

Please look hardest at whether any other path authenticates without consulting the provider, and at the deploy-ordering hazard (migration 0034 must precede the code, or every agents query breaks).

@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '=== Current revision and changed files ==='
git rev-parse HEAD
git diff --name-status "$(git merge-base HEAD origin/main 2>/dev/null || git merge-base HEAD main)" HEAD 2>/dev/null || true

printf '%s\n' '=== Authentication, webhook, and migration structure ==='
ast-grep outline packages/engine/src --items all --type function,class,interface --match 'Auth|auth|Agent|agent|Webhook|webhook|Migrat|migrat' 2>/dev/null || true

printf '%s\n' '=== AuthProvider declarations, implementations, and calls ==='
rg -n -P -C 4 '\b(AuthProvider|revokeAgentCredential|authenticateAgent|agent_token_revoked|revocation_unsupported)\b' packages --glob '*.{ts,tsx}' || true

printf '%s\n' '=== Direct agent token/hash access and agent-table queries ==='
rg -n -P -C 4 '\b(tokenHash|token_hash)\b|(?:from|update|insert into|delete from)\s*\(?\s*agents\b|\bagents\.(tokenHash|revokedAt)\b' packages --glob '*.{ts,sql}' || true

printf '%s\n' '=== Routes and request authentication middleware ==='
ast-grep outline packages/engine/src/routes --items all --type function --match 'agent|Agent|webhook|Webhook|auth|Auth' 2>/dev/null || true
rg -n -P -C 5 '\b(router|app)\.(get|post|put|patch|delete)\s*\(|\b(authenticate|authorize|verify|tokenHash|token_hash)\b' packages/engine/src --glob '*.{ts,tsx}' || true

printf '%s\n' '=== Migrations, migration runner, and schema references ==='
fd -t f . packages/engine/src/db | sort
rg -n -P -C 4 '\b(migrate|migrationsFolder|0034_agent_token_revocation|revoked_at|revokedAt)\b' packages --glob '*.{ts,sql,json}' || true

printf '%s\n' '=== Changed source and tests ==='
for f in \
  packages/engine/src/auth/index.ts \
  packages/engine/src/engine/agent.ts \
  packages/engine/src/routes/agent.ts \
  packages/engine/src/db/schema.ts \
  packages/engine/src/db/migrations/0034_agent_token_revocation.sql \
  packages/engine/src/engine/__tests__/agentRevocation.test.ts \
  docs/revoking-an-agent-credential.md
do
  if [ -f "$f" ]; then
    echo "--- $f"
    cat -n "$f"
  fi
done

Length of output: 117274


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '=== Candidate files ==='
fd -t f . packages | rg '/(auth|middleware|routes|adapters|engine)/|migrat|deploy|worker|wrangler|sst' | sort

printf '%s\n' '=== Auth provider and middleware ==='
for f in packages/engine/src/auth/index.ts packages/engine/src/middleware/auth.ts; do
  if [ -f "$f" ]; then
    echo "--- $f"
    cat -n "$f"
  fi
done

printf '%s\n' '=== Exact direct credential comparisons ==='
rg -n -P -C 8 \
  '(?:eq|ne|and|or)\s*\(\s*(?:agents|a2aAgents)\.(?:tokenHash|id|name)|\.(?:tokenHash|token_hash)\s*(?:===|!==|==|!=)|\bhashToken\s*\(|\bverifyToken\s*\(|\btoken\s*[:=]' \
  packages --glob '*.{ts,tsx}' || true

printf '%s\n' '=== Provider authentication call sites and all request token readers ==='
rg -n -P -C 6 \
  '\.(?:authenticate|revokeAgentCredential)\s*\(|\bAuthProvider\b|Authorization|Bearer|at_live_|requireAgentToken|requireAuth' \
  packages --glob '*.{ts,tsx}' || true

printf '%s\n' '=== A2A inbound routes and webhook handlers ==='
rg -n -i -P -C 8 \
  '\ba2a\b|agent.?card|webhook|inbound|relayAgentId|relay_agent_id' \
  packages/engine/src --glob '*.{ts,tsx}' || true

printf '%s\n' '=== Upgrade, WebSocket, and non-Hono request authentication ==='
rg -n -i -P -C 8 \
  '\bwebsocket\b|\bupgrade\b|\bfetch\s*\(|serve\s*\(|onRequest|authorization|bearer|tokenHash' \
  packages/engine/src --glob '*.{ts,tsx}' || true

printf '%s\n' '=== Migration runner and deployment configuration ==='
rg -n -P -C 8 \
  '\b(runMigrations|migrate|migrationsFolder|wrangler|d1|database_id|RelaycastDatabase|migration)\b' \
  . \
  -g '!node_modules/**' -g '!dist/**' -g '!build/**' || true

printf '%s\n' '=== Migration journal/order ==='
fd -t f . packages/engine/src/db/migrations | sort | while read -r f; do
  echo "--- $f"
  sed -n '1,120p' "$f"
done

Length of output: 144530


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '=== Auth and A2A candidate files ==='
fd -t f -E node_modules -E dist -E build . \
  | rg -i '(^|/)(auth|a2a|webhook|cloudflare|worker|app|server|routes).*\.(ts|tsx|yml|yaml|json|toml)$|(^|/)(wrangler\.(toml|json|jsonc)|sst\.config\.ts|package\.json)$' \
  | sort

printf '%s\n' '=== Files that contain both A2A or inbound webhooks and agent credential terms ==='
rg -l -i -P '(a2a|inbound.{0,20}webhook|webhook.{0,20}inbound)' packages/engine/src --glob '*.{ts,tsx}' \
  | while read -r f; do
      if rg -q -i -P '(auth|token|bearer|agent|credential|hash)' "$f"; then
        echo "$f"
      fi
    done

printf '%s\n' '=== Exact authentication provider ==='
cat -n packages/engine/src/auth/index.ts
printf '%s\n' '=== Exact HTTP authentication middleware ==='
cat -n packages/engine/src/middleware/auth.ts

printf '%s\n' '=== A2A and inbound-webhook implementations ==='
fd -t f -E node_modules -E dist -E build . packages/engine/src \
  | rg -i '(a2a|inbound.*webhook|webhook.*inbound)' \
  | sort \
  | while read -r f; do
      echo "--- $f"
      cat -n "$f"
    done

printf '%s\n' '=== Revocation HTTP and A2A receipt tests ==='
fd -t f packages/engine/src -E node_modules \
  | rg -i '(agentRevocationReceipt|a2a|webhook)' \
  | while read -r f; do
      echo "--- $f"
      cat -n "$f"
    done

printf '%s\n' '=== Deployment workflow and D1 configuration ==='
fd -t f -E node_modules -E dist -E build . .github \
  | rg -i '(^|/)(workflows/.*\.(yml|yaml)|wrangler\.(toml|json|jsonc)|sst\.config\.ts|package\.json)$' \
  | sort \
  | while read -r f; do
      echo "--- $f"
      cat -n "$f"
    done

Length of output: 116458


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '=== WebSocket authentication and runtime entry points ==='
fd . packages/engine/src -t f \
  | rg -i '(wsAuth|websocket|upgrade|entrypoint|engine\.ts$|index\.ts$)' \
  | sort \
  | while read -r f; do
      echo "--- $f"
      cat -n "$f"
    done

printf '%s\n' '=== All direct agents.tokenHash access outside tests ==='
rg -n -P -C 6 '\bagents\.tokenHash\b|\btoken_hash\b' \
  packages/engine/src \
  --glob '*.{ts,tsx}' \
  -g '!**/__tests__/**' || true

printf '%s\n' '=== Authentication invocation sites outside tests ==='
rg -n -P -C 4 '\bauthenticate\s*\(\s*\{' \
  packages/engine/src \
  --glob '*.{ts,tsx}' \
  -g '!**/__tests__/**' || true

printf '%s\n' '=== Revocation HTTP and A2A receipt tests ==='
fd . packages/engine/src -t f \
  | rg -i '(agentRevocationReceipt|a2a.*test|test.*a2a)' \
  | sort \
  | while read -r f; do
      echo "--- $f"
      cat -n "$f"
    done

printf '%s\n' '=== CI, deploy, and D1 configuration ==='
{
  fd . .github -t f 2>/dev/null || true
  fd -t f -d 2 . 2>/dev/null | rg '(^|/)(wrangler\.(toml|json|jsonc)|sst\.config\.(ts|js)|package\.json)$' || true
} | sort -u | while read -r f; do
  echo "--- $f"
  cat -n "$f"
done

Length of output: 156442


@khaliqgant: I completed the focused re-review of ea82dc6.

I found no remaining agent credential path that bypasses AuthProvider in this repository.

  • HTTP middleware calls AuthProvider.authenticate().
  • The A2A webhook in packages/engine/src/routes/a2a.ts authenticates through the provider, then checks the resolved agent ID against the relay-agent binding.
  • The only direct agents.tokenHash lookup is in SqliteApiKeyAuthProvider.authenticate(). That lookup rejects revokedAt.
  • WebSocket paths do not accept agent tokens. Node WebSocket authentication accepts node tokens only.

The A2A HTTP receipt test covers the previous bypass. It verifies that a revoked relay token returns 401 agent_token_revoked before payload validation.

I found one non-functional issue:

  • packages/engine/src/auth/index.ts Lines 68-76 and packages/engine/src/__tests__/agentRevocationReceipt.test.ts Lines 13-18 still state that the A2A webhook bypasses the authentication provider. The current implementation does not bypass it. Update these comments to describe the regression that the test prevents.

Migration ordering remains an operational requirement.

  • The self-host Node adapter runs migrations before it serves requests by default.
  • The D1 path requires an external wrangler d1 migrations apply operation.
  • This repository does not contain the hosted-engine deployment workflow. Therefore, I cannot verify that production applies migration 0034_agent_token_revocation.sql before worker rollout.
  • The runbook states the required order correctly. The hosted deployment pipeline must enforce the same order.

I found no blocking implementation issue in the reviewed changes.

You are interacting with an AI system.

@khaliqgant

Copy link
Copy Markdown
Member Author

Veto triple-scan — ea82dc6

Check Score Result
Code Review 78 approved_with_warnings
Security 82 approved_with_warnings
Secrets 100 approved

Overall verdict warn, exit code 0. The one "blocking issue" is an artifact: Veto's secrets check sets clean: false whenever the findings array is non-empty, and both secrets findings are info with "none required" — the summary literally reads no credential material present. I left them in rather than returning an empty array to force a green, since suppressing accurate notes to game a gate is the wrong trade on this PR of all PRs.

MCP sampling was unavailable, so Veto delegated the three specialist analyses back to this session. Flagging that plainly: these are self-authored findings on my own change, and should be weighted accordingly against the independent CodeRabbit and Codex passes.

New finding — verified, and NOT fixed here

A node token can still post as a revoked agent. routes/message.ts:111 resolves the from agent by (workspace_id, name) with no revoked_at predicate, so a revoked identity can still emit messages via any valid node token.

The agent's own credential is contained; the identity is not. An operator will reasonably read "revoked seat" as "this identity can no longer speak", and today that is not true.

Fix is small — an isNull(agents.revokedAt) predicate on that lookup, or reject with agent_token_revoked. I deliberately have not made it here: this PR is mid security review, and quietly widening it while reviewers are reading is how a review loses its meaning. Reviewer's call whether it lands in this PR or a follow-up — but it should be an explicit decision, not an omission.

Other residual items from the scans, all unfixed and deliberate:

  • Migration ordering is enforced by documentation only. 0034 must precede the code or every agents query breaks. Worth a pipeline guard.
  • No structural guard against a future direct token_hash match. The A2A bypass is closed and current matches are limited to the provider (verified by exhaustive grep), but a lint rule would make the next one fail CI instead of shipping.
  • revokeAgentToken returns null for both "no such agent" and "could not confirm", so the route reports 404 for an unconfirmed revocation. In a containment runbook that could read as a wrong seat name.

CI: green on ea82dc6 (previous failure was @relaycast/types#test, an unclassified OpenAPI route in the SDK sync guard). No merge, no deploy.

@khaliqgant
khaliqgant marked this pull request as draft August 6, 2026 05:33
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.

1 participant