Skip to main content

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}