Watch
1
0
Fork
You've already forked souveraine
0

feat(federation): Phase 7 — agent identity vs device identity

reach/consult was classified by trusting the event_type field, so any
peer could claim "reach" and skip the consent gate. Federation also used
one per-machine seed for everything.

Split the two identities the codebase already had: the agent seed
(agents/{id}/seed/, travels with the memfs, identical across one agent's
machines) now signs the summon payload; the device seed keeps signing
the transport envelope. The receiver classifies reach vs consult by
verifying the agent signature against its own agent pubkey — a match is
genuinely self (reach, no consent gate), a mismatch is a separate being
(consult, consent-gated), an invalid signature is dropped. The lite
listener verifies the same way, reading hosted agent pubkeys straight
from seed/public.key with no engine load. authorized-summoners.md is now
keyed on agent pubkeys — consent is per-being, not per-machine.

New core/identity/summon.rs carries the signing helpers and round-trip,
tampered-field, and wrong-pubkey tests.
This commit is contained in:
Fimeg 2026-05-15 15:38:35 -04:00
commit 055a3d7b38
5 changed files with 304 additions and 56 deletions

View file

@ -1,3 +1,5 @@
pub mod seed;
pub mod summon;
pub use seed::{glyph_from_pubkey, SeedId};
pub use summon::{sign_summon, verify_summon};

117
src/core/identity/summon.rs Normal file
View file

@ -0,0 +1,117 @@
//! Agent-identity signing for federation summons.
//!
//! A `reach`/`consult` request is signed by the *agent* seed — the keypair
//! kept at `agents/{id}/seed/`, which travels with the memfs and is therefore
//! identical across every machine one agent runs on. The receiver verifies
//! this signature against its *own* agent pubkey:
//!
//! - match → genuinely the same being → `reach`, no summoner-consent gate
//! - differ → a separate being → `consult`, consent-gated
//!
//! This is distinct from the transport `SignedEvent` envelope, which the
//! *machine* seed signs ("this event came from that box"). Device identity
//! authenticates the wire; agent identity authenticates the being. Both are
//! needed, and they are not the same key.
use ed25519_dalek::Signature;
use super::SeedId;
/// The canonical bytes an agent signature covers — the fields that pin a
/// summon to one request, one declared intent, one target machine, one
/// payload. Caller (signing) and receiver (verifying) must build these
/// byte-identically or every verification fails.
pub fn summon_signing_bytes(request_id: &str, tool: &str, target: &str, prompt: &str) -> Vec<u8> {
format!("{request_id}\n{tool}\n{target}\n{prompt}").into_bytes()
}
/// Sign a summon with the agent seed. Returns the hex-encoded signature.
pub fn sign_summon(
agent_seed: &SeedId,
request_id: &str,
tool: &str,
target: &str,
prompt: &str,
) -> String {
let bytes = summon_signing_bytes(request_id, tool, target, prompt);
hex::encode(agent_seed.sign(&bytes).to_bytes())
}
/// Verify a summon's agent signature against the claimed agent pubkey (hex).
/// Returns true only if the signature is valid over those exact fields — a
/// malformed pubkey, malformed signature, or any tampered field fails closed.
pub fn verify_summon(
agent_pubkey_hex: &str,
agent_sig_hex: &str,
request_id: &str,
tool: &str,
target: &str,
prompt: &str,
) -> bool {
let pubkey: [u8; 32] = match hex::decode(agent_pubkey_hex)
.ok()
.and_then(|b| b.try_into().ok())
{
Some(p) => p,
None => return false,
};
let sig_bytes: [u8; 64] = match hex::decode(agent_sig_hex)
.ok()
.and_then(|b| b.try_into().ok())
{
Some(s) => s,
None => return false,
};
let signature = Signature::from_bytes(&sig_bytes);
let bytes = summon_signing_bytes(request_id, tool, target, prompt);
SeedId::verify_with_pubkey(&pubkey, &bytes, &signature)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trip_verifies() {
let seed = SeedId::generate();
let sig = sign_summon(&seed, "req-1", "reach", "machineX", "carry this");
assert!(verify_summon(
&seed.public_key_hex(),
&sig,
"req-1",
"reach",
"machineX",
"carry this",
));
}
#[test]
fn tampered_field_fails() {
let seed = SeedId::generate();
let sig = sign_summon(&seed, "req-1", "reach", "machineX", "carry this");
// Prompt changed after signing.
assert!(!verify_summon(
&seed.public_key_hex(),
&sig,
"req-1",
"reach",
"machineX",
"carry something else",
));
}
#[test]
fn wrong_pubkey_fails() {
let signer = SeedId::generate();
let other = SeedId::generate();
let sig = sign_summon(&signer, "req-1", "consult", "machineX", "a question");
assert!(!verify_summon(
&other.public_key_hex(),
&sig,
"req-1",
"consult",
"machineX",
"a question",
));
}
}

View file

@ -132,25 +132,50 @@ fn dispatch_summon(
))
})?;
let local_seed_id = SeedId::load_or_generate(&SeedId::default_dir(&base))
// The *machine* seed addresses this box — it is the reply route, and the
// bridge signs the transport envelope with it.
let machine_seed_id = SeedId::load_or_generate(&SeedId::default_dir(&base))
.map(|s| s.public_key_hex())
.map_err(|e| ToolError::invalid_input(&format!(
"I couldn't load my seed identity: {e}"
"I couldn't load my machine seed identity: {e}"
)))?;
// The *agent* seed proves who is reaching. It lives beside the memfs
// (`agents/{id}/seed/`), identical across all of one agent's machines —
// so the receiver can tell self-extension (reach) from a peer (consult)
// by verifying this signature, not by trusting an event field.
let agent_seed_dir = ctx.memory_root.as_ref()
.and_then(|m| m.parent())
.map(|p| p.join("seed"))
.ok_or_else(|| ToolError::invalid_input(
"I can't locate my agent identity — reach and consult need my \
memory root to find my seed."
))?;
let agent_seed = SeedId::load_or_generate(&agent_seed_dir)
.map_err(|e| ToolError::invalid_input(&format!(
"I couldn't load my agent seed: {e}"
)))?;
let agent_pubkey = agent_seed.public_key_hex();
let request_id = uuid::Uuid::new_v4().to_string();
let agent_sig = crate::core::identity::sign_summon(
&agent_seed, &request_id, tool, &target_seed_id, prompt,
);
let event = SensorEvent {
sensor_name: "summon_request".into(),
timestamp: chrono::Utc::now(),
event_type: tool.to_string(), // "reach" | "consult"
event_type: tool.to_string(), // declared intent — receiver verifies it
target: Some(target_seed_id),
urgency: 0.5,
payload: Some(serde_json::json!({
"request_id": request_id,
"prompt": prompt,
"agent_pubkey": agent_pubkey,
"agent_sig": agent_sig,
})),
seed_id: None, // local-origin; the bridge signs + forwards
reply_to: Some(local_seed_id),
reply_to: Some(machine_seed_id),
};
bus.send(event);

View file

@ -20,7 +20,7 @@ use axum::{
};
use crate::core::config::FederationConfig;
use crate::core::identity::SeedId;
use crate::core::identity::{verify_summon, SeedId};
use crate::core::nervous::{EventBus, SensorEvent};
use crate::server::federation::{FederationBridge, SignedEvent};
@ -29,10 +29,33 @@ use crate::server::federation::{FederationBridge, SignedEvent};
pub struct LiteListener {
pub event_bus: EventBus,
pub local_seed_id: String,
/// Agent pubkeys (hex) permitted to `consult` here — the consent floor.
pub authorized_summoners: Vec<String>,
/// Agent pubkeys (hex) this device hosts. A summon signed by one of these
/// is a genuine self-extension (`reach`) and bypasses the consent floor.
/// Read from each agent's `seed/public.key` — no engine, no memfs load.
pub hosted_agent_pubkeys: Vec<String>,
pub auto_wake: bool,
}
/// Read the agent pubkeys this device hosts from `server/agents/*/seed/public.key`.
/// Cheap enough for the lite path — a handful of 32-byte files, no DB.
fn load_hosted_agent_pubkeys(base: &Path) -> Vec<String> {
let agents_dir = base.join("server").join("agents");
let mut out = Vec::new();
if let Ok(entries) = std::fs::read_dir(&agents_dir) {
for entry in entries.flatten() {
let pk = entry.path().join("seed").join("public.key");
if let Ok(bytes) = std::fs::read(&pk) {
if bytes.len() == 32 {
out.push(hex::encode(bytes));
}
}
}
}
out
}
impl LiteListener {
/// Build the listener: load the seed identity, start the federation
/// bridge to configured peers, and spawn the summon-wake watcher.
@ -45,6 +68,7 @@ impl LiteListener {
event_bus: event_bus.clone(),
local_seed_id,
authorized_summoners: config.authorized_summoners.clone(),
hosted_agent_pubkeys: load_hosted_agent_pubkeys(&base),
auto_wake: config.auto_wake,
});
@ -74,13 +98,40 @@ impl LiteListener {
if event.target.as_deref() != Some(&self.local_seed_id) {
continue;
}
// reach is self-extension (no consent gate); consult must be
// from an authorized summoner.
let summoner = event.seed_id.as_deref().unwrap_or("");
let authorized = event.event_type == "reach"
|| self.authorized_summoners.iter().any(|s| s == summoner);
// Authenticate by the agent signature — never by trusting the
// event's `event_type`. A summon signed by an agent we host is
// a genuine self-extension (reach) and bypasses the consent
// floor; anything else must be an authorized summoner (consult).
let field = |k: &str| event.payload.as_ref()
.and_then(|p| p.get(k))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let request_id = field("request_id");
let prompt = field("prompt");
let agent_pubkey = field("agent_pubkey");
let agent_sig = field("agent_sig");
let target = event.target.clone().unwrap_or_default();
if !verify_summon(
&agent_pubkey, &agent_sig, &request_id,
&event.event_type, &target, &prompt,
) {
tracing::warn!(
request_id = %request_id,
"lite listener: invalid agent signature — summon ignored"
);
continue;
}
let is_self = self.hosted_agent_pubkeys.iter().any(|p| p == &agent_pubkey);
let authorized = is_self
|| self.authorized_summoners.iter().any(|s| s == &agent_pubkey);
if !authorized {
tracing::warn!(summoner, "lite listener: unauthorized summon ignored");
tracing::warn!(
summoner = %agent_pubkey,
"lite listener: unauthorized summon ignored"
);
continue;
}
if let Err(e) = park_summon(&base, &event) {

View file

@ -207,52 +207,89 @@ impl SummonHandler {
return;
}
let tool_type = event.event_type.as_str(); // "reach" or "consult"
let request_id = event.payload
.as_ref()
.and_then(|p| p.get("request_id"))
let declared = event.event_type.clone(); // "reach" | "consult" — a claim
let payload = event.payload.clone().unwrap_or_default();
let str_field = |k: &str| payload.get(k)
.and_then(|v| v.as_str())
.unwrap_or("unknown");
.unwrap_or("")
.to_string();
let request_id = {
let r = str_field("request_id");
if r.is_empty() { "unknown".to_string() } else { r }
};
let prompt = str_field("prompt");
let agent_pubkey = str_field("agent_pubkey");
let agent_sig = str_field("agent_sig");
let target = event.target.clone().unwrap_or_default();
// Authenticate the agent identity. The summon must genuinely
// come from the holder of `agent_pubkey`, over these exact
// fields — a forged or tampered request fails here, silently
// (no ack to an attacker).
if !crate::core::identity::verify_summon(
&agent_pubkey, &agent_sig, &request_id, &declared, &target, &prompt,
) {
tracing::warn!(
request_id = %request_id,
"summon_handler: agent signature invalid — rejected"
);
return;
}
let agent_id = match self.resolve_primary_agent() {
Some(id) => id,
None => return,
};
// Classify by *identity*, not by the declared event field:
// a summon whose agent key matches ours is genuinely self
// (reach); anything else is a separate being (consult).
let is_self = self.own_agent_pubkey(&agent_id).as_deref()
== Some(agent_pubkey.as_str());
let classified = if is_self { "reach" } else { "consult" };
if classified != declared {
tracing::info!(
request_id = %request_id,
declared = %declared,
classified,
"summon_handler: declared intent overridden by signature"
);
}
tracing::info!(
request_id,
tool = tool_type,
from = ?event.seed_id,
request_id = %request_id,
tool = classified,
"summon_handler: inbound request"
);
// Consult requires consent check. Reach does not.
if tool_type == "consult" {
let sum = event.seed_id.as_deref().unwrap_or("");
if !self.is_authorized_summoner(sum) {
tracing::warn!(
summoner = sum,
"summon_handler: unauthorized consult rejected"
);
// Fire a rejection response.
let reject = SensorEvent {
sensor_name: "summon_response".into(),
timestamp: Utc::now(),
event_type: "rejected".into(),
target: event.reply_to.clone(),
urgency: 0.3,
payload: Some(serde_json::json!({
"request_id": request_id,
"reason": "unauthorized — not in authorized-summoners.md",
})),
seed_id: None,
reply_to: Some(self.local_seed_id.clone()),
};
self.event_bus.send(reject);
return;
}
// Consult is consent-gated on the summoner's *agent* pubkey.
// Reach is not — you do not petition yourself — but it is
// only reach because the signature proved it.
if classified == "consult" && !self.is_authorized_summoner(&agent_pubkey) {
tracing::warn!(
summoner = %agent_pubkey,
"summon_handler: unauthorized consult rejected"
);
let reject = SensorEvent {
sensor_name: "summon_response".into(),
timestamp: Utc::now(),
event_type: "rejected".into(),
target: event.reply_to.clone(),
urgency: 0.3,
payload: Some(serde_json::json!({
"request_id": request_id,
"reason": "unauthorized — not in authorized-summoners.md",
})),
seed_id: None,
reply_to: Some(self.local_seed_id.clone()),
};
self.event_bus.send(reject);
return;
}
// Write to the summoned agent's inbox, and remember who to
// answer so a reply from the outbox can be routed home.
let agent_id = self.resolve_primary_agent();
if let Some(agent_id) = agent_id {
let target_box = if tool_type == "reach" { "pending" } else { "intrusive" };
{
let target_box = if classified == "reach" { "pending" } else { "intrusive" };
match self.write_inbox(&agent_id, target_box, &event) {
Err(e) => tracing::warn!(
error = %e,
@ -260,16 +297,16 @@ impl SummonHandler {
),
Ok(()) => {
if let Some(reply_to) = event.reply_to.clone() {
self.inbound.insert(request_id.to_string(), reply_to);
self.inbound.insert(request_id.clone(), reply_to);
}
// Auto-wake: nudge the agent to look now rather
// than waiting for her next natural turn. Opt-in
// (auto_wake) and a no-op without an injector.
self.maybe_wake(
&agent_id,
tool_type,
request_id,
event.seed_id.as_deref(),
classified,
&request_id,
Some(agent_pubkey.as_str()),
);
}
}
@ -369,20 +406,36 @@ impl SummonHandler {
});
}
/// Check authorized-summoners.md for consent. The basic floor:
/// only agents listed here may send consult requests to this instance.
fn is_authorized_summoner(&self, seed_id: &str) -> bool {
/// This instance's own agent pubkey (hex) — read straight from the
/// agent seed's `public.key`. No private key, no generation: classifying
/// an inbound summon only needs to *verify*, never sign.
fn own_agent_pubkey(&self, agent_id: &str) -> Option<String> {
let path = self.souveraine_base
.join("server").join("agents").join(agent_id)
.join("seed").join("public.key");
let bytes = std::fs::read(&path).ok()?;
if bytes.len() != 32 {
return None;
}
Some(hex::encode(bytes))
}
/// Check authorized-summoners.md for consent. The basic floor: only
/// *agent* pubkeys listed here may `consult` an agent on this instance.
/// Consent is per-being — Sam is Sam on any of his machines — so it
/// gates on agent identity, not the machine seed.
fn is_authorized_summoner(&self, agent_pubkey: &str) -> bool {
let path = self.souveraine_base
.join("federation")
.join("authorized-summoners.md");
match std::fs::read_to_string(&path) {
Ok(content) => content.lines().any(|l| l.trim() == seed_id),
Ok(content) => content.lines().any(|l| l.trim() == agent_pubkey),
Err(_) => {
// No file = no consent floor yet. For now, allow (Phase 4
// basic gating), but log a warning.
tracing::warn!(
"federation/authorized-summoners.md not found — \
allowing consult by default (add a seed_id line to restrict)"
allowing consult by default (add an agent pubkey line to restrict)"
);
true
}