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.
|
# 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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
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 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;
|
||||||
|
|
|
||||||
|
|
@ -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
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,
|
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 {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue