diff --git a/crates/agent-artifact-admission/src/pypi_cache_directory_authority.rs b/crates/agent-artifact-admission/src/pypi_cache_directory_authority.rs index c687e436..14d18b7d 100644 --- a/crates/agent-artifact-admission/src/pypi_cache_directory_authority.rs +++ b/crates/agent-artifact-admission/src/pypi_cache_directory_authority.rs @@ -36,6 +36,13 @@ pub(crate) fn requests_unapproved_pypi_cache_directory_authority(intent: &Instal } } +/// Match only the canonical pip General Option spelling. Pre-command option +/// normalization intentionally does not inherit install-parser abbreviations +/// without separate upstream evidence for that parser phase. +pub(crate) fn matches_canonical_pip_cache_directory_option(argument: &str) -> bool { + argument.split_once('=').map_or(argument, |(name, _)| name) == "--cache-dir" +} + /// pip uses Python optparse, which accepts an unambiguous long-option prefix. /// `--ca` is the shortest prefix of `--cache-dir` that does not collide with /// another current `pip install` long option at the reviewed upstream commit. diff --git a/crates/agent-artifact-admission/src/pypi_global_option_authority.rs b/crates/agent-artifact-admission/src/pypi_global_option_authority.rs index 024211d8..80649702 100644 --- a/crates/agent-artifact-admission/src/pypi_global_option_authority.rs +++ b/crates/agent-artifact-admission/src/pypi_global_option_authority.rs @@ -1,4 +1,5 @@ use crate::InstallIntent; +use crate::pypi_cache_directory_authority::matches_canonical_pip_cache_directory_option; use crate::pypi_certificate_store_authority::matches_pip_certificate_store_abbreviation; use crate::pypi_client_certificate_authority::matches_pip_client_certificate_option; use crate::pypi_log_output_authority::matches_pip_log_option; @@ -124,6 +125,26 @@ pub(crate) fn normalize_reviewed_direct_pip_global_options( continue; } + if matches_canonical_pip_cache_directory_option(argument) { + if let Some((_, value)) = argument.split_once('=') { + if value.is_empty() { + return None; + } + reviewed_global_arguments.push(arguments[index].clone()); + index += 1; + } else { + // A separate cache path is consumed General Option grammar. Attach + // it only in the internal policy copy so it cannot masquerade as + // an artifact operand; the submitted argv/hash stay authoritative. + push_attached_normalized_value_argument( + arguments, + &mut reviewed_global_arguments, + &mut index, + )?; + } + continue; + } + if matches_pip_log_option(argument) { if let Some((_, value)) = argument.split_once('=') { if value.is_empty() { diff --git a/crates/agent-artifact-admission/tests/pypi_cache_dir_authority_contract.rs b/crates/agent-artifact-admission/tests/pypi_cache_dir_authority_contract.rs index 1b120034..9b284a96 100644 --- a/crates/agent-artifact-admission/tests/pypi_cache_dir_authority_contract.rs +++ b/crates/agent-artifact-admission/tests/pypi_cache_dir_authority_contract.rs @@ -1,6 +1,6 @@ use wardnet_agent_artifact_admission::{ AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, - InstallIntent, InstructionSource, InstructionSourceKind, admission_decision, + InstallIntent, InstructionSource, InstructionSourceKind, admission_decision, sha256_hex, }; #[test] @@ -46,6 +46,60 @@ fn approved_pip_install_cannot_gain_caller_selected_cache_directory_authority() } } +#[test] +fn valid_global_pip_cache_directory_options_remain_causal_write_authority_evidence() { + for executable in ["pip", "pip3"] { + let (policy, control_intent) = approved_pip_install(executable); + + for global_cache_arguments in [ + vec!["--cache-dir=/tmp/wardnet-pip-cache"], + vec!["--cache-dir", "/tmp/wardnet-pip-cache"], + ] { + let mut intent = control_intent.clone(); + let mut argv = Vec::with_capacity(intent.argv.len() + global_cache_arguments.len()); + argv.push(executable.to_string()); + argv.extend( + global_cache_arguments + .iter() + .map(|argument| (*argument).to_string()), + ); + argv.extend(control_intent.argv.iter().skip(1).cloned()); + intent.argv = argv; + let submitted_argv = intent.argv.clone(); + + let decision = admission_decision(&policy, &intent); + assert_eq!( + decision.decision, + DecisionKind::Block, + "parser-valid global {executable} {} must fail closed", + global_cache_arguments.join(" ") + ); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "alternate_install_root"), + "parser-valid global {executable} {} must retain causal cache-directory evidence: {:?}", + global_cache_arguments.join(" "), + decision.reason_codes + ); + assert!( + !decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "artifact_not_approved"), + "the cache path consumed by pip General Options must not masquerade as an artifact operand: {:?}", + decision.reason_codes + ); + assert_eq!( + decision.command_sha256, + sha256_hex(submitted_argv.join("\u{1f}").as_bytes()), + "policy normalization must preserve the exact submitted argv audit identity" + ); + } + } +} + fn approved_pip_install(executable: &str) -> (AdmissionPolicy, InstallIntent) { let artifact = ArtifactCoordinate { ecosystem: "pypi".to_string(),