Skip to content

feat(agent-to-agent): per-message approval policies on connected agents - #2793

Merged
moshe-nanoco merged 16 commits into
mainfrom
feat/a2a-approval-policies
Jun 18, 2026
Merged

feat(agent-to-agent): per-message approval policies on connected agents#2793
moshe-nanoco merged 16 commits into
mainfrom
feat/a2a-approval-policies

Conversation

@moshe-nanoco

Copy link
Copy Markdown
Collaborator

What

Adds an optional, directed, per-message require-approval gate on top of an existing agent-to-agent connection. No policy = today's free flow (fully backward compatible). When a policy exists for A→B, each message A sends to B is held, an approval card showing the message goes to B's admins, and the message is delivered on approve / declined on reject. Rejecting one message never blocks the connection — the next send simply asks again.

Why

Today, once two agents are connected (an agent_destinations edge) they message each other freely and permanently — all-or-nothing. An operator can't require review on a sensitive connection (e.g. a general assistant → a finance agent) without tearing the wiring down. This adds oversight without un-wiring.

How

  • New agent_message_policies table — directed (from, to); a row's existence = require approval (no mode column in v1); approvers JSON (NULL = target's admins/owners). Deleted alongside its connection so a stale rule can't reactivate on re-wire.
  • Gate inside routeAgentMessage — after the existing self/hasDestination checks, look up the policy; if present, requestApproval and return (consume the outbound row like a system action). The held message rides in the approval payload and is re-routed by applyA2aMessageGate on approve. Self/internal messages are never gated.
  • requestApproval gains approverAgentGroupId / approverUserIds and stamps agent_group_id on the pending row, so the target's admins pass the click-auth gate.
  • ncl policies list/set/remove — operator-only (not in the container cli_scope allowlist, so agents can't manage their own gates); set validates named approvers are admins/owners of the target.

Reuses the existing requestApproval / pending_approvals / approval-handler spine (same shape as create_agent). Host-only — no container changes.

Decisions

Locked in a grill session; full record on the hub at engineering/discovery/a2a-approval-policies-decisions. Highlights: consume-and-replay hold; target-scoped approver; one card to first reachable; fail-closed; per-message (no ordering enforcement); card shows truncated body + attachment names.

Test

  • pnpm run build clean; pnpm test512 pass (8 new in message-gate.test.ts): policy round-trip, no-policy routes, policy holds + scopes approver to target, named-approver passthrough, self-message never gated, approve re-routes, ghost-gate cleanup (both paths).
  • Verified live end-to-end on a fresh install: ncl policies set → message held → approval card → approve/reject → free flow when no policy.

Deferred (out of v1)

create_agent policy-attach; trust-on-first-use mode; strict ordering; broadcast approvers; named non-admin approvers; manual ncl approvals approve/reject for a lost-card stuck message.

🤖 Generated with Claude Code

Comment thread src/modules/agent-to-agent/agent-route.ts Outdated
Comment thread src/modules/agent-to-agent/agent-route.ts Outdated
Moshe Krupper and others added 16 commits June 18, 2026 18:09
Add an optional, directed, per-message require-approval gate on top of an
existing agent-to-agent connection. No policy = today's free flow (fully
backward compatible). When a policy exists for A→B, each message A sends to B
is held, an approval card showing the message goes to B's admins, and the
message is delivered on approve / declined on reject. Rejecting one message
never blocks the connection.

- New `agent_message_policies` table (directed from→to; row exists = require
  approval; `approvers` JSON, NULL = target admins). Deleted alongside its
  connection so a stale rule can't reactivate on re-wire.
- Gate inside `routeAgentMessage` after the self/`hasDestination` checks:
  holds the message via `requestApproval` and returns to consume it (like a
  system action); the held message rides in the approval payload and is
  re-routed by `applyA2aMessageGate` on approve. Self/internal messages are
  never gated.
- `requestApproval` gains `approverAgentGroupId` / `approverUserIds` and stamps
  `agent_group_id` on the pending row so the target's admins pass the
  click-auth gate.
- `ncl policies list/set/remove`, operator-only (not in the container cli_scope
  allowlist); `set` validates named approvers are admins/owners of the target.

Reuses the existing requestApproval / pending_approvals / approval-handler
spine (same shape as create_agent). Host-only; no container changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- policies.ts: drop the 10-line top banner. Sibling resource files carry no
  descriptive header (only destinations.ts, and only for a non-obvious
  side-effect); the prose already lives in the resource `description`.
- agent-message-policies.ts: remove `listMessagePolicies` — no production
  caller (the `ncl policies list` op uses the generic table-based CRUD); only
  its own test referenced it.
- message-gate.test.ts: assert the upsert-no-duplicate invariant via a direct
  row count instead of the removed helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Shorten the verbose doc/inline comments added with the approval-policy gate
down to terse one-liners, matching the surrounding style (e.g. agent-destinations,
write-destinations). No behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…sage

Address PR review: hoist session.agent_group_id into a named local
`sourceAgentGroupId`, mirroring `targetAgentGroupId`, and use it throughout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address PR review: remove the `approvers` option entirely for v1 — the
approver is always the target group's admins/owners. Drops the `approvers`
DB column, the `--approvers` flag + its set-time validation, the now-unused
`approverUserIds` param on requestApproval, and the related tests. The
target-scoped approver pick (`approverAgentGroupId`) stays. Named approvers
can be re-added later via a migration when needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address PR review: extract `parseMessageContent` (text + attachment names from
the message content JSON) so `buildGateQuestion` reads as pure formatting, and
name the body-length cap (`GATE_CARD_BODY_MAX`) instead of a bare 1500.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per review, add an optional `approver` to a policy: a specific admin/owner of
the target who receives the approval card (instead of all target admins). NULL
keeps the default (all target admins/owners).

- `approver` column on agent_message_policies; carried on AgentMessagePolicy.
- `ncl policies set --approver <user-id>` validates the user is an admin/owner
  of the target at set-time, so the existing click-auth gate is unchanged.
- `requestApproval` gains `approverUserId` (single) to deliver the card to that
  one user; the gate passes `policy.approver`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per review, the policy approver is now required, not optional. Every policy
names one specific admin/owner of the target who approves.

- `approver` column is NOT NULL; `AgentMessagePolicy.approver` is non-nullable.
- `ncl policies set --approver <user-id>` is required and validated to be an
  admin/owner of the target.
- The gate always delivers the card to `policy.approver`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per review: destructure the approval payload once instead of repeating
`payload.x`, and narrow `platform_id` up front so it's used directly (drops the
separate `targetAgentGroupId` local).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ver may be source or target

Per review (no new pending_approvals column): the gate carries `approver` inside
the existing approval `payload`, and isAuthorizedApprovalClick authorizes the
named approver (or an owner/global admin) when an approval names one — reading
the real value at click time, no group re-derivation.

- `ncl policies set --approver` validates the user is an admin/owner of the
  source OR target.
- Drops `approverAgentGroupId` and the agent_group_id stamp; `requestApproval`
  keeps `approverUserId` only for delivery.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
With payload-based click-auth (clicker === approver), the approver no longer
needs to be a group admin — the operator (operator-only command) designates
whoever should approve, and only that user (or an owner) can resolve the card.
Removes the now-redundant hasAdminPrivilege validation and its import.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Remove redundant doc/inline comments where the code speaks for itself; keep only
the non-obvious notes (return-vs-throw consume, ghost-gate cleanup, caller-does-
auth, reject-handled-elsewhere, stored-vs-click payload). Also drops a couple of
now-stale "target admin" descriptions. No behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…eated access

Per review: pull `approverUserId` into the `opts` destructure in requestApproval,
and `approver` out of `policy` in the gate, instead of accessing the property
twice. (policies.ts already binds args.* to locals.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…r may resolve

Per review: drop the owner/global-admin override on assigned approvals. When an
approval names an approver, only that exact user can resolve it. (Non-assigned
approvals are unchanged — still group/owner authorized.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t the payload

Per review: move the assigned approver from the approval payload to a dedicated
`approver_user_id` column on pending_approvals.

- New migration adds the column; createPendingApproval + requestApproval write it.
- isAuthorizedApprovalClick reads approval.approver_user_id directly (drops the
  payload-parsing helper); when set, only that exact user may resolve.
- The gate no longer stuffs `approver` into the payload.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rename the two new migration files to the numbered convention used by the core
migrations (001–016), with matching migrationNNN exports, instead of the
module- prefix. Versions (17, 18) and stable migration `name`s are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@moshe-nanoco
moshe-nanoco force-pushed the feat/a2a-approval-policies branch from e146067 to 9977af6 Compare June 18, 2026 15:09

@omri-maya omri-maya left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Looks good!

@@ -0,0 +1,20 @@
import type Database from 'better-sqlite3';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Migrations conventions to be prefixed with the incremental number

@@ -0,0 +1,14 @@
import type { Migration } from './index.js';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Migrations conventions to be prefixed with the incremental number

@moshe-nanoco
moshe-nanoco merged commit 886c657 into main Jun 18, 2026
1 check passed
@gavrielc

Copy link
Copy Markdown
Collaborator

@moshe-nanoco Looks goood!!

technicalpickles pushed a commit to technicalpickles/nanoclaw that referenced this pull request Jul 12, 2026
…cies

feat(agent-to-agent): per-message approval policies on connected agents
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.

3 participants