Watch
0
0
Fork
You've already forked hyperhive
0

swarm-nats-auth: limit agent subscribe to its own inbox and input

An agent's queue credential could subscribe to every subject in the swarm
account, so any agent could read every other agent's terminal, turn state,
operator input and request replies. Agents are the least trusted clients
on the queue (mara's ruling on #4901), so their grants now carry a
subscribe allow-list:

- an agent's own credential (the harness and its subagent daemon):
  `_INBOX.agent.<agent>.>` and `$SWARM.input.<agent>`
- the hive's shared agent client: `_INBOX.client.<client-id>.>`

Hive and swarm-controller grants are unchanged (no `sub` claim, so still
every subject).

The clients connect with the matching inbox prefix, so the replies they
rely on still arrive: the icon write's JetStream ack and the schedule
DIRECT.GET. `connect_with_token` takes the agent name and sets the prefix
itself; the hive-shared fallback uses the new
`connect_with_inbox_prefix`.

The harness no longer subscribes to its input subject when it fell back
to the hive's shared client: that identity names no agent, so it cannot
be granted one agent's input without all of them. It logs that and
skips, the same as the schedule reader on that path.

Refs #4901
This commit is contained in:
atlas 2026-10-04 18:05:44 +02:00 • committed by mara
commit 52037133e2
8 changed files with 236 additions and 33 deletions

View file

@ -277,6 +277,27 @@ pub const AGENT_INPUT_SUBJECT_WILDCARD: &str = "$SWARM.input.*";
/// Shared by [`agent_input_subject`] and [`AGENT_INPUT_SUBJECT_WILDCARD`].
const AGENT_INPUT_SUBJECT_PREFIX: &str = "$SWARM.input";
/// The reply-inbox prefix a client presenting `agent`'s own credential
/// connects with.
///
/// The queue grants that credential subscribe on this prefix's `.>` and its
/// [`agent_input_subject`], nothing else, so a request made under the
/// library's default `_INBOX.<nuid>` is refused its reply and times out. The
/// `agent` token keeps it disjoint from [`client_inbox_prefix`] and from the
/// default, whose second token is a 22-character nuid.
#[must_use]
pub fn agent_inbox_prefix(agent: &str) -> String {
format!("_INBOX.agent.{agent}")
}
/// The reply-inbox prefix for a client authenticating as the OIDC client
/// `client_id`, for a client whose grant limits subscribe to this prefix's
/// `.>` — see [`agent_inbox_prefix`].
#[must_use]
pub fn client_inbox_prefix(client_id: &str) -> String {
format!("_INBOX.client.{client_id}")
}
/// What an [`agent_input_subject`] message carries.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct AgentInput {
@ -750,6 +771,22 @@ pub fn install_crypto_provider() {
/// token expires, the controller keeps serving, its status data quietly stops
/// updating, and nothing says so until someone reads a dashboard.
pub async fn connect(cfg: QueueConfig) -> Result<async_nats::Client, Error> {
connect_inner(cfg, None).await
}
/// [`connect`], with reply inboxes under `inbox_prefix`. For a client whose
/// grant limits subscribe to that prefix, such as [`client_inbox_prefix`].
pub async fn connect_with_inbox_prefix(
cfg: QueueConfig,
inbox_prefix: String,
) -> Result<async_nats::Client, Error> {
connect_inner(cfg, Some(inbox_prefix)).await
}
async fn connect_inner(
cfg: QueueConfig,
inbox_prefix: Option<String>,
) -> Result<async_nats::Client, Error> {
install_crypto_provider();
// Trusts `cfg.ca_file` when set (the swarm's own CA, when the token
// endpoint is signed by it) — see `build_http_client`. A timeout too,
@ -834,6 +871,9 @@ pub async fn connect(cfg: QueueConfig) -> Result<async_nats::Client, Error> {
if let Some(path) = ca_file {
options = options.add_root_certificates(path);
}
if let Some(prefix) = inbox_prefix {
options = options.custom_inbox_prefix(prefix);
}
let client = options
.connect(&url)
@ -846,8 +886,8 @@ pub async fn connect(cfg: QueueConfig) -> Result<async_nats::Client, Error> {
Ok(client)
}
/// Connect presenting the token `token` resolves to, as an agent presents its
/// own queue credential.
/// Connect presenting the token `token` resolves to, as `agent` presents its
/// own queue credential, with reply inboxes under [`agent_inbox_prefix`].
///
/// `token` runs once per connection attempt, reconnects included, so a
/// credential replaced at its source is presented on the next attempt. An
@ -861,6 +901,7 @@ pub async fn connect(cfg: QueueConfig) -> Result<async_nats::Client, Error> {
pub async fn connect_with_token<F, Fut>(
url: &str,
ca_file: Option<&std::path::Path>,
agent: &str,
token: F,
) -> Result<async_nats::Client, Error>
where
@ -875,7 +916,8 @@ where
Ok(auth)
}
})
.reconnect_delay_callback(reconnect_delay);
.reconnect_delay_callback(reconnect_delay)
.custom_inbox_prefix(agent_inbox_prefix(agent));
if let Some(path) = ca_file {
options = options.add_root_certificates(path.to_path_buf());
}