Skip to content

HF15: agent access — delegate listed operations to an agent account - #167

Merged
On1x merged 15 commits into
pmfrom
agent-access
Sep 28, 2026
Merged

On1x merged 15 commits into
pmfrom
agent-access

Conversation

@On1x

@On1x On1x commented Sep 28, 2026 •

Copy link
Copy Markdown
Member

What

A principal account can register agents: named keys that may broadcast a listed set of operations on the principal's behalf. An agent is not an account — it is a record on the principal: agent_name (unique per principal, [a-z0-9_-]) + agent_key (public key) + operation list + expiration. A transaction signed by the agent key passes as the principal's when every authority-requiring operation is in the list. The principal's own keys are never shared.

New operation set_agent_permission (op-id 105): account, agent_name, agent_key, operations (wire names; empty = revoke), expiration (epoch = perpetual; past = revoke), addons (off-chain scopes such as vizhub, at most 10, each shorter than 64 bytes, not interpreted by the node and granting nothing on chain; an addon-only agent is valid; revoke = both lists empty). The key is required on grant, not on revoke. Requires the principal's active authority; rejected before HF15.

Rules

  • Never delegable: set_agent_permission, proposal_create/update/delete, account_update, recover_account, change_recovery_account, set_account_price, set_subaccount_price, target_account_sale.
  • One key, one agent: a key already bound to another agent name of the same principal is rejected; reissuing by name replaces the key (key rotation = reissue).
  • Authority hook (database::_validate_transaction): only after HF15; the transaction needs no master/regular authority; the principal's own signatures do not already satisfy it; a live row covers every authority-requiring operation; the agent key must be among the signatures. No nested reach through account_auths.
  • Cap: at most 16 live agents per principal (CHAIN_AGENT_MAX_PER_ACCOUNT); a grant sweeps the principal's expired rows first.
  • Wipes all agents of the principal in the same step as: master change, active change, recover_account, direct sale, auction close. Regular-only changes keep them.
  • Snapshot: the index (with agent_name/agent_key) is exported and imported (new object type → testnet redeploy from snapshot).

Read API

get_agent_permissions(account) in database_api: the principal's agents with name, key, operations, expiration, addons and an expired flag.

Tests

  • Protocol: tests/pm/agent_access_test.cpp.
  • Chain level: tests/consensus_sim/scenarios/test_agent_access.cpp (16 cases, incl. addon-only agent grants nothing on chain, BUILD_TESTNET) — agent key carries the op, principal still signs, foreign key rejected, full coverage, master-mixed tx, invalid rows and revoke, nested authorities, key uniqueness and rotation, wipes on active/master change, kept on regular change, wipes on direct sale / auction close / recovery, cap + expired sweep.
  • Mutation-checked: coverage rule, wipe, key uniqueness — each mutant fails its test.

🤖 Generated with Claude Code

On1x added 15 commits September 28, 2026 06:03
…id 105

Account-level delegation of an explicit operation list: `agent` may broadcast the
listed operations on behalf of `account`, signing with its own active key, until
`expiration` (epoch = perpetual, empty list = revoke). No roles, no masks: a mask
would silently widen the grant when a new operation is appended to the chain.

Wiring:
- op struct appended at the END of the operation static_variant (index 105), so no
  existing op-id moves;
- operation_wire_name() / is_broadcastable_operation_wire_name() in operations.cpp:
  name lookup derived from the same type names the wire format uses, with virtual
  operations excluded (they are never broadcast, so a delegation to one is dead);
- validate() refuses what looks fine but is not: unknown names, virtual names,
  legacy aliases (would duplicate a canonical name), malformed names, self-agent,
  set_agent_permission itself (an agent must not re-delegate) and the proposal
  wrappers (a proposal carries arbitrary operations whose authorities are collected
  at execution time, so delegating proposal_create would bypass the explicit list).

Tests: tests/pm/agent_access_test.cpp (6 cases: op-id anchors on both sides of the
append, wire-name classification, and the validate() accept/refuse sets). Proven to
be able to fail: with the never-delegable assert disabled, exactly the 4 escalation
checks go red.
… delegation names

Step 2 of HF15 agent access: the consensus state behind set_agent_permission.

Object `agent_permission_object` holds one row per (principal, agent) pair: the canonical
`,`-joined list of operation names and an expiration (epoch = perpetual). Unique index on
(principal, agent) so a re-grant replaces rather than accumulates, and a non-unique index on
the agent — the wipe rules need both directions.

Two adversarial findings drove the deny-list change (protocol side, same branch):
- `account_update` is an account takeover: an op WITHOUT the master field is satisfied by the
  ACTIVE authority and may carry a new `active` authority, so an agent granted account_update
  for "metadata edits" can rotate the principal's active key to its own and own the account.
- master-only operations (recover_account, change_recovery_account, set_account_price,
  set_subaccount_price, target_account_sale) are unreachable through a delegation — the hook
  never substitutes master — so granting one would be a permission that can never succeed.

The deny-list is now a single source of truth in protocol (`never_delegable_operation_names`),
checked both at grant time and, in step 3, in the authority hook. The evaluator re-checks the
list and the operation names on every grant: the hook trusts these rows, so a bad row would be
live escalation rather than cosmetics.

Tests: 7 cases / 33 checks green (op-id anchors, wire names, accept/refuse sets). Verified:
graphene_protocol + graphene_chain build clean (rc=0) with the new object type in the enum.
A principal's active authority is answered with its agent's active
authority only when a live delegation row covers every
authority-requiring operation of the transaction, the principal's own
signatures do not already satisfy it, and the transaction asks for no
master or regular authority. Signature recovery is deferred until a row
exists, so ordinary transactions pay nothing extra.

Chain-level tests (consensus_sim, BUILD_TESTNET): granted op, principal
still signs, full-coverage rule (mutation-checked), master-mixed tx,
missing/expired/deny-listed/unknown rows, no leak through nested
account_auths.
Export the index and import it with an explicit setter for the
shared_string operations list.
Rows go on both sides (principal and agent) in the same step as a
master change (account_update, recover_account), an active change, a
direct sale (buy_account) and an auction close. Regular-only updates
keep the row: agents sign with active.

Tests: principal active rotation and agent master rotation wipe the row
(mutation-checked), regular-only change keeps it.
The auction-close path is left untested for now: bid extensions are
measured by wall clock, which the virtual-clock harness cannot reach
(fixed separately in fix-auction-wallclock).
… on grant

The authority hook walks all of a principal's rows for each transaction
the principal did not sign itself, and a rejected transaction pays no
bandwidth, so the row count must be bounded. Every grant now removes
the principal's expired rows before counting, so a dead row lives at
most until the next grant and the total stays <= 16.

Test: cap refuses the 17th agent, a re-grant is an overwrite, an
expired row is swept and its slot reused (cap mutation-checked).
Possible now that #166 (auction extension from head block time) is in pm.
An agent is no longer a separate account but a record on the principal:
agent_name (unique per principal) + agent_key + operation list + expiration.
A transaction signed by the agent key passes on behalf of the principal when
every operation is in the list.

- one key per agent; reissue by name replaces the key; key reuse across names rejected
- all agents wiped on master/active change, recovery, direct sale and auction close;
  regular-only change keeps them
- snapshot imports agent_name/agent_key
- 15 chain tests rewritten; mutations (coverage, wipe, key uniqueness) are caught
Returns a principal's agents (name, key, operation list, expiration) ordered
by name, with expired rows flagged. At most CHAIN_AGENT_MAX_PER_ACCOUNT rows
per principal, so no paging.
Agents carry an optional set of addons, e.g. ["vizhub"]: scopes external
services read to accept the agent key for their own actions. The node stores
them and never interprets them; they grant nothing on chain. At most 10, each
shorter than 64 bytes, no ','. An addon-only agent is valid; revoke = both
lists empty. Stored in the object, snapshot and get_agent_permissions.
…object

The addons import landed in import_transactions (same expiration line) and used
variant::contains, which does not exist; the Docker build failed on it.
@On1x
On1x merged commit cf733fd into pm Sep 28, 2026
4 checks passed
@On1x
On1x deleted the agent-access branch September 28, 2026 14:41
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