Skip to main content

kovra_wrapper/
wrapper.rs

1//! The Wrapper (spec §5) — `kovra run`'s engine.
2//!
3//! Ties the layers together for a single launch:
4//! 1. **Resolve** the `.env.refs` (L4) into concrete values — the resolver does
5//!    *not* confirm or gate; that is this module's job.
6//! 2. Compute the two **independent** injection gates (KOV-25): the
7//!    **allowlist** set (I15 — `high` or `prod`, via
8//!    [`kovra_core::inject_requires_allowlist`]) and the **confirm** set (I3 —
9//!    `high` only, via [`kovra_core::inject_requires_confirmation`], orthogonal
10//!    to environment).
11//! 3. If any var is allowlist-gated, enforce the **executor allowlist** (I15):
12//!    the resolved program must be a reviewed, allowlisted executable, else
13//!    injection is refused before anything launches.
14//! 4. If any var is confirm-gated (`high`), **confirm** through the broker (I3)
15//!    with an authoritative [`ConfirmRequest`] whose `resolved_command` is the
16//!    exact `argv` (I16). Denied / timed-out ⇒ refuse; the child never launches.
17//!    A deliberately-downgraded `prod` secret is allowlist-gated but **not**
18//!    confirm-gated — it injects without a prompt (KOV-25).
19//! 5. **Inject** the resolved values into the child process environment and
20//!    launch it. Nothing is written to disk (I7).
21//! 6. Optionally **mask** injected vault-backed secret values in the child's
22//!    output (§5.1 margin defense — a net, never a boundary; plain literals and
23//!    `${env:}` passthrough are not masked).
24//!
25//! `inject-only` is **not** gated for confirmation: injection is its only
26//! delivery, and it is not `high`. dev/test throwaway (`low`/`medium`, non-prod)
27//! values inject freely with no allowlist and no prompt (§5.1).
28
29use std::path::Path;
30use std::time::Duration;
31
32use kovra_core::{
33    AuditAction, AuditEvent, AuditSink, Clock, ConfirmOutcome, ConfirmRequest, Confirmer, EnvRefs,
34    EnvSource, Keyring, Origin, PROD, Registry, SecretProvider, Sensitivity,
35    inject_requires_allowlist, inject_requires_confirmation, outcome_result, resolve,
36};
37
38use crate::allowlist::Allowlist;
39use crate::error::WrapperError;
40use crate::runner::{Command, Output, ProcessRunner};
41use crate::sanitize::mask_secrets;
42
43/// The Wrapper bundles the core dependencies (all behind traits, so the whole
44/// thing is mock-testable) and the launch policy knobs.
45pub struct Wrapper<'a> {
46    /// The vault registry (L2) consulted during resolution.
47    pub registry: &'a Registry,
48    /// The keyring providing the master key (L2).
49    pub keyring: &'a dyn Keyring,
50    /// The execution environment source for `${env:}` passthrough (L4).
51    pub env_source: &'a dyn EnvSource,
52    /// The provider used to materialize references (L4/L6).
53    pub provider: &'a dyn SecretProvider,
54    /// The confirmation broker for `high`/`prod` injection (L3/L8).
55    pub confirmer: &'a dyn Confirmer,
56    /// The audit sink (L3).
57    pub audit: &'a dyn AuditSink,
58    /// The clock used to stamp audit events (L3).
59    pub clock: &'a dyn Clock,
60    /// The executor allowlist gating `high`/`prod` injection (I15, §5.1).
61    pub allowlist: &'a Allowlist,
62    /// The process runner that actually launches the child (or mocks it).
63    pub runner: &'a dyn ProcessRunner,
64    /// How long to wait for an attended confirmation before failing safe to
65    /// denial (§8).
66    pub confirm_timeout: Duration,
67    /// Whether to mask injected values in the child's output before returning
68    /// (margin defense, §5.1 — a net, never a boundary).
69    pub sanitize_output: bool,
70    /// Inherit the parent's stdin/stdout/stderr into the child instead of
71    /// capturing its output (KOV-65). Required to wrap interactive processes and
72    /// **stdio servers** (e.g. an MCP server speaking JSON-RPC over stdin/stdout):
73    /// without inherited stdin the child sees EOF and the handshake closes. In
74    /// this mode the output is not captured, so masking (§5.1) does not apply —
75    /// the secret is still injected via the environment only (I6/I7) and the
76    /// `high`/`prod` gates (I3/I15) still run before the spawn. Default `false`
77    /// keeps the capture-and-mask behaviour for ordinary `kovra run`.
78    pub stdio_passthrough: bool,
79    /// The **trusted, observed** requesting-process identity for the I16 prompt
80    /// (§8.3). For `kovra run` this is the observed parent of the wrapper process
81    /// (who launched the run — see [`crate::observe_parent`]); for the MCP/FFI
82    /// face it is the client/agent identity threaded through the trusted PyO3
83    /// boundary. `None` (e.g. examples/tests) simply omits the line. Never
84    /// sourced from untrusted requester text; carries no secret value (I7/I12).
85    pub requesting_process: Option<String>,
86}
87
88impl Wrapper<'_> {
89    /// Resolve `refs` under `env`, gate/confirm, inject, and launch
90    /// `program args...`. `origin` distinguishes an agent-initiated run from a
91    /// human one (weighs into the prompt, §8.3). `project_override` wins over the
92    /// `.env.refs` `project =` line.
93    pub fn run(
94        &self,
95        refs: &EnvRefs,
96        env: &str,
97        project_override: Option<&str>,
98        program: &Path,
99        args: &[String],
100        origin: Origin,
101    ) -> Result<Output, WrapperError> {
102        // 1. Resolve (L4). `high`/`prod` are intentionally NOT confirmed here.
103        let resolved = resolve(
104            refs,
105            env,
106            self.registry,
107            self.keyring,
108            self.env_source,
109            self.provider,
110            self.audit,
111            self.clock,
112            origin,
113            project_override,
114        )?;
115
116        // 2. Two independent injection gates (KOV-25), each from its own core
117        //    predicate so they cannot drift (collect owned facts so the borrow on
118        //    `resolved` ends here and the values move into the child env below):
119        //    - allowlist (I15): `high` OR `prod` — the executable must be reviewed.
120        //    - confirm (I3): `high` only — attended biometric prompt, orthogonal
121        //      to environment (a deliberately-downgraded `prod` secret injects
122        //      without a prompt, but still needs an allowlisted executable).
123        let mut allowlist_gated: Vec<GatedVar> = Vec::new();
124        let mut confirm_gated: Vec<GatedVar> = Vec::new();
125        for v in &resolved.vars {
126            let Some(coordinate) = v.coordinate.clone() else {
127                continue;
128            };
129            let sensitivity = v.sensitivity.unwrap_or(Sensitivity::Low);
130            let is_prod = v.environment == PROD;
131            if inject_requires_allowlist(sensitivity, is_prod) {
132                allowlist_gated.push(GatedVar {
133                    coordinate: coordinate.clone(),
134                    environment: v.environment.clone(),
135                    sensitivity,
136                });
137            }
138            if inject_requires_confirmation(sensitivity) {
139                confirm_gated.push(GatedVar {
140                    coordinate,
141                    environment: v.environment.clone(),
142                    sensitivity,
143                });
144            }
145        }
146
147        // The authoritative I16 headline shows the program path as requested (what
148        // the human recognizes); the *executed* file is resolved below.
149        let resolved_command = render_argv(program, args);
150
151        // Resolve the program to the exact file we will launch. For an
152        // allowlist-gated run we execute the **canonicalized** path — the same
153        // path the allowlist vets below — so a reviewed symlink cannot be
154        // repointed to an attacker file during the confirmation window (I15
155        // TOCTOU: checking the canonical path but spawning the raw one resolves
156        // it twice, independently). A non-gated `low`/`medium` run keeps the path
157        // as-given (no allowlist constraint, and some multi-call binaries dispatch
158        // on `argv[0]`).
159        let exec_program = if allowlist_gated.is_empty() {
160            program.to_path_buf()
161        } else {
162            crate::allowlist::resolve_program(program)
163        };
164
165        // 3. I15 — only a reviewed, allowlisted executable may receive high/prod
166        //    injection. Refuse before confirming or launching. Environment-aware:
167        //    a downgraded prod secret is still allowlist-gated (containment), even
168        //    though it is no longer confirmation-gated. The check is on the same
169        //    resolved path we will execute.
170        if !allowlist_gated.is_empty() && !self.allowlist.allows(&exec_program) {
171            for g in &allowlist_gated {
172                self.record(
173                    AuditEvent::new(self.clock, AuditAction::Deny, "denied:not-allowlisted")
174                        .at(&g.coordinate, &g.environment)
175                        .by(origin),
176                );
177            }
178            return Err(WrapperError::NotAllowlisted {
179                program: program.display().to_string(),
180            });
181        }
182
183        // 4. I3/I16 — a `high` injection blocks on the broker (sensitivity-only;
184        //    orthogonal to environment). The authoritative prompt's headline is the
185        //    resolved command (what varies between legit and suspicious). One
186        //    scarce prompt per run.
187        if !confirm_gated.is_empty() {
188            let coordinates = confirm_gated
189                .iter()
190                .map(|g| g.coordinate.as_str())
191                .collect::<Vec<_>>()
192                .join(", ");
193            let mut req = ConfirmRequest::new(
194                coordinates,
195                representative_sensitivity(&confirm_gated),
196                representative_environment(&confirm_gated),
197                origin,
198            )
199            .with_command(resolved_command);
200            // The resolved command is the headline (what runs); the requesting
201            // process is the observed/threaded caller (who asked). Trusted fact.
202            if let Some(proc) = self.requesting_process.as_deref() {
203                req = req.with_requesting_process(proc);
204            }
205            let outcome = self.confirmer.confirm(&req, self.confirm_timeout);
206
207            let action = match outcome {
208                ConfirmOutcome::Approved => AuditAction::Approve,
209                ConfirmOutcome::Denied => AuditAction::Deny,
210                ConfirmOutcome::TimedOut => AuditAction::Timeout,
211            };
212            for g in &confirm_gated {
213                self.record(
214                    AuditEvent::new(self.clock, action, outcome_result(outcome))
215                        .at(&g.coordinate, &g.environment)
216                        .by(origin),
217                );
218            }
219            match outcome {
220                ConfirmOutcome::Approved => {}
221                ConfirmOutcome::Denied => return Err(WrapperError::ConfirmationDenied),
222                ConfirmOutcome::TimedOut => return Err(WrapperError::ConfirmationTimedOut),
223            }
224        }
225
226        // 5. Build the child command, moving the resolved values into the env
227        //    (no copy, no disk — I7). Audit each vault-backed injection (§11), and
228        //    remember which variables are vault-backed secrets (the masking net
229        //    targets those, not plain literals / `${env:}` passthrough — §5.1).
230        let mut env = Vec::with_capacity(resolved.vars.len());
231        let mut secret_names: Vec<String> = Vec::new();
232        for v in resolved.vars {
233            if let Some(coordinate) = &v.coordinate {
234                self.record(
235                    AuditEvent::new(self.clock, AuditAction::Inject, "injected")
236                        .at(coordinate, &v.environment)
237                        .by(origin),
238                );
239                secret_names.push(v.name.clone());
240            }
241            env.push((v.name, v.value));
242        }
243        let command = Command {
244            program: exec_program,
245            args: args.to_vec(),
246            env,
247            inherit_stdio: self.stdio_passthrough,
248        };
249
250        // 6. Launch, then optionally mask the vault-backed secret values in the
251        //    output (margin defense, §5.1 — a net, never a boundary). In
252        //    stdio-passthrough mode (KOV-65) the output is inherited, not
253        //    captured, so there is nothing to mask — the child streams straight
254        //    through (the secret never leaves the child env: I6/I7).
255        let mut output = self.runner.run(&command)?;
256        if self.sanitize_output && !self.stdio_passthrough {
257            let secrets: Vec<&[u8]> = command
258                .env
259                .iter()
260                .filter(|(name, _)| secret_names.contains(name))
261                .map(|(_, v)| v.expose())
262                .collect();
263            output.stdout = mask_secrets(&output.stdout, &secrets);
264            output.stderr = mask_secrets(&output.stderr, &secrets);
265        }
266        Ok(output)
267    }
268
269    /// Record an audit event, ignoring a sink error (audit is detection, not a
270    /// gate; a logging failure must not silently allow a value to flow, but
271    /// neither should it abort an already-decided action — the broker decision
272    /// has already been made and recorded by the caller's intent).
273    fn record(&self, event: AuditEvent) {
274        let _ = self.audit.record(&event);
275    }
276}
277
278/// A variable that triggered the injection gate, with the facts needed for the
279/// allowlist refusal, the confirmation prompt, and the audit trail.
280struct GatedVar {
281    coordinate: String,
282    environment: String,
283    sensitivity: Sensitivity,
284}
285
286/// Render the exact `argv` for the authoritative prompt (I16): program then
287/// arguments, space-joined, not paraphrased.
288fn render_argv(program: &Path, args: &[String]) -> String {
289    let mut s = program.display().to_string();
290    for a in args {
291        s.push(' ');
292        s.push_str(a);
293    }
294    s
295}
296
297/// The sensitivity to show in the prompt: `high` if any gated var is high, else
298/// the first gated var's level (a deliberately-downgraded prod secret).
299fn representative_sensitivity(gated: &[GatedVar]) -> Sensitivity {
300    if gated.iter().any(|g| g.sensitivity == Sensitivity::High) {
301        Sensitivity::High
302    } else {
303        gated[0].sensitivity
304    }
305}
306
307/// The environment to show: `prod` (highlighted by the renderer) if any gated
308/// var is prod, else the first gated var's environment.
309fn representative_environment(gated: &[GatedVar]) -> String {
310    if let Some(g) = gated.iter().find(|g| g.environment == PROD) {
311        g.environment.clone()
312    } else {
313        gated[0].environment.clone()
314    }
315}