Integrate trusted-host configuration

# Conflicts:
#	Cargo.lock
#	Cargo.toml
#	tools/lazaret_verify.py
This commit is contained in:
ms2k 2026-07-15 00:56:14 +09:00
commit 0716bab960
12 changed files with 4099 additions and 142 deletions

162
Cargo.lock generated
View file

@ -8,6 +8,21 @@ version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
[[package]]
name = "block-buffer"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
dependencies = [
"hybrid-array",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]] [[package]]
name = "cobs" name = "cobs"
version = "0.3.0" version = "0.3.0"
@ -17,6 +32,41 @@ dependencies = [
"thiserror", "thiserror",
] ]
[[package]]
name = "const-oid"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "cpufeatures"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
dependencies = [
"libc",
]
[[package]]
name = "crypto-common"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
"hybrid-array",
]
[[package]]
name = "digest"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer",
"const-oid",
"crypto-common",
]
[[package]] [[package]]
name = "embedded-io" name = "embedded-io"
version = "0.4.0" version = "0.4.0"
@ -29,6 +79,12 @@ version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]] [[package]]
name = "errno" name = "errno"
version = "0.3.14" version = "0.3.14"
@ -39,6 +95,31 @@ dependencies = [
"windows-sys", "windows-sys",
] ]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "hybrid-array"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c"
dependencies = [
"typenum",
]
[[package]]
name = "indexmap"
version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]] [[package]]
name = "lazaret-client" name = "lazaret-client"
version = "0.0.0" version = "0.0.0"
@ -51,7 +132,11 @@ name = "lazaret-core"
version = "0.0.0" version = "0.0.0"
dependencies = [ dependencies = [
"postcard", "postcard",
"rustix",
"serde", "serde",
"sha2",
"static_assertions",
"toml",
] ]
[[package]] [[package]]
@ -175,6 +260,32 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "serde_spanned"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
dependencies = [
"serde_core",
]
[[package]]
name = "sha2"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.118" version = "2.0.118"
@ -206,6 +317,51 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "toml"
version = "1.1.3+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned",
"toml_datetime",
"toml_parser",
"toml_writer",
"winnow",
]
[[package]]
name = "toml_datetime"
version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_parser"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
dependencies = [
"winnow",
]
[[package]]
name = "toml_writer"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.24" version = "1.0.24"
@ -226,3 +382,9 @@ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "winnow"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"

View file

@ -20,6 +20,8 @@ license = "Apache-2.0"
postcard = { version = "=1.1.3", default-features = false, features = ["use-std"] } postcard = { version = "=1.1.3", default-features = false, features = ["use-std"] }
serde = { version = "=1.0.228", features = ["derive"] } serde = { version = "=1.0.228", features = ["derive"] }
rustix = { version = "=1.1.4", features = ["event", "fs", "net", "pipe", "process", "thread"] } rustix = { version = "=1.1.4", features = ["event", "fs", "net", "pipe", "process", "thread"] }
sha2 = "=0.11.0"
toml = { version = "=1.1.3", default-features = false, features = ["parse", "serde", "std"] }
[workspace.lints.rust] [workspace.lints.rust]
unsafe_code = "forbid" unsafe_code = "forbid"

View file

@ -7,7 +7,13 @@ license.workspace = true
[dependencies] [dependencies]
postcard.workspace = true postcard.workspace = true
rustix.workspace = true
serde.workspace = true serde.workspace = true
sha2.workspace = true
toml.workspace = true
[dev-dependencies]
static_assertions = "=1.1.0"
[lints] [lints]
workspace = true workspace = true

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,7 @@
#![doc = "Shared production interfaces for Lazaret processes."] #![doc = "Shared production interfaces for Lazaret processes."]
pub mod codec; pub mod codec;
pub mod configuration;
pub mod identity; pub mod identity;
pub mod records; pub mod records;

View file

@ -1,3 +1,4 @@
use std::collections::BTreeMap;
use std::fmt; use std::fmt;
use std::num::NonZeroU16; use std::num::NonZeroU16;
@ -20,6 +21,199 @@ pub const STORAGE_CATALOG_SCHEMA_ID: &str = "lazaret-storage-catalog-v1";
pub const DOMAIN_STORE_SCHEMA_VERSION: u32 = 2; pub const DOMAIN_STORE_SCHEMA_VERSION: u32 = 2;
pub const LEGACY_DOMAIN_STORE_SCHEMA_VERSION: u32 = 1; pub const LEGACY_DOMAIN_STORE_SCHEMA_VERSION: u32 = 1;
pub const STORAGE_CATALOG_SCHEMA_VERSION: u32 = 1; pub const STORAGE_CATALOG_SCHEMA_VERSION: u32 = 1;
pub const TRUSTED_HOST_CONFIGURATION_SCHEMA_ID: &str = "lazaret-trusted-host-config-v1";
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct StorageConfigurationV1 {
#[serde(default)]
pub data_root: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct NetworkConfigurationV1 {
#[serde(default)]
pub default_allow: Vec<String>,
#[serde(default)]
pub default_deny: Vec<String>,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum VolumeKind {
File,
Directory,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum VolumeAccess {
ReadOnly,
#[default]
ReadWrite,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct VolumeConfigurationV1 {
pub host: String,
pub container: String,
pub kind: VolumeKind,
#[serde(default)]
pub access: VolumeAccess,
#[serde(default)]
pub create: bool,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct AutoApprovalRuleConfigurationV1 {
pub executable: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub allow_extra_args: bool,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct EscalationConfigurationV1 {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub auto_approve: Vec<AutoApprovalRuleConfigurationV1>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct ProfileConfigurationV1 {
pub image: String,
#[serde(default = "default_isolate_gitignored")]
pub isolate_gitignored: bool,
#[serde(default)]
pub persistent_dirs: Vec<String>,
#[serde(default)]
pub allow_egress: Vec<String>,
#[serde(default)]
pub deny_egress: Vec<String>,
#[serde(default)]
pub env: BTreeMap<String, String>,
#[serde(default)]
pub volumes: Vec<VolumeConfigurationV1>,
#[serde(default)]
pub escalation: EscalationConfigurationV1,
}
const fn default_isolate_gitignored() -> bool {
true
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct TrustedHostConfigurationV1 {
#[serde(rename = "schema")]
schema_id: String,
storage: StorageConfigurationV1,
network: NetworkConfigurationV1,
profiles: BTreeMap<String, ProfileConfigurationV1>,
}
impl TrustedHostConfigurationV1 {
#[must_use]
pub fn schema_id(&self) -> &str {
&self.schema_id
}
#[must_use]
pub const fn storage(&self) -> &StorageConfigurationV1 {
&self.storage
}
#[must_use]
pub const fn network(&self) -> &NetworkConfigurationV1 {
&self.network
}
#[must_use]
pub const fn profiles(&self) -> &BTreeMap<String, ProfileConfigurationV1> {
&self.profiles
}
}
impl<'de> Deserialize<'de> for TrustedHostConfigurationV1 {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Fields {
#[serde(rename = "schema")]
schema_id: String,
#[serde(default)]
storage: StorageConfigurationV1,
#[serde(default)]
network: NetworkConfigurationV1,
profiles: BTreeMap<String, ProfileConfigurationV1>,
}
let fields = Fields::deserialize(deserializer)?;
if fields.schema_id != TRUSTED_HOST_CONFIGURATION_SCHEMA_ID {
return Err(serde::de::Error::custom(
"unknown trusted-host configuration schema",
));
}
if fields.profiles.is_empty() {
return Err(serde::de::Error::custom(
"trusted-host configuration has no profiles",
));
}
Ok(Self {
schema_id: fields.schema_id,
storage: fields.storage,
network: fields.network,
profiles: fields.profiles,
})
}
}
#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
pub struct EnvironmentReference {
pub sandbox_name: String,
pub host_name: String,
}
#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
pub struct ResolvedVolume {
pub canonical_host_source: HostPath,
pub container_path: String,
pub kind: VolumeKind,
pub access: VolumeAccess,
pub create: bool,
}
#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
pub struct ResolvedAutoApprovalRule {
pub canonical_executable: HostPath,
pub args: Vec<String>,
pub allow_extra_args: bool,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct ConfigurationFingerprintRecordV1 {
pub selected_profile: String,
pub storage_root: HostPath,
pub runtime_root: HostPath,
pub image: String,
pub isolate_gitignored: bool,
pub persistent_dirs: Vec<String>,
pub environment_references: Vec<EnvironmentReference>,
pub volumes: Vec<ResolvedVolume>,
pub effective_allow_prefixes: Vec<([u8; 4], u8)>,
pub effective_deny_prefixes: Vec<([u8; 4], u8)>,
pub escalation_enabled: bool,
pub auto_approval_rules: Vec<ResolvedAutoApprovalRule>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)] #[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct StoreMetadata { pub struct StoreMetadata {
@ -1493,15 +1687,138 @@ pub struct SignedProtocolRecord {
pub signature: Vec<u8>, pub signature: Vec<u8>,
} }
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct MailboxRequest { pub struct MailboxRequest {
pub protocol_version: ProtocolVersion, protocol_version: ProtocolVersion,
pub session_binding: SessionBinding, session_binding: SessionBinding,
pub request_id: RequestId, request_id: RequestId,
pub session_capability: SessionRequestCapability, session_capability: SessionRequestCapability,
pub sandbox_credential_id: ProtocolCredentialId, sandbox_credential_id: ProtocolCredentialId,
pub command_argv: Vec<Vec<u8>>, command_argv: Vec<Vec<u8>>,
pub justification: String, justification: String,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MailboxRequestError {
EmptyCommand,
InvalidArgument,
NonAbsoluteExecutable,
}
impl fmt::Display for MailboxRequestError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::EmptyCommand => formatter.write_str("command argument vector is empty"),
Self::InvalidArgument => formatter.write_str("command argument is not NUL-free UTF-8"),
Self::NonAbsoluteExecutable => {
formatter.write_str("command executable is not an absolute path")
}
}
}
}
impl std::error::Error for MailboxRequestError {}
impl MailboxRequest {
/// # Errors
/// The command must contain an absolute executable followed by NUL-free UTF-8 arguments.
pub fn new(
session_binding: SessionBinding,
request_id: RequestId,
session_capability: SessionRequestCapability,
sandbox_credential_id: ProtocolCredentialId,
command_argv: Vec<Vec<u8>>,
justification: String,
) -> Result<Self, MailboxRequestError> {
let Some(executable) = command_argv.first() else {
return Err(MailboxRequestError::EmptyCommand);
};
if command_argv
.iter()
.any(|argument| argument.contains(&0) || std::str::from_utf8(argument).is_err())
{
return Err(MailboxRequestError::InvalidArgument);
}
if !executable.starts_with(b"/") {
return Err(MailboxRequestError::NonAbsoluteExecutable);
}
Ok(Self {
protocol_version: ProtocolVersion::V1,
session_binding,
request_id,
session_capability,
sandbox_credential_id,
command_argv,
justification,
})
}
#[must_use]
pub const fn protocol_version(&self) -> ProtocolVersion {
self.protocol_version
}
#[must_use]
pub const fn session_binding(&self) -> &SessionBinding {
&self.session_binding
}
#[must_use]
pub const fn request_id(&self) -> RequestId {
self.request_id
}
#[must_use]
pub const fn session_capability(&self) -> &SessionRequestCapability {
&self.session_capability
}
#[must_use]
pub const fn sandbox_credential_id(&self) -> ProtocolCredentialId {
self.sandbox_credential_id
}
#[must_use]
pub fn command_argv(&self) -> &[Vec<u8>] {
&self.command_argv
}
#[must_use]
pub fn justification(&self) -> &str {
&self.justification
}
}
impl<'de> Deserialize<'de> for MailboxRequest {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
struct Fields {
protocol_version: ProtocolVersion,
session_binding: SessionBinding,
request_id: RequestId,
session_capability: SessionRequestCapability,
sandbox_credential_id: ProtocolCredentialId,
command_argv: Vec<Vec<u8>>,
justification: String,
}
let fields = Fields::deserialize(deserializer)?;
if fields.protocol_version != ProtocolVersion::V1 {
return Err(serde::de::Error::custom("unknown mailbox request version"));
}
Self::new(
fields.session_binding,
fields.request_id,
fields.session_capability,
fields.sandbox_credential_id,
fields.command_argv,
fields.justification,
)
.map_err(serde::de::Error::custom)
}
} }
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
@ -2605,14 +2922,18 @@ pub trait SchemaType {
} }
macro_rules! schema_types { macro_rules! schema_types {
($($name:literal => $record:ty),+ $(,)?) => { ($($name:literal => $record:ty),+; $($declared:literal),* $(,)?) => {
$( $(
impl SchemaType for $record { impl SchemaType for $record {
const NAME: &'static str = $name; const NAME: &'static str = $name;
} }
)+ )+
pub const ALL_SCHEMA_TYPE_NAMES: &[&str] = &[$($name),+]; pub const IMPLEMENTED_SERIALIZED_SCHEMA_TYPE_NAMES: &[&str] = &[$($name),+];
pub const DECLARED_SERIALIZED_SCHEMA_TYPE_NAMES: &[&str] = &[
$($name),+,
$($declared),*
];
}; };
} }
@ -2697,6 +3018,17 @@ schema_types!(
"DeniedDestinationObservation" => DeniedDestinationObservation, "DeniedDestinationObservation" => DeniedDestinationObservation,
"PermittedDestinationObservation" => PermittedDestinationObservation, "PermittedDestinationObservation" => PermittedDestinationObservation,
"DnsObservation" => DnsObservation, "DnsObservation" => DnsObservation,
"TrustedHostConfigurationV1" => TrustedHostConfigurationV1,
"ConfigurationFingerprintRecordV1" => ConfigurationFingerprintRecordV1,
"EnvironmentReference" => EnvironmentReference,
"ResolvedVolume" => ResolvedVolume,
"ResolvedAutoApprovalRule" => ResolvedAutoApprovalRule,
"DomainStore" => DomainStore, "DomainStore" => DomainStore,
"StorageCatalog" => StorageCatalog, "StorageCatalog" => StorageCatalog;
); );
pub const DECLARED_RUNTIME_SCHEMA_TYPE_NAMES: &[&str] = &[
"ResolvedDomainConfiguration",
"SandboxLayoutV1",
"ConfinedPathPlan",
];

View file

@ -0,0 +1,735 @@
use std::collections::BTreeMap;
use std::fs;
use std::os::unix::fs::{MetadataExt, PermissionsExt, symlink};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use lazaret_core::codec::{CodecError, decode, encode};
use lazaret_core::configuration::{
ConfigurationErrorKind, ConfigurationLoadRequest, ConfinedPathPlan, ConfinedPathType,
ResolvedDomainConfiguration, SandboxLayoutV1, derive_configuration_fingerprint,
derive_domain_id, load_trusted_configuration, parse_trusted_configuration,
};
use lazaret_core::identity::HostPath;
use lazaret_core::records::{
ConfigurationFingerprintRecordV1, EnvironmentReference, ResolvedAutoApprovalRule,
ResolvedVolume, VolumeAccess, VolumeKind,
};
use static_assertions::assert_not_impl_any;
assert_not_impl_any!(ResolvedDomainConfiguration: serde::Serialize, serde::de::DeserializeOwned);
assert_not_impl_any!(SandboxLayoutV1: serde::Serialize, serde::de::DeserializeOwned);
assert_not_impl_any!(ConfinedPathPlan: serde::Serialize, serde::de::DeserializeOwned);
static NEXT_TREE: AtomicU64 = AtomicU64::new(0);
struct TestTree(PathBuf);
impl TestTree {
fn new() -> Self {
let sequence = NEXT_TREE.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"lazaret-configuration-{}-{sequence}",
std::process::id()
));
fs::create_dir(&path).unwrap();
Self(path)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TestTree {
fn drop(&mut self) {
fs::remove_dir_all(&self.0).unwrap();
}
}
fn write_configuration(tree: &TestTree, body: &str) -> PathBuf {
let directory = tree.path().join("config");
fs::create_dir(&directory).unwrap();
let path = directory.join("config.toml");
fs::write(&path, body).unwrap();
path
}
fn layout() -> SandboxLayoutV1 {
SandboxLayoutV1::from_production_layout([
"/workspace".to_owned(),
"/proc".to_owned(),
"/sys".to_owned(),
"/dev".to_owned(),
"/run/lazaret".to_owned(),
])
.unwrap()
}
fn request(
tree: &TestTree,
config: PathBuf,
environment: BTreeMap<String, std::ffi::OsString>,
) -> ConfigurationLoadRequest {
let workspace = tree.path().join("workspace");
let runtime = tree.path().join("runtime");
fs::create_dir_all(&workspace).unwrap();
fs::create_dir_all(&runtime).unwrap();
ConfigurationLoadRequest {
explicit_configuration_path: Some(config),
selected_profile: "default".into(),
workspace,
home: tree.path().join("home"),
xdg_config_home: None,
xdg_data_home: None,
xdg_runtime_dir: runtime,
environment,
sandbox_layout: layout(),
}
}
fn valid_configuration(tree: &TestTree) -> String {
format!(
r#"schema = "lazaret-trusted-host-config-v1"
[storage]
data_root = "{}"
[network]
default_allow = ["192.0.2.4/24", "198.51.100.53/32"]
[profiles.default]
image = "example.invalid/agent:v1"
persistent_dirs = ["/root"]
allow_egress = ["10.1.2.3/24"]
env = {{ TOKEN = "env:HOST_TOKEN" }}
volumes = [
{{ host = "{{profile_dir}}/agent-state", container = "/root/.local/state/agent", kind = "directory", access = "read-write", create = true }},
]
[profiles.default.escalation]
enabled = false
"#,
tree.path().join("data").display()
)
}
#[test]
fn strict_parser_applies_defaults_and_rejects_every_unknown_level() {
let minimal = r#"schema = "lazaret-trusted-host-config-v1"
[profiles.default]
image = "example.invalid/agent:v1"
"#;
let parsed = parse_trusted_configuration(minimal.as_bytes()).unwrap();
let profile = &parsed.profiles()["default"];
assert!(profile.isolate_gitignored);
assert!(profile.persistent_dirs.is_empty());
assert!(profile.allow_egress.is_empty());
assert!(profile.deny_egress.is_empty());
assert!(profile.env.is_empty());
assert!(profile.volumes.is_empty());
assert!(!profile.escalation.enabled);
assert!(profile.escalation.auto_approve.is_empty());
assert!(parsed.storage().data_root.is_none());
assert!(parsed.network().default_allow.is_empty());
assert!(parsed.network().default_deny.is_empty());
for invalid in [
minimal.replace("schema", "unknown"),
format!("{minimal}unknown = true\n"),
minimal.replace(
"image = \"example.invalid/agent:v1\"",
"image = \"example.invalid/agent:v1\"\nunknown = true",
),
minimal.replace(
"example.invalid/agent:v1",
"https://example.invalid/agent:v1",
),
minimal.replace("example.invalid/agent:v1", ""),
minimal.replace("-v1", "-v2"),
] {
assert!(parse_trusted_configuration(invalid.as_bytes()).is_err());
}
let duplicate = format!("{minimal}schema = \"lazaret-trusted-host-config-v1\"\n");
assert!(parse_trusted_configuration(duplicate.as_bytes()).is_err());
for invalid in [
format!("{minimal}\n[storage]\nunknown = true\n"),
format!("{minimal}\n[network]\nunknown = true\n"),
r#"schema = "lazaret-trusted-host-config-v1"
[profiles.default]
image = "example.invalid/agent:v1"
volumes = [{ host = "/source", container = "/root/source", kind = "directory", unknown = true }]
"#
.into(),
format!("{minimal}\n[profiles.default.escalation]\nunknown = true\n"),
format!(
"{minimal}\n[profiles.default.escalation]\nenabled = true\n[[profiles.default.escalation.auto_approve]]\nexecutable = \"/bin/true\"\nunknown = true\n"
),
r#"schema = "lazaret-trusted-host-config-v1"
[profiles.default]
image = "example.invalid/agent:v1"
volumes = [{ host = "/source", container = "/root/source", kind = "socket" }]
"#
.into(),
] {
assert!(parse_trusted_configuration(invalid.as_bytes()).is_err());
}
}
#[test]
fn sandbox_layout_is_closed_normalized_and_requires_workspace() {
assert!(SandboxLayoutV1::from_production_layout(["/proc".into()]).is_err());
assert!(
SandboxLayoutV1::from_production_layout(["/workspace".into(), "/workspace".into()])
.is_err()
);
for invalid in ["workspace", "/", "/workspace/", "/workspace/../control"] {
assert!(
SandboxLayoutV1::from_production_layout(["/workspace".into(), invalid.into(),])
.is_err()
);
}
}
#[test]
fn repository_example_loads_with_ipv4_normalization_and_private_denies() {
let tree = TestTree::new();
let config = write_configuration(&tree, include_str!("../../../docs/example.toml"));
let resolved = load_trusted_configuration(request(&tree, config, BTreeMap::new())).unwrap();
assert_eq!(resolved.selected_profile(), "default");
assert!(
resolved
.effective_allow_prefixes()
.iter()
.any(|prefix| prefix.address() == [1, 1, 1, 1] && prefix.prefix_length() == 32)
);
for (address, length) in [
([10, 0, 0, 0], 8),
([172, 16, 0, 0], 12),
([192, 168, 0, 0], 16),
] {
assert!(
resolved
.effective_deny_prefixes()
.iter()
.any(|prefix| prefix.address() == address && prefix.prefix_length() == length)
);
}
}
#[test]
fn explicit_tilde_configuration_path_expands_only_against_home() {
let tree = TestTree::new();
let home = tree.path().join("home");
fs::create_dir(&home).unwrap();
fs::set_permissions(&home, fs::Permissions::from_mode(0o700)).unwrap();
fs::write(home.join("trusted.toml"), valid_configuration(&tree)).unwrap();
let mut environment = BTreeMap::new();
environment.insert("HOST_TOKEN".into(), "value".into());
let resolved =
load_trusted_configuration(request(&tree, PathBuf::from("~/trusted.toml"), environment))
.unwrap();
assert_eq!(resolved.selected_profile(), "default");
}
#[test]
fn nonselected_profiles_are_decoded_but_do_not_resolve_host_authority() {
let tree = TestTree::new();
let body = format!(
"{}\n[profiles.other]\nimage = \"example.invalid/other:v1\"\nenv = {{ SECRET = \"env:UNSET\" }}\nvolumes = [{{ host = \"/does/not/exist\", container = \"/root/missing\", kind = \"directory\" }}]\n",
valid_configuration(&tree)
);
let config = write_configuration(&tree, &body);
let mut environment = BTreeMap::new();
environment.insert("HOST_TOKEN".into(), "value".into());
assert!(load_trusted_configuration(request(&tree, config, environment)).is_ok());
}
#[test]
fn nonselected_profiles_cannot_hide_invalid_approval_semantics() {
for other_profile in [
r#"[profiles.other]
image = "example.invalid/other:v1"
[[profiles.other.escalation.auto_approve]]
executable = "/bin/true"
"#,
r#"[profiles.other]
image = "example.invalid/other:v1"
[profiles.other.escalation]
enabled = true
[[profiles.other.escalation.auto_approve]]
executable = "/bin/true"
allow_extra_args = true
"#,
] {
let document = format!(
"schema = \"lazaret-trusted-host-config-v1\"\n[profiles.default]\nimage = \"example.invalid/default:v1\"\n{other_profile}"
);
let error = parse_trusted_configuration(document.as_bytes()).unwrap_err();
assert_eq!(error.kind(), ConfigurationErrorKind::InvalidAutoApproval);
}
}
#[test]
fn selected_profile_loads_into_confined_nonserializable_authority() {
let tree = TestTree::new();
let config = write_configuration(&tree, &valid_configuration(&tree));
let mut environment = BTreeMap::new();
environment.insert("HOST_TOKEN".into(), "top-secret".into());
let resolved = load_trusted_configuration(request(&tree, config, environment)).unwrap();
assert_eq!(resolved.selected_profile(), "default");
assert_eq!(
resolved.effective_allow_prefixes()[0].address(),
[1, 2, 3, 0]
);
assert!(
resolved
.effective_deny_prefixes()
.iter()
.any(|prefix| prefix.address() == [10, 0, 0, 0] && prefix.prefix_length() == 8)
);
assert_eq!(
resolved.ephemeral_environment()["TOKEN"].expose().to_str(),
Some("top-secret")
);
assert!(!format!("{resolved:?}").contains("top-secret"));
assert_eq!(resolved.volumes().len(), 1);
resolved.volumes()[0].open_source().unwrap();
assert_eq!(
fs::metadata(tree.path().join("data"))
.unwrap()
.permissions()
.mode()
& 0o777,
0o700
);
}
#[test]
fn environment_values_never_change_the_fingerprint() {
let tree = TestTree::new();
let config = write_configuration(&tree, &valid_configuration(&tree));
let mut first = BTreeMap::new();
first.insert("HOST_TOKEN".into(), "first-secret".into());
let first = load_trusted_configuration(request(&tree, config.clone(), first)).unwrap();
let mut second = BTreeMap::new();
second.insert("HOST_TOKEN".into(), "second-secret".into());
let second = load_trusted_configuration(request(&tree, config, second)).unwrap();
assert_eq!(
first.configuration_fingerprint(),
second.configuration_fingerprint()
);
}
#[test]
fn ignored_routing_is_explicit_and_fingerprinted() {
let first_tree = TestTree::new();
let first_config = write_configuration(&first_tree, &valid_configuration(&first_tree));
let mut environment = BTreeMap::new();
environment.insert("HOST_TOKEN".into(), "value".into());
let first =
load_trusted_configuration(request(&first_tree, first_config, environment)).unwrap();
assert!(first.fingerprint_record().isolate_gitignored);
let second_tree = TestTree::new();
let body = valid_configuration(&second_tree).replace(
"image = \"example.invalid/agent:v1\"",
"image = \"example.invalid/agent:v1\"\nisolate_gitignored = false",
);
let second_config = write_configuration(&second_tree, &body);
let mut environment = BTreeMap::new();
environment.insert("HOST_TOKEN".into(), "value".into());
let second =
load_trusted_configuration(request(&second_tree, second_config, environment)).unwrap();
assert!(!second.fingerprint_record().isolate_gitignored);
let disabled = derive_configuration_fingerprint(second.fingerprint_record()).unwrap();
let mut enabled_record = second.fingerprint_record().clone();
enabled_record.isolate_gitignored = true;
assert_ne!(
disabled,
derive_configuration_fingerprint(&enabled_record).unwrap()
);
}
#[test]
fn retained_ancestor_authority_survives_path_replacement() {
let tree = TestTree::new();
let config = write_configuration(&tree, &valid_configuration(&tree));
let mut environment = BTreeMap::new();
environment.insert("HOST_TOKEN".into(), "value".into());
let resolved = load_trusted_configuration(request(&tree, config, environment)).unwrap();
let original_source = tree.path().join("data/profiles/default/agent-state");
let original_inode = fs::metadata(&original_source).unwrap().ino();
let profile = tree.path().join("data/profiles/default");
let displaced = tree.path().join("data/profiles/displaced");
fs::rename(&profile, &displaced).unwrap();
fs::create_dir(&profile).unwrap();
fs::set_permissions(&profile, fs::Permissions::from_mode(0o700)).unwrap();
fs::create_dir(profile.join("agent-state")).unwrap();
let replacement_inode = fs::metadata(profile.join("agent-state")).unwrap().ino();
let opened = resolved.volumes()[0].open_source().unwrap();
let opened_inode = rustix::fs::fstat(&opened).unwrap().st_ino;
assert_eq!(opened_inode, original_inode);
assert_ne!(opened_inode, replacement_inode);
}
#[test]
fn consumer_api_is_descriptor_relative_and_refuses_final_identity_drift() {
let tree = TestTree::new();
let config = write_configuration(&tree, &valid_configuration(&tree));
let mut environment = BTreeMap::new();
environment.insert("HOST_TOKEN".into(), "value".into());
let load_request = request(&tree, config, environment);
let nested = load_request.workspace.join("nested");
fs::create_dir(&nested).unwrap();
fs::write(nested.join("data"), b"validated").unwrap();
symlink("data", nested.join("alias")).unwrap();
let expected_inode = fs::metadata(nested.join("data")).unwrap().ino();
let resolved = load_trusted_configuration(load_request).unwrap();
let opened = resolved
.workspace()
.open_beneath(Path::new("nested/data"), ConfinedPathType::RegularFile)
.unwrap();
assert_eq!(rustix::fs::fstat(&opened).unwrap().st_ino, expected_inode);
for refused in ["../outside", "/absolute", "nested/./data", "nested/alias"] {
assert!(
resolved
.workspace()
.open_beneath(Path::new(refused), ConfinedPathType::RegularFile)
.is_err(),
"{refused} must not escape or redirect authority"
);
}
let workspace = tree.path().join("workspace");
fs::rename(&workspace, tree.path().join("displaced-workspace")).unwrap();
fs::create_dir(&workspace).unwrap();
assert!(resolved.workspace().open_authority().is_err());
let source = tree.path().join("data/profiles/default/agent-state");
fs::rename(&source, tree.path().join("displaced-agent-state")).unwrap();
fs::create_dir(&source).unwrap();
assert!(resolved.volumes()[0].open_source().is_err());
}
#[test]
fn auto_approval_uses_only_the_canonical_direct_vector_and_safe_object() {
let tree = TestTree::new();
let binary_directory = tree.path().join("bin");
fs::create_dir(&binary_directory).unwrap();
let executable = binary_directory.join("tool");
fs::write(&executable, b"#!/bin/sh\nexit 0\n").unwrap();
fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap();
let body = valid_configuration(&tree).replace(
"[profiles.default.escalation]\nenabled = false",
&format!(
"[profiles.default.escalation]\nenabled = true\n\n[[profiles.default.escalation.auto_approve]]\nexecutable = \"{}\"\nargs = [\"fixed\"]\nallow_extra_args = false",
executable.display()
),
);
let config = write_configuration(&tree, &body);
let mut environment = BTreeMap::new();
environment.insert("HOST_TOKEN".into(), "value".into());
let resolved = load_trusted_configuration(request(&tree, config, environment)).unwrap();
let rule = &resolved.auto_approval_rules()[0];
let canonical = fs::canonicalize(&executable)
.unwrap()
.to_str()
.unwrap()
.as_bytes()
.to_vec();
assert!(rule.matches_command_argv(&[canonical.clone(), b"fixed".to_vec()]));
assert!(!rule.matches_command_argv(&[b"tool".to_vec(), b"fixed".to_vec()]));
assert!(!rule.matches_command_argv(std::slice::from_ref(&canonical)));
assert!(
!rule.matches_command_argv(&[canonical.clone(), b"fixed".to_vec(), b"extra".to_vec(),])
);
assert!(!rule.matches_command_argv(&[canonical, b"fixed\0extra".to_vec()]));
let prefix_tree = TestTree::new();
let binary_directory = prefix_tree.path().join("bin");
fs::create_dir(&binary_directory).unwrap();
let executable = binary_directory.join("tool");
fs::write(&executable, b"#!/bin/sh\nexit 0\n").unwrap();
fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap();
let body = valid_configuration(&prefix_tree).replace(
"[profiles.default.escalation]\nenabled = false",
&format!(
"[profiles.default.escalation]\nenabled = true\n\n[[profiles.default.escalation.auto_approve]]\nexecutable = \"{}\"\nargs = [\"fixed\"]\nallow_extra_args = true",
executable.display()
),
);
let config = write_configuration(&prefix_tree, &body);
let mut environment = BTreeMap::new();
environment.insert("HOST_TOKEN".into(), "value".into());
let resolved = load_trusted_configuration(request(&prefix_tree, config, environment)).unwrap();
let canonical = fs::canonicalize(executable)
.unwrap()
.to_str()
.unwrap()
.as_bytes()
.to_vec();
let rule = &resolved.auto_approval_rules()[0];
assert!(rule.matches_command_argv(&[canonical.clone(), b"fixed".to_vec()]));
assert!(rule.matches_command_argv(&[canonical, b"fixed".to_vec(), b"extra".to_vec(),]));
let hardlink_tree = TestTree::new();
let binary_directory = hardlink_tree.path().join("bin");
fs::create_dir(&binary_directory).unwrap();
let executable = binary_directory.join("tool");
fs::write(&executable, b"#!/bin/sh\nexit 0\n").unwrap();
fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap();
fs::hard_link(&executable, binary_directory.join("alias")).unwrap();
let body = valid_configuration(&hardlink_tree).replace(
"[profiles.default.escalation]\nenabled = false",
&format!(
"[profiles.default.escalation]\nenabled = true\n\n[[profiles.default.escalation.auto_approve]]\nexecutable = \"{}\"",
executable.display()
),
);
let config = write_configuration(&hardlink_tree, &body);
let mut environment = BTreeMap::new();
environment.insert("HOST_TOKEN".into(), "value".into());
assert_eq!(
load_trusted_configuration(request(&hardlink_tree, config, environment))
.unwrap_err()
.kind(),
ConfigurationErrorKind::InvalidAutoApproval
);
}
#[test]
fn path_network_environment_and_volume_conflicts_refuse_loading() {
let tree = TestTree::new();
let base = valid_configuration(&tree);
let cases = [
(
base.replace(
"persistent_dirs = [\"/root\"]",
"persistent_dirs = [\"/workspace/cache\"]",
),
ConfigurationErrorKind::PathConflict,
),
(
base.replace(
"allow_egress = [\"10.1.2.3/24\"]",
"allow_egress = [\"10.0.0.0/8\"]",
),
ConfigurationErrorKind::InvalidNetwork,
),
(
base.replace("/root/.local/state/agent", "/root"),
ConfigurationErrorKind::InvalidVolume,
),
(
base.replace(
"persistent_dirs = [\"/root\"]",
"persistent_dirs = [\"/root\", \"/root/cache\"]",
),
ConfigurationErrorKind::InvalidProfile,
),
(
base.replace("kind = \"directory\"", "kind = \"file\""),
ConfigurationErrorKind::InvalidVolume,
),
(
format!(
"{base}\n[[profiles.default.escalation.auto_approve]]\nexecutable = \"/bin/true\"\n"
),
ConfigurationErrorKind::InvalidAutoApproval,
),
(
base.replace(
"[profiles.default.escalation]\nenabled = false",
"[profiles.default.escalation]\nenabled = true\n[[profiles.default.escalation.auto_approve]]\nexecutable = \"/bin/true\"\nallow_extra_args = true",
),
ConfigurationErrorKind::InvalidAutoApproval,
),
];
for (index, (body, expected)) in cases.into_iter().enumerate() {
let case_tree = TestTree::new();
let body = body.replace(
&tree.path().join("data").display().to_string(),
&case_tree.path().join("data").display().to_string(),
);
let config = write_configuration(&case_tree, &body);
let mut environment = BTreeMap::new();
environment.insert("HOST_TOKEN".into(), "value".into());
let error = load_trusted_configuration(request(&case_tree, config, environment))
.expect_err(&format!("case {index} must fail"));
assert_eq!(error.kind(), expected);
}
let config = write_configuration(&tree, &base);
let error = load_trusted_configuration(request(&tree, config, BTreeMap::new())).unwrap_err();
assert_eq!(error.kind(), ConfigurationErrorKind::MissingEnvironment);
}
#[test]
fn secret_environment_resolution_occurs_after_authority_validation() {
let volume_tree = TestTree::new();
let body = valid_configuration(&volume_tree).replace("kind = \"directory\"", "kind = \"file\"");
let config = write_configuration(&volume_tree, &body);
assert_eq!(
load_trusted_configuration(request(&volume_tree, config, BTreeMap::new()))
.unwrap_err()
.kind(),
ConfigurationErrorKind::InvalidVolume
);
let executable_tree = TestTree::new();
let executable = executable_tree.path().join("unsafe-tool");
fs::write(&executable, b"#!/bin/sh\nexit 0\n").unwrap();
fs::set_permissions(&executable, fs::Permissions::from_mode(0o722)).unwrap();
let body = valid_configuration(&executable_tree).replace(
"[profiles.default.escalation]\nenabled = false",
&format!(
"[profiles.default.escalation]\nenabled = true\n[[profiles.default.escalation.auto_approve]]\nexecutable = \"{}\"",
executable.display()
),
);
let config = write_configuration(&executable_tree, &body);
assert_eq!(
load_trusted_configuration(request(&executable_tree, config, BTreeMap::new()))
.unwrap_err()
.kind(),
ConfigurationErrorKind::InvalidAutoApproval
);
}
#[test]
fn volume_symlinks_and_lazaret_owned_mode_drift_are_rejected() {
let tree = TestTree::new();
let target = tree.path().join("target");
fs::create_dir(&target).unwrap();
let link = tree.path().join("volume-link");
symlink(&target, &link).unwrap();
let body = valid_configuration(&tree)
.replace("{profile_dir}/agent-state", link.to_str().unwrap())
.replace("create = true", "create = false");
let config = write_configuration(&tree, &body);
let mut environment = BTreeMap::new();
environment.insert("HOST_TOKEN".into(), "value".into());
assert_eq!(
load_trusted_configuration(request(&tree, config, environment))
.unwrap_err()
.kind(),
ConfigurationErrorKind::InvalidVolume
);
let mode_tree = TestTree::new();
let data = mode_tree.path().join("data");
fs::create_dir(&data).unwrap();
fs::set_permissions(&data, fs::Permissions::from_mode(0o755)).unwrap();
let config = write_configuration(&mode_tree, &valid_configuration(&mode_tree));
let mut environment = BTreeMap::new();
environment.insert("HOST_TOKEN".into(), "value".into());
assert_eq!(
load_trusted_configuration(request(&mode_tree, config, environment))
.unwrap_err()
.kind(),
ConfigurationErrorKind::InvalidPath
);
}
#[test]
fn configuration_source_permissions_links_and_size_are_bounded() {
for mutation in 0..3 {
let tree = TestTree::new();
let config = write_configuration(&tree, &valid_configuration(&tree));
match mutation {
0 => fs::set_permissions(&config, fs::Permissions::from_mode(0o666)).unwrap(),
1 => fs::hard_link(&config, tree.path().join("config-link")).unwrap(),
2 => fs::write(&config, vec![b'x'; 1_048_577]).unwrap(),
_ => unreachable!(),
}
let mut environment = BTreeMap::new();
environment.insert("HOST_TOKEN".into(), "value".into());
assert!(
load_trusted_configuration(request(&tree, config, environment)).is_err(),
"mutation {mutation} must fail"
);
}
}
#[test]
fn digest_vectors_are_framed_and_order_independent() {
assert_eq!(
derive_domain_id("default", b"/workspace/project")
.unwrap()
.as_bytes(),
&[
0x62, 0x64, 0xea, 0x6c, 0x25, 0x85, 0x2f, 0x5b, 0x77, 0x03, 0x90, 0x33, 0x9b, 0xb1,
0xd9, 0x75, 0x01, 0x9b, 0x70, 0xa6, 0x04, 0x55, 0xff, 0xfa, 0x2a, 0x1c, 0xf3, 0x0e,
0xf1, 0xc6, 0xbe, 0xf9,
]
);
let mut record = ConfigurationFingerprintRecordV1 {
selected_profile: "default".into(),
storage_root: HostPath::new(b"/data".to_vec()),
runtime_root: HostPath::new(b"/run/user/1000/lazaret".to_vec()),
image: "example.invalid/agent:v1".into(),
isolate_gitignored: true,
persistent_dirs: vec!["/var".into(), "/root".into()],
environment_references: vec![
EnvironmentReference {
sandbox_name: "B".into(),
host_name: "HOST_B".into(),
},
EnvironmentReference {
sandbox_name: "A".into(),
host_name: "HOST_A".into(),
},
],
volumes: vec![ResolvedVolume {
canonical_host_source: HostPath::new(b"/source".to_vec()),
container_path: "/root/source".into(),
kind: VolumeKind::Directory,
access: VolumeAccess::ReadOnly,
create: false,
}],
effective_allow_prefixes: vec![([8, 8, 8, 8], 32), ([1, 1, 1, 1], 32)],
effective_deny_prefixes: vec![([10, 0, 0, 0], 8)],
escalation_enabled: true,
auto_approval_rules: vec![ResolvedAutoApprovalRule {
canonical_executable: HostPath::new(b"/usr/bin/true".to_vec()),
args: vec!["a".into(), "b".into()],
allow_extra_args: false,
}],
};
let first = derive_configuration_fingerprint(&record).unwrap();
assert_eq!(
first.as_bytes(),
&[
0xd3, 0xba, 0xef, 0x91, 0x21, 0x0f, 0x98, 0x31, 0x6c, 0xea, 0x34, 0xfb, 0x41, 0xbd,
0x6c, 0xa5, 0x07, 0xf0, 0xb8, 0x9b, 0x90, 0x77, 0xaf, 0x0a, 0x0e, 0xd0, 0x6a, 0xd6,
0x46, 0xc7, 0x2d, 0xbf,
]
);
record.persistent_dirs.reverse();
record.environment_references.reverse();
record.effective_allow_prefixes.reverse();
assert_eq!(first, derive_configuration_fingerprint(&record).unwrap());
record.auto_approval_rules[0].args.reverse();
assert_ne!(first, derive_configuration_fingerprint(&record).unwrap());
}
#[test]
fn serialized_configuration_records_use_strict_postcard() {
let reference = EnvironmentReference {
sandbox_name: "TOKEN".into(),
host_name: "HOST_TOKEN".into(),
};
let mut encoded = encode(&reference).unwrap();
encoded.push(0);
assert!(matches!(
decode::<EnvironmentReference>(&encoded),
Err(CodecError::TrailingPayload)
));
}

View file

@ -1,8 +1,9 @@
use std::collections::HashSet; use std::collections::{BTreeMap, HashMap, HashSet};
use std::fmt::Debug; use std::fmt::Debug;
use std::num::NonZeroU16; use std::num::NonZeroU16;
use lazaret_core::codec::{CodecError, MAX_SCHEMA_RECORD_BYTES, decode, encode}; use lazaret_core::codec::{CodecError, MAX_SCHEMA_RECORD_BYTES, decode, encode};
use lazaret_core::configuration::parse_trusted_configuration;
use lazaret_core::identity::{ use lazaret_core::identity::{
BootId, BundleDigest, CapabilityId, CertificateDigest, ClientId, ConfigurationFingerprint, BootId, BundleDigest, CapabilityId, CertificateDigest, ClientId, ConfigurationFingerprint,
ContainerId, CoordinatorId, DerivedPolicyDigest, DeviceId, DomainId, FileDescriptor, ContainerId, CoordinatorId, DerivedPolicyDigest, DeviceId, DomainId, FileDescriptor,
@ -148,6 +149,25 @@ struct RawNetworkReadinessIdentity {
enforcement_namespace: NamespaceIdentity, enforcement_namespace: NamespaceIdentity,
} }
#[derive(Serialize)]
struct RawMailboxRequest {
protocol_version: ProtocolVersion,
session_binding: SessionBinding,
request_id: RequestId,
session_capability: SessionRequestCapability,
sandbox_credential_id: ProtocolCredentialId,
command_argv: Vec<Vec<u8>>,
justification: String,
}
#[derive(Serialize)]
struct RawTrustedHostConfigurationV1 {
schema_id: String,
storage: StorageConfigurationV1,
network: NetworkConfigurationV1,
profiles: BTreeMap<String, ProfileConfigurationV1>,
}
fn opaque(byte: u8) -> [u8; 32] { fn opaque(byte: u8) -> [u8; 32] {
[byte; 32] [byte; 32]
} }
@ -657,15 +677,15 @@ fn every_schema_shape_round_trips_and_rejects_malformed_or_unknown_versions() {
vec![39, 40], vec![39, 40],
CertificateDigest::new(opaque(41)), CertificateDigest::new(opaque(41)),
); );
let request = MailboxRequest { let request = MailboxRequest::new(
protocol_version: ProtocolVersion::V1, session_binding(),
session_binding: session_binding(), RequestId::new(opaque(42)),
request_id: RequestId::new(opaque(42)), session_capability(),
session_capability: session_capability(), ProtocolCredentialId::new(opaque(17)),
sandbox_credential_id: ProtocolCredentialId::new(opaque(17)), vec![b"/usr/bin/printf".to_vec(), b"value".to_vec()],
command_argv: vec![b"printf".to_vec(), vec![0xff]], "build dependency".into(),
justification: "build dependency".into(), )
}; .unwrap();
let prepared_artifact = PreparedArtifact { let prepared_artifact = PreparedArtifact {
prepared_artifact_id: PreparedArtifactId::new(opaque(24)), prepared_artifact_id: PreparedArtifactId::new(opaque(24)),
source_identity: SourceIdentity::new(vec![25, 26]), source_identity: SourceIdentity::new(vec![25, 26]),
@ -846,7 +866,7 @@ fn every_schema_shape_round_trips_and_rejects_malformed_or_unknown_versions() {
request_id: RequestId::new(opaque(42)), request_id: RequestId::new(opaque(42)),
request_sha256: RequestDigest::new(opaque(59)), request_sha256: RequestDigest::new(opaque(59)),
decision: DeveloperDecisionKind::Approve, decision: DeveloperDecisionKind::Approve,
approved_command_argv: request.command_argv.clone(), approved_command_argv: request.command_argv().to_vec(),
}, },
&mut seen, &mut seen,
); );
@ -1054,24 +1074,229 @@ fn every_schema_shape_round_trips_and_rejects_malformed_or_unknown_versions() {
}, },
&mut seen, &mut seen,
); );
verify_shape(
parse_trusted_configuration(
br#"schema = "lazaret-trusted-host-config-v1"
[profiles.trusted]
image = "example.invalid/agent:v1"
"#,
)
.unwrap(),
&mut seen,
);
verify_shape(
ConfigurationFingerprintRecordV1 {
selected_profile: "trusted".into(),
storage_root: HostPath::new(b"/data/lazaret".to_vec()),
runtime_root: HostPath::new(b"/run/user/1000/lazaret".to_vec()),
image: "example.invalid/agent:v1".into(),
isolate_gitignored: true,
persistent_dirs: vec!["/root".into()],
environment_references: vec![EnvironmentReference {
sandbox_name: "TOKEN".into(),
host_name: "HOST_TOKEN".into(),
}],
volumes: vec![ResolvedVolume {
canonical_host_source: HostPath::new(b"/data/source".to_vec()),
container_path: "/root/source".into(),
kind: VolumeKind::Directory,
access: VolumeAccess::ReadOnly,
create: false,
}],
effective_allow_prefixes: vec![([1, 1, 1, 1], 32)],
effective_deny_prefixes: vec![([10, 0, 0, 0], 8)],
escalation_enabled: true,
auto_approval_rules: vec![ResolvedAutoApprovalRule {
canonical_executable: HostPath::new(b"/usr/bin/true".to_vec()),
args: vec!["--version".into()],
allow_extra_args: false,
}],
},
&mut seen,
);
verify_shape(
EnvironmentReference {
sandbox_name: "TOKEN".into(),
host_name: "HOST_TOKEN".into(),
},
&mut seen,
);
verify_shape(
ResolvedVolume {
canonical_host_source: HostPath::new(b"/data/source".to_vec()),
container_path: "/root/source".into(),
kind: VolumeKind::Directory,
access: VolumeAccess::ReadWrite,
create: false,
},
&mut seen,
);
verify_shape(
ResolvedAutoApprovalRule {
canonical_executable: HostPath::new(b"/usr/bin/true".to_vec()),
args: vec![],
allow_extra_args: false,
},
&mut seen,
);
assert_eq!(seen.len(), ALL_SCHEMA_TYPE_NAMES.len()); assert_eq!(seen.len(), IMPLEMENTED_SERIALIZED_SCHEMA_TYPE_NAMES.len());
assert!(ALL_SCHEMA_TYPE_NAMES.iter().all(|name| seen.contains(name))); assert!(
IMPLEMENTED_SERIALIZED_SCHEMA_TYPE_NAMES
.iter()
.all(|name| seen.contains(name))
);
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum SchemaCategory {
Serialized,
Runtime,
}
fn parse_schema_categories(
schema: &str,
serialized_registry: &HashSet<&str>,
runtime_registry: &HashSet<&str>,
) -> Result<HashMap<String, SchemaCategory>, String> {
if let Some(name) = serialized_registry.intersection(runtime_registry).next() {
return Err(format!("schema registries overlap at {name}"));
}
let mut documented = HashMap::new();
let mut current: Option<(&str, Option<SchemaCategory>)> = None;
for line in schema.lines() {
let trimmed = line.trim();
if let Some(declaration) = trimmed.strip_prefix("class ") {
if current.is_some() {
return Err("nested class declaration".into());
}
let name = declaration
.split(|character: char| character.is_whitespace() || character == '{')
.next()
.filter(|name| !name.is_empty())
.ok_or_else(|| "class declaration has no name".to_owned())?;
if !declaration.contains('{') {
return Err(format!("class {name} has no stereotype block"));
}
if !serialized_registry.contains(name) && !runtime_registry.contains(name) {
return Err(format!("unknown schema class {name}"));
}
current = Some((name, None));
continue;
}
let Some((name, category)) = current.as_mut() else {
continue;
};
if trimmed == "}" {
let category = category.ok_or_else(|| format!("class {name} is unmarked"))?;
match documented.insert((*name).to_owned(), category) {
Some(existing) if existing != category => {
return Err(format!("class {name} has conflicting categories"));
}
_ => {}
}
current = None;
continue;
}
let Some(stereotype) = trimmed
.strip_prefix("<<")
.and_then(|value| value.strip_suffix(">>"))
else {
continue;
};
let parsed = match stereotype {
"serialized" => Some(SchemaCategory::Serialized),
"runtime" => Some(SchemaCategory::Runtime),
"enumeration" | "union" => None,
_ => return Err(format!("class {name} has unknown stereotype {stereotype}")),
};
if let Some(parsed) = parsed
&& category.replace(parsed).is_some()
{
return Err(format!("class {name} has multiple category markers"));
}
}
if let Some((name, _)) = current {
return Err(format!("class {name} has no closing brace"));
}
for name in serialized_registry.union(runtime_registry) {
if !documented.contains_key(*name) {
return Err(format!("registered schema class {name} is undocumented"));
}
}
Ok(documented)
} }
#[test] #[test]
fn shape_registry_matches_the_normative_schema_classes() { fn schema_categories_match_exact_disjoint_registries() {
let documented = include_str!("../../../docs/schema.md") let serialized = DECLARED_SERIALIZED_SCHEMA_TYPE_NAMES
.lines()
.filter_map(|line| line.strip_prefix(" class "))
.map(|name| name.split_whitespace().next().unwrap_or(name))
.collect::<HashSet<_>>();
let registered = ALL_SCHEMA_TYPE_NAMES
.iter() .iter()
.copied() .copied()
.collect::<HashSet<_>>(); .collect::<HashSet<_>>();
let runtime = DECLARED_RUNTIME_SCHEMA_TYPE_NAMES
.iter()
.copied()
.collect::<HashSet<_>>();
assert_eq!(
serialized.len(),
DECLARED_SERIALIZED_SCHEMA_TYPE_NAMES.len()
);
assert_eq!(runtime.len(), DECLARED_RUNTIME_SCHEMA_TYPE_NAMES.len());
assert_eq!(documented, registered); let documented = parse_schema_categories(
include_str!("../../../docs/schema.md"),
&serialized,
&runtime,
)
.unwrap();
let documented_serialized = documented
.iter()
.filter_map(|(name, category)| {
(*category == SchemaCategory::Serialized).then_some(name.as_str())
})
.collect::<HashSet<_>>();
let documented_runtime = documented
.iter()
.filter_map(|(name, category)| {
(*category == SchemaCategory::Runtime).then_some(name.as_str())
})
.collect::<HashSet<_>>();
assert_eq!(documented_serialized, serialized);
assert_eq!(documented_runtime, runtime);
}
#[test]
fn schema_category_parser_fails_closed() {
let serialized = HashSet::from(["Known"]);
let runtime = HashSet::from(["Runtime"]);
let overlapping = HashSet::from(["Known"]);
for invalid in [
"class Known {\nfield\n}",
"class Known {\n<<serialized>>\n<<serialized>>\n}",
"class Known {\n<<serialized>>\n<<runtime>>\n}",
"class Unknown {\n<<serialized>>\n}",
"class Known {\n<<unexpected>>\n}",
] {
assert!(parse_schema_categories(invalid, &serialized, &runtime).is_err());
}
assert!(parse_schema_categories("", &serialized, &runtime).is_err());
assert!(parse_schema_categories("", &serialized, &overlapping).is_err());
assert!(
parse_schema_categories(
"class Known {\n<<serialized>>\n}\nclass Runtime {\n<<runtime>>\n}",
&serialized,
&runtime,
)
.is_ok()
);
} }
fn assert_unknown_protocol_version<T>(record: &T) fn assert_unknown_protocol_version<T>(record: &T)
@ -1156,27 +1381,27 @@ fn unknown_enums_and_protocol_versions_are_rejected() {
)); ));
assert_unknown_protocol_version(&session_capability()); assert_unknown_protocol_version(&session_capability());
let request = MailboxRequest { let request = MailboxRequest::new(
protocol_version: ProtocolVersion::V1, session_binding(),
session_binding: session_binding(), RequestId::new(opaque(5)),
request_id: RequestId::new(opaque(5)), session_capability(),
session_capability: session_capability(), ProtocolCredentialId::new(opaque(6)),
sandbox_credential_id: ProtocolCredentialId::new(opaque(6)), vec![b"/usr/bin/true".to_vec()],
command_argv: vec![b"true".to_vec()], "test".into(),
justification: "test".into(), )
}; .unwrap();
assert_unknown_protocol_version(&request); assert_unknown_protocol_version(&request);
assert_unknown_protocol_version(&MailboxCancellation { assert_unknown_protocol_version(&MailboxCancellation {
protocol_version: ProtocolVersion::V1, protocol_version: ProtocolVersion::V1,
session_binding: session_binding(), session_binding: session_binding(),
request_id: request.request_id, request_id: request.request_id(),
session_capability: session_capability(), session_capability: session_capability(),
sandbox_credential_id: ProtocolCredentialId::new(opaque(6)), sandbox_credential_id: ProtocolCredentialId::new(opaque(6)),
}); });
assert_unknown_protocol_version(&MailboxResultAck { assert_unknown_protocol_version(&MailboxResultAck {
protocol_version: ProtocolVersion::V1, protocol_version: ProtocolVersion::V1,
session_binding: session_binding(), session_binding: session_binding(),
request_id: request.request_id, request_id: request.request_id(),
request_sha256: RequestDigest::new(opaque(7)), request_sha256: RequestDigest::new(opaque(7)),
session_capability: session_capability(), session_capability: session_capability(),
sandbox_credential_id: ProtocolCredentialId::new(opaque(6)), sandbox_credential_id: ProtocolCredentialId::new(opaque(6)),
@ -1185,7 +1410,7 @@ fn unknown_enums_and_protocol_versions_are_rejected() {
assert_unknown_protocol_version(&DeveloperDecision { assert_unknown_protocol_version(&DeveloperDecision {
protocol_version: ProtocolVersion::V1, protocol_version: ProtocolVersion::V1,
session_binding: session_binding(), session_binding: session_binding(),
request_id: request.request_id, request_id: request.request_id(),
request_sha256: RequestDigest::new(opaque(7)), request_sha256: RequestDigest::new(opaque(7)),
decision: DeveloperDecisionKind::Deny, decision: DeveloperDecisionKind::Deny,
approved_command_argv: vec![], approved_command_argv: vec![],
@ -1193,7 +1418,7 @@ fn unknown_enums_and_protocol_versions_are_rejected() {
assert_unknown_protocol_version(&MailboxResult { assert_unknown_protocol_version(&MailboxResult {
protocol_version: ProtocolVersion::V1, protocol_version: ProtocolVersion::V1,
session_binding: session_binding(), session_binding: session_binding(),
request_id: request.request_id, request_id: request.request_id(),
request_sha256: RequestDigest::new(opaque(7)), request_sha256: RequestDigest::new(opaque(7)),
coordinator_credential_id: ProtocolCredentialId::new(opaque(8)), coordinator_credential_id: ProtocolCredentialId::new(opaque(8)),
outcome: EscalationOutcome::Denied, outcome: EscalationOutcome::Denied,
@ -1205,7 +1430,7 @@ fn unknown_enums_and_protocol_versions_are_rejected() {
assert_unknown_protocol_version(&MailboxResultChunk { assert_unknown_protocol_version(&MailboxResultChunk {
protocol_version: ProtocolVersion::V1, protocol_version: ProtocolVersion::V1,
session_binding: session_binding(), session_binding: session_binding(),
request_id: request.request_id, request_id: request.request_id(),
request_sha256: RequestDigest::new(opaque(7)), request_sha256: RequestDigest::new(opaque(7)),
coordinator_credential_id: ProtocolCredentialId::new(opaque(8)), coordinator_credential_id: ProtocolCredentialId::new(opaque(8)),
sequence: 0, sequence: 0,
@ -2842,6 +3067,40 @@ fn network_document_schema_identities_are_fixed_to_v1() {
} }
} }
#[test]
fn trusted_host_configuration_postcard_dispatch_rejects_unknown_schema() {
let mut profiles = BTreeMap::new();
profiles.insert(
"default".into(),
ProfileConfigurationV1 {
image: "example.invalid/agent:v1".into(),
isolate_gitignored: true,
persistent_dirs: vec![],
allow_egress: vec![],
deny_egress: vec![],
env: BTreeMap::new(),
volumes: vec![],
escalation: EscalationConfigurationV1::default(),
},
);
for schema_id in [
"",
"lazaret-trusted-host-config-v0",
"lazaret-trusted-host-config-v2",
] {
let raw = RawTrustedHostConfigurationV1 {
schema_id: schema_id.into(),
storage: StorageConfigurationV1::default(),
network: NetworkConfigurationV1::default(),
profiles: profiles.clone(),
};
assert!(matches!(
decode::<TrustedHostConfigurationV1>(&encode(&raw).unwrap()),
Err(CodecError::MalformedPayload(_))
));
}
}
#[test] #[test]
fn zero_terminal_dimensions_are_rejected() { fn zero_terminal_dimensions_are_rejected() {
assert!(NonZeroU16::new(0).is_none()); assert!(NonZeroU16::new(0).is_none());
@ -2875,6 +3134,41 @@ fn generic_schema_record_limit_is_enforced_before_decode() {
)); ));
} }
#[test]
fn mailbox_request_command_vector_is_closed_and_direct() {
for command_argv in [
vec![],
vec![b"true".to_vec()],
vec![b"/usr/bin/true".to_vec(), vec![0xff]],
vec![b"/usr/bin/true".to_vec(), b"bad\0argument".to_vec()],
] {
let raw = RawMailboxRequest {
protocol_version: ProtocolVersion::V1,
session_binding: session_binding(),
request_id: RequestId::new(opaque(42)),
session_capability: session_capability(),
sandbox_credential_id: ProtocolCredentialId::new(opaque(17)),
command_argv,
justification: "test".into(),
};
assert!(matches!(
decode::<MailboxRequest>(&encode(&raw).unwrap()),
Err(CodecError::MalformedPayload(_))
));
}
assert!(
MailboxRequest::new(
session_binding(),
RequestId::new(opaque(42)),
session_capability(),
ProtocolCredentialId::new(opaque(17)),
vec![b"/bin/sh".to_vec(), b"-c".to_vec(), b"fixed".to_vec()],
"exact direct vector".into(),
)
.is_ok()
);
}
#[test] #[test]
fn session_exit_code_is_accepted_only_for_exited() { fn session_exit_code_is_accepted_only_for_exited() {
let encoded = encode(&InvalidSessionExit { let encoded = encode(&InvalidSessionExit {

View file

@ -1,90 +1,21 @@
# Lazaret Configuration Template (config.toml) # Lazaret trusted-host configuration v1
# Copy this file to ~/.config/lazaret/config.toml.
# Lazaret configuration is host-only and must not be placed in a mounted workspace. schema = "lazaret-trusted-host-config-v1"
[storage] [storage]
# Mount path on the host where persistent profile directories
# (e.g., usr, var, etc, opt, root) will be initialized and stored.
data_root = "~/.local/share/lazaret" data_root = "~/.local/share/lazaret"
[network] [network]
# Default global whitelist applied to the Netlink router gateway. default_allow = ["192.0.2.53/32", "198.51.100.53/32"]
# Nameservers from the host's /etc/resolv.conf are added during Domain startup.
default_allow = [
"198.51.100.53/32", # Documentation address
"192.0.2.53/32" # Documentation address
]
# Default global blacklist applied to the Netlink router gateway. [profiles.default]
# Private RFC 1918 ranges are blackholed by default to prevent scanning/accessing the local network.
default_deny = [
"10.0.0.0/8", # Class A Private Network Range
"172.16.0.0/12", # Class B Private Network Range
"192.168.0.0/16" # Class C Private Network Range
]
[escalation]
# Set to true to enable agent-to-host escalation through Lazaret's host-private control plane.
enabled = true
# runtime_dir is optional. Lazaret defaults to a private directory beneath
# $XDG_RUNTIME_DIR.
# Host commands that the agent is allowed to execute on the host automatically
# without triggering an interactive Human-In-The-Loop (HITL) prompt.
[[escalation.auto_approve]]
argv = ["aws", "sts", "get-caller-identity"]
allow_extra_args = false
# AI AGENT PROFILES
[profiles.gemini]
image = "docker.io/library/debian:trixie" image = "docker.io/library/debian:trixie"
isolate_gitignored = true isolate_gitignored = true
persistent_dirs = ["/root", "/usr", "/var", "/etc", "/opt"] persistent_dirs = ["/root"]
allow_egress = ["10.222.0.0/16"] allow_egress = ["203.0.113.10/32"]
env = { GEMINI_API_KEY = "env:GEMINI_API_KEY" }
volumes = [ volumes = [
{ host = "{profile_dir}/.gemini", container = "/root/.gemini", mode = "z" } { host = "{profile_dir}/agent-state", container = "/root/.local/state/agent", kind = "directory", access = "read-write", create = true },
] ]
[profiles.claude] [profiles.default.escalation]
image = "docker.io/library/debian:trixie" enabled = false
isolate_gitignored = true
persistent_dirs = ["/root", "/usr", "/var", "/etc", "/opt"]
allow_egress = ["10.222.0.0/16"]
env = { ANTHROPIC_API_KEY = "env:ANTHROPIC_API_KEY" }
volumes = [
{ host = "{profile_dir}/.claude", container = "/root/.claude", mode = "z" }
]
[profiles.codex]
image = "docker.io/library/debian:trixie"
isolate_gitignored = true
persistent_dirs = ["/root", "/usr", "/var", "/etc", "/opt"]
allow_egress = ["10.222.0.0/16"]
env = { OPENAI_API_KEY = "env:OPENAI_API_KEY" }
volumes = [
{ host = "{profile_dir}/.codex", container = "/root/.codex", mode = "z" }
]
[profiles.grok]
image = "docker.io/library/debian:trixie"
isolate_gitignored = true
persistent_dirs = ["/root", "/usr", "/var", "/etc", "/opt"]
allow_egress = ["10.222.0.0/16"]
env = { XAI_API_KEY = "env:XAI_API_KEY" }
volumes = [
{ host = "{profile_dir}/.grok", container = "/root/.grok", mode = "z" }
]
[profiles.opencode]
image = "docker.io/library/debian:trixie"
isolate_gitignored = true
persistent_dirs = ["/root", "/usr", "/var", "/etc", "/opt"]
allow_egress = ["10.222.0.0/16"]
env = { ANTHROPIC_API_KEY = "env:ANTHROPIC_API_KEY", OPENAI_API_KEY = "env:OPENAI_API_KEY", GEMINI_API_KEY = "env:GEMINI_API_KEY" }
volumes = [
{ host = "{profile_dir}/opencode-config", container = "/root/.config/opencode", mode = "z" },
{ host = "{profile_dir}/opencode-share", container = "/root/.local/share/opencode", mode = "z" }
]

View file

@ -1,30 +1,300 @@
# Lazaret schemas # Lazaret schemas
This document is the normative design schema for Lazaret records and protocol This document is a mutable coordination schema for Lazaret records and protocol
data shapes. The data shapes. The [specification](specification.md) defines their authoritative
[specification](specification.md) defines their physical use, while the TLA+ physical use, while the TLA+ models define authoritative lifecycle and ordering
models define lifecycle and ordering above the implementation abstraction. The above the implementation abstraction. The diagrams coordinate logical fields,
diagrams define logical fields, variants, and relationships; they do not select a variants, relationships, and serialization boundaries across implementation
programming-language representation. work; they do not select a programming-language representation or override the
specification or models.
Every class carries exactly one serialization-category stereotype:
`<<serialized>>` identifies a record that may cross a configuration,
durable-storage, or protocol codec boundary, while `<<runtime>>` identifies an
in-memory authority or plan that must not implement Serde serialization or
deserialization. Shape stereotypes such as `<<enumeration>>` and `<<union>>` are
orthogonal and may coexist.
## Trusted host configuration
The trusted-host configuration dispatch identifier is exactly
`lazaret-trusted-host-config-v1`. The v1 TOML document has the following closed
shape; every table recursively rejects unknown fields, duplicate keys, duplicate
tables, field aliases, and unknown enumeration values.
| Table | Field | Presence and value |
| :--- | :--- | :--- |
| root | `schema` | required; exact dispatch identifier |
| root | `storage` | optional; defaults to an empty table |
| root | `network` | optional; defaults to an empty table |
| root | `profiles` | required; at least one dynamically named profile |
| `storage` | `data_root` | optional host path; defaults beneath the XDG data home |
| `network` | `default_allow` | optional list of IPv4 prefixes; defaults empty |
| `network` | `default_deny` | optional list of IPv4 prefixes; defaults empty |
| profile | `image` | required nonempty OCI image reference |
| profile | `isolate_gitignored` | optional Boolean; defaults `true` |
| profile | `persistent_dirs` | optional list of sandbox paths; defaults empty |
| profile | `allow_egress` | optional list of IPv4 prefixes; defaults empty |
| profile | `deny_egress` | optional list of IPv4 prefixes; defaults empty |
| profile | `env` | optional map from sandbox environment name to `env:` reference; defaults empty |
| profile | `volumes` | optional list of volume tables; defaults empty |
| profile | `escalation` | optional table; defaults to disabled with no rules |
| volume | `host` | required host path |
| volume | `container` | required sandbox path |
| volume | `kind` | required: `file` or `directory` |
| volume | `access` | optional: `read-only` or `read-write`; defaults `read-write` |
| volume | `create` | optional Boolean; defaults `false` |
| `escalation` | `enabled` | optional Boolean; defaults `false` |
| `escalation` | `auto_approve` | optional list of auto-approval rules; defaults empty |
| auto-approval rule | `executable` | required host executable path |
| auto-approval rule | `args` | optional ordered list of arguments; defaults empty |
| auto-approval rule | `allow_extra_args` | optional Boolean; defaults `false` |
Profile names are ASCII and match
`[A-Za-z0-9][A-Za-z0-9._-]{0,63}`, excluding `.` and `..`. The caller selects
one profile by its exact case-sensitive name; the document has no default
profile and declaration order never selects one. All configuration strings are
UTF-8 and NUL-free. Environment map keys and the names following `env:` match
`[A-Za-z_][A-Za-z0-9_]*`. An environment value is a reference only; literal
values and other reference schemes are invalid.
Host path declarations are absolute or begin with exactly `~/`; only a volume
`host` may instead begin with exactly `{profile_dir}/`. A declaration rejects an
empty suffix, an empty component, repeated or trailing separators, `.` or `..`,
and every other expansion, variable, glob, or shell form. In particular,
`~//etc` and `{profile_dir}//etc` are invalid. Sandbox paths are normalized
absolute UTF-8 paths with the same component restrictions; the root path is
invalid.
Persistent paths are pairwise distinct and neither equal nor ancestral in
either direction. Volume container paths are pairwise distinct and
non-ancestral. Each volume container path is a strict descendant of exactly one
persistent path; equality with that path is invalid. Resolution requires an
internal `SandboxLayoutV1`, whose closed protected-destination set contains
`/workspace` and every control, credential, mailbox, proc, sys, dev, runtime,
and other product-owned sandbox mount destination selected by the production
layout. Persistent and volume paths must not intersect any protected
destination in either ancestry direction. Configuration semantics do not assign
destinations other than the specification-fixed `/workspace`; the production
sandbox-layout constructor owns that complete set and cannot construct a layout
that omits `/workspace`. A volume source has the declared kind. `create=true` is
valid only for a directory source anchored strictly beneath `{profile_dir}`; a
file source is never created. Symlinks, magic links, mount crossings, sockets,
devices, FIFOs, and other special files cannot satisfy a source.
The v1 network language is IPv4-only. Each prefix is parsed, has host bits
cleared, and is deduplicated. Effective allow prefixes are the union of global
and selected-profile allows. Effective deny prefixes are the union of global,
selected-profile, and the three RFC 1918 prefixes. An exact normalized prefix
present in both sets is invalid; overlaps at different prefix lengths are valid
and use longest-prefix routing. The Agent Sandbox disables IPv6 for v1.
An auto-approval rule is valid only while its profile escalation table is
enabled. Its executable resolves to a canonical regular executable outside the
Agent Workspace, every configured volume source, the profile directory, and
every Lazaret data, runtime, Domain, and control tree. At load and before every
execution, the object must have exactly one hard link, be owned by root or the
invoking user, grant no group or other write permission, and be executable by
the invoking user. Rule arguments are UTF-8, NUL-free strings.
`allow_extra_args=false` matches only the exact ordered argument list.
`allow_extra_args=true` requires at least one configured argument and matches
that nonempty ordered prefix followed by zero or more arguments. Exact duplicate
normalized rules are invalid.
`MailboxRequest.command_argv` is nonempty and every element is UTF-8 and
NUL-free. Element zero is an absolute path and must be byte-for-byte equal to
the rule's canonical executable path; elements one onward are the arguments
matched against `args`. Lazaret performs no `PATH` lookup. The structured v1
request language has no command-string or shell-expansion form; such an
unsupported input is `CommandRequiresShell` and cannot auto-approve. The host
launcher never inserts or interprets a shell. An explicitly configured exact
vector such as `/bin/sh`, `-c`, and a fixed command string remains a direct
vector and can match only that exact rule. A configured script remains eligible:
executing the same opened script object uses ordinary kernel shebang handling
without turning its arguments into a Lazaret-interpreted shell command.
```mermaid
classDiagram
class TrustedHostConfigurationV1 {
<<serialized>>
schema_id
profiles
}
class ResolvedDomainConfiguration {
<<runtime>>
selected_profile
domain_id
configuration_fingerprint
effective_network_policy
ephemeral_environment
confined_authorities
}
class SandboxLayoutV1 {
<<runtime>>
protected_destinations
}
class ConfigurationFingerprintRecordV1 {
<<serialized>>
selected_profile
storage_root
runtime_root
image
isolate_gitignored
persistent_dirs
environment_references
volumes
effective_allow_prefixes
effective_deny_prefixes
escalation_enabled
auto_approval_rules
}
class EnvironmentReference {
<<serialized>>
sandbox_name
host_name
}
class ResolvedVolume {
<<serialized>>
canonical_host_source
container_path
kind
access
create
}
class ResolvedAutoApprovalRule {
<<serialized>>
canonical_executable
args
allow_extra_args
}
class ConfinedPathPlan {
<<runtime>>
retained_ancestor
ancestor_device
ancestor_inode
ancestor_mount_id
relative_suffix
canonical_label
}
TrustedHostConfigurationV1 --> ResolvedDomainConfiguration : resolves selected profile
SandboxLayoutV1 --> ResolvedDomainConfiguration : constrains sandbox destinations
ResolvedDomainConfiguration --> ConfigurationFingerprintRecordV1
ConfigurationFingerprintRecordV1 --> EnvironmentReference
ConfigurationFingerprintRecordV1 --> ResolvedVolume
ConfigurationFingerprintRecordV1 --> ResolvedAutoApprovalRule
ResolvedDomainConfiguration --> ConfinedPathPlan
```
`ResolvedDomainConfiguration` is one move-owned, non-serializable value. It is
the only successful output of loading a selected profile and owns all retained
path descriptors, normalized selected-profile data, confined use plans, and
resolved environment values. Resolved environment values have no field in
`ConfigurationFingerprintRecordV1`; they are never serialized, hashed, logged,
or persisted. The parsed source and nonselected profiles do not become runtime
configuration authority.
`SandboxLayoutV1` is an internal, non-serializable product value with no public
unchecked constructor. Configuration resolution cannot succeed without it. Its
production constructor is owned by sandbox-layout construction and supplies the
complete normalized protected-destination set; configuration loading neither
guesses unnamed destinations nor permits a caller to remove `/workspace`. Every
member is a normalized, absolute, non-root sandbox path using the sandbox-path
grammar above; the finite set is nonempty and contains no duplicate member.
`ConfinedPathPlan` owns a retained ancestor directory descriptor and exposes to
consumers only its typed device, inode, and mount identity plus a normalized
relative suffix. `canonical_label` is diagnostic or fingerprint input, not open
authority. Consumers reopen relative to the retained descriptor and revalidate
the intended type and identity constraints. They do not receive an ambient
absolute path with which to repeat authorization.
### Domain and configuration digests
`DomainIdV1` is the 32-byte SHA-256 digest of the following preimage:
```text
"lazaret-domain-id-v1\0"
|| bytes(selected_profile_utf8)
|| bytes(canonical_workspace_path)
```
`ConfigurationFingerprintV1` is the 32-byte SHA-256 digest of the following
canonical record:
```text
"lazaret-configuration-fingerprint-v1\0"
|| bytes(selected_profile_utf8)
|| bytes(canonical_storage_root)
|| bytes(canonical_runtime_root)
|| bytes(image_utf8)
|| boolean(isolate_gitignored)
|| list(persistent_dirs)
|| list(environment_reference_records)
|| list(volume_records)
|| list(effective_allow_prefixes)
|| list(effective_deny_prefixes)
|| boolean(escalation_enabled)
|| list(auto_approval_rule_records)
```
The Domain-ID separator is the exact 20 ASCII bytes
`lazaret-domain-id-v1` followed by one NUL byte. Both variable fields use
`bytes`; concatenating their unframed bytes is not a valid v1 preimage.
`bytes(x)` is `u32be(length(x)) || x`; lengths and list counts must fit in
`u32`. A Boolean is one byte, `0x00` or `0x01`. Each list is
`u32be(item_count)` followed by `bytes(item_encoding)` for every item. Every
listed collection is order-independent and sorts complete item encodings by
unsigned bytewise order before framing, except that arguments inside one
auto-approval record retain their declared order. Strings use UTF-8 bytes and
canonical host paths use raw Linux pathname bytes.
An environment-reference record is `bytes(sandbox_name) || bytes(host_name)`.
A persistent-path item is `bytes(path)`, and an argument item is `bytes(arg)`.
A volume record is `bytes(canonical_host_source) || bytes(container_path) ||
kind || access || boolean(create)`, where file/directory are `0x00`/`0x01` and
read-only/read-write are `0x00`/`0x01`. An IPv4 prefix is its four network-order
address bytes followed by its one-byte prefix length. An auto-approval record is
`bytes(canonical_executable) || list_in_declared_order(args) ||
boolean(allow_extra_args)`. A declared-order argument list uses the same list
framing without sorting.
Source comments, TOML declaration order, defaults versus their explicit equal
values, nonselected profiles, configuration source pathname, host environment
values, retained descriptor numbers, filesystem inode metadata, and executable
contents are excluded. Every resolved selected-profile field above is included.
Neither digest is an authentication credential.
## Domain Store ## Domain Store
```mermaid ```mermaid
classDiagram classDiagram
class DomainStore class DomainStore {
<<serialized>>
}
class StoreMetadata { class StoreMetadata {
<<serialized>>
schema_id schema_id
schema_version schema_version
} }
class Domain { class Domain {
<<serialized>>
domain_id domain_id
profile_name profile_name
canonical_workspace canonical_workspace
} }
class Generation { class Generation {
<<serialized>>
domain_id domain_id
generation_id generation_id
configuration_fingerprint configuration_fingerprint
@ -35,6 +305,7 @@ classDiagram
} }
class Participant { class Participant {
<<serialized>>
generation_id generation_id
role role
state state
@ -42,6 +313,7 @@ classDiagram
} }
class ParticipantState { class ParticipantState {
<<serialized>>
<<enumeration>> <<enumeration>>
Intent Intent
LeaseHeld LeaseHeld
@ -52,6 +324,7 @@ classDiagram
} }
class ParticipantOwnership { class ParticipantOwnership {
<<serialized>>
<<union>> <<union>>
PreparedLower PreparedLower
StateAnchor StateAnchor
@ -63,6 +336,7 @@ classDiagram
} }
class Session { class Session {
<<serialized>>
generation_id generation_id
session_id session_id
cli_id cli_id
@ -71,6 +345,7 @@ classDiagram
} }
class SessionTerminal { class SessionTerminal {
<<serialized>>
generation_id generation_id
session_id session_id
state state
@ -78,6 +353,7 @@ classDiagram
} }
class SessionTerminalState { class SessionTerminalState {
<<serialized>>
<<enumeration>> <<enumeration>>
Intent Intent
Running Running
@ -86,6 +362,7 @@ classDiagram
} }
class Mailbox { class Mailbox {
<<serialized>>
generation_id generation_id
identity_path identity_path
inbox_path inbox_path
@ -118,28 +395,33 @@ Storage Catalog remains `lazaret-storage-catalog-v1` with version `1`.
```mermaid ```mermaid
classDiagram classDiagram
class PidfsProcessAuthorityV1 { class PidfsProcessAuthorityV1 {
<<serialized>>
boot_id boot_id
pidfs_st_dev pidfs_st_dev
pidfs_st_ino pidfs_st_ino
} }
class PidfsProcessIdentityV1 { class PidfsProcessIdentityV1 {
<<serialized>>
host_pid host_pid
start_time_ticks start_time_ticks
} }
class LegacyUnverifiedProcessIdentityV1 { class LegacyUnverifiedProcessIdentityV1 {
<<serialized>>
host_pid host_pid
start_time_ticks start_time_ticks
} }
class HostProcessIdentity { class HostProcessIdentity {
<<serialized>>
<<union>> <<union>>
PidfsV1 PidfsV1
LegacyUnverifiedV1 LegacyUnverifiedV1
} }
class PersistedHostProcessOwnership { class PersistedHostProcessOwnership {
<<serialized>>
<<union>> <<union>>
Unassigned Unassigned
Staged Staged
@ -147,6 +429,7 @@ classDiagram
} }
class HostProcessOwnershipSlot { class HostProcessOwnershipSlot {
<<serialized>>
<<enumeration>> <<enumeration>>
SandboxHostCoordinator SandboxHostCoordinator
SandboxRuntimeSupervisor SandboxRuntimeSupervisor
@ -162,11 +445,13 @@ classDiagram
} }
class ProcessOwnershipMessageVersion { class ProcessOwnershipMessageVersion {
<<serialized>>
<<enumeration>> <<enumeration>>
V1 V1
} }
class StagedProcessOwnership { class StagedProcessOwnership {
<<serialized>>
version version
domain_id domain_id
generation_id generation_id
@ -176,6 +461,7 @@ classDiagram
} }
class ProcessOwnershipCommitAck { class ProcessOwnershipCommitAck {
<<serialized>>
version version
committed committed
} }
@ -229,6 +515,7 @@ the durable boundary; they do not select the authentication or transport codec.
```mermaid ```mermaid
classDiagram classDiagram
class SessionTerminal { class SessionTerminal {
<<serialized>>
generation_id generation_id
session_id session_id
state state
@ -236,12 +523,14 @@ classDiagram
} }
class PtyIdentity { class PtyIdentity {
<<serialized>>
master_fd master_fd
device device
inode inode
} }
class GuestProcessIdentity { class GuestProcessIdentity {
<<serialized>>
leader_pid leader_pid
leader_start_time leader_start_time
process_group_id process_group_id
@ -275,15 +564,18 @@ proven.
```mermaid ```mermaid
classDiagram classDiagram
class SessionTerminalEvent { class SessionTerminalEvent {
<<serialized>>
<<union>> <<union>>
} }
class TerminalInput { class TerminalInput {
<<serialized>>
session_id session_id
bytes bytes
} }
class TerminalResize { class TerminalResize {
<<serialized>>
session_id session_id
rows rows
columns columns
@ -292,11 +584,13 @@ classDiagram
} }
class TerminalSignal { class TerminalSignal {
<<serialized>>
session_id session_id
signal signal
} }
class TerminalSignalKind { class TerminalSignalKind {
<<serialized>>
<<enumeration>> <<enumeration>>
Interrupt Interrupt
Quit Quit
@ -304,25 +598,30 @@ classDiagram
} }
class SessionClose { class SessionClose {
<<serialized>>
session_id session_id
} }
class SessionTerminalMessage { class SessionTerminalMessage {
<<serialized>>
<<union>> <<union>>
} }
class TerminalOutput { class TerminalOutput {
<<serialized>>
session_id session_id
bytes bytes
} }
class SessionExit { class SessionExit {
<<serialized>>
session_id session_id
outcome outcome
exit_code exit_code
} }
class SessionExitOutcome { class SessionExitOutcome {
<<serialized>>
<<enumeration>> <<enumeration>>
Exited Exited
Terminated Terminated
@ -348,6 +647,7 @@ it does not preserve stdout and stderr as separate channels.
```mermaid ```mermaid
classDiagram classDiagram
class GenerationIdentity { class GenerationIdentity {
<<serialized>>
agent_profile agent_profile
domain_id domain_id
generation_id generation_id
@ -357,6 +657,7 @@ classDiagram
} }
class TrustManifest { class TrustManifest {
<<serialized>>
protocol_version protocol_version
generation_identity generation_identity
tls_ca_sha256 tls_ca_sha256
@ -365,6 +666,7 @@ classDiagram
} }
class TlsCredential { class TlsCredential {
<<serialized>>
generation_identity generation_identity
role role
subject_id subject_id
@ -373,6 +675,7 @@ classDiagram
} }
class ProtocolPublicationIdentity { class ProtocolPublicationIdentity {
<<serialized>>
protocol_version protocol_version
generation_identity generation_identity
tls_ca_certificate_der tls_ca_certificate_der
@ -381,6 +684,7 @@ classDiagram
} }
class SandboxCredentialBundle { class SandboxCredentialBundle {
<<serialized>>
protocol_version protocol_version
generation_identity generation_identity
trust_manifest trust_manifest
@ -390,12 +694,14 @@ classDiagram
} }
class TlsRole { class TlsRole {
<<serialized>>
<<enumeration>> <<enumeration>>
SessionCoordinator SessionCoordinator
ClientCLI ClientCLI
} }
class ProtocolCredential { class ProtocolCredential {
<<serialized>>
protocol_version protocol_version
credential_id credential_id
generation_identity generation_identity
@ -406,12 +712,14 @@ classDiagram
} }
class ProtocolCredentialRole { class ProtocolCredentialRole {
<<serialized>>
<<enumeration>> <<enumeration>>
SessionCoordinator SessionCoordinator
AgentSandbox AgentSandbox
} }
class SessionBinding { class SessionBinding {
<<serialized>>
generation_identity generation_identity
cli_id cli_id
session_id session_id
@ -419,6 +727,7 @@ classDiagram
} }
class SessionRequestCapability { class SessionRequestCapability {
<<serialized>>
protocol_version protocol_version
capability_id capability_id
session_binding session_binding
@ -451,12 +760,14 @@ protocol material, not Domain Store records.
```mermaid ```mermaid
classDiagram classDiagram
class SignedProtocolRecord { class SignedProtocolRecord {
<<serialized>>
<<union>> <<union>>
payload_bytes payload_bytes
signature signature
} }
class MailboxRequest { class MailboxRequest {
<<serialized>>
protocol_version protocol_version
session_binding session_binding
request_id request_id
@ -467,6 +778,7 @@ classDiagram
} }
class MailboxCancellation { class MailboxCancellation {
<<serialized>>
protocol_version protocol_version
session_binding session_binding
request_id request_id
@ -475,6 +787,7 @@ classDiagram
} }
class MailboxResultAck { class MailboxResultAck {
<<serialized>>
protocol_version protocol_version
session_binding session_binding
request_id request_id
@ -485,6 +798,7 @@ classDiagram
} }
class DeveloperDecision { class DeveloperDecision {
<<serialized>>
protocol_version protocol_version
session_binding session_binding
request_id request_id
@ -494,12 +808,14 @@ classDiagram
} }
class DeveloperDecisionKind { class DeveloperDecisionKind {
<<serialized>>
<<enumeration>> <<enumeration>>
Approve Approve
Deny Deny
} }
class MailboxResult { class MailboxResult {
<<serialized>>
protocol_version protocol_version
session_binding session_binding
request_id request_id
@ -513,6 +829,7 @@ classDiagram
} }
class MailboxResultChunk { class MailboxResultChunk {
<<serialized>>
protocol_version protocol_version
session_binding session_binding
request_id request_id
@ -524,12 +841,14 @@ classDiagram
} }
class ResultStream { class ResultStream {
<<serialized>>
<<enumeration>> <<enumeration>>
Stdout Stdout
Stderr Stderr
} }
class EscalationOutcome { class EscalationOutcome {
<<serialized>>
<<enumeration>> <<enumeration>>
Completed Completed
Denied Denied
@ -567,21 +886,25 @@ encoding, signing, size, and validation rules are defined by the specification.
```mermaid ```mermaid
classDiagram classDiagram
class ParticipantOwnership { class ParticipantOwnership {
<<serialized>>
<<union>> <<union>>
} }
class NamespaceIdentity { class NamespaceIdentity {
<<serialized>>
device device
inode inode
} }
class MountIdentity { class MountIdentity {
<<serialized>>
mount_id mount_id
device device
inode inode
} }
class PreparedLowerOwnership { class PreparedLowerOwnership {
<<serialized>>
optional prepared_artifact_id optional prepared_artifact_id
optional source_identity optional source_identity
optional lease_state optional lease_state
@ -591,6 +914,7 @@ classDiagram
} }
class StateAnchorOwnership { class StateAnchorOwnership {
<<serialized>>
optional lower_artifact_id optional lower_artifact_id
optional upper_path optional upper_path
optional work_path optional work_path
@ -600,6 +924,7 @@ classDiagram
} }
class GitRoutingOwnership { class GitRoutingOwnership {
<<serialized>>
optional mountpoint optional mountpoint
optional host_root optional host_root
optional isolated_root optional isolated_root
@ -608,6 +933,7 @@ classDiagram
} }
class NetworkOwnership { class NetworkOwnership {
<<serialized>>
optional derived_policy_digest optional derived_policy_digest
optional readiness_fingerprint optional readiness_fingerprint
optional readiness_evidence optional readiness_evidence
@ -616,12 +942,14 @@ classDiagram
} }
class HelperOwnership { class HelperOwnership {
<<serialized>>
optional network_sidecar_id optional network_sidecar_id
connectivity_helper connectivity_helper
runtime_supervisor runtime_supervisor
} }
class SandboxOwnership { class SandboxOwnership {
<<serialized>>
optional container_id optional container_id
optional runtime_root optional runtime_root
optional bundle_digest optional bundle_digest
@ -631,6 +959,7 @@ classDiagram
} }
class PublicationOwnership { class PublicationOwnership {
<<serialized>>
optional socket_path optional socket_path
optional identity_path optional identity_path
} }
@ -702,14 +1031,18 @@ lease from an absent lease phase.
```mermaid ```mermaid
classDiagram classDiagram
class StorageCatalog class StorageCatalog {
<<serialized>>
}
class StoreMetadata { class StoreMetadata {
<<serialized>>
schema_id schema_id
schema_version schema_version
} }
class PreparedArtifact { class PreparedArtifact {
<<serialized>>
prepared_artifact_id prepared_artifact_id
source_identity source_identity
selected_manifest_digest selected_manifest_digest
@ -722,18 +1055,21 @@ classDiagram
} }
class ArtifactState { class ArtifactState {
<<serialized>>
<<enumeration>> <<enumeration>>
Available Available
Reclaiming Reclaiming
} }
class ArtifactLease { class ArtifactLease {
<<serialized>>
prepared_artifact_id prepared_artifact_id
domain_id domain_id
generation_id generation_id
} }
class PrepareOperation { class PrepareOperation {
<<serialized>>
operation_id operation_id
expected_artifact_id expected_artifact_id
source_identity source_identity
@ -767,6 +1103,7 @@ remains until bootstrap Host Coordinator recovery records release intent.
```mermaid ```mermaid
classDiagram classDiagram
class DerivedNetworkPolicy { class DerivedNetworkPolicy {
<<serialized>>
schema_id schema_id
address_families address_families
default_actions default_actions
@ -775,33 +1112,39 @@ classDiagram
} }
class AddressFamilyDisposition { class AddressFamilyDisposition {
<<serialized>>
family family
disposition disposition
} }
class DefaultAction { class DefaultAction {
<<serialized>>
family family
action action
} }
class PrefixRule { class PrefixRule {
<<serialized>>
family family
network network
action action
} }
class Resolver { class Resolver {
<<serialized>>
family family
address address
} }
class AddressFamily { class AddressFamily {
<<serialized>>
<<enumeration>> <<enumeration>>
IPv4 IPv4
IPv6 IPv6
} }
class PolicyAction { class PolicyAction {
<<serialized>>
<<enumeration>> <<enumeration>>
Allow Allow
Deny Deny
@ -829,6 +1172,7 @@ of its canonical form with a schema-specific domain separator.
```mermaid ```mermaid
classDiagram classDiagram
class NetworkReadinessEvidence { class NetworkReadinessEvidence {
<<serialized>>
domain_id domain_id
generation_id generation_id
network_sidecar_id network_sidecar_id
@ -836,6 +1180,7 @@ classDiagram
} }
class NetworkReadinessAttestation { class NetworkReadinessAttestation {
<<serialized>>
schema_id schema_id
domain_id domain_id
generation_id generation_id
@ -849,6 +1194,7 @@ classDiagram
} }
class NetworkObservation { class NetworkObservation {
<<serialized>>
policy_delivery policy_delivery
helper_ready helper_ready
observed_routes observed_routes
@ -856,11 +1202,13 @@ classDiagram
} }
class PolicyDelivery { class PolicyDelivery {
<<serialized>>
<<enumeration>> <<enumeration>>
SealedMemfd SealedMemfd
} }
class NetworkReadinessIdentity { class NetworkReadinessIdentity {
<<serialized>>
schema_id schema_id
domain_id domain_id
generation_id generation_id
@ -871,27 +1219,32 @@ classDiagram
} }
class NamespaceIdentity { class NamespaceIdentity {
<<serialized>>
device device
inode inode
} }
class NetworkReachabilityProbe { class NetworkReachabilityProbe {
<<serialized>>
domain_id domain_id
generation_id generation_id
enforcement_namespace enforcement_namespace
} }
class DeniedDestinationObservation { class DeniedDestinationObservation {
<<serialized>>
destination destination
unreachable unreachable
} }
class PermittedDestinationObservation { class PermittedDestinationObservation {
<<serialized>>
destination destination
reachable reachable
} }
class DnsObservation { class DnsObservation {
<<serialized>>
resolver resolver
query_name query_name
resolved_addresses resolved_addresses

View file

@ -63,9 +63,10 @@ workspace paths remain writable, while Git-ignored paths are isolated into
Domain-scoped sandbox state by default. The developer may explicitly disable that Domain-scoped sandbox state by default. The developer may explicitly disable that
isolation in trusted host configuration. isolation in trusted host configuration.
The physical refinement of model configuration confinement canonicalizes the The trusted-host configuration refinement below owns strict parsing,
configuration file and every bind-mount source, compares their resolved ancestry, canonicalization, path authority, and configuration fingerprinting. Successful
and omits configuration bytes from the sandbox mount specification. loading is the physical meaning of the model's `ConfigSafe`; configuration bytes
never enter a sandbox mount specification.
## 2. Glossary ## 2. Glossary
@ -178,7 +179,154 @@ Runtime paths and durable rows carry Domain ID, generation, configuration
fingerprint, role identity, and physical ownership fields. Bootstrap recovery is fingerprint, role identity, and physical ownership fields. Bootstrap recovery is
initiated by a host Lazaret CLI invocation, not by a resident scheduler. initiated by a host Lazaret CLI invocation, not by a resident scheduler.
### 3.2 Domain and Session Lifecycle Refinement ### 3.2 Trusted Host Configuration Refinement
The trusted-host configuration has the closed v1 shape in the
[design schemas](schema.md). Lazaret chooses its configuration file from an
explicit host CLI option or, when absent, from
`$XDG_CONFIG_HOME/lazaret/config.toml`, with
`$HOME/.config/lazaret/config.toml` as the XDG fallback. It derives the Storage
Root from `storage.data_root` when present, otherwise from
`$XDG_DATA_HOME/lazaret`, with `$HOME/.local/share/lazaret` as the fallback. The
Runtime Root is `$XDG_RUNTIME_DIR/lazaret`; v1 has no configuration field that
can redirect runtime control state elsewhere. Required XDG and home inputs must
be NUL-free host paths. An unset, empty, or relative XDG config or data value is
treated as unset and uses its home fallback; the home path and XDG runtime path
must be present and absolute. An invalid fallback or runtime value refuses
loading.
The caller supplies one profile name explicitly. Lazaret never selects the
first profile, infers a profile from an image or executable, or falls back to a
different profile. The canonical Agent Workspace is the existing directory
selected by the caller, normally the current working directory. Lazaret opens
it as a directory, obtains its canonical raw pathname and filesystem identity,
and retains the descriptor. The selected profile name and canonical workspace
bytes produce `DomainIdV1` exactly as defined by the schema.
The explicit configuration path follows the schema's strict host-path grammar.
The loader resolves any declared symlink once to a canonical target and builds
a no-symlink confined plan to that target. The source is limited to 1,048,576
bytes, must be a regular file owned by root or the invoking user with exactly
one hard link, and must not grant group or other write permission. Lazaret first
opens it
with an O_PATH-style descriptor so validating the file type cannot activate a
special file, reopens that exact descriptor for nonblocking read, and compares
their identities. It records size and nanosecond change time, reads exactly the
recorded size into owned bytes, refuses trailing growth or a short read, reads
the same range a second time, and requires equal bytes and unchanged identity,
size, and change time before and after both reads. The loader never retries a
changed source. It then performs recursive strict TOML decoding and v1 dispatch.
No parser default or permissive flattening may make an unknown, duplicate,
aliased, or unknown-version field meaningful. Every profile is structurally
decoded, but filesystem and environment resolution applies only to the
explicitly selected profile.
Path resolution produces a `ConfinedPathPlan`, not an ambient absolute path.
Each plan retains a validated ancestor directory descriptor and a normalized
relative suffix, records the ancestor's device, inode, and mount identity, and
keeps canonical raw pathname bytes only as a diagnostic label or digest input.
Every consumer uses descriptor-relative opens with beneath, no-magic-link,
no-symlink, and no-mount-crossing resolution and revalidates the expected type.
The consumer refuses a replaced ancestor, mount transition, symlink, special
file, type mismatch, or any source that cannot be revalidated; it does not
recover by opening the canonical label as an absolute pathname.
Lazaret creates a missing Storage, Runtime, profile, or Domain root one path
component at a time beneath its retained existing ancestor. Every created
component has mode `0700`. Creation refuses symlinks, magic links, and child
mounts, and reopens each new component before proceeding. The selected root, if
it already exists, and every component Lazaret creates beneath the closest
existing ambient ancestor are Lazaret-owned: each must be a directory owned by
the invoking user with mode exactly `0700`. Ambient ancestors above that
ownership boundary use the same descriptor-relative confinement but need not
use Lazaret's ownership or mode; an ordinary `0755` home-directory ancestor
therefore does not by itself refuse loading. A creation or validation failure is
final for that load and never authorizes a partially resolved root.
The profile directory is the `profiles/<profile-name>` child of the Storage
Root. Persistent and runtime Domain roots use the lowercase hexadecimal Domain
ID beneath separate `domains` children of the Storage and Runtime Roots. Those
fixed layouts are constructed only from validated root handles; a path string
or matching digest alone is not authority over a root.
Configuration confinement compares canonical component ancestry and retained
filesystem identities after every source has been opened. The configuration
file, Agent Workspace, Storage Root, Runtime Root, and their Domain/control
trees may not overlap in either ancestor direction. Volume sources may be
strict descendants of the profile directory, but they may not overlap the
configuration source, Agent Workspace, runtime or Domain control trees, or one
another. Other volume sources must be disjoint from all Lazaret data and control
trees as well. All paths are reopened through their plans after the comparisons;
any identity or mount change refuses the complete load. Volume creation is
limited to a declared directory strictly beneath the profile directory and uses
the same component-wise `0700` creation rules.
Configuration resolution also requires an internal `SandboxLayoutV1`. Its
closed protected-destination set contains `/workspace` and every product-owned
sandbox mount destination selected by production layout construction. Each
persistent or volume destination is compared with every member in both ancestry
directions. The configuration loader does not guess paths for control,
credential, mailbox, proc, sys, dev, runtime, or later product mounts, and a
layout cannot be constructed without `/workspace`. Production layout
construction, rather than trusted-host configuration syntax, owns the remaining
exact destination choices.
The loader resolves environment references only after structural, path, and
network validation. A missing, non-UTF-8, or NUL-containing host value refuses
the selected profile. Resolved values live only in the move-owned
`ResolvedDomainConfiguration`; its representation is not serializable and its
diagnostics redact values. Only reference names enter the configuration
fingerprint. Configuration bytes, nonselected profiles, and unreferenced host
environment values are not retained as runtime authority or copied into the
Agent Sandbox.
The Host Coordinator derives the selected profile's effective IPv4 policy and
`ConfigurationFingerprintV1` using the schema's exact normalization and
canonical framing. IPv6 is disabled for a v1 sandbox. Joining a running Domain
requires the same Domain ID and configuration fingerprint; the modeled
configuration-mismatch path refuses every other value.
For auto-approval, the configured executable resolves to one canonical path
outside every sandbox-writable or Lazaret control tree. That path, rather than a
`PATH` lookup or basename, is fingerprinted and matched. On every approved
execution the Host Coordinator reopens the current object through its confined
plan, requires a regular file with exactly one hard link, root or invoking-user
ownership, no group or other write permission, and an executable mode for the
invoking user, and passes that open descriptor into the causal launch wrapper.
The same checks apply when configuration is loaded. The wrapper
executes `/proc/self/fd/<inherited-descriptor>` while retaining that descriptor,
so pathname replacement after validation cannot select another object. There is
no shell, numeric descriptor supplied by the requester, ambient-path reopen, or
fallback execution path. Scripts are subject to the kernel's ordinary shebang
handling; the rule authorizes the exact opened script object, not an arbitrary
requester-selected interpreter.
`MailboxRequest.command_argv` is nonempty. Every element is UTF-8 and NUL-free;
element zero is an absolute path byte-equal to the rule's canonical executable,
and elements one onward are matched against the rule arguments. Lazaret performs
no `PATH` lookup. The v1 structured request language accepts no command-string
or shell-expansion form; an unsupported form has the physical
`CommandRequiresShell` classification and cannot auto-approve. The launcher
never inserts or interprets a shell. An explicitly configured `/bin/sh -c ...`
vector can match only its exact configured rule. A directly configured script
continues to use ordinary kernel shebang handling for the same opened script
object.
Any shell-requiring request form, invalid executable, post-load path conflict,
or argument mismatch follows the modeled developer approval path or is refused;
it is never widened into automatic approval. Requiring one hard link rejects
hardlinked package layouts, including affected Nix-style binaries, in v1. This
is a deliberate conservative compatibility boundary. Relaxing it requires a
new executable-alias authority policy in a later version; v1 has no exception or
fallback.
`ResolvedDomainConfiguration` is created before a generation, durable intent,
or external runtime resource. Every production configuration consumer receives
that same owned resolved value or a capability-bounded borrow from it; consumers
do not reparse the TOML document or independently repeat default, path, network,
environment, fingerprint, or approval decisions.
### 3.3 Domain and Session Lifecycle Refinement
The Host Coordinator implements Domain ownership with a non-blocking host The Host Coordinator implements Domain ownership with a non-blocking host
`flock`, Domain-scoped runtime paths, an exclusive Domain Store handle, and `flock`, Domain-scoped runtime paths, an exclusive Domain Store handle, and
@ -375,7 +523,7 @@ withdraws publication before dropping keys and credential artifacts. HC loss
destroys the in-memory package; bootstrap never restores it into another destroys the in-memory package; bootstrap never restores it into another
generation. generation.
### 3.3 Session Terminals ### 3.4 Session Terminals
The Host Coordinator owns one host PTY master per interactive Client Session. The The Host Coordinator owns one host PTY master per interactive Client Session. The
PTY slave is attached to non-detached `runsc exec` standard streams with a guest PTY slave is attached to non-detached `runsc exec` standard streams with a guest
@ -491,7 +639,7 @@ after that runtime-absence check. A process, container, PTY, or generation
mismatch fails closed and retains the record; it is never adopted or signaled as mismatch fails closed and retains the record; it is never adopted or signaled as
the recorded session. the recorded session.
### 3.4 Filesystem and Git Model ### 3.5 Filesystem and Git Model
The Agent Workspace is mounted read-write. Tracked paths and untracked paths that The Agent Workspace is mounted read-write. Tracked paths and untracked paths that
are not ignored propagate in both directions. The Git index, `HEAD`, references, are not ignored propagate in both directions. The Git index, `HEAD`, references,
@ -523,7 +671,7 @@ These protections do not make the workspace immutable. Their purpose is to stop
the sandbox from writing the protected host Git configuration and default hook the sandbox from writing the protected host Git configuration and default hook
directory while preserving normal Git operations and bidirectional source edits. directory while preserving normal Git operations and bidirectional source edits.
### 3.5 Persistent Sandbox State ### 3.6 Persistent Sandbox State
The State Anchor preserves developer tooling and selected sandbox system paths for The State Anchor preserves developer tooling and selected sandbox system paths for
one Domain. It combines a Lazaret-owned prepared lower from the selected base one Domain. It combines a Lazaret-owned prepared lower from the selected base
@ -654,7 +802,7 @@ allowing unmodified base-image content to follow the selected image.
Persistent state is sandbox state, not host configuration. It must not contain or Persistent state is sandbox state, not host configuration. It must not contain or
override Lazaret's host control plane. override Lazaret's host control plane.
### 3.6 Network Isolation ### 3.7 Network Isolation
The Network Sidecar role is realized on the Runtime Supervisor-owned install The Network Sidecar role is realized on the Runtime Supervisor-owned install
path. The Runtime Supervisor holds network-administration capability while it path. The Runtime Supervisor holds network-administration capability while it
@ -712,7 +860,7 @@ The OCI configuration joins only the Domain enforcement network namespace, and
`slirp4netns` attached to that namespace is the sole external egress mechanism. `slirp4netns` attached to that namespace is the sole external egress mechanism.
Dynamic policy changes are outside the current physical refinement. Dynamic policy changes are outside the current physical refinement.
### 3.7 Host Escalation ### 3.8 Host Escalation
The Escalation Client exchanges request and result records through the The Escalation Client exchanges request and result records through the
Escalation Mailbox: regular files written by atomic rename beneath host-private, Escalation Mailbox: regular files written by atomic rename beneath host-private,
@ -748,7 +896,7 @@ Escalation authentication, correlation, approval, execution, cancellation, resul
delivery, and terminal routing are defined by the delivery, and terminal routing are defined by the
[TLA+ application protocol model](protocol/Lazaret.tla). [TLA+ application protocol model](protocol/Lazaret.tla).
### 3.8 State Inspection and Reset ### 3.9 State Inspection and Reset
The host CLI provides read-only discovery, inspection, structural verification, The host CLI provides read-only discovery, inspection, structural verification,
and explicit reset operations for Domain State Anchors. These operations resolve and explicit reset operations for Domain State Anchors. These operations resolve

View file

@ -349,6 +349,7 @@ def audit_dependencies(metadata: dict) -> None:
def license_permitted(expression: str, allowed: set[str]) -> bool: def license_permitted(expression: str, allowed: set[str]) -> bool:
expression = re.sub(r"\s+WITH\s+[A-Za-z0-9.-]+", "", expression)
return bool(expression) and any( return bool(expression) and any(
all( all(
term.strip(" ()") in allowed term.strip(" ()") in allowed