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
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
Problem
ctrl:despawnis currently an ordinary message body with privileged lifecycle side effects.despawn.shsends it throughsend.sh;watch.shbranches 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.shverifies that the suppliedfrom_agentandto_agentnames exist in the team roster. Its--forceoption can bypass that membership check. Neither path proves that the caller owns the claimed sender session or is authorized to control the target.The
messagestable 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
mainThe behavior introduced by #129 remains present on
mainat commitc1415e9ea571069d5bae5692d0da9424aabf71dc(VERSION1.1.11).scripts/despawn.shsends the magic body through the normal message API.scripts/send.shperforms roster membership checks and then persists the caller-supplied sender label and body.scripts/watch.shexecutes the lifecycle branch frombody == "ctrl:despawn"without authorizing the sender before resetting the role.scripts/internal/init-db.shgives ordinary messages no typed-control or authorization fields.tests/test_watch.batsuses 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
Separate data from control. Ordinary rows in
messagesare 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.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.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.
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_agentas proof of authority. Reusable authorization material must not entermessages.body, history output, or normal logs.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.
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.
Keep force recovery separate.
despawn --forceremains 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.Migrate safely. Add and test the typed path before disabling the legacy magic-body branch. End state: literal
ctrl:despawntext in an ordinary message is data and never tears down a role.Non-goals
Acceptance criteria
ctrl:despawncannot drop a role, release its lock, or close a pane, regardless of its claimed sender label.lifecycle.despawnfor the current target generation performs graceful teardown once and records one durable terminal result.despawn --forceremains 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
ctrl:*from catch-up because stale controls are unsafe to replay.These items are related context; none currently authenticates local lifecycle controls.