From d9126dba9fad4332eec16cb441d3fb7a569452b4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 12:47:53 +0900 Subject: [PATCH 1/2] test(security): expose credential permission gap --- .../tests/cli_contract.rs | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/crates/agent-artifact-admission/tests/cli_contract.rs b/crates/agent-artifact-admission/tests/cli_contract.rs index 717b7755..1c8b28a6 100644 --- a/crates/agent-artifact-admission/tests/cli_contract.rs +++ b/crates/agent-artifact-admission/tests/cli_contract.rs @@ -1,6 +1,9 @@ use std::fs; use std::time::{SystemTime, UNIX_EPOCH}; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; + use wardnet_agent_artifact_admission::{ AdmissionPolicy, AdmissionServiceConfig, ApprovedArtifact, ApprovedManifest, CredentialFile, load_admin_token, load_config, parse_cli_args, validate_service_config, @@ -182,6 +185,9 @@ fn loaders_are_bounded_strict_and_do_not_accept_short_credentials() { serde_json::to_vec(&credential).expect("credential must serialize"), ) .expect("credential fixture must write"); + #[cfg(unix)] + fs::set_permissions(&credential_path, fs::Permissions::from_mode(0o600)) + .expect("credential fixture must be owner-only"); assert_eq!( load_admin_token(&credential_path).expect("valid credential must load"), credential.admin_token @@ -204,3 +210,35 @@ fn loaders_are_bounded_strict_and_do_not_accept_short_credentials() { let _ = fs::remove_file(config_path); let _ = fs::remove_file(credential_path); } + +#[cfg(unix)] +#[test] +fn credential_loader_rejects_group_or_other_permissions() { + let credential_path = temp_path("credential-permissions"); + let credential = CredentialFile { + admin_token: "0123456789abcdef0123456789abcdef".to_string(), + }; + let encoded = serde_json::to_vec(&credential).expect("credential must serialize"); + fs::write(&credential_path, &encoded).expect("credential fixture must write"); + + for secure_mode in [0o600, 0o400] { + fs::set_permissions(&credential_path, fs::Permissions::from_mode(secure_mode)) + .expect("secure credential mode must apply"); + assert_eq!( + load_admin_token(&credential_path).expect("owner-only credential must load"), + credential.admin_token, + "mode {secure_mode:o}" + ); + } + + for unsafe_mode in [0o640, 0o604, 0o620, 0o602, 0o610, 0o601] { + fs::set_permissions(&credential_path, fs::Permissions::from_mode(unsafe_mode)) + .expect("unsafe credential mode must apply"); + assert!( + load_admin_token(&credential_path).is_err(), + "credential mode {unsafe_mode:o} exposed authority outside the owner boundary" + ); + } + + let _ = fs::remove_file(credential_path); +} From 2efe188cd94b77d039c749368cfeee977dbb51e4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 13:09:01 +0900 Subject: [PATCH 2/2] fix(security): require owner-only admission credentials --- crates/agent-artifact-admission/src/config.rs | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/crates/agent-artifact-admission/src/config.rs b/crates/agent-artifact-admission/src/config.rs index 47021328..e0b38fb4 100644 --- a/crates/agent-artifact-admission/src/config.rs +++ b/crates/agent-artifact-admission/src/config.rs @@ -129,7 +129,7 @@ pub fn load_config(path: &Path) -> Result { /// Load the bounded credentials document and return its validated bearer token. pub fn load_admin_token(path: &Path) -> Result { - let bytes = read_bounded(path, MAX_CREDENTIAL_FILE_BYTES)?; + let bytes = read_credential_bounded(path, MAX_CREDENTIAL_FILE_BYTES)?; let credential: CredentialFile = serde_json::from_slice(&bytes).map_err(|_| ConfigError::InvalidJson)?; validate_admin_token(&credential.admin_token)?; @@ -244,8 +244,38 @@ fn valid_executable(value: &str) -> bool { .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'+')) } +fn read_credential_bounded(path: &Path, maximum_bytes: u64) -> Result, ConfigError> { + let file = File::open(path).map_err(|_| ConfigError::Io)?; + validate_credential_file_permissions(&file)?; + read_open_file_bounded(file, maximum_bytes) +} + +#[cfg(unix)] +fn validate_credential_file_permissions(file: &File) -> Result<(), ConfigError> { + use std::os::unix::fs::PermissionsExt; + + let mode = file + .metadata() + .map_err(|_| ConfigError::Io)? + .permissions() + .mode(); + if mode & 0o077 != 0 { + return Err(ConfigError::InvalidCredential); + } + Ok(()) +} + +#[cfg(not(unix))] +fn validate_credential_file_permissions(_file: &File) -> Result<(), ConfigError> { + Err(ConfigError::InvalidCredential) +} + fn read_bounded(path: &Path, maximum_bytes: u64) -> Result, ConfigError> { let file = File::open(path).map_err(|_| ConfigError::Io)?; + read_open_file_bounded(file, maximum_bytes) +} + +fn read_open_file_bounded(file: File, maximum_bytes: u64) -> Result, ConfigError> { let mut bytes = Vec::new(); file.take(maximum_bytes + 1) .read_to_end(&mut bytes)