Integrate trusted-host configuration
# Conflicts: # Cargo.lock # Cargo.toml # tools/lazaret_verify.py
This commit is contained in:
commit
0716bab960
12 changed files with 4099 additions and 142 deletions
162
Cargo.lock
generated
162
Cargo.lock
generated
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
1992
crates/lazaret-core/src/configuration.rs
Normal file
1992
crates/lazaret-core/src/configuration.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
];
|
||||||
|
|
|
||||||
735
crates/lazaret-core/tests/configuration.rs
Normal file
735
crates/lazaret-core/tests/configuration.rs
Normal 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)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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" }
|
|
||||||
]
|
|
||||||
|
|
|
||||||
369
docs/schema.md
369
docs/schema.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue