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:
parent
14298e73f8
commit
0f8fe22ab6
10 changed files with 745 additions and 170 deletions
|
|
@ -206,7 +206,8 @@ jobs:
|
|||
# secrets rail + machined: also hand-copied on the phone until now.
|
||||
./scripts/build-cross.sh --features secrets --bin souveraine-secrets
|
||||
./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"
|
||||
file "$B" | grep -q aarch64 || { echo "$b is not aarch64" >&2; exit 1; }
|
||||
cp "$B" "$b-aarch64"
|
||||
|
|
@ -220,7 +221,8 @@ jobs:
|
|||
|
||||
cargo build --release --features secrets --bin souveraine-secrets
|
||||
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"
|
||||
file "$B" | grep -q 'x86-64' || { echo "$b is not x86_64" >&2; exit 1; }
|
||||
cp "$B" "$b-x86_64"
|
||||
|
|
@ -257,6 +259,7 @@ jobs:
|
|||
souveraine-usb-kvm-aarch64 \
|
||||
souveraine-secrets-aarch64 souveraine-secrets-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-binaries.sha256
|
||||
|
||||
|
|
@ -403,6 +406,7 @@ jobs:
|
|||
cp "souveraine-$ARCH" "$PKG_WORK/souveraine-binary"
|
||||
cp "souveraine-secrets-$ARCH" "$PKG_WORK/souveraine-secrets-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-sensord-$ARCH" "$PKG_WORK/souveraine-sensord-binary"
|
||||
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 'etc/pam.d/souveraine-sessiond'
|
||||
bsdtar -tf "$PKG" | grep -qx 'usr/lib/sysusers.d/souveraine.conf'
|
||||
bsdtar -tf "$PKG" | grep -qx 'usr/bin/souveraine-admit'
|
||||
if [ "$ARCH" = aarch64 ]; then
|
||||
bsdtar -tf "$PKG" | grep -qx 'usr/bin/souveraine-usb-kvm'
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -211,6 +211,13 @@ required-features = ["secrets"]
|
|||
name = "souveraine-machined"
|
||||
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]]
|
||||
name = "souveraine-sessiond"
|
||||
path = "src/bin/souveraine-sessiond.rs"
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ package() {
|
|||
# System tier: machine identity daemon + its service user. pacman runs
|
||||
# systemd-sysusers on install, so the `souveraine` user exists before
|
||||
# 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 -Dm644 packaging/souveraine-machined.service \
|
||||
"$pkgdir/usr/lib/systemd/system/souveraine-machined.service"
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ backup=('etc/pam.d/souveraine-stepup' 'etc/pam.d/souveraine-sessiond')
|
|||
source=('souveraine-binary' 'souveraine.service'
|
||||
'souveraine-secrets-binary' 'souveraine-secrets.service'
|
||||
'souveraine-machined-binary' 'souveraine-machined.service'
|
||||
'souveraine-admit-binary'
|
||||
'souveraine.sysusers'
|
||||
'souveraine-sessiond-binary' 'souveraine-sessiond.service'
|
||||
'souveraine-sensord-binary' 'souveraine-sensord.service'
|
||||
|
|
@ -24,7 +25,7 @@ source=('souveraine-binary' 'souveraine.service'
|
|||
'souveraine-stepup.pam' 'org.souveraine.stepup.policy'
|
||||
'LICENSE')
|
||||
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
|
||||
# its user unit does not enable or start it; activation remains target-owned.
|
||||
if [ "$CARCH" = aarch64 ]; then
|
||||
|
|
@ -94,6 +95,9 @@ package() {
|
|||
# souveraine-secrets.service already owns the bus name at runtime, so the
|
||||
# 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 -Dm644 "$srcdir/souveraine-machined.service" \
|
||||
"$pkgdir/usr/lib/systemd/system/souveraine-machined.service"
|
||||
|
|
|
|||
|
|
@ -99,25 +99,8 @@ impl AgentPrincipalConfig {
|
|||
let account = self
|
||||
.account
|
||||
.as_deref()
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| anyhow::anyhow!("dedicated principal requires an account"))?;
|
||||
let valid = account.len() <= 31
|
||||
&& 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");
|
||||
}
|
||||
crate::core::principal_map::validate_account_name(account)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
|
|
|||
307
src/bin/souveraine-admit.rs
Normal file
307
src/bin/souveraine-admit.rs
Normal 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])
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ pub mod model_cache;
|
|||
pub mod nervous;
|
||||
pub mod prompt;
|
||||
pub mod principal;
|
||||
pub mod principal_map;
|
||||
pub mod reflection;
|
||||
pub mod seeds;
|
||||
pub mod sensorium;
|
||||
|
|
|
|||
|
|
@ -5,14 +5,13 @@
|
|||
//! an authorization decision. Actual authority gates must inspect peer/process
|
||||
//! credentials again at their own boundary.
|
||||
|
||||
use std::ffi::{CStr, CString};
|
||||
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
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)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
|
|
@ -22,6 +21,23 @@ pub enum PrincipalPosture {
|
|||
Unadmitted,
|
||||
ActingAsHuman,
|
||||
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)]
|
||||
|
|
@ -39,6 +55,15 @@ pub struct PrincipalHealth {
|
|||
pub account_exists: bool,
|
||||
pub node_mapping_exists: 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_uid: u32,
|
||||
pub node_id: String,
|
||||
|
|
@ -51,25 +76,6 @@ pub struct PrincipalHealth {
|
|||
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
|
||||
/// intent. This process-level fact is honest for today's monolithic server:
|
||||
/// 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 account_exists = expected.is_some();
|
||||
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_matches = mapping.as_ref().is_some_and(|mapping| {
|
||||
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_matches = worker_agent.as_deref() == Some(agent.id.as_str());
|
||||
|
||||
let posture = decide_posture(
|
||||
agent.souveraine.principal.intent,
|
||||
let effective_owned_by_agent =
|
||||
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,
|
||||
effective_uid,
|
||||
effective_owned_by_other_agent: effective_owned_by_agent.is_some(),
|
||||
worker_matches,
|
||||
node_mapping_matches,
|
||||
);
|
||||
seed_matches_mapping,
|
||||
});
|
||||
|
||||
PrincipalHealth {
|
||||
agent_id: agent.id.clone(),
|
||||
|
|
@ -119,6 +141,9 @@ pub fn observe(agent: &AgentState, trigger: impl Into<String>) -> PrincipalHealt
|
|||
account_exists,
|
||||
node_mapping_exists,
|
||||
node_mapping_matches,
|
||||
seed_glyph: live_seed.map(|(_, glyph)| glyph),
|
||||
seed_matches_mapping,
|
||||
effective_owned_by_agent,
|
||||
effective_account,
|
||||
effective_uid,
|
||||
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
|
||||
/// 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.
|
||||
fn decide_posture(
|
||||
intent: PrincipalIntent,
|
||||
expected_uid: Option<u32>,
|
||||
effective_uid: u32,
|
||||
worker_matches: bool,
|
||||
node_mapping_matches: bool,
|
||||
) -> PrincipalPosture {
|
||||
if intent == PrincipalIntent::BorrowedUser {
|
||||
fn decide_posture(facts: PrincipalFacts) -> PrincipalPosture {
|
||||
// Running under a uid another agent owns is impersonation whatever this
|
||||
// agent intended. It is never the human's label and never partial success.
|
||||
if facts.effective_owned_by_other_agent {
|
||||
return PrincipalPosture::PrincipalDrift;
|
||||
}
|
||||
if facts.intent == PrincipalIntent::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,
|
||||
Some(uid) if uid == effective_uid => {
|
||||
if worker_matches && node_mapping_matches {
|
||||
Some(uid) if uid == facts.effective_uid => {
|
||||
if facts.worker_matches && facts.node_mapping_matches {
|
||||
PrincipalPosture::Isolated
|
||||
} else {
|
||||
PrincipalPosture::PrincipalDrift
|
||||
|
|
@ -155,13 +184,29 @@ fn decide_posture(
|
|||
// Linux's ordinary human uid range. A diagnostic label, not an
|
||||
// authorization input; unknown or system controllers stay drift
|
||||
// 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
|
||||
}
|
||||
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 {
|
||||
/// Fresh, non-persisted system context for a model call. The wording is
|
||||
/// intentionally operational: it tells hosted modes how to handle borrowed
|
||||
|
|
@ -184,12 +229,22 @@ impl PrincipalHealth {
|
|||
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."
|
||||
}
|
||||
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!(
|
||||
"[RUNTIME PRINCIPAL — fresh observation, not conversation memory]\n\
|
||||
agent_id: {}\n\
|
||||
display_name: {}\n\
|
||||
seed_glyph: {}\n\
|
||||
principal_intent: {:?}\n\
|
||||
expected_account: {}\n\
|
||||
effective_account: {}\n\
|
||||
|
|
@ -198,10 +253,11 @@ impl PrincipalHealth {
|
|||
trigger: {}\n\
|
||||
observed_at: {}\n\
|
||||
posture: {:?}\n\
|
||||
{}\n\
|
||||
{}{}\n\
|
||||
Authorization gates recheck kernel credentials; this block never grants authority.",
|
||||
self.agent_id,
|
||||
self.display_name,
|
||||
self.seed_glyph.as_deref().unwrap_or("none"),
|
||||
self.principal_intent,
|
||||
expected,
|
||||
self.effective_account,
|
||||
|
|
@ -211,6 +267,7 @@ impl PrincipalHealth {
|
|||
self.observed_at.to_rfc3339(),
|
||||
self.posture,
|
||||
posture,
|
||||
borrowed_from,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -223,94 +280,6 @@ fn hostname_or_unknown() -> 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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -318,55 +287,111 @@ mod tests {
|
|||
use PrincipalIntent::{BorrowedUser, Dedicated};
|
||||
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]
|
||||
fn a_passwd_entry_alone_is_never_isolation() {
|
||||
// Account exists and the process is even running as it — but no
|
||||
// worker binding and no root-owned mapping. This is the shape a
|
||||
// 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!(
|
||||
decide_posture(Dedicated, Some(1003), 1003, false, false),
|
||||
decide_posture(PrincipalFacts { worker_matches: true, ..base }),
|
||||
P::PrincipalDrift
|
||||
);
|
||||
assert_eq!(
|
||||
decide_posture(Dedicated, Some(1003), 1003, true, false),
|
||||
decide_posture(PrincipalFacts { node_mapping_matches: true, ..base }),
|
||||
P::PrincipalDrift
|
||||
);
|
||||
assert_eq!(
|
||||
decide_posture(Dedicated, Some(1003), 1003, false, true),
|
||||
P::PrincipalDrift
|
||||
);
|
||||
assert_eq!(
|
||||
decide_posture(Dedicated, Some(1003), 1003, true, true),
|
||||
decide_posture(PrincipalFacts {
|
||||
worker_matches: true,
|
||||
node_mapping_matches: true,
|
||||
..base
|
||||
}),
|
||||
P::Isolated
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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!(
|
||||
decide_posture(Dedicated, Some(1003), 1000, false, false),
|
||||
decide_posture(facts(Dedicated, Some(1003), 1000)),
|
||||
P::ActingAsHuman
|
||||
);
|
||||
// A system uid that is not hers is drift, not the human.
|
||||
assert_eq!(
|
||||
decide_posture(Dedicated, Some(1003), 950, false, false),
|
||||
decide_posture(facts(Dedicated, Some(1003), 950)),
|
||||
P::PrincipalDrift
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_borrowed_mode_never_drifts_into_isolation() {
|
||||
for mapping in [false, true] {
|
||||
for worker in [false, true] {
|
||||
for node_mapping_matches in [false, true] {
|
||||
for worker_matches in [false, true] {
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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]
|
||||
fn effective_uid_resolves_through_nss() {
|
||||
let uid = unsafe { libc::geteuid() };
|
||||
|
|
|
|||
245
src/core/principal_map.rs
Normal file
245
src/core/principal_map.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -316,15 +316,12 @@ impl SetupState {
|
|||
SetupFlow::FederationSync => SetupStep::FederationConfig,
|
||||
};
|
||||
let letta_agents = discover_letta_agents();
|
||||
let agent_name = if flow == SetupFlow::FreshInstall {
|
||||
"Souveraine"
|
||||
} else {
|
||||
"Ani"
|
||||
};
|
||||
let account_name = if flow == SetupFlow::FreshInstall {
|
||||
"souvie"
|
||||
} else {
|
||||
""
|
||||
// Souveraine, called Souvie, is the one default agent and `souvie` the
|
||||
// one default account. Import and federation carry their own identity;
|
||||
// proposing a name there writes one fleet's agents into the product.
|
||||
let (agent_name, account_name) = match flow {
|
||||
SetupFlow::FreshInstall => ("Souveraine", "souvie"),
|
||||
_ => ("", ""),
|
||||
};
|
||||
|
||||
Self {
|
||||
|
|
|
|||
Loading…
Reference in a new issue