Skip to content

Security hardening: replace message-text lifecycle commands with typed, authorized controls #514

Description

@yui-stingray

Problem

ctrl:despawn is currently an ordinary message body with privileged lifecycle side effects. despawn.sh sends it through send.sh; watch.sh branches on exact body equality and, for an exclusive watcher, drops the target role and can close its tmux pane or a matching recorded herdr pane.

In normal mode, send.sh verifies that the supplied from_agent and to_agent names exist in the team roster. Its --force option can bypass that membership check. Neither path proves that the caller owns the claimed sender session or is authorized to control the target.

The messages table stores a team, sender label, recipient label, body, timestamps, and read state. It has no record type, operation, issuer session, target generation, authorization proof, expiry, or replay key.

This creates data/control-plane confusion: a path permitted to submit an ordinary message under a registered logical sender can cause an exclusive target watcher to interpret message text as lifecycle authority. Existing broad-watcher and placement guards validate the receiver and placement, not the issuer. Current placement metadata also has no owner/session authority to validate.

The immediate impact is control integrity and availability inside an agent team: a watcher-backed role can be unregistered and its session terminated. This is not a local privilege-escalation claim.

Evidence on current main

The behavior introduced by #129 remains present on main at commit c1415e9ea571069d5bae5692d0da9424aabf71dc (VERSION 1.1.11).

  • scripts/despawn.sh sends the magic body through the normal message API.
  • scripts/send.sh performs roster membership checks and then persists the caller-supplied sender label and body.
  • scripts/watch.sh executes the lifecycle branch from body == "ctrl:despawn" without authorizing the sender before resetting the role.
  • The schema in scripts/internal/init-db.sh gives ordinary messages no typed-control or authorization fields.
  • tests/test_watch.bats uses an ordinary registered peer to deliver the control text that drives watcher teardown. This pins the current mechanism; it is not evidence that the peer has lifecycle authority.

I reviewed this revision and its existing regression tests statically. I did not execute teardown against a live pane or session.

Security boundary

This issue protects the control dispatcher from treating untrusted inbound data, caller-selected sender labels, stale controls, or misrouted controls as lifecycle authority. A message body remains untrusted data even when another registered role sent it.

This does not claim isolation from a hostile process or user that can edit agmsg's database or run state, replace scripts, invoke local teardown commands directly, or signal sibling processes under the same OS account. A fully compromised peer with arbitrary same-user shell access is outside this boundary.

Local OS-user access remains the ultimate trust boundary. Any future remote-to-local lifecycle operation requires an explicit local authorization decision; it must not become local process-control authority merely because a remote credential was accepted.

Proposed contract

  1. Separate data from control. Ordinary rows in messages are always data. A watcher must never perform lifecycle actions from a body prefix or exact body value. Introduce a dedicated typed control API and storage separate from ordinary message bodies.

  2. Start with one closed operation. Version 1 supports only lifecycle.despawn. Future operations such as spawn, drop, monitor stop, or rebind require an explicit operation definition and authorization policy. Unknown versions and operations fail closed.

  3. Bind issuer and target. At spawn time, record the controller identity/session, a unique target generation, and authority material for that generation. A control identifies its team, operation, issuer identity/session, target role, exact target generation, issuance and expiry times, and idempotency/replay key.

  4. Use application-level proof, not a sender label. Bind the controller through a per-spawn capability or equivalent verifier. Do not treat a caller-supplied from_agent as proof of authority. Reusable authorization material must not enter messages.body, history output, or normal logs.

  5. Authorize before side effects. Graceful despawn is accepted only from the recorded controller or another explicitly defined local authority, and only for the current target generation. Liveness and placement matching are necessary checks, but neither is authority.

  6. Make results durable and idempotent. Persist accepted, rejected, and completed state. Re-delivery does not repeat teardown. Expired, replayed, wrong-controller, wrong-target, unknown, or malformed controls produce no lifecycle side effect and expose only a bounded status result.

  7. Keep force recovery separate. despawn --force remains an explicit local recovery command outside inbound message/control ingestion. It validates the recorded placement before signalling and cannot be triggered by an inbound record. This does not protect against a same-user process bypassing agmsg and signalling the target directly.

  8. Migrate safely. Add and test the typed path before disabling the legacy magic-body branch. End state: literal ctrl:despawn text in an ordinary message is data and never tears down a role.

Non-goals

Acceptance criteria

  • An ordinary message whose body is ctrl:despawn cannot drop a role, release its lock, or close a pane, regardless of its claimed sender label.
  • A valid, authorized lifecycle.despawn for the current target generation performs graceful teardown once and records one durable terminal result.
  • Missing or invalid authority, spoofed issuer, wrong target generation, expiry, replay, duplicate delivery, unknown operation/version, and malformed records fail closed without lifecycle side effects.
  • A broad watcher or unrelated session cannot execute a control for an exclusive target even if it observes the record.
  • No capability or reusable secret appears in the message table, history output, normal logs, or test snapshots.
  • Existing installations upgrade without losing message history, with an explicit compatibility/deprecation path.
  • despawn --force remains a separate local recovery path unreachable through inbound control ingestion.

Tests

Add deterministic coverage for ordinary-body regression, authorized graceful despawn, unauthorized/spoofed senders, wrong generation/stale placement, expiry/replay/duplicates/malformed/unknown versions, broad versus exclusive watchers, crash/restart, tmux/herdr placement mismatch without a real host pane, schema migration, and secret absence.

Related work

These items are related context; none currently authenticates local lifecycle controls.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions