kovra_wrapper/runner.rs
1//! The process runner — the seam that actually launches the child (or mocks it).
2//!
3//! The runner is a trait so the Wrapper's orchestration (resolve → policy →
4//! confirm → allowlist → inject) is tested deterministically with [`MockRunner`],
5//! while production uses [`SystemRunner`]. The injected values are
6//! [`SecretValue`]s exposed **only** at the moment of spawning and placed into
7//! the child's environment — never written to disk (I7).
8
9use std::path::PathBuf;
10use std::sync::Mutex;
11
12use kovra_core::SecretValue;
13
14use crate::error::WrapperError;
15
16/// A fully-resolved command ready to launch: the program, its arguments, and the
17/// environment to inject into the child.
18pub struct Command {
19 /// The program to execute (the resolved `argv[0]`).
20 pub program: PathBuf,
21 /// The arguments after the program.
22 pub args: Vec<String>,
23 /// Variables to inject into the child's environment. Values stay protected
24 /// until the runner exposes them at spawn time (I7 — never to disk).
25 pub env: Vec<(String, SecretValue)>,
26 /// Inherit the parent's stdin/stdout/stderr directly instead of capturing
27 /// the child's output (KOV-65). Set for long-running interactive processes
28 /// and **stdio servers** (e.g. an MCP server over JSON-RPC on stdin/stdout):
29 /// without inherited stdin the child sees EOF and a stdio server's handshake
30 /// closes immediately. The trade-off is that an inherited stream cannot be
31 /// captured, so output masking (§5.1) does not apply in this mode — the
32 /// child streams straight through, exec-style. The secret is still injected
33 /// via the environment only (never argv/disk — I6/I7), and the high/prod
34 /// gates (I3/I15) still run before the spawn.
35 pub inherit_stdio: bool,
36}
37
38/// The captured result of a finished child process.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct Output {
41 /// The exit status code, or `None` if the process was terminated by a signal.
42 pub status: Option<i32>,
43 /// Captured standard output (possibly sanitized by the Wrapper, §5.1).
44 pub stdout: Vec<u8>,
45 /// Captured standard error (possibly sanitized by the Wrapper, §5.1).
46 pub stderr: Vec<u8>,
47}
48
49/// Launches a [`Command`]. The Wrapper depends on this trait, not on
50/// `std::process`, so its logic is testable with [`MockRunner`].
51pub trait ProcessRunner {
52 /// Run the command to completion and capture its output.
53 fn run(&self, command: &Command) -> Result<Output, WrapperError>;
54}
55
56/// The real runner: launches via `std::process::Command`, injecting the resolved
57/// environment into the child. Inherits the parent environment **except** kovra's
58/// own `KOVRA_*` variables (scrubbed — see [`SystemRunner::run`]) and overrides it
59/// with the injected variables. Nothing is written to disk (I7).
60pub struct SystemRunner;
61
62impl ProcessRunner for SystemRunner {
63 fn run(&self, command: &Command) -> Result<Output, WrapperError> {
64 let mut cmd = std::process::Command::new(&command.program);
65 cmd.args(&command.args);
66 // The child must never inherit kovra's own configuration/secret
67 // environment. `KOVRA_PASSPHRASE` (the master key in passphrase mode),
68 // `KOVRA_RECIPIENT_KEY`, and `KOVRA_EXCHANGE_TOKEN` are credentials that
69 // unlock the vault; the remaining `KOVRA_*` vars are control flags a
70 // launched (possibly agent-authored) process has no business reading.
71 // Strip every inherited `KOVRA_*` before injecting, so even a non-gated
72 // `low`/`medium` run cannot scrape the vault key out of its own
73 // environment (I2/I7 — containment of kovra's own secrets). Injected
74 // variables are applied afterwards and win if a name were to collide.
75 for (key, _) in std::env::vars_os() {
76 if key.to_string_lossy().starts_with("KOVRA_") {
77 cmd.env_remove(&key);
78 }
79 }
80 for (name, value) in &command.env {
81 // Expose the value only here, straight into the child's env. A value
82 // that is not valid UTF-8 cannot be placed in the process
83 // environment portably; reject it without echoing the value (I12).
84 let s = std::str::from_utf8(value.expose()).map_err(|_| {
85 WrapperError::Spawn(format!(
86 "value for `{name}` is not valid UTF-8 and cannot be injected"
87 ))
88 })?;
89 cmd.env(name, s);
90 }
91 // KOV-65: in stdio-passthrough mode, inherit the parent's stdin/stdout/
92 // stderr (the default for `status()`) so the child can stream — required
93 // for interactive processes and stdio servers (MCP). Nothing is captured,
94 // so nothing is masked; the secret stays in the child env only (I6/I7).
95 if command.inherit_stdio {
96 let status = cmd.status().map_err(|e| {
97 WrapperError::Spawn(format!("launch {}: {e}", command.program.display()))
98 })?;
99 return Ok(Output {
100 status: status.code(),
101 stdout: Vec::new(),
102 stderr: Vec::new(),
103 });
104 }
105 let out = cmd.output().map_err(|e| {
106 WrapperError::Spawn(format!("launch {}: {e}", command.program.display()))
107 })?;
108 Ok(Output {
109 status: out.status.code(),
110 stdout: out.stdout,
111 stderr: out.stderr,
112 })
113 }
114}
115
116/// A single recorded invocation, for test assertions. The exposed env values are
117/// captured **only** because this is a test double; production never copies a
118/// value out like this.
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct RecordedRun {
121 /// The program that would have launched.
122 pub program: PathBuf,
123 /// Its arguments.
124 pub args: Vec<String>,
125 /// The injected environment, exposed for assertions (name → value).
126 pub env: Vec<(String, String)>,
127 /// Whether the run requested stdio passthrough (KOV-65).
128 pub inherit_stdio: bool,
129}
130
131impl RecordedRun {
132 /// The injected value for `name`, if present.
133 pub fn env_value(&self, name: &str) -> Option<&str> {
134 self.env
135 .iter()
136 .find(|(k, _)| k == name)
137 .map(|(_, v)| v.as_str())
138 }
139}
140
141/// A test runner that records each invocation and returns a configured
142/// [`Output`] without launching anything.
143pub struct MockRunner {
144 output: Output,
145 invocations: Mutex<Vec<RecordedRun>>,
146}
147
148impl MockRunner {
149 /// A runner returning `output` for every call.
150 pub fn new(output: Output) -> Self {
151 Self {
152 output,
153 invocations: Mutex::new(Vec::new()),
154 }
155 }
156
157 /// A runner returning a successful empty output (exit code 0).
158 pub fn ok() -> Self {
159 Self::new(Output {
160 status: Some(0),
161 stdout: Vec::new(),
162 stderr: Vec::new(),
163 })
164 }
165
166 /// A snapshot of the recorded invocations.
167 pub fn invocations(&self) -> Vec<RecordedRun> {
168 self.invocations
169 .lock()
170 .expect("runner mutex poisoned")
171 .clone()
172 }
173
174 /// Whether the runner was ever invoked (i.e. a child would have launched).
175 pub fn was_invoked(&self) -> bool {
176 !self
177 .invocations
178 .lock()
179 .expect("runner mutex poisoned")
180 .is_empty()
181 }
182}
183
184impl ProcessRunner for MockRunner {
185 fn run(&self, command: &Command) -> Result<Output, WrapperError> {
186 let env = command
187 .env
188 .iter()
189 .map(|(k, v)| (k.clone(), String::from_utf8_lossy(v.expose()).into_owned()))
190 .collect();
191 self.invocations
192 .lock()
193 .expect("runner mutex poisoned")
194 .push(RecordedRun {
195 program: command.program.clone(),
196 args: command.args.clone(),
197 env,
198 inherit_stdio: command.inherit_stdio,
199 });
200 Ok(self.output.clone())
201 }
202}