2026-07-15 00:43:42 +09:00
|
|
|
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,
|
2026-07-16 20:41:13 +09:00
|
|
|
ResolvedDnsProbeTargetV1, ResolvedNetworkProbeTargetV1, ResolvedNetworkReadinessV1,
|
2026-07-15 00:43:42 +09:00
|
|
|
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 }},
|
|
|
|
|
]
|
|
|
|
|
|
2026-07-16 20:41:13 +09:00
|
|
|
[profiles.default.readiness]
|
|
|
|
|
denied = [{{ address = "10.2.3.4", port = 443 }}]
|
|
|
|
|
permitted = [{{ address = "192.0.2.4", port = 443 }}]
|
|
|
|
|
dns = [{{ resolver = "198.51.100.53", query = "example.invalid" }}]
|
|
|
|
|
|
2026-07-15 00:43:42 +09:00
|
|
|
[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()
|
2026-07-15 02:49:39 +09:00
|
|
|
.any(|prefix| prefix.address() == [192, 0, 2, 53] && prefix.prefix_length() == 32)
|
2026-07-15 00:43:42 +09:00
|
|
|
);
|
|
|
|
|
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");
|
2026-07-15 02:49:39 +09:00
|
|
|
assert!(
|
|
|
|
|
resolved
|
|
|
|
|
.effective_allow_prefixes()
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|prefix| prefix.address() == [192, 0, 2, 0] && prefix.prefix_length() == 24)
|
2026-07-15 00:43:42 +09:00
|
|
|
);
|
|
|
|
|
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)],
|
2026-07-16 20:41:13 +09:00
|
|
|
network_readiness: ResolvedNetworkReadinessV1 {
|
|
|
|
|
denied: vec![ResolvedNetworkProbeTargetV1 {
|
|
|
|
|
address: [10, 0, 0, 1],
|
|
|
|
|
port: 9,
|
|
|
|
|
}],
|
|
|
|
|
permitted: vec![ResolvedNetworkProbeTargetV1 {
|
|
|
|
|
address: [1, 1, 1, 1],
|
|
|
|
|
port: 443,
|
|
|
|
|
}],
|
|
|
|
|
dns: vec![ResolvedDnsProbeTargetV1 {
|
|
|
|
|
resolver: [8, 8, 8, 8],
|
|
|
|
|
query: "example.invalid".into(),
|
|
|
|
|
}],
|
|
|
|
|
},
|
2026-07-15 00:43:42 +09:00
|
|
|
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(),
|
|
|
|
|
&[
|
2026-07-16 20:41:13 +09:00
|
|
|
0x22, 0x57, 0x11, 0xe7, 0xc9, 0x11, 0xba, 0x8e, 0x08, 0x11, 0xae, 0x56, 0x5b, 0x7d,
|
|
|
|
|
0x89, 0xb0, 0x73, 0x48, 0xaa, 0x73, 0x87, 0x19, 0x5e, 0xcd, 0xf8, 0xfa, 0xd1, 0x48,
|
|
|
|
|
0xc1, 0x5b, 0x90, 0x57,
|
2026-07-15 00:43:42 +09:00
|
|
|
]
|
|
|
|
|
);
|
|
|
|
|
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)
|
|
|
|
|
));
|
|
|
|
|
}
|