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}