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:
parent
c02e2b7f8e
commit
055a3d7b38
5 changed files with 304 additions and 56 deletions
|
|
@ -1,3 +1,5 @@
|
||||||
pub mod seed;
|
pub mod seed;
|
||||||
|
pub mod summon;
|
||||||
|
|
||||||
pub use seed::{glyph_from_pubkey, SeedId};
|
pub use seed::{glyph_from_pubkey, SeedId};
|
||||||
|
pub use summon::{sign_summon, verify_summon};
|
||||||
|
|
|
||||||
117
src/core/identity/summon.rs
Normal file
117
src/core/identity/summon.rs
Normal 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",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(|s| s.public_key_hex())
|
||||||
.map_err(|e| ToolError::invalid_input(&format!(
|
.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 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 {
|
let event = SensorEvent {
|
||||||
sensor_name: "summon_request".into(),
|
sensor_name: "summon_request".into(),
|
||||||
timestamp: chrono::Utc::now(),
|
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),
|
target: Some(target_seed_id),
|
||||||
urgency: 0.5,
|
urgency: 0.5,
|
||||||
payload: Some(serde_json::json!({
|
payload: Some(serde_json::json!({
|
||||||
"request_id": request_id,
|
"request_id": request_id,
|
||||||
"prompt": prompt,
|
"prompt": prompt,
|
||||||
|
"agent_pubkey": agent_pubkey,
|
||||||
|
"agent_sig": agent_sig,
|
||||||
})),
|
})),
|
||||||
seed_id: None, // local-origin; the bridge signs + forwards
|
seed_id: None, // local-origin; the bridge signs + forwards
|
||||||
reply_to: Some(local_seed_id),
|
reply_to: Some(machine_seed_id),
|
||||||
};
|
};
|
||||||
bus.send(event);
|
bus.send(event);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ use axum::{
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::core::config::FederationConfig;
|
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::core::nervous::{EventBus, SensorEvent};
|
||||||
use crate::server::federation::{FederationBridge, SignedEvent};
|
use crate::server::federation::{FederationBridge, SignedEvent};
|
||||||
|
|
||||||
|
|
@ -29,10 +29,33 @@ use crate::server::federation::{FederationBridge, SignedEvent};
|
||||||
pub struct LiteListener {
|
pub struct LiteListener {
|
||||||
pub event_bus: EventBus,
|
pub event_bus: EventBus,
|
||||||
pub local_seed_id: String,
|
pub local_seed_id: String,
|
||||||
|
/// Agent pubkeys (hex) permitted to `consult` here — the consent floor.
|
||||||
pub authorized_summoners: Vec<String>,
|
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,
|
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 {
|
impl LiteListener {
|
||||||
/// Build the listener: load the seed identity, start the federation
|
/// Build the listener: load the seed identity, start the federation
|
||||||
/// bridge to configured peers, and spawn the summon-wake watcher.
|
/// bridge to configured peers, and spawn the summon-wake watcher.
|
||||||
|
|
@ -45,6 +68,7 @@ impl LiteListener {
|
||||||
event_bus: event_bus.clone(),
|
event_bus: event_bus.clone(),
|
||||||
local_seed_id,
|
local_seed_id,
|
||||||
authorized_summoners: config.authorized_summoners.clone(),
|
authorized_summoners: config.authorized_summoners.clone(),
|
||||||
|
hosted_agent_pubkeys: load_hosted_agent_pubkeys(&base),
|
||||||
auto_wake: config.auto_wake,
|
auto_wake: config.auto_wake,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -74,13 +98,40 @@ impl LiteListener {
|
||||||
if event.target.as_deref() != Some(&self.local_seed_id) {
|
if event.target.as_deref() != Some(&self.local_seed_id) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// reach is self-extension (no consent gate); consult must be
|
// Authenticate by the agent signature — never by trusting the
|
||||||
// from an authorized summoner.
|
// event's `event_type`. A summon signed by an agent we host is
|
||||||
let summoner = event.seed_id.as_deref().unwrap_or("");
|
// a genuine self-extension (reach) and bypasses the consent
|
||||||
let authorized = event.event_type == "reach"
|
// floor; anything else must be an authorized summoner (consult).
|
||||||
|| self.authorized_summoners.iter().any(|s| s == summoner);
|
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 {
|
if !authorized {
|
||||||
tracing::warn!(summoner, "lite listener: unauthorized summon ignored");
|
tracing::warn!(
|
||||||
|
summoner = %agent_pubkey,
|
||||||
|
"lite listener: unauthorized summon ignored"
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Err(e) = park_summon(&base, &event) {
|
if let Err(e) = park_summon(&base, &event) {
|
||||||
|
|
|
||||||
|
|
@ -207,52 +207,89 @@ impl SummonHandler {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let tool_type = event.event_type.as_str(); // "reach" or "consult"
|
let declared = event.event_type.clone(); // "reach" | "consult" — a claim
|
||||||
let request_id = event.payload
|
let payload = event.payload.clone().unwrap_or_default();
|
||||||
.as_ref()
|
let str_field = |k: &str| payload.get(k)
|
||||||
.and_then(|p| p.get("request_id"))
|
|
||||||
.and_then(|v| v.as_str())
|
.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!(
|
tracing::info!(
|
||||||
request_id,
|
request_id = %request_id,
|
||||||
tool = tool_type,
|
tool = classified,
|
||||||
from = ?event.seed_id,
|
|
||||||
"summon_handler: inbound request"
|
"summon_handler: inbound request"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Consult requires consent check. Reach does not.
|
// Consult is consent-gated on the summoner's *agent* pubkey.
|
||||||
if tool_type == "consult" {
|
// Reach is not — you do not petition yourself — but it is
|
||||||
let sum = event.seed_id.as_deref().unwrap_or("");
|
// only reach because the signature proved it.
|
||||||
if !self.is_authorized_summoner(sum) {
|
if classified == "consult" && !self.is_authorized_summoner(&agent_pubkey) {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
summoner = sum,
|
summoner = %agent_pubkey,
|
||||||
"summon_handler: unauthorized consult rejected"
|
"summon_handler: unauthorized consult rejected"
|
||||||
);
|
);
|
||||||
// Fire a rejection response.
|
let reject = SensorEvent {
|
||||||
let reject = SensorEvent {
|
sensor_name: "summon_response".into(),
|
||||||
sensor_name: "summon_response".into(),
|
timestamp: Utc::now(),
|
||||||
timestamp: Utc::now(),
|
event_type: "rejected".into(),
|
||||||
event_type: "rejected".into(),
|
target: event.reply_to.clone(),
|
||||||
target: event.reply_to.clone(),
|
urgency: 0.3,
|
||||||
urgency: 0.3,
|
payload: Some(serde_json::json!({
|
||||||
payload: Some(serde_json::json!({
|
"request_id": request_id,
|
||||||
"request_id": request_id,
|
"reason": "unauthorized — not in authorized-summoners.md",
|
||||||
"reason": "unauthorized — not in authorized-summoners.md",
|
})),
|
||||||
})),
|
seed_id: None,
|
||||||
seed_id: None,
|
reply_to: Some(self.local_seed_id.clone()),
|
||||||
reply_to: Some(self.local_seed_id.clone()),
|
};
|
||||||
};
|
self.event_bus.send(reject);
|
||||||
self.event_bus.send(reject);
|
return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write to the summoned agent's inbox, and remember who to
|
// Write to the summoned agent's inbox, and remember who to
|
||||||
// answer so a reply from the outbox can be routed home.
|
// 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 classified == "reach" { "pending" } else { "intrusive" };
|
||||||
let target_box = if tool_type == "reach" { "pending" } else { "intrusive" };
|
|
||||||
match self.write_inbox(&agent_id, target_box, &event) {
|
match self.write_inbox(&agent_id, target_box, &event) {
|
||||||
Err(e) => tracing::warn!(
|
Err(e) => tracing::warn!(
|
||||||
error = %e,
|
error = %e,
|
||||||
|
|
@ -260,16 +297,16 @@ impl SummonHandler {
|
||||||
),
|
),
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
if let Some(reply_to) = event.reply_to.clone() {
|
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
|
// Auto-wake: nudge the agent to look now rather
|
||||||
// than waiting for her next natural turn. Opt-in
|
// than waiting for her next natural turn. Opt-in
|
||||||
// (auto_wake) and a no-op without an injector.
|
// (auto_wake) and a no-op without an injector.
|
||||||
self.maybe_wake(
|
self.maybe_wake(
|
||||||
&agent_id,
|
&agent_id,
|
||||||
tool_type,
|
classified,
|
||||||
request_id,
|
&request_id,
|
||||||
event.seed_id.as_deref(),
|
Some(agent_pubkey.as_str()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -369,20 +406,36 @@ impl SummonHandler {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check authorized-summoners.md for consent. The basic floor:
|
/// This instance's own agent pubkey (hex) — read straight from the
|
||||||
/// only agents listed here may send consult requests to this instance.
|
/// agent seed's `public.key`. No private key, no generation: classifying
|
||||||
fn is_authorized_summoner(&self, seed_id: &str) -> bool {
|
/// 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
|
let path = self.souveraine_base
|
||||||
.join("federation")
|
.join("federation")
|
||||||
.join("authorized-summoners.md");
|
.join("authorized-summoners.md");
|
||||||
match std::fs::read_to_string(&path) {
|
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(_) => {
|
Err(_) => {
|
||||||
// No file = no consent floor yet. For now, allow (Phase 4
|
// No file = no consent floor yet. For now, allow (Phase 4
|
||||||
// basic gating), but log a warning.
|
// basic gating), but log a warning.
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"federation/authorized-summoners.md not found — \
|
"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
|
true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue