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    /// The **trusted, observed** requesting-process identity for the I16 prompt
71    /// (§8.3). For `kovra run` this is the observed parent of the wrapper process
72    /// (who launched the run — see [`crate::observe_parent`]); for the MCP/FFI
73    /// face it is the client/agent identity threaded through the trusted PyO3
74    /// boundary. `None` (e.g. examples/tests) simply omits the line. Never
75    /// sourced from untrusted requester text; carries no secret value (I7/I12).
76    pub requesting_process: Option<String>,
77}
78
79impl Wrapper<'_> {
80    /// Resolve `refs` under `env`, gate/confirm, inject, and launch
81    /// `program args...`. `origin` distinguishes an agent-initiated run from a
82    /// human one (weighs into the prompt, §8.3). `project_override` wins over the
83    /// `.env.refs` `project =` line.
84    pub fn run(
85        &self,
86        refs: &EnvRefs,
87        env: &str,
88        project_override: Option<&str>,
89        program: &Path,
90        args: &[String],
91        origin: Origin,
92    ) -> Result<Output, WrapperError> {
93        // 1. Resolve (L4). `high`/`prod` are intentionally NOT confirmed here.
94        let resolved = resolve(
95            refs,
96            env,
97            self.registry,
98            self.keyring,
99            self.env_source,
100            self.provider,
101            self.audit,
102            self.clock,
103            origin,
104            project_override,
105        )?;
106
107        // 2. Two independent injection gates (KOV-25), each from its own core
108        //    predicate so they cannot drift (collect owned facts so the borrow on
109        //    `resolved` ends here and the values move into the child env below):
110        //    - allowlist (I15): `high` OR `prod` — the executable must be reviewed.
111        //    - confirm (I3): `high` only — attended biometric prompt, orthogonal
112        //      to environment (a deliberately-downgraded `prod` secret injects
113        //      without a prompt, but still needs an allowlisted executable).
114        let mut allowlist_gated: Vec<GatedVar> = Vec::new();
115        let mut confirm_gated: Vec<GatedVar> = Vec::new();
116        for v in &resolved.vars {
117            let Some(coordinate) = v.coordinate.clone() else {
118                continue;
119            };
120            let sensitivity = v.sensitivity.unwrap_or(Sensitivity::Low);
121            let is_prod = v.environment == PROD;
122            if inject_requires_allowlist(sensitivity, is_prod) {
123                allowlist_gated.push(GatedVar {
124                    coordinate: coordinate.clone(),
125                    environment: v.environment.clone(),
126                    sensitivity,
127                });
128            }
129            if inject_requires_confirmation(sensitivity) {
130                confirm_gated.push(GatedVar {
131                    coordinate,
132                    environment: v.environment.clone(),
133                    sensitivity,
134                });
135            }
136        }
137
138        let resolved_command = render_argv(program, args);
139
140        // 3. I15 — only a reviewed, allowlisted executable may receive high/prod
141        //    injection. Refuse before confirming or launching. Environment-aware:
142        //    a downgraded prod secret is still allowlist-gated (containment), even
143        //    though it is no longer confirmation-gated.
144        if !allowlist_gated.is_empty() && !self.allowlist.allows(program) {
145            for g in &allowlist_gated {
146                self.record(
147                    AuditEvent::new(self.clock, AuditAction::Deny, "denied:not-allowlisted")
148                        .at(&g.coordinate, &g.environment)
149                        .by(origin),
150                );
151            }
152            return Err(WrapperError::NotAllowlisted {
153                program: program.display().to_string(),
154            });
155        }
156
157        // 4. I3/I16 — a `high` injection blocks on the broker (sensitivity-only;
158        //    orthogonal to environment). The authoritative prompt's headline is the
159        //    resolved command (what varies between legit and suspicious). One
160        //    scarce prompt per run.
161        if !confirm_gated.is_empty() {
162            let coordinates = confirm_gated
163                .iter()
164                .map(|g| g.coordinate.as_str())
165                .collect::<Vec<_>>()
166                .join(", ");
167            let mut req = ConfirmRequest::new(
168                coordinates,
169                representative_sensitivity(&confirm_gated),
170                representative_environment(&confirm_gated),
171                origin,
172            )
173            .with_command(resolved_command);
174            // The resolved command is the headline (what runs); the requesting
175            // process is the observed/threaded caller (who asked). Trusted fact.
176            if let Some(proc) = self.requesting_process.as_deref() {
177                req = req.with_requesting_process(proc);
178            }
179            let outcome = self.confirmer.confirm(&req, self.confirm_timeout);
180
181            let action = match outcome {
182                ConfirmOutcome::Approved => AuditAction::Approve,
183                ConfirmOutcome::Denied => AuditAction::Deny,
184                ConfirmOutcome::TimedOut => AuditAction::Timeout,
185            };
186            for g in &confirm_gated {
187                self.record(
188                    AuditEvent::new(self.clock, action, outcome_result(outcome))
189                        .at(&g.coordinate, &g.environment)
190                        .by(origin),
191                );
192            }
193            match outcome {
194                ConfirmOutcome::Approved => {}
195                ConfirmOutcome::Denied => return Err(WrapperError::ConfirmationDenied),
196                ConfirmOutcome::TimedOut => return Err(WrapperError::ConfirmationTimedOut),
197            }
198        }
199
200        // 5. Build the child command, moving the resolved values into the env
201        //    (no copy, no disk — I7). Audit each vault-backed injection (§11), and
202        //    remember which variables are vault-backed secrets (the masking net
203        //    targets those, not plain literals / `${env:}` passthrough — §5.1).
204        let mut env = Vec::with_capacity(resolved.vars.len());
205        let mut secret_names: Vec<String> = Vec::new();
206        for v in resolved.vars {
207            if let Some(coordinate) = &v.coordinate {
208                self.record(
209                    AuditEvent::new(self.clock, AuditAction::Inject, "injected")
210                        .at(coordinate, &v.environment)
211                        .by(origin),
212                );
213                secret_names.push(v.name.clone());
214            }
215            env.push((v.name, v.value));
216        }
217        let command = Command {
218            program: program.to_path_buf(),
219            args: args.to_vec(),
220            env,
221        };
222
223        // 6. Launch, then optionally mask the vault-backed secret values in the
224        //    output (margin defense, §5.1 — a net, never a boundary).
225        let mut output = self.runner.run(&command)?;
226        if self.sanitize_output {
227            let secrets: Vec<&[u8]> = command
228                .env
229                .iter()
230                .filter(|(name, _)| secret_names.contains(name))
231                .map(|(_, v)| v.expose())
232                .collect();
233            output.stdout = mask_secrets(&output.stdout, &secrets);
234            output.stderr = mask_secrets(&output.stderr, &secrets);
235        }
236        Ok(output)
237    }
238
239    /// Record an audit event, ignoring a sink error (audit is detection, not a
240    /// gate; a logging failure must not silently allow a value to flow, but
241    /// neither should it abort an already-decided action — the broker decision
242    /// has already been made and recorded by the caller's intent).
243    fn record(&self, event: AuditEvent) {
244        let _ = self.audit.record(&event);
245    }
246}
247
248/// A variable that triggered the injection gate, with the facts needed for the
249/// allowlist refusal, the confirmation prompt, and the audit trail.
250struct GatedVar {
251    coordinate: String,
252    environment: String,
253    sensitivity: Sensitivity,
254}
255
256/// Render the exact `argv` for the authoritative prompt (I16): program then
257/// arguments, space-joined, not paraphrased.
258fn render_argv(program: &Path, args: &[String]) -> String {
259    let mut s = program.display().to_string();
260    for a in args {
261        s.push(' ');
262        s.push_str(a);
263    }
264    s
265}
266
267/// The sensitivity to show in the prompt: `high` if any gated var is high, else
268/// the first gated var's level (a deliberately-downgraded prod secret).
269fn representative_sensitivity(gated: &[GatedVar]) -> Sensitivity {
270    if gated.iter().any(|g| g.sensitivity == Sensitivity::High) {
271        Sensitivity::High
272    } else {
273        gated[0].sensitivity
274    }
275}
276
277/// The environment to show: `prod` (highlighted by the renderer) if any gated
278/// var is prod, else the first gated var's environment.
279fn representative_environment(gated: &[GatedVar]) -> String {
280    if let Some(g) = gated.iter().find(|g| g.environment == PROD) {
281        g.environment.clone()
282    } else {
283        gated[0].environment.clone()
284    }
285}