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}