Watch
1
0
Fork
You've already forked souveraine
0

admit: give an agent her own uid through one writer

souveraine-admit creates or adopts one account via systemd-sysusers, writes
the root-owned mapping health reads, and reports what NSS says afterwards
instead of what it asked for. An account belongs to one agent: handing
annie's to souvie is refused in both directions, as is adopting a uid >=1000.
Binding is by SeedID, not the UUID filename — a mismatch is identity-drift.
This commit is contained in:
Fimeg 2026-08-17 15:16:30 -04:00
commit 0f8fe22ab6
10 changed files with 745 additions and 170 deletions

View file

@ -206,7 +206,8 @@ jobs:
# secrets rail + machined: also hand-copied on the phone until now. # secrets rail + machined: also hand-copied on the phone until now.
./scripts/build-cross.sh --features secrets --bin souveraine-secrets ./scripts/build-cross.sh --features secrets --bin souveraine-secrets
./scripts/build-cross.sh --bin souveraine-machined ./scripts/build-cross.sh --bin souveraine-machined
for b in souveraine-secrets souveraine-machined; do ./scripts/build-cross.sh --bin souveraine-admit
for b in souveraine-secrets souveraine-machined souveraine-admit; do
B="$CARGO_TARGET_DIR/aarch64-unknown-linux-gnu/release/$b" B="$CARGO_TARGET_DIR/aarch64-unknown-linux-gnu/release/$b"
file "$B" | grep -q aarch64 || { echo "$b is not aarch64" >&2; exit 1; } file "$B" | grep -q aarch64 || { echo "$b is not aarch64" >&2; exit 1; }
cp "$B" "$b-aarch64" cp "$B" "$b-aarch64"
@ -220,7 +221,8 @@ jobs:
cargo build --release --features secrets --bin souveraine-secrets cargo build --release --features secrets --bin souveraine-secrets
cargo build --release --bin souveraine-machined cargo build --release --bin souveraine-machined
for b in souveraine-secrets souveraine-machined; do cargo build --release --bin souveraine-admit
for b in souveraine-secrets souveraine-machined souveraine-admit; do
B="$CARGO_TARGET_DIR/release/$b" B="$CARGO_TARGET_DIR/release/$b"
file "$B" | grep -q 'x86-64' || { echo "$b is not x86_64" >&2; exit 1; } file "$B" | grep -q 'x86-64' || { echo "$b is not x86_64" >&2; exit 1; }
cp "$B" "$b-x86_64" cp "$B" "$b-x86_64"
@ -257,6 +259,7 @@ jobs:
souveraine-usb-kvm-aarch64 \ souveraine-usb-kvm-aarch64 \
souveraine-secrets-aarch64 souveraine-secrets-x86_64 \ souveraine-secrets-aarch64 souveraine-secrets-x86_64 \
souveraine-machined-aarch64 souveraine-machined-x86_64 \ souveraine-machined-aarch64 souveraine-machined-x86_64 \
souveraine-admit-aarch64 souveraine-admit-x86_64 \
souveraine-sessiond-x86_64 souveraine-sensord-x86_64 \ souveraine-sessiond-x86_64 souveraine-sensord-x86_64 \
> souveraine-binaries.sha256 > souveraine-binaries.sha256
@ -403,6 +406,7 @@ jobs:
cp "souveraine-$ARCH" "$PKG_WORK/souveraine-binary" cp "souveraine-$ARCH" "$PKG_WORK/souveraine-binary"
cp "souveraine-secrets-$ARCH" "$PKG_WORK/souveraine-secrets-binary" cp "souveraine-secrets-$ARCH" "$PKG_WORK/souveraine-secrets-binary"
cp "souveraine-machined-$ARCH" "$PKG_WORK/souveraine-machined-binary" cp "souveraine-machined-$ARCH" "$PKG_WORK/souveraine-machined-binary"
cp "souveraine-admit-$ARCH" "$PKG_WORK/souveraine-admit-binary"
cp "souveraine-sessiond-$ARCH" "$PKG_WORK/souveraine-sessiond-binary" cp "souveraine-sessiond-$ARCH" "$PKG_WORK/souveraine-sessiond-binary"
cp "souveraine-sensord-$ARCH" "$PKG_WORK/souveraine-sensord-binary" cp "souveraine-sensord-$ARCH" "$PKG_WORK/souveraine-sensord-binary"
cp packaging/souveraine.service packaging/souveraine-secrets.service \ cp packaging/souveraine.service packaging/souveraine-secrets.service \
@ -442,6 +446,7 @@ jobs:
bsdtar -tf "$PKG" | grep -qx 'usr/bin/souveraine-sensord' bsdtar -tf "$PKG" | grep -qx 'usr/bin/souveraine-sensord'
bsdtar -tf "$PKG" | grep -qx 'etc/pam.d/souveraine-sessiond' bsdtar -tf "$PKG" | grep -qx 'etc/pam.d/souveraine-sessiond'
bsdtar -tf "$PKG" | grep -qx 'usr/lib/sysusers.d/souveraine.conf' bsdtar -tf "$PKG" | grep -qx 'usr/lib/sysusers.d/souveraine.conf'
bsdtar -tf "$PKG" | grep -qx 'usr/bin/souveraine-admit'
if [ "$ARCH" = aarch64 ]; then if [ "$ARCH" = aarch64 ]; then
bsdtar -tf "$PKG" | grep -qx 'usr/bin/souveraine-usb-kvm' bsdtar -tf "$PKG" | grep -qx 'usr/bin/souveraine-usb-kvm'
fi fi

View file

@ -211,6 +211,13 @@ required-features = ["secrets"]
name = "souveraine-machined" name = "souveraine-machined"
path = "src/bin/souveraine-machined.rs" path = "src/bin/souveraine-machined.rs"
# The admission executor. Root-only, no feature gate, no daemon: agents get
# their Unix principal through exactly one writer, and admission is a rare
# deliberate ceremony rather than something a background service does.
[[bin]]
name = "souveraine-admit"
path = "src/bin/souveraine-admit.rs"
[[bin]] [[bin]]
name = "souveraine-sessiond" name = "souveraine-sessiond"
path = "src/bin/souveraine-sessiond.rs" path = "src/bin/souveraine-sessiond.rs"

View file

@ -38,6 +38,7 @@ package() {
# System tier: machine identity daemon + its service user. pacman runs # System tier: machine identity daemon + its service user. pacman runs
# systemd-sysusers on install, so the `souveraine` user exists before # systemd-sysusers on install, so the `souveraine` user exists before
# anyone runs `souveraine machine init`. # anyone runs `souveraine machine init`.
install -Dm755 target/release/souveraine-admit "$pkgdir/usr/bin/souveraine-admit"
install -Dm755 target/release/souveraine-machined "$pkgdir/usr/bin/souveraine-machined" install -Dm755 target/release/souveraine-machined "$pkgdir/usr/bin/souveraine-machined"
install -Dm644 packaging/souveraine-machined.service \ install -Dm644 packaging/souveraine-machined.service \
"$pkgdir/usr/lib/systemd/system/souveraine-machined.service" "$pkgdir/usr/lib/systemd/system/souveraine-machined.service"

View file

@ -16,6 +16,7 @@ backup=('etc/pam.d/souveraine-stepup' 'etc/pam.d/souveraine-sessiond')
source=('souveraine-binary' 'souveraine.service' source=('souveraine-binary' 'souveraine.service'
'souveraine-secrets-binary' 'souveraine-secrets.service' 'souveraine-secrets-binary' 'souveraine-secrets.service'
'souveraine-machined-binary' 'souveraine-machined.service' 'souveraine-machined-binary' 'souveraine-machined.service'
'souveraine-admit-binary'
'souveraine.sysusers' 'souveraine.sysusers'
'souveraine-sessiond-binary' 'souveraine-sessiond.service' 'souveraine-sessiond-binary' 'souveraine-sessiond.service'
'souveraine-sensord-binary' 'souveraine-sensord.service' 'souveraine-sensord-binary' 'souveraine-sensord.service'
@ -24,7 +25,7 @@ source=('souveraine-binary' 'souveraine.service'
'souveraine-stepup.pam' 'org.souveraine.stepup.policy' 'souveraine-stepup.pam' 'org.souveraine.stepup.policy'
'LICENSE') 'LICENSE')
sha256sums=('SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP' sha256sums=('SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP'
'SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP') 'SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP')
# sessiond is built and packaged for both supported architectures. Installing # sessiond is built and packaged for both supported architectures. Installing
# its user unit does not enable or start it; activation remains target-owned. # its user unit does not enable or start it; activation remains target-owned.
if [ "$CARCH" = aarch64 ]; then if [ "$CARCH" = aarch64 ]; then
@ -94,6 +95,9 @@ package() {
# souveraine-secrets.service already owns the bus name at runtime, so the # souveraine-secrets.service already owns the bus name at runtime, so the
# rail works without stealing the D-Bus activation file. # rail works without stealing the D-Bus activation file.
# The admission executor. Root-only in use, so it ships 0755 and refuses
# itself rather than carrying setuid.
install -Dm755 "$srcdir/souveraine-admit-binary" "$pkgdir/usr/bin/souveraine-admit"
install -Dm755 "$srcdir/souveraine-machined-binary" "$pkgdir/usr/bin/souveraine-machined" install -Dm755 "$srcdir/souveraine-machined-binary" "$pkgdir/usr/bin/souveraine-machined"
install -Dm644 "$srcdir/souveraine-machined.service" \ install -Dm644 "$srcdir/souveraine-machined.service" \
"$pkgdir/usr/lib/systemd/system/souveraine-machined.service" "$pkgdir/usr/lib/systemd/system/souveraine-machined.service"

View file

@ -99,25 +99,8 @@ impl AgentPrincipalConfig {
let account = self let account = self
.account .account
.as_deref() .as_deref()
.filter(|value| !value.is_empty())
.ok_or_else(|| anyhow::anyhow!("dedicated principal requires an account"))?; .ok_or_else(|| anyhow::anyhow!("dedicated principal requires an account"))?;
let valid = account.len() <= 31 crate::core::principal_map::validate_account_name(account)?;
&& account
.bytes()
.enumerate()
.all(|(index, byte)| match (index, byte) {
(0, b'a'..=b'z' | b'_') => true,
(_, b'a'..=b'z' | b'0'..=b'9' | b'_' | b'-') => true,
_ => false,
});
if !valid {
anyhow::bail!(
"invalid dedicated account `{account}`; use a lowercase Unix account name"
);
}
if matches!(account, "root" | "souveraine" | "souveraine-session") {
anyhow::bail!("account `{account}` is reserved and cannot be an agent principal");
}
} }
} }
Ok(()) Ok(())

307
src/bin/souveraine-admit.rs Normal file
View file

@ -0,0 +1,307 @@
//! souveraine-admit — the admission executor.
//!
//! The only writer of agent Unix principals. It creates or adopts exactly one
//! account through `systemd-sysusers`, writes the root-owned agent→principal
//! mapping, and then reports the passwd facts it actually observes rather than
//! assuming its own command worked.
//!
//! What it deliberately does not do: read a human's home, install a worker
//! unit, move agent data, or recursively chown anything outside the agent's own
//! state root. Those are separate transitions. Until the worker exists, an
//! admitted agent still reads `acting-as-human` — this binary makes that state
//! reachable, it does not make it green.
//!
//! Contract: souveraine/saf/identity/02-agent-principal.md
#[path = "../core/identity/seed.rs"]
mod identity;
#[path = "../core/principal_map.rs"]
mod principal_map;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use chrono::Utc;
use clap::{Parser, Subcommand};
use principal_map::*;
#[derive(Parser)]
#[command(
name = "souveraine-admit",
about = "Admit an agent to this node as her own Unix principal",
long_about = "Creates or adopts one Unix account for one agent, writes the root-owned\nagent-to-principal mapping, and reports what NSS says afterwards.\n\nIntent lives in the agent record and is written by the server. Admission is\nthis binary and needs root. Neither one starts a worker: until per-agent\nworkers exist, an admitted agent still reports acting-as-human."
)]
struct Cli {
/// Operate on an alternate filesystem root (image prep, tests)
#[arg(long, global = true, default_value = "/")]
root: PathBuf,
/// Speak in data
#[arg(long, global = true)]
json: bool,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
/// Report the admission facts for one agent. Writes nothing.
Status { agent_id: String },
/// Create or adopt the account and write the mapping. Idempotent.
Apply {
agent_id: String,
/// Local account this agent runs as
#[arg(long)]
account: String,
/// The agent's SeedID public key (hex). Recorded so a copied record
/// cannot inherit an admitted account.
#[arg(long, default_value = "")]
seed_id: String,
/// Print the plan and touch nothing
#[arg(long)]
dry_run: bool,
},
}
fn main() -> Result<()> {
let cli = Cli::parse();
match &cli.command {
Command::Status { agent_id } => status(&cli, agent_id),
Command::Apply {
agent_id,
account,
seed_id,
dry_run,
} => apply(&cli, agent_id, account, seed_id, *dry_run),
}
}
fn status(cli: &Cli, agent_id: &str) -> Result<()> {
let mapping = load_node_mapping(&cli.root, agent_id);
let account = mapping
.as_ref()
.and_then(|m| account_lookup(&cli.root, &m.account));
if cli.json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"agent_id": agent_id,
"mapping": mapping,
"account_exists": account.is_some(),
"account_uid": account.as_ref().map(|a| a.uid),
"account_shell": account.as_ref().map(|a| a.shell.clone()),
"worker_installed": false,
}))?
);
return Ok(());
}
match (&mapping, &account) {
(None, _) => println!("unadmitted — no mapping for {agent_id} on this node"),
(Some(m), None) => println!(
"principal-drift — mapped to `{}` (uid {}), which does not exist here",
m.account, m.uid
),
(Some(m), Some(a)) if a.uid != m.uid => println!(
"principal-drift — mapping says uid {} but `{}` is uid {}",
m.uid, m.account, a.uid
),
(Some(m), Some(a)) => {
println!("admitted {}{} (uid {})", agent_id, m.account, a.uid);
println!("state root {}", m.state_root);
println!("shell {}", a.shell);
println!("seed {}", glyph_or_none(&m.agent_seed_id));
println!("worker none — turns still run as the invoking user");
}
}
Ok(())
}
fn apply(cli: &Cli, agent_id: &str, account: &str, seed_id: &str, dry_run: bool) -> Result<()> {
let alternate_root = cli.root != Path::new("/");
if !alternate_root && unsafe { libc::geteuid() } != 0 && !dry_run {
bail!("admission needs root; re-run with sudo, or use --root for an image or test tree");
}
validate_account_name(account)?;
if agent_id.is_empty() || agent_id.contains('/') || agent_id.contains("..") {
bail!("refusing agent id `{agent_id}`: it becomes a filename");
}
if !seed_id.is_empty() && (seed_id.len() != 64 || !seed_id.bytes().all(|b| b.is_ascii_hexdigit()))
{
bail!("--seed-id must be a 64-character hex public key");
}
// An account belongs to at most one agent. Adopting one that another agent
// already holds is the exact collapse per-agent principals exist to stop.
if let Some(owner) = mapping_owner_of_account(&cli.root, account) {
if owner != agent_id {
bail!("account `{account}` is already admitted to agent {owner}");
}
}
// A pre-existing mapping for a *different* account is a rename, not a
// repair. Refuse rather than strand the old account and its data.
let existing = load_node_mapping(&cli.root, agent_id);
if let Some(prior) = &existing {
if prior.account != account {
bail!(
"agent {agent_id} is already admitted as `{}`; decommission before admitting `{account}`",
prior.account
);
}
if !prior.agent_seed_id.is_empty() && !seed_id.is_empty() && prior.agent_seed_id != seed_id {
bail!("mapping records a different SeedID for {agent_id}; this is identity drift, not a repair");
}
}
// Adopting an existing human login would hand an agent a person's account.
if let Some(found) = account_lookup(&cli.root, account) {
if found.uid >= HUMAN_UID_FLOOR && existing.is_none() {
bail!(
"`{account}` already exists as uid {} — that is a login account, not a free agent principal",
found.uid
);
}
}
let state_root = agent_state_root(&cli.root, agent_id);
let state_root_abs = Path::new("/")
.join(AGENT_STATE_ROOT)
.join(agent_id)
.to_string_lossy()
.into_owned();
let drop_in = sysusers_drop_in_path(&cli.root, agent_id);
let declaration = format!(
"# Generated by souveraine-admit for agent {agent_id}. Do not hand-edit:\n\
# the mapping in /{PRINCIPAL_MAP_DIR}/{agent_id}.json is the record of\n\
# truth and health compares this file against it.\n\
u {account} - \"Souveraine agent {agent_id}\" {state_root_abs} /usr/bin/nologin\n"
);
if dry_run {
println!("would write {}", drop_in.display());
print!("{declaration}");
println!("would run systemd-sysusers {}", drop_in.display());
println!("would write {}", node_mapping_path(&cli.root, agent_id).display());
println!("would own {} as {account}", state_root.display());
return Ok(());
}
write_file(&drop_in, &declaration, 0o644)?;
run_sysusers(&cli.root, &drop_in)?;
// Report what the system says, never what the command intended.
let created = account_lookup(&cli.root, account).ok_or_else(|| {
anyhow::anyhow!("systemd-sysusers reported success but `{account}` still does not resolve")
})?;
if created.shell != "/usr/bin/nologin" {
bail!(
"`{account}` resolved with shell {} — refusing to record a login-capable agent principal",
created.shell
);
}
let mapping = NodePrincipalMapping {
agent_id: agent_id.to_string(),
agent_seed_id: if seed_id.is_empty() {
existing.as_ref().map(|p| p.agent_seed_id.clone()).unwrap_or_default()
} else {
seed_id.to_string()
},
account: created.name.clone(),
uid: created.uid,
node_id: node_id(&cli.root),
state_root: state_root_abs,
admitted_at: existing.as_ref().map(|p| p.admitted_at).unwrap_or_else(Utc::now),
};
write_file(
&node_mapping_path(&cli.root, agent_id),
&serde_json::to_string_pretty(&mapping)?,
0o644,
)?;
// Ownership is applied inside her own root only. Never a human home, never
// a recursive pass over ~/.souveraine.
std::fs::create_dir_all(&state_root)
.with_context(|| format!("creating {}", state_root.display()))?;
if unsafe { libc::geteuid() } == 0 {
chown(&state_root, created.uid)?;
restrict(&state_root, 0o700)?;
}
if cli.json {
println!("{}", serde_json::to_string_pretty(&mapping)?);
} else {
println!("admitted {}{} (uid {})", agent_id, mapping.account, mapping.uid);
println!("state root {}", mapping.state_root);
println!("seed {}", glyph_or_none(&mapping.agent_seed_id));
println!("worker none — her turns still run as whoever invokes them");
}
Ok(())
}
fn run_sysusers(root: &Path, drop_in: &Path) -> Result<()> {
let mut cmd = std::process::Command::new("systemd-sysusers");
if root != Path::new("/") {
cmd.arg(format!("--root={}", root.display()));
}
cmd.arg(drop_in);
let out = cmd.output().context("running systemd-sysusers")?;
if !out.status.success() {
bail!(
"systemd-sysusers failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(())
}
fn write_file(path: &Path, contents: &str, mode: u32) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
}
std::fs::write(path, contents).with_context(|| format!("writing {}", path.display()))?;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))?;
Ok(())
}
fn restrict(path: &Path, mode: u32) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))?;
Ok(())
}
fn chown(path: &Path, uid: u32) -> Result<()> {
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
let c = CString::new(path.as_os_str().as_bytes())?;
if unsafe { libc::chown(c.as_ptr(), uid, uid) } != 0 {
bail!(
"chown {} to {uid}: {}",
path.display(),
std::io::Error::last_os_error()
);
}
Ok(())
}
fn node_id(root: &Path) -> String {
std::fs::read_to_string(root.join("etc/hostname"))
.ok()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
.unwrap_or_else(|| "unknown".to_string())
}
fn glyph_or_none(seed_hex: &str) -> String {
if seed_hex.is_empty() {
return "none recorded".to_string();
}
let bytes: Vec<u8> = (0..seed_hex.len().min(4))
.step_by(2)
.filter_map(|i| u8::from_str_radix(&seed_hex[i..i + 2], 16).ok())
.collect();
format!("{} {}", identity::glyph_from_pubkey(&bytes), &seed_hex[..16])
}

View file

@ -19,6 +19,7 @@ pub mod model_cache;
pub mod nervous; pub mod nervous;
pub mod prompt; pub mod prompt;
pub mod principal; pub mod principal;
pub mod principal_map;
pub mod reflection; pub mod reflection;
pub mod seeds; pub mod seeds;
pub mod sensorium; pub mod sensorium;

View file

@ -5,14 +5,13 @@
//! an authorization decision. Actual authority gates must inspect peer/process //! an authorization decision. Actual authority gates must inspect peer/process
//! credentials again at their own boundary. //! credentials again at their own boundary.
use std::ffi::{CStr, CString};
use chrono::Utc; use chrono::Utc;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::api::models::{AgentState, PrincipalIntent}; use crate::api::models::{AgentState, PrincipalIntent};
use crate::core::principal_map;
pub const PRINCIPAL_MAP_DIR: &str = "/etc/souveraine/agent-principals.d"; pub use principal_map::{nss_account_by_name, nss_account_by_uid};
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
@ -22,6 +21,23 @@ pub enum PrincipalPosture {
Unadmitted, Unadmitted,
ActingAsHuman, ActingAsHuman,
PrincipalDrift, PrincipalDrift,
IdentityDrift,
}
/// Everything the posture table decides from. A struct rather than six
/// positional flags because `worker_matches, mapping_matches, seed_matches`
/// is exactly the argument order that silently inverts.
#[derive(Debug, Clone, Copy)]
struct PrincipalFacts {
intent: PrincipalIntent,
expected_uid: Option<u32>,
effective_uid: u32,
/// The live uid is mapped to a *different* agent.
effective_owned_by_other_agent: bool,
worker_matches: bool,
node_mapping_matches: bool,
/// None when there is no mapping, or no seed recorded in it, to compare.
seed_matches_mapping: Option<bool>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -39,6 +55,15 @@ pub struct PrincipalHealth {
pub account_exists: bool, pub account_exists: bool,
pub node_mapping_exists: bool, pub node_mapping_exists: bool,
pub node_mapping_matches: bool, pub node_mapping_matches: bool,
/// The agent's live SeedID glyph — four characters Casey can read at a
/// glance to confirm which being this record is actually about.
pub seed_glyph: Option<String>,
/// Whether the mapping's recorded SeedID still matches her live key.
/// `Some(false)` is a different being wearing an admitted account.
pub seed_matches_mapping: Option<bool>,
/// Set when this process runs under a uid the mapping assigns to someone
/// else — Souvie's account carrying Annie's turn, and its inverse.
pub effective_owned_by_agent: Option<String>,
pub effective_account: String, pub effective_account: String,
pub effective_uid: u32, pub effective_uid: u32,
pub node_id: String, pub node_id: String,
@ -51,25 +76,6 @@ pub struct PrincipalHealth {
pub proof_limit: String, pub proof_limit: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NodePrincipalMapping {
pub agent_id: String,
pub agent_seed_id: String,
pub account: String,
pub uid: u32,
pub node_id: String,
pub state_root: String,
pub admitted_at: chrono::DateTime<Utc>,
}
#[derive(Debug)]
pub struct NssAccount {
pub name: String,
pub uid: u32,
pub home: String,
pub shell: String,
}
/// Observe the effective process and compare it with one agent's durable /// Observe the effective process and compare it with one agent's durable
/// intent. This process-level fact is honest for today's monolithic server: /// intent. This process-level fact is honest for today's monolithic server:
/// dedicated agents report acting-as-human rather than inheriting a green /// dedicated agents report acting-as-human rather than inheriting a green
@ -86,7 +92,8 @@ pub fn observe(agent: &AgentState, trigger: impl Into<String>) -> PrincipalHealt
let expected = expected_account.as_deref().and_then(nss_account_by_name); let expected = expected_account.as_deref().and_then(nss_account_by_name);
let account_exists = expected.is_some(); let account_exists = expected.is_some();
let expected_uid = expected.as_ref().map(|record| record.uid); let expected_uid = expected.as_ref().map(|record| record.uid);
let mapping = load_node_mapping(&agent.id); let root = std::path::Path::new("/");
let mapping = principal_map::load_node_mapping(root, &agent.id);
let node_mapping_exists = mapping.is_some(); let node_mapping_exists = mapping.is_some();
let node_mapping_matches = mapping.as_ref().is_some_and(|mapping| { let node_mapping_matches = mapping.as_ref().is_some_and(|mapping| {
mapping.agent_id == agent.id mapping.agent_id == agent.id
@ -100,13 +107,28 @@ pub fn observe(agent: &AgentState, trigger: impl Into<String>) -> PrincipalHealt
let worker_agent = std::env::var("SOUVERAINE_WORKER_AGENT_ID").ok(); let worker_agent = std::env::var("SOUVERAINE_WORKER_AGENT_ID").ok();
let worker_matches = worker_agent.as_deref() == Some(agent.id.as_str()); let worker_matches = worker_agent.as_deref() == Some(agent.id.as_str());
let posture = decide_posture( let effective_owned_by_agent =
agent.souveraine.principal.intent, principal_map::mapping_owner_of_uid(root, effective_uid).filter(|id| id != &agent.id);
// The UUID is a filename; the SeedID is the being. Compare the mapping's
// recorded key against the one on disk so a copied or re-minted record
// cannot inherit an admitted account.
let live_seed = agent_seed(&agent.id);
let seed_matches_mapping = mapping.as_ref().and_then(|mapping| {
(!mapping.agent_seed_id.is_empty())
.then(|| live_seed.as_ref().map(|(hex, _)| hex == &mapping.agent_seed_id))
.flatten()
});
let posture = decide_posture(PrincipalFacts {
intent: agent.souveraine.principal.intent,
expected_uid, expected_uid,
effective_uid, effective_uid,
effective_owned_by_other_agent: effective_owned_by_agent.is_some(),
worker_matches, worker_matches,
node_mapping_matches, node_mapping_matches,
); seed_matches_mapping,
});
PrincipalHealth { PrincipalHealth {
agent_id: agent.id.clone(), agent_id: agent.id.clone(),
@ -119,6 +141,9 @@ pub fn observe(agent: &AgentState, trigger: impl Into<String>) -> PrincipalHealt
account_exists, account_exists,
node_mapping_exists, node_mapping_exists,
node_mapping_matches, node_mapping_matches,
seed_glyph: live_seed.map(|(_, glyph)| glyph),
seed_matches_mapping,
effective_owned_by_agent,
effective_account, effective_account,
effective_uid, effective_uid,
node_id: hostname_or_unknown(), node_id: hostname_or_unknown(),
@ -133,20 +158,24 @@ pub fn observe(agent: &AgentState, trigger: impl Into<String>) -> PrincipalHealt
/// The posture table, kept pure so the safety property is testable without a /// The posture table, kept pure so the safety property is testable without a
/// process: `isolated` needs the account, the matching UID, the worker binding /// process: `isolated` needs the account, the matching UID, the worker binding
/// and the root-owned mapping to agree. A passwd entry alone never reaches it. /// and the root-owned mapping to agree. A passwd entry alone never reaches it.
fn decide_posture( fn decide_posture(facts: PrincipalFacts) -> PrincipalPosture {
intent: PrincipalIntent, // Running under a uid another agent owns is impersonation whatever this
expected_uid: Option<u32>, // agent intended. It is never the human's label and never partial success.
effective_uid: u32, if facts.effective_owned_by_other_agent {
worker_matches: bool, return PrincipalPosture::PrincipalDrift;
node_mapping_matches: bool, }
) -> PrincipalPosture { if facts.intent == PrincipalIntent::BorrowedUser {
if intent == PrincipalIntent::BorrowedUser {
return PrincipalPosture::BorrowedUser; return PrincipalPosture::BorrowedUser;
} }
match expected_uid { // A mapping whose SeedID no longer matches her key is a different being
// wearing an admitted account. Healthy passwd facts cannot redeem that.
if facts.seed_matches_mapping == Some(false) {
return PrincipalPosture::IdentityDrift;
}
match facts.expected_uid {
None => PrincipalPosture::Unadmitted, None => PrincipalPosture::Unadmitted,
Some(uid) if uid == effective_uid => { Some(uid) if uid == facts.effective_uid => {
if worker_matches && node_mapping_matches { if facts.worker_matches && facts.node_mapping_matches {
PrincipalPosture::Isolated PrincipalPosture::Isolated
} else { } else {
PrincipalPosture::PrincipalDrift PrincipalPosture::PrincipalDrift
@ -155,13 +184,29 @@ fn decide_posture(
// Linux's ordinary human uid range. A diagnostic label, not an // Linux's ordinary human uid range. A diagnostic label, not an
// authorization input; unknown or system controllers stay drift // authorization input; unknown or system controllers stay drift
// rather than being mislabeled as the human. // rather than being mislabeled as the human.
Some(_) if effective_uid >= 1000 && effective_uid != u32::MAX => { Some(_)
if facts.effective_uid >= principal_map::HUMAN_UID_FLOOR
&& facts.effective_uid != u32::MAX =>
{
PrincipalPosture::ActingAsHuman PrincipalPosture::ActingAsHuman
} }
Some(_) => PrincipalPosture::PrincipalDrift, Some(_) => PrincipalPosture::PrincipalDrift,
} }
} }
/// The agent's live SeedID as (public key hex, glyph). Absent when she has no
/// seed on this body yet — which is itself a fact health should show rather
/// than treat as agreement.
fn agent_seed(agent_id: &str) -> Option<(String, String)> {
let dir = dirs::home_dir()?
.join(".souveraine")
.join("agents")
.join(agent_id)
.join("seed");
let seed = crate::core::identity::SeedId::load(&dir).ok()?;
Some((seed.public_key_hex(), seed.glyph()))
}
impl PrincipalHealth { impl PrincipalHealth {
/// Fresh, non-persisted system context for a model call. The wording is /// Fresh, non-persisted system context for a model call. The wording is
/// intentionally operational: it tells hosted modes how to handle borrowed /// intentionally operational: it tells hosted modes how to handle borrowed
@ -184,12 +229,22 @@ impl PrincipalHealth {
PrincipalPosture::PrincipalDrift => { PrincipalPosture::PrincipalDrift => {
"The requested account and live worker evidence disagree. Do not claim isolation or exercise personal/step-up authority until Agent Health is repaired." "The requested account and live worker evidence disagree. Do not claim isolation or exercise personal/step-up authority until Agent Health is repaired."
} }
PrincipalPosture::IdentityDrift => {
"The admitted account's recorded SeedID does not match this agent's live key. Treat the account as belonging to someone else: claim nothing, exercise no personal or step-up authority, and surface the mismatch rather than working around it."
}
};
let borrowed_from = match &self.effective_owned_by_agent {
Some(other) => format!(
"\nWARNING: this process is running under a uid the node maps to agent {other}, not to you. Do not act on that reach.",
),
None => String::new(),
}; };
format!( format!(
"[RUNTIME PRINCIPAL — fresh observation, not conversation memory]\n\ "[RUNTIME PRINCIPAL — fresh observation, not conversation memory]\n\
agent_id: {}\n\ agent_id: {}\n\
display_name: {}\n\ display_name: {}\n\
seed_glyph: {}\n\
principal_intent: {:?}\n\ principal_intent: {:?}\n\
expected_account: {}\n\ expected_account: {}\n\
effective_account: {}\n\ effective_account: {}\n\
@ -198,10 +253,11 @@ impl PrincipalHealth {
trigger: {}\n\ trigger: {}\n\
observed_at: {}\n\ observed_at: {}\n\
posture: {:?}\n\ posture: {:?}\n\
{}\n\ {}{}\n\
Authorization gates recheck kernel credentials; this block never grants authority.", Authorization gates recheck kernel credentials; this block never grants authority.",
self.agent_id, self.agent_id,
self.display_name, self.display_name,
self.seed_glyph.as_deref().unwrap_or("none"),
self.principal_intent, self.principal_intent,
expected, expected,
self.effective_account, self.effective_account,
@ -211,6 +267,7 @@ impl PrincipalHealth {
self.observed_at.to_rfc3339(), self.observed_at.to_rfc3339(),
self.posture, self.posture,
posture, posture,
borrowed_from,
) )
} }
} }
@ -223,94 +280,6 @@ fn hostname_or_unknown() -> String {
.unwrap_or_else(|| "unknown".to_string()) .unwrap_or_else(|| "unknown".to_string())
} }
pub fn nss_account_by_uid(uid: u32) -> Option<NssAccount> {
let mut passwd = unsafe { std::mem::zeroed::<libc::passwd>() };
let mut result = std::ptr::null_mut();
let mut buffer = vec![0u8; passwd_buffer_size()];
let rc = unsafe {
libc::getpwuid_r(
uid,
&mut passwd,
buffer.as_mut_ptr().cast(),
buffer.len(),
&mut result,
)
};
if rc != 0 || result.is_null() || passwd.pw_name.is_null() {
return None;
}
let name = unsafe { CStr::from_ptr(passwd.pw_name) }
.to_str()
.ok()?
.to_string();
let home = c_field(passwd.pw_dir);
let shell = c_field(passwd.pw_shell);
Some(NssAccount {
name,
uid: passwd.pw_uid,
home,
shell,
})
}
pub fn nss_account_by_name(name: &str) -> Option<NssAccount> {
let name = CString::new(name).ok()?;
let mut passwd = unsafe { std::mem::zeroed::<libc::passwd>() };
let mut result = std::ptr::null_mut();
let mut buffer = vec![0u8; passwd_buffer_size()];
let rc = unsafe {
libc::getpwnam_r(
name.as_ptr(),
&mut passwd,
buffer.as_mut_ptr().cast(),
buffer.len(),
&mut result,
)
};
if rc != 0 || result.is_null() || passwd.pw_name.is_null() {
return None;
}
let name = unsafe { CStr::from_ptr(passwd.pw_name) }
.to_str()
.ok()?
.to_string();
let home = c_field(passwd.pw_dir);
let shell = c_field(passwd.pw_shell);
Some(NssAccount {
name,
uid: passwd.pw_uid,
home,
shell,
})
}
pub fn node_mapping_path(agent_id: &str) -> std::path::PathBuf {
std::path::Path::new(PRINCIPAL_MAP_DIR).join(format!("{agent_id}.json"))
}
pub fn load_node_mapping(agent_id: &str) -> Option<NodePrincipalMapping> {
let raw = std::fs::read_to_string(node_mapping_path(agent_id)).ok()?;
serde_json::from_str(&raw).ok()
}
fn c_field(value: *const libc::c_char) -> String {
if value.is_null() {
return String::new();
}
unsafe { CStr::from_ptr(value) }
.to_string_lossy()
.into_owned()
}
fn passwd_buffer_size() -> usize {
let suggested = unsafe { libc::sysconf(libc::_SC_GETPW_R_SIZE_MAX) };
if suggested > 0 {
suggested as usize
} else {
16 * 1024
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -318,55 +287,111 @@ mod tests {
use PrincipalIntent::{BorrowedUser, Dedicated}; use PrincipalIntent::{BorrowedUser, Dedicated};
use PrincipalPosture as P; use PrincipalPosture as P;
fn facts(intent: PrincipalIntent, expected_uid: Option<u32>, effective_uid: u32) -> PrincipalFacts {
PrincipalFacts {
intent,
expected_uid,
effective_uid,
effective_owned_by_other_agent: false,
worker_matches: false,
node_mapping_matches: false,
seed_matches_mapping: None,
}
}
#[test] #[test]
fn a_passwd_entry_alone_is_never_isolation() { fn a_passwd_entry_alone_is_never_isolation() {
// Account exists and the process is even running as it — but no // Account exists and the process is even running as it — but no
// worker binding and no root-owned mapping. This is the shape a // worker binding and no root-owned mapping. This is the shape a
// hand-created `useradd annie` produces, and it must not read green. // hand-created `useradd annie` produces, and it must not read green.
let base = facts(Dedicated, Some(1003), 1003);
assert_eq!(decide_posture(base), P::PrincipalDrift);
assert_eq!( assert_eq!(
decide_posture(Dedicated, Some(1003), 1003, false, false), decide_posture(PrincipalFacts { worker_matches: true, ..base }),
P::PrincipalDrift P::PrincipalDrift
); );
assert_eq!( assert_eq!(
decide_posture(Dedicated, Some(1003), 1003, true, false), decide_posture(PrincipalFacts { node_mapping_matches: true, ..base }),
P::PrincipalDrift P::PrincipalDrift
); );
assert_eq!( assert_eq!(
decide_posture(Dedicated, Some(1003), 1003, false, true), decide_posture(PrincipalFacts {
P::PrincipalDrift worker_matches: true,
); node_mapping_matches: true,
assert_eq!( ..base
decide_posture(Dedicated, Some(1003), 1003, true, true), }),
P::Isolated P::Isolated
); );
} }
#[test] #[test]
fn a_dedicated_agent_running_as_the_human_says_so() { fn a_dedicated_agent_running_as_the_human_says_so() {
assert_eq!(decide_posture(Dedicated, None, 1000, false, false), P::Unadmitted); assert_eq!(decide_posture(facts(Dedicated, None, 1000)), P::Unadmitted);
assert_eq!( assert_eq!(
decide_posture(Dedicated, Some(1003), 1000, false, false), decide_posture(facts(Dedicated, Some(1003), 1000)),
P::ActingAsHuman P::ActingAsHuman
); );
// A system uid that is not hers is drift, not the human. // A system uid that is not hers is drift, not the human.
assert_eq!( assert_eq!(
decide_posture(Dedicated, Some(1003), 950, false, false), decide_posture(facts(Dedicated, Some(1003), 950)),
P::PrincipalDrift P::PrincipalDrift
); );
} }
#[test] #[test]
fn a_borrowed_mode_never_drifts_into_isolation() { fn a_borrowed_mode_never_drifts_into_isolation() {
for mapping in [false, true] { for node_mapping_matches in [false, true] {
for worker in [false, true] { for worker_matches in [false, true] {
assert_eq!( assert_eq!(
decide_posture(BorrowedUser, Some(1000), 1000, worker, mapping), decide_posture(PrincipalFacts {
worker_matches,
node_mapping_matches,
..facts(BorrowedUser, Some(1000), 1000)
}),
P::BorrowedUser P::BorrowedUser
); );
} }
} }
} }
#[test]
fn wearing_another_agents_uid_is_never_borrowed_or_human() {
// Annie's turn executing under souvie's account. Her own intent, her
// own worker flag and her own mapping all agree — and it still must
// not read isolated, borrowed, or acting-as-human.
let crossed = PrincipalFacts {
effective_owned_by_other_agent: true,
worker_matches: true,
node_mapping_matches: true,
..facts(Dedicated, Some(1003), 1003)
};
assert_eq!(decide_posture(crossed), P::PrincipalDrift);
assert_eq!(
decide_posture(PrincipalFacts {
intent: BorrowedUser,
..crossed
}),
P::PrincipalDrift
);
}
#[test]
fn a_mismatched_seed_outranks_healthy_account_facts() {
let sound = PrincipalFacts {
worker_matches: true,
node_mapping_matches: true,
..facts(Dedicated, Some(1003), 1003)
};
assert_eq!(
decide_posture(PrincipalFacts { seed_matches_mapping: Some(true), ..sound }),
P::Isolated
);
assert_eq!(
decide_posture(PrincipalFacts { seed_matches_mapping: Some(false), ..sound }),
P::IdentityDrift
);
}
#[test] #[test]
fn effective_uid_resolves_through_nss() { fn effective_uid_resolves_through_nss() {
let uid = unsafe { libc::geteuid() }; let uid = unsafe { libc::geteuid() };

245
src/core/principal_map.rs Normal file
View file

@ -0,0 +1,245 @@
//! The root-owned agent → Unix principal mapping.
//!
//! Shared verbatim between the server, which only ever reads it, and the
//! admission executor, which is its only writer. Deliberately free of
//! crate-internal types so the system-tier binary can `#[path]`-include it
//! without dragging in the model tree.
//!
//! Paths are relative and joined onto a root so `--root` can prepare an image
//! or a test tree without touching the live one.
// Two crates include this module and each uses a different half of it: the
// server reads mappings, the executor writes them.
#![allow(dead_code)]
use std::ffi::{CStr, CString};
use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
pub const PRINCIPAL_MAP_DIR: &str = "etc/souveraine/agent-principals.d";
pub const SYSUSERS_DIR: &str = "etc/sysusers.d";
pub const AGENT_STATE_ROOT: &str = "var/lib/souveraine-agents";
/// Names an agent may never be attached to. `souveraine` is machined's
/// machine-tier account and `souveraine-session` is the lock authority's;
/// adopting either would collapse a tier boundary the whole design rests on.
pub const RESERVED_ACCOUNTS: &[&str] = &["root", "souveraine", "souveraine-session"];
/// The lowest uid this executor treats as a human login. An account in this
/// range that is not already mapped to the agent is a collision, never
/// something to adopt.
pub const HUMAN_UID_FLOOR: u32 = 1000;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct NodePrincipalMapping {
pub agent_id: String,
pub agent_seed_id: String,
pub account: String,
pub uid: u32,
pub node_id: String,
pub state_root: String,
pub admitted_at: DateTime<Utc>,
}
pub fn validate_account_name(account: &str) -> anyhow::Result<()> {
if account.is_empty() {
anyhow::bail!("dedicated principal requires an account");
}
let mut bytes = account.bytes();
let first_ok = matches!(bytes.next(), Some(b'a'..=b'z' | b'_'));
let rest_ok = bytes.all(|byte| matches!(byte, b'a'..=b'z' | b'0'..=b'9' | b'_' | b'-'));
let valid = account.len() <= 31 && first_ok && rest_ok;
if !valid {
anyhow::bail!("invalid dedicated account `{account}`; use a lowercase Unix account name");
}
if RESERVED_ACCOUNTS.contains(&account) {
anyhow::bail!("account `{account}` is reserved and cannot be an agent principal");
}
Ok(())
}
/// State root for an agent, keyed to her ID. Never the display name — a
/// rename must not strand her data or point two agents at one tree.
pub fn agent_state_root(root: &Path, agent_id: &str) -> PathBuf {
root.join(AGENT_STATE_ROOT).join(agent_id)
}
pub fn node_mapping_path(root: &Path, agent_id: &str) -> PathBuf {
root.join(PRINCIPAL_MAP_DIR).join(format!("{agent_id}.json"))
}
pub fn sysusers_drop_in_path(root: &Path, agent_id: &str) -> PathBuf {
root.join(SYSUSERS_DIR)
.join(format!("souveraine-agent-{agent_id}.conf"))
}
pub fn load_node_mapping(root: &Path, agent_id: &str) -> Option<NodePrincipalMapping> {
let raw = std::fs::read_to_string(node_mapping_path(root, agent_id)).ok()?;
serde_json::from_str(&raw).ok()
}
pub fn all_mappings(root: &Path) -> Vec<NodePrincipalMapping> {
let Ok(entries) = std::fs::read_dir(root.join(PRINCIPAL_MAP_DIR)) else {
return Vec::new();
};
entries
.flatten()
.filter(|e| e.path().extension().is_some_and(|x| x == "json"))
.filter_map(|e| std::fs::read_to_string(e.path()).ok())
.filter_map(|raw| serde_json::from_str(&raw).ok())
.collect()
}
/// Which agent, if any, owns this uid. An account belongs to at most one
/// agent: a turn running under a uid mapped to someone else is impersonation,
/// not a degraded version of running as yourself.
pub fn mapping_owner_of_uid(root: &Path, uid: u32) -> Option<String> {
all_mappings(root)
.into_iter()
.find(|m| m.uid == uid)
.map(|m| m.agent_id)
}
/// Which agent, if any, already holds this account name.
pub fn mapping_owner_of_account(root: &Path, account: &str) -> Option<String> {
all_mappings(root)
.into_iter()
.find(|m| m.account == account)
.map(|m| m.agent_id)
}
/// Read one account out of a passwd file. Used only when operating on an
/// alternate root, where NSS still answers for the live system.
pub fn passwd_file_lookup(root: &Path, account: &str) -> Option<(String, u32, String, String)> {
let raw = std::fs::read_to_string(root.join("etc/passwd")).ok()?;
for line in raw.lines() {
let fields: Vec<&str> = line.split(':').collect();
if fields.len() >= 7 && fields[0] == account {
return Some((
fields[0].to_string(),
fields[2].parse().ok()?,
fields[5].to_string(),
fields[6].to_string(),
));
}
}
None
}
#[derive(Debug, Clone)]
pub struct NssAccount {
pub name: String,
pub uid: u32,
pub home: String,
pub shell: String,
}
/// Resolve an account the way the running system would. On an alternate root
/// NSS still answers for the live machine, so the passwd file is read instead.
pub fn account_lookup(root: &Path, account: &str) -> Option<NssAccount> {
if root == Path::new("/") {
nss_account_by_name(account)
} else {
passwd_file_lookup(root, account).map(|(name, uid, home, shell)| NssAccount {
name,
uid,
home,
shell,
})
}
}
pub fn nss_account_by_uid(uid: u32) -> Option<NssAccount> {
let mut passwd = unsafe { std::mem::zeroed::<libc::passwd>() };
let mut result = std::ptr::null_mut();
let mut buffer = vec![0u8; passwd_buffer_size()];
let rc = unsafe {
libc::getpwuid_r(
uid,
&mut passwd,
buffer.as_mut_ptr().cast(),
buffer.len(),
&mut result,
)
};
account_from_passwd(rc, result, &passwd)
}
pub fn nss_account_by_name(name: &str) -> Option<NssAccount> {
let name = CString::new(name).ok()?;
let mut passwd = unsafe { std::mem::zeroed::<libc::passwd>() };
let mut result = std::ptr::null_mut();
let mut buffer = vec![0u8; passwd_buffer_size()];
let rc = unsafe {
libc::getpwnam_r(
name.as_ptr(),
&mut passwd,
buffer.as_mut_ptr().cast(),
buffer.len(),
&mut result,
)
};
account_from_passwd(rc, result, &passwd)
}
fn account_from_passwd(
rc: libc::c_int,
result: *mut libc::passwd,
passwd: &libc::passwd,
) -> Option<NssAccount> {
if rc != 0 || result.is_null() || passwd.pw_name.is_null() {
return None;
}
Some(NssAccount {
name: c_field(passwd.pw_name),
uid: passwd.pw_uid,
home: c_field(passwd.pw_dir),
shell: c_field(passwd.pw_shell),
})
}
fn c_field(value: *const libc::c_char) -> String {
if value.is_null() {
return String::new();
}
unsafe { CStr::from_ptr(value) }
.to_string_lossy()
.into_owned()
}
fn passwd_buffer_size() -> usize {
let suggested = unsafe { libc::sysconf(libc::_SC_GETPW_R_SIZE_MAX) };
if suggested > 0 {
suggested as usize
} else {
16 * 1024
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reserved_and_malformed_accounts_are_refused() {
assert!(validate_account_name("annie").is_ok());
assert!(validate_account_name("agent_1-x").is_ok());
assert!(validate_account_name("Annie").is_err());
assert!(validate_account_name("").is_err());
assert!(validate_account_name("1annie").is_err());
assert!(validate_account_name("annie;rm").is_err());
for reserved in RESERVED_ACCOUNTS {
assert!(validate_account_name(reserved).is_err(), "{reserved}");
}
}
#[test]
fn state_root_follows_the_id_not_the_name() {
let root = Path::new("/");
let by_id = agent_state_root(root, "agent-e2b683bf");
assert!(by_id.ends_with("agent-e2b683bf"));
assert!(!by_id.to_string_lossy().contains("Annie"));
}
}

View file

@ -316,15 +316,12 @@ impl SetupState {
SetupFlow::FederationSync => SetupStep::FederationConfig, SetupFlow::FederationSync => SetupStep::FederationConfig,
}; };
let letta_agents = discover_letta_agents(); let letta_agents = discover_letta_agents();
let agent_name = if flow == SetupFlow::FreshInstall { // Souveraine, called Souvie, is the one default agent and `souvie` the
"Souveraine" // one default account. Import and federation carry their own identity;
} else { // proposing a name there writes one fleet's agents into the product.
"Ani" let (agent_name, account_name) = match flow {
}; SetupFlow::FreshInstall => ("Souveraine", "souvie"),
let account_name = if flow == SetupFlow::FreshInstall { _ => ("", ""),
"souvie"
} else {
""
}; };
Self { Self {