Integrate trusted-host configuration

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

@ -1,3 +1,4 @@
use std::collections::BTreeMap;
use std::fmt;
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 LEGACY_DOMAIN_STORE_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)]
pub struct StoreMetadata {
@ -1493,15 +1687,138 @@ pub struct SignedProtocolRecord {
pub signature: Vec<u8>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct MailboxRequest {
pub protocol_version: ProtocolVersion,
pub session_binding: SessionBinding,
pub request_id: RequestId,
pub session_capability: SessionRequestCapability,
pub sandbox_credential_id: ProtocolCredentialId,
pub command_argv: Vec<Vec<u8>>,
pub justification: String,
protocol_version: ProtocolVersion,
session_binding: SessionBinding,
request_id: RequestId,
session_capability: SessionRequestCapability,
sandbox_credential_id: ProtocolCredentialId,
command_argv: Vec<Vec<u8>>,
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)]
@ -2605,14 +2922,18 @@ pub trait SchemaType {
}
macro_rules! schema_types {
($($name:literal => $record:ty),+ $(,)?) => {
($($name:literal => $record:ty),+; $($declared:literal),* $(,)?) => {
$(
impl SchemaType for $record {
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,
"PermittedDestinationObservation" => PermittedDestinationObservation,
"DnsObservation" => DnsObservation,
"TrustedHostConfigurationV1" => TrustedHostConfigurationV1,
"ConfigurationFingerprintRecordV1" => ConfigurationFingerprintRecordV1,
"EnvironmentReference" => EnvironmentReference,
"ResolvedVolume" => ResolvedVolume,
"ResolvedAutoApprovalRule" => ResolvedAutoApprovalRule,
"DomainStore" => DomainStore,
"StorageCatalog" => StorageCatalog,
"StorageCatalog" => StorageCatalog;
);
pub const DECLARED_RUNTIME_SCHEMA_TYPE_NAMES: &[&str] = &[
"ResolvedDomainConfiguration",
"SandboxLayoutV1",
"ConfinedPathPlan",
];

View file

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

View file

@ -1,8 +1,9 @@
use std::collections::HashSet;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::fmt::Debug;
use std::num::NonZeroU16;
use lazaret_core::codec::{CodecError, MAX_SCHEMA_RECORD_BYTES, decode, encode};
use lazaret_core::configuration::parse_trusted_configuration;
use lazaret_core::identity::{
BootId, BundleDigest, CapabilityId, CertificateDigest, ClientId, ConfigurationFingerprint,
ContainerId, CoordinatorId, DerivedPolicyDigest, DeviceId, DomainId, FileDescriptor,
@ -148,6 +149,25 @@ struct RawNetworkReadinessIdentity {
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] {
[byte; 32]
}
@ -657,15 +677,15 @@ fn every_schema_shape_round_trips_and_rejects_malformed_or_unknown_versions() {
vec![39, 40],
CertificateDigest::new(opaque(41)),
);
let request = MailboxRequest {
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: vec![b"printf".to_vec(), vec![0xff]],
justification: "build dependency".into(),
};
let request = MailboxRequest::new(
session_binding(),
RequestId::new(opaque(42)),
session_capability(),
ProtocolCredentialId::new(opaque(17)),
vec![b"/usr/bin/printf".to_vec(), b"value".to_vec()],
"build dependency".into(),
)
.unwrap();
let prepared_artifact = PreparedArtifact {
prepared_artifact_id: PreparedArtifactId::new(opaque(24)),
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_sha256: RequestDigest::new(opaque(59)),
decision: DeveloperDecisionKind::Approve,
approved_command_argv: request.command_argv.clone(),
approved_command_argv: request.command_argv().to_vec(),
},
&mut seen,
);
@ -1054,24 +1074,229 @@ fn every_schema_shape_round_trips_and_rejects_malformed_or_unknown_versions() {
},
&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!(ALL_SCHEMA_TYPE_NAMES.iter().all(|name| seen.contains(name)));
assert_eq!(seen.len(), IMPLEMENTED_SERIALIZED_SCHEMA_TYPE_NAMES.len());
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]
fn shape_registry_matches_the_normative_schema_classes() {
let documented = include_str!("../../../docs/schema.md")
.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
fn schema_categories_match_exact_disjoint_registries() {
let serialized = DECLARED_SERIALIZED_SCHEMA_TYPE_NAMES
.iter()
.copied()
.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)
@ -1156,27 +1381,27 @@ fn unknown_enums_and_protocol_versions_are_rejected() {
));
assert_unknown_protocol_version(&session_capability());
let request = MailboxRequest {
protocol_version: ProtocolVersion::V1,
session_binding: session_binding(),
request_id: RequestId::new(opaque(5)),
session_capability: session_capability(),
sandbox_credential_id: ProtocolCredentialId::new(opaque(6)),
command_argv: vec![b"true".to_vec()],
justification: "test".into(),
};
let request = MailboxRequest::new(
session_binding(),
RequestId::new(opaque(5)),
session_capability(),
ProtocolCredentialId::new(opaque(6)),
vec![b"/usr/bin/true".to_vec()],
"test".into(),
)
.unwrap();
assert_unknown_protocol_version(&request);
assert_unknown_protocol_version(&MailboxCancellation {
protocol_version: ProtocolVersion::V1,
session_binding: session_binding(),
request_id: request.request_id,
request_id: request.request_id(),
session_capability: session_capability(),
sandbox_credential_id: ProtocolCredentialId::new(opaque(6)),
});
assert_unknown_protocol_version(&MailboxResultAck {
protocol_version: ProtocolVersion::V1,
session_binding: session_binding(),
request_id: request.request_id,
request_id: request.request_id(),
request_sha256: RequestDigest::new(opaque(7)),
session_capability: session_capability(),
sandbox_credential_id: ProtocolCredentialId::new(opaque(6)),
@ -1185,7 +1410,7 @@ fn unknown_enums_and_protocol_versions_are_rejected() {
assert_unknown_protocol_version(&DeveloperDecision {
protocol_version: ProtocolVersion::V1,
session_binding: session_binding(),
request_id: request.request_id,
request_id: request.request_id(),
request_sha256: RequestDigest::new(opaque(7)),
decision: DeveloperDecisionKind::Deny,
approved_command_argv: vec![],
@ -1193,7 +1418,7 @@ fn unknown_enums_and_protocol_versions_are_rejected() {
assert_unknown_protocol_version(&MailboxResult {
protocol_version: ProtocolVersion::V1,
session_binding: session_binding(),
request_id: request.request_id,
request_id: request.request_id(),
request_sha256: RequestDigest::new(opaque(7)),
coordinator_credential_id: ProtocolCredentialId::new(opaque(8)),
outcome: EscalationOutcome::Denied,
@ -1205,7 +1430,7 @@ fn unknown_enums_and_protocol_versions_are_rejected() {
assert_unknown_protocol_version(&MailboxResultChunk {
protocol_version: ProtocolVersion::V1,
session_binding: session_binding(),
request_id: request.request_id,
request_id: request.request_id(),
request_sha256: RequestDigest::new(opaque(7)),
coordinator_credential_id: ProtocolCredentialId::new(opaque(8)),
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]
fn zero_terminal_dimensions_are_rejected() {
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]
fn session_exit_code_is_accepted_only_for_exited() {
let encoded = encode(&InvalidSessionExit {