use std::path::Path;
use std::str::FromStr;
use std::sync::Mutex;
use std::time::Duration;
use kovra_core::{
AuditAction, CliApproveConfirmer, ConfirmOutcome, ConfirmRequest, Confirmer, Coordinate,
EnvRefs, KEY_LEN, MockAuditSink, MockClock, MockConfirmer, MockEnvSource, MockKeyring,
MockProvider, Origin, Registry, SecretRecord, SecretValue, Sensitivity, seal, store,
};
use kovra_wrapper::{Allowlist, MockRunner, Output, ProcessRunner, Wrapper, WrapperError};
#[cfg(unix)]
use kovra_wrapper::SystemRunner;
const MASTER: [u8; KEY_LEN] = [0x5a; KEY_LEN];
fn lit(value: &str, sensitivity: Sensitivity, env: &str, comp: &str, key: &str) -> SecretRecord {
SecretRecord::Literal {
value: SecretValue::from(value),
sensitivity,
revealable: false,
environment: env.to_string(),
component: comp.to_string(),
key: key.to_string(),
description: None,
created: "2026-05-30T00:00:00Z".to_string(),
updated: "2026-05-30T00:00:00Z".to_string(),
}
}
struct Fixture {
_tmp: tempfile::TempDir,
reg: Registry,
keyring: MockKeyring,
env_source: MockEnvSource,
provider: MockProvider,
audit: MockAuditSink,
clock: MockClock,
requesting_process: Option<String>,
}
impl Fixture {
fn new() -> Self {
let tmp = tempfile::tempdir().unwrap();
let reg = Registry::open(tmp.path()).unwrap();
Self {
_tmp: tmp,
reg,
keyring: MockKeyring::with_key(MASTER),
env_source: MockEnvSource::new(),
provider: MockProvider::new(),
audit: MockAuditSink::new(),
clock: MockClock::default(),
requesting_process: None,
}
}
fn with_requesting_process(mut self, s: &str) -> Self {
self.requesting_process = Some(s.to_string());
self
}
fn seed_global(&self, coord: &str, record: SecretRecord) {
let c = Coordinate::from_str(coord).unwrap();
store::write_record(&self.reg.global_dir(), &c, &seal(&record, &MASTER).unwrap()).unwrap();
}
fn wrapper<'a>(
&'a self,
confirmer: &'a dyn Confirmer,
allowlist: &'a Allowlist,
runner: &'a dyn ProcessRunner,
confirm_timeout: Duration,
sanitize_output: bool,
) -> Wrapper<'a> {
Wrapper {
registry: &self.reg,
keyring: &self.keyring,
env_source: &self.env_source,
provider: &self.provider,
confirmer,
audit: &self.audit,
clock: &self.clock,
allowlist,
runner,
confirm_timeout,
sanitize_output,
stdio_passthrough: false,
requesting_process: self.requesting_process.clone(),
}
}
}
struct RecordingConfirmer {
outcome: ConfirmOutcome,
seen: Mutex<Option<ConfirmRequest>>,
}
impl RecordingConfirmer {
fn new(outcome: ConfirmOutcome) -> Self {
Self {
outcome,
seen: Mutex::new(None),
}
}
fn request(&self) -> Option<ConfirmRequest> {
self.seen.lock().unwrap().clone()
}
}
impl Confirmer for RecordingConfirmer {
fn confirm(&self, req: &ConfirmRequest, _timeout: Duration) -> ConfirmOutcome {
*self.seen.lock().unwrap() = Some(req.clone());
self.outcome
}
}
fn reviewed_exe(dir: &Path) -> std::path::PathBuf {
let p = dir.join("deploy.sh");
std::fs::write(&p, b"#!/bin/sh\n").unwrap();
p
}
#[test]
#[cfg(unix)]
fn injects_value_via_child_env_and_writes_nothing_to_disk() {
let fx = Fixture::new();
fx.seed_global(
"secret:dev/app/token",
lit("s3cr3t-dev", Sensitivity::Medium, "dev", "app", "token"),
);
let scratch = tempfile::tempdir().unwrap();
let allow = Allowlist::empty();
let runner = SystemRunner;
let deny = MockConfirmer::always(ConfirmOutcome::Denied);
let w = fx.wrapper(&deny, &allow, &runner, Duration::from_secs(1), false);
let refs = EnvRefs::parse("TOKEN=secret:dev/app/token").unwrap();
let out = w
.run(
&refs,
"dev",
None,
Path::new("/bin/sh"),
&["-c".to_string(), "printf %s \"$TOKEN\"".to_string()],
Origin::Human,
)
.unwrap();
assert_eq!(out.status, Some(0));
assert_eq!(String::from_utf8_lossy(&out.stdout), "s3cr3t-dev");
assert_eq!(
std::fs::read_dir(scratch.path()).unwrap().count(),
0,
"the Wrapper must not write any file to disk (I7)"
);
}
#[test]
fn stdio_passthrough_threads_inherit_stdio_and_skips_masking() {
let fx = Fixture::new();
fx.seed_global(
"secret:dev/app/token",
lit("supersecret", Sensitivity::Low, "dev", "app", "token"),
);
let refs = EnvRefs::parse("TOKEN=secret:dev/app/token").unwrap();
let deny = MockConfirmer::always(ConfirmOutcome::Denied); let allow = Allowlist::empty();
let runner = MockRunner::new(Output {
status: Some(0),
stdout: b"child echoed supersecret".to_vec(),
stderr: Vec::new(),
});
let mut w = fx.wrapper(&deny, &allow, &runner, Duration::from_secs(1), true);
w.stdio_passthrough = true;
let out = w
.run(
&refs,
"dev",
None,
Path::new("/bin/true"),
&[],
Origin::Human,
)
.unwrap();
let runs = runner.invocations();
assert_eq!(runs.len(), 1);
assert!(
runs[0].inherit_stdio,
"passthrough must thread inherit_stdio"
);
assert_eq!(
out.stdout, b"child echoed supersecret",
"masking is skipped"
);
let runner2 = MockRunner::new(Output {
status: Some(0),
stdout: b"child echoed supersecret".to_vec(),
stderr: Vec::new(),
});
let w2 = fx.wrapper(&deny, &allow, &runner2, Duration::from_secs(1), true);
let out2 = w2
.run(
&refs,
"dev",
None,
Path::new("/bin/true"),
&[],
Origin::Human,
)
.unwrap();
assert!(!runner2.invocations()[0].inherit_stdio);
assert!(
!String::from_utf8_lossy(&out2.stdout).contains("supersecret"),
"non-passthrough run masks the vault-backed secret"
);
}
#[test]
#[cfg(unix)]
fn stdio_passthrough_inherits_output_instead_of_capturing() {
let fx = Fixture::new();
fx.seed_global(
"secret:dev/app/token",
lit("s3cr3t", Sensitivity::Low, "dev", "app", "token"),
);
let runner = SystemRunner;
let deny = MockConfirmer::always(ConfirmOutcome::Denied);
let allow = Allowlist::empty();
let mut w = fx.wrapper(&deny, &allow, &runner, Duration::from_secs(5), true);
w.stdio_passthrough = true;
let refs = EnvRefs::parse("TOKEN=secret:dev/app/token").unwrap();
let out = w
.run(
&refs,
"dev",
None,
Path::new("/bin/sh"),
&["-c".to_string(), "true".to_string()],
Origin::Human,
)
.unwrap();
assert_eq!(out.status, Some(0));
assert!(
out.stdout.is_empty(),
"passthrough inherits stdout; nothing is captured by kovra"
);
}
#[test]
fn high_injection_into_non_allowlisted_command_is_refused() {
let fx = Fixture::new();
fx.seed_global(
"secret:dev/app/key",
lit("hunter2", Sensitivity::High, "dev", "app", "key"),
);
let allow = Allowlist::empty(); let runner = MockRunner::ok();
let approve = MockConfirmer::always(ConfirmOutcome::Approved);
let w = fx.wrapper(&approve, &allow, &runner, Duration::from_secs(1), false);
let refs = EnvRefs::parse("KEY=secret:dev/app/key").unwrap();
let err = w
.run(
&refs,
"dev",
None,
Path::new("/bin/sh"),
&["-c".to_string(), "true".to_string()],
Origin::Agent,
)
.unwrap_err();
assert!(matches!(err, WrapperError::NotAllowlisted { .. }));
assert!(!runner.was_invoked(), "the child must never launch (I15)");
assert!(
fx.audit
.events()
.iter()
.any(|e| e.result == "denied:not-allowlisted"),
"the refusal is audited"
);
}
#[test]
fn prod_high_denied_blocks_injection_and_prompt_shows_argv() {
let fx = Fixture::new();
fx.seed_global(
"secret:prod/db/password",
lit("prod-pw", Sensitivity::High, "prod", "db", "password"),
);
let bin = tempfile::tempdir().unwrap();
let deploy = reviewed_exe(bin.path());
let allow = Allowlist::from_paths([&deploy]);
let runner = MockRunner::ok();
let confirmer = RecordingConfirmer::new(ConfirmOutcome::Denied);
let w = fx.wrapper(&confirmer, &allow, &runner, Duration::from_secs(1), false);
let refs = EnvRefs::parse("DB=secret:prod/db/password").unwrap();
let err = w
.run(
&refs,
"prod",
None,
&deploy,
&["--now".to_string()],
Origin::Human,
)
.unwrap_err();
assert!(matches!(err, WrapperError::ConfirmationDenied));
assert!(!runner.was_invoked(), "denied ⇒ child never launches");
let req = confirmer.request().expect("the broker was consulted");
let expected = format!("{} --now", deploy.display());
assert_eq!(
req.resolved_command.as_deref(),
Some(expected.as_str()),
"the prompt shows the exact resolved argv (I16)"
);
assert_eq!(req.coordinate, "prod/db/password");
assert_eq!(req.environment, "prod");
assert_eq!(req.sensitivity, Sensitivity::High);
assert!(
req.requester_description.is_none(),
"authoritative prompt carries no requester free-text"
);
assert!(fx.audit.events().iter().any(|e| e.result == "denied"));
}
#[test]
fn prompt_carries_observed_requesting_process() {
let fx = Fixture::new().with_requesting_process("node (pid 4242)");
fx.seed_global(
"secret:prod/db/password",
lit("prod-pw", Sensitivity::High, "prod", "db", "password"),
);
let bin = tempfile::tempdir().unwrap();
let deploy = reviewed_exe(bin.path());
let allow = Allowlist::from_paths([&deploy]);
let runner = MockRunner::ok();
let confirmer = RecordingConfirmer::new(ConfirmOutcome::Denied);
let w = fx.wrapper(&confirmer, &allow, &runner, Duration::from_secs(1), false);
let refs = EnvRefs::parse("DB=secret:prod/db/password").unwrap();
let _ = w.run(&refs, "prod", None, &deploy, &[], Origin::Agent);
let req = confirmer.request().expect("the broker was consulted");
assert_eq!(
req.requesting_process.as_deref(),
Some("node (pid 4242)"),
"the observed requesting process threads onto the prompt (I16/§8.3)"
);
assert!(req.requester_description.is_none());
}
#[test]
fn prod_high_approved_injects_and_audits() {
let fx = Fixture::new();
fx.seed_global(
"secret:prod/db/password",
lit("prod-pw", Sensitivity::High, "prod", "db", "password"),
);
let bin = tempfile::tempdir().unwrap();
let deploy = reviewed_exe(bin.path());
let allow = Allowlist::from_paths([&deploy]);
let runner = MockRunner::ok();
let approve = MockConfirmer::always(ConfirmOutcome::Approved);
let w = fx.wrapper(&approve, &allow, &runner, Duration::from_secs(1), false);
let refs = EnvRefs::parse("DB=secret:prod/db/password").unwrap();
let out = w
.run(&refs, "prod", None, &deploy, &[], Origin::Human)
.unwrap();
assert_eq!(
out,
Output {
status: Some(0),
stdout: Vec::new(),
stderr: Vec::new()
}
);
let runs = runner.invocations();
assert_eq!(runs.len(), 1);
assert_eq!(
runs[0].env_value("DB"),
Some("prod-pw"),
"value injected into child env"
);
let actions: Vec<_> = fx.audit.events().iter().map(|e| e.action).collect();
assert!(actions.contains(&AuditAction::Approve));
assert!(actions.contains(&AuditAction::Inject));
}
#[test]
fn downgraded_prod_low_injects_without_prompt_but_still_needs_allowlist() {
let fx = Fixture::new();
fx.seed_global(
"secret:prod/db/password",
lit("prod-pw", Sensitivity::Low, "prod", "db", "password"),
);
let bin = tempfile::tempdir().unwrap();
let deploy = reviewed_exe(bin.path());
let allow = Allowlist::from_paths([&deploy]);
let runner = MockRunner::ok();
let confirmer = RecordingConfirmer::new(ConfirmOutcome::Denied);
let w = fx.wrapper(&confirmer, &allow, &runner, Duration::from_secs(1), false);
let refs = EnvRefs::parse("DB=secret:prod/db/password").unwrap();
let out = w
.run(&refs, "prod", None, &deploy, &[], Origin::Human)
.unwrap();
assert_eq!(
out.status,
Some(0),
"downgraded prod injects without a prompt"
);
assert!(
confirmer.request().is_none(),
"the broker is NOT consulted for a `low` secret (I3 — sensitivity-only)"
);
assert!(runner.was_invoked(), "the child launches");
let runner2 = MockRunner::ok();
let empty = Allowlist::empty();
let approve = MockConfirmer::always(ConfirmOutcome::Approved);
let w2 = fx.wrapper(&approve, &empty, &runner2, Duration::from_secs(1), false);
let err = w2
.run(&refs, "prod", None, &deploy, &[], Origin::Human)
.unwrap_err();
assert!(
matches!(err, WrapperError::NotAllowlisted { .. }),
"a downgraded prod secret still requires an allowlisted executable (I15)"
);
assert!(!runner2.was_invoked());
}
#[test]
fn inject_only_non_prod_passes_without_confirmation() {
let fx = Fixture::new();
fx.seed_global(
"secret:dev/app/secret",
lit("io-value", Sensitivity::InjectOnly, "dev", "app", "secret"),
);
let allow = Allowlist::empty();
let runner = MockRunner::ok();
let deny = MockConfirmer::always(ConfirmOutcome::Denied);
let w = fx.wrapper(&deny, &allow, &runner, Duration::from_secs(1), false);
let refs = EnvRefs::parse("S=secret:dev/app/secret").unwrap();
w.run(
&refs,
"dev",
None,
Path::new("/bin/sh"),
&["-c".to_string(), "true".to_string()],
Origin::Agent,
)
.unwrap();
let runs = runner.invocations();
assert_eq!(runs.len(), 1, "inject-only delivers by injection");
assert_eq!(runs[0].env_value("S"), Some("io-value"));
}
#[test]
fn confirmation_timeout_denies_injection() {
let fx = Fixture::new();
fx.seed_global(
"secret:prod/db/password",
lit("prod-pw", Sensitivity::High, "prod", "db", "password"),
);
let bin = tempfile::tempdir().unwrap();
let deploy = reviewed_exe(bin.path());
let allow = Allowlist::from_paths([&deploy]);
let runner = MockRunner::ok();
let confirmer = CliApproveConfirmer::new(); let w = fx.wrapper(
&confirmer,
&allow,
&runner,
Duration::from_millis(20),
false,
);
let refs = EnvRefs::parse("DB=secret:prod/db/password").unwrap();
let err = w
.run(&refs, "prod", None, &deploy, &[], Origin::Human)
.unwrap_err();
assert!(matches!(err, WrapperError::ConfirmationTimedOut));
assert!(!runner.was_invoked());
assert!(fx.audit.events().iter().any(|e| e.result == "timeout"));
}
#[test]
#[cfg(unix)]
fn sanitization_masks_injected_value_in_output() {
let fx = Fixture::new();
fx.seed_global(
"secret:dev/app/token",
lit("leak-me-123", Sensitivity::Medium, "dev", "app", "token"),
);
let allow = Allowlist::empty();
let runner = SystemRunner;
let deny = MockConfirmer::always(ConfirmOutcome::Denied);
let w = fx.wrapper(&deny, &allow, &runner, Duration::from_secs(1), true);
let refs = EnvRefs::parse("TOKEN=secret:dev/app/token").unwrap();
let out = w
.run(
&refs,
"dev",
None,
Path::new("/bin/sh"),
&["-c".to_string(), "printf %s \"$TOKEN\"".to_string()],
Origin::Human,
)
.unwrap();
assert_eq!(String::from_utf8_lossy(&out.stdout), "***");
assert!(!String::from_utf8_lossy(&out.stdout).contains("leak-me-123"));
}
#[test]
#[cfg(unix)]
fn sanitization_masks_only_vault_backed_secrets() {
let fx = Fixture::new();
fx.seed_global(
"secret:dev/app/token",
lit("sekret9", Sensitivity::Medium, "dev", "app", "token"),
);
let allow = Allowlist::empty();
let runner = SystemRunner;
let deny = MockConfirmer::always(ConfirmOutcome::Denied);
let w = fx.wrapper(&deny, &allow, &runner, Duration::from_secs(1), true);
let refs = EnvRefs::parse("PORT=8080\nTOKEN=secret:dev/app/token").unwrap();
let out = w
.run(
&refs,
"dev",
None,
Path::new("/bin/sh"),
&[
"-c".to_string(),
"printf 'PORT=%s TOKEN=%s' \"$PORT\" \"$TOKEN\"".to_string(),
],
Origin::Human,
)
.unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
assert_eq!(
stdout, "PORT=8080 TOKEN=***",
"literal visible, secret masked"
);
}
#[test]
#[cfg(unix)]
fn child_never_inherits_kovra_own_env() {
use kovra_wrapper::Command as RunCommand;
unsafe {
std::env::set_var("KOVRA_PASSPHRASE", "leak-me-master-key");
std::env::set_var("KOVRA_RECIPIENT_KEY", "leak-me-recipient");
}
let runner = SystemRunner;
let cmd = RunCommand {
program: Path::new("/bin/sh").to_path_buf(),
args: vec!["-c".to_string(), "env".to_string()],
env: vec![("TOKEN".to_string(), SecretValue::from("injected-ok"))],
inherit_stdio: false,
};
let out = runner.run(&cmd).unwrap();
unsafe {
std::env::remove_var("KOVRA_PASSPHRASE");
std::env::remove_var("KOVRA_RECIPIENT_KEY");
}
let dump = String::from_utf8_lossy(&out.stdout);
assert!(
!dump.contains("KOVRA_PASSPHRASE") && !dump.contains("leak-me-master-key"),
"child must not inherit KOVRA_PASSPHRASE (I2/I7)"
);
assert!(
!dump.contains("KOVRA_RECIPIENT_KEY") && !dump.contains("leak-me-recipient"),
"child must not inherit KOVRA_RECIPIENT_KEY (I2/I7)"
);
assert!(
!dump.lines().any(|l| l.starts_with("KOVRA_")),
"no KOVRA_* variable may reach the child"
);
assert!(
dump.contains("TOKEN=injected-ok"),
"explicitly injected variables are still delivered"
);
}
#[test]
#[cfg(unix)]
fn gated_run_executes_canonical_path_not_symlink() {
let fx = Fixture::new();
fx.seed_global(
"secret:prod/db/password",
lit("prod-pw", Sensitivity::High, "prod", "db", "password"),
);
let dir = tempfile::tempdir().unwrap();
let real = dir.path().join("real-deploy.sh");
std::fs::write(&real, b"#!/bin/sh\n").unwrap();
let link = dir.path().join("deploy.sh");
std::os::unix::fs::symlink(&real, &link).unwrap();
let allow = Allowlist::from_paths([&link]);
let runner = MockRunner::ok();
let approve = MockConfirmer::always(ConfirmOutcome::Approved);
let w = fx.wrapper(&approve, &allow, &runner, Duration::from_secs(1), false);
let refs = EnvRefs::parse("DB=secret:prod/db/password").unwrap();
w.run(&refs, "prod", None, &link, &[], Origin::Human)
.unwrap();
let runs = runner.invocations();
assert_eq!(runs.len(), 1, "the allowlisted run launched");
let canonical_real = std::fs::canonicalize(&real).unwrap();
assert_eq!(
runs[0].program, canonical_real,
"gated run spawns the canonicalized reviewed file, not the symlink (I15 TOCTOU)"
);
assert_ne!(
runs[0].program, link,
"the raw symlink path is not the spawn target"
);
}