gwm/trust.rs
1//! TOFU (trust-on-first-use) trust ledger for `.gwm.toml` (issue #95).
2//!
3//! The ledger persists `(origin URL, sha256 of .gwm.toml)` tuples to a
4//! per-user file (default `~/.config/gwm/trust.toml`, overridable via
5//! the `GWM_TRUST_LEDGER` env var). On every `gwm create` / `gwm
6//! bootstrap` we hash the current `.gwm.toml`, look the tuple up, and
7//! either skip silently (already trusted) or prompt the user before
8//! handing control to `bootstrap::run` (which is the RCE primitive).
9//!
10//! Threat model: an attacker who controls a remote repository (a fork,
11//! a fresh hostile clone, a co-worker compromise on a shared repo) can
12//! drop arbitrary `[[bootstrap.command]]` lines and have them executed
13//! the next time anyone runs `gwm create` against that repo. Hashing
14//! the raw bytes catches both wholesale rewrites and surgical edits
15//! (whitespace included — `rm -rf /tmp/` and `rm -rf /tmp /` are one
16//! byte apart and behave catastrophically differently). Storing the
17//! origin URL alongside the hash means moving a config from one repo
18//! to another forces a fresh trust decision.
19//!
20//! The ledger format is plain TOML so it can be inspected by hand
21//! (`gwm trust show` prints the active path) and version-controlled
22//! per-machine if a team wants to share trust decisions explicitly.
23
24use crate::config::{Config, CONFIG_FILE};
25use crate::error::{GwmError, Result};
26use chrono::{DateTime, Utc};
27use serde::{Deserialize, Serialize};
28use sha2::{Digest, Sha256};
29use std::fs;
30use std::io::Write;
31use std::path::{Path, PathBuf};
32use tempfile::Builder;
33
34/// Trust gating mode resolved at the CLI / TUI entrypoint and threaded
35/// through every code path that may invoke `bootstrap::run`. Moved
36/// here (from `src/cli.rs`) so the TUI can take the same decision
37/// without duplicating the resolution logic — see [`evaluate`].
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum TrustMode {
40 /// Default: load the ledger, prompt the user on miss, abort on
41 /// non-tty.
42 Prompt,
43 /// `--allow-bootstrap` or `GWM_ALLOW_BOOTSTRAP=1` — skip the prompt
44 /// without touching the ledger. CI bypasses must NOT pollute the
45 /// local ledger of whoever ends up running an interactive `gwm`
46 /// from the same machine later.
47 Allow,
48 /// `--deny-bootstrap` — refuse to run bootstrap even if already
49 /// trusted. For forensic / first-look inspection of a hostile repo.
50 Deny,
51}
52
53/// Resolve a [`TrustMode`] from CLI flags + env. Both `--allow-bootstrap`
54/// and `--deny-bootstrap` map onto this — `conflicts_with` at the clap
55/// level guarantees they aren't both set, the explicit ordering here is
56/// defence in depth.
57pub fn resolve_mode(allow_flag: bool, deny_flag: bool) -> TrustMode {
58 if deny_flag {
59 return TrustMode::Deny;
60 }
61 if allow_flag || env_truthy("GWM_ALLOW_BOOTSTRAP") {
62 return TrustMode::Allow;
63 }
64 TrustMode::Prompt
65}
66
67/// Truthy env semantics: any non-empty value other than `0`, `false`,
68/// or `no` (case-insensitive) counts as true. Documented here as the
69/// source of truth — gwm has no shared env-bool helper yet, so this
70/// fn is the canonical reference for `GWM_*` flag-style env vars.
71pub fn env_truthy(key: &str) -> bool {
72 match std::env::var(key) {
73 Ok(v) => {
74 let v = v.trim().to_ascii_lowercase();
75 !v.is_empty() && v != "0" && v != "false" && v != "no"
76 }
77 Err(_) => false,
78 }
79}
80
81/// The trust-ledger key for `repo` — [`resolve_origin_key`] fed from the
82/// `origin` remote.
83///
84/// One helper rather than the extraction repeated at each call site.
85/// That repetition is what broke: the doc below used to argue the module
86/// should stay git2-free because "every caller already holds a
87/// Repository and extracts the URL on their side in three lines", and
88/// three of the four callers wrote the same three lines while the fourth
89/// reached for `RemoteRef::web_origin` instead (issue #463).
90///
91/// The difference is not cosmetic. `web_origin` is scheme + host, so the
92/// key stopped identifying a *repo* and started identifying a *host* —
93/// collapsing `(origin, sha256)` into a pair shared by every repo on
94/// that host with an identically-hashing `.gwm.toml`. Since that file is
95/// normally a template copied across a team's repos, identical hashes
96/// are the ordinary case, and a hostile repo could inherit a sibling's
97/// approval by shipping its config verbatim. The two gates also stopped
98/// seeing each other's entries, so `gwm trust add` reported success and
99/// `gwm create` in the same repo still refused.
100pub fn origin_key_for_repo(repo: &git2::Repository, workdir: &Path) -> String {
101 let url = repo
102 .find_remote("origin")
103 .ok()
104 .and_then(|r| r.url().ok().map(String::from));
105 resolve_origin_key(url.as_deref(), workdir)
106}
107
108/// Resolve the trust-ledger key for the current repo: prefer the
109/// `origin` remote URL (the hostile-clone threat axis), fall back to
110/// the canonicalised workdir path (purely-local repos with no remote
111/// still benefit from the drift-detection half of the feature).
112///
113/// Prefer [`origin_key_for_repo`] when you hold a `Repository`. This
114/// lower-level form stays for callers that genuinely have only a URL.
115pub fn resolve_origin_key(origin_url: Option<&str>, workdir: &Path) -> String {
116 if let Some(url) = origin_url {
117 if !url.is_empty() {
118 return url.to_string();
119 }
120 }
121 workdir
122 .canonicalize()
123 .unwrap_or_else(|_| workdir.to_path_buf())
124 .display()
125 .to_string()
126}
127
128/// Outcome of a trust gate evaluation. The caller (CLI or TUI) decides
129/// what to do with each variant — CLI prompts on `Prompt`, TUI refuses
130/// with a helpful message because the alternate-screen mode can't host
131/// a stdin read without a full modal view (deferred follow-up).
132#[derive(Debug)]
133pub enum TrustOutcome {
134 /// Cleared to invoke `bootstrap::run`: trusted entry hit, empty
135 /// surface, no `.gwm.toml`, or `TrustMode::Allow`.
136 Proceed,
137 /// Refuse outright. `message` is the user-facing reason; render it
138 /// verbatim in stderr (CLI) or status bar (TUI). Used for `Deny`
139 /// mode by `evaluate`; the TUI also synthesises a `Refuse` from a
140 /// `Prompt` outcome since it can't prompt today.
141 Refuse { message: String },
142 /// Caller must obtain user approval interactively. On approval the
143 /// caller records into `ledger`, then saves to `ledger_path`. The
144 /// `body` and `sha` are passed back so the prompt can display a
145 /// summary without re-reading the file.
146 Prompt {
147 cfg_path: PathBuf,
148 body: Vec<u8>,
149 sha: String,
150 origin: String,
151 ledger: TrustLedger,
152 ledger_path: PathBuf,
153 },
154}
155
156/// Is the repo's own `.gwm.toml` approved in the ledger?
157///
158/// A second, narrower question than [`evaluate`], and it deliberately
159/// does **not** reuse it. `evaluate` short-circuits to `Proceed` on an
160/// empty bootstrap surface — no commands to run, nothing to gate — and
161/// a `.gwm.toml` whose entire content is `forge = "gitlab"` has exactly
162/// that shape. It is still a file that ships with the repo telling gwm
163/// which host to send an authenticated call to (Codex review #458), so
164/// the surface short-circuit is wrong for this question.
165///
166/// Same ledger, same `(origin, sha256)` key, same `GWM_ALLOW_BOOTSTRAP`
167/// escape hatch: approving a repo approves the whole file, and editing
168/// the file revokes that approval. Non-interactive by construction —
169/// [`crate::forge::resolve`] runs on the TUI's selection path, which
170/// cannot host a prompt. `gwm trust add` is how a user answers it.
171///
172/// No `.gwm.toml` at all is `false`, and the distinction matters.
173///
174/// This answers "does *the repo's own file* authorise this?", so an
175/// absent file is an absent statement, not a blanket yes. It briefly
176/// returned `true` on the reasoning that with no repo-controlled file in
177/// play the request must have come from the user's own config — which
178/// held only while a bare global `forge` key was itself an authority.
179/// Once that key stopped authorising hosts, the same `true` became the
180/// way around the gate: a repo with no `.gwm.toml` on an unrecognised
181/// host inherits `forge` from the global config by merge, reaches this
182/// question, and was waved through on the strength of the file it does
183/// not have.
184pub fn config_is_trusted(workdir: &Path, origin: &str, mode: TrustMode) -> Result<bool> {
185 let bytes = match fs::read(workdir.join(CONFIG_FILE)) {
186 Ok(b) => b,
187 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
188 Err(e) => return Err(e.into()),
189 };
190 match mode {
191 TrustMode::Deny => return Ok(false),
192 TrustMode::Allow => return Ok(true),
193 TrustMode::Prompt => {}
194 }
195 let ledger_path = default_ledger_path()?;
196 Ok(TrustLedger::load(&ledger_path)?.lookup(origin, &hash_config(&bytes)))
197}
198
199/// Record the current repo's `.gwm.toml` as trusted. Backs `gwm trust
200/// add`, which exists because the forge gate above is the first thing
201/// that can refuse on a config with **no** bootstrap surface — nothing
202/// else would ever prompt for it, so without this the refusal would be
203/// unclearable.
204pub fn record_config(workdir: &Path, origin: &str, actor: &str) -> Result<Option<String>> {
205 let path = workdir.join(CONFIG_FILE);
206 let bytes = match fs::read(&path) {
207 Ok(b) => b,
208 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
209 Err(e) => return Err(e.into()),
210 };
211 let sha = hash_config(&bytes);
212 let ledger_path = default_ledger_path()?;
213 let mut ledger = TrustLedger::load(&ledger_path)?;
214 ledger.record(origin, &sha, actor);
215 ledger.save(&ledger_path)?;
216 Ok(Some(sha))
217}
218
219/// Silent gate evaluation. Reads `.gwm.toml`, hashes it, applies the
220/// short-circuits in this order:
221///
222/// 1. No `.gwm.toml` → `Proceed` (nothing to execute).
223/// 2. `TrustMode::Deny` → `Refuse` (forensic mode).
224/// 3. Empty bootstrap surface → `Proceed` (UX, see comment in
225/// [`trust_or_prompt`]).
226/// 4. `TrustMode::Allow` → `Proceed` BEFORE touching the ledger
227/// (malformed ledger must not break the CI bypass).
228/// 5. Ledger hit on `(origin, sha)` → `Proceed`.
229/// 6. Otherwise → `Prompt` (caller decides whether to actually
230/// prompt or refuse).
231///
232/// This is the single source of truth shared by `cli::trust_or_prompt`
233/// and the TUI gate — keeps the security policy in one place.
234pub fn evaluate(workdir: &Path, origin: &str, mode: TrustMode) -> Result<TrustOutcome> {
235 let cfg_path = workdir.join(CONFIG_FILE);
236 let bytes = match fs::read(&cfg_path) {
237 Ok(b) => b,
238 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(TrustOutcome::Proceed),
239 Err(e) => return Err(e.into()),
240 };
241 let sha = hash_config(&bytes);
242
243 if mode == TrustMode::Deny {
244 let short_sha: String = sha.chars().take(12).collect();
245 return Ok(TrustOutcome::Refuse {
246 message: format!(
247 "--deny-bootstrap: refusing to run .gwm.toml bootstrap (config hash: {})",
248 short_sha
249 ),
250 });
251 }
252
253 // Empty surface short-circuit (defence-in-depth fallthrough on
254 // parse error — a broken parser must not open a bypass).
255 if let Ok(body_str) = std::str::from_utf8(&bytes) {
256 if let Ok(cfg) = toml::from_str::<Config>(body_str) {
257 let bs = &cfg.bootstrap;
258 if bs.copy.is_empty()
259 && bs.guard.is_empty()
260 && bs.no_symlink.is_empty()
261 && bs.command.is_empty()
262 && !cfg.hooks.has_any()
263 {
264 return Ok(TrustOutcome::Proceed);
265 }
266 }
267 }
268
269 // Allow short-circuit before any ledger I/O so a malformed
270 // trust.toml never breaks `--allow-bootstrap` / `GWM_ALLOW_BOOTSTRAP=1`.
271 if mode == TrustMode::Allow {
272 return Ok(TrustOutcome::Proceed);
273 }
274
275 let ledger_path = default_ledger_path()?;
276 let ledger = TrustLedger::load(&ledger_path)?;
277
278 if ledger.lookup(origin, &sha) {
279 return Ok(TrustOutcome::Proceed);
280 }
281
282 Ok(TrustOutcome::Prompt {
283 cfg_path,
284 body: bytes,
285 sha,
286 origin: origin.to_string(),
287 ledger,
288 ledger_path,
289 })
290}
291
292/// On-disk ledger schema. `serde` defaults make adding new optional
293/// fields backward-compatible: older binaries still parse newer files,
294/// they just ignore the extra keys.
295#[derive(Debug, Clone, Default, Serialize, Deserialize)]
296pub struct TrustLedger {
297 #[serde(default, rename = "entries")]
298 pub entries: Vec<TrustEntry>,
299}
300
301/// One trust grant. `origin` is the remote URL (kept verbatim so SSH
302/// and HTTPS flavours of the same repo are treated as distinct trust
303/// boundaries — they ARE distinct: different auth path, different
304/// failure modes on intercept). `config_sha` is the lowercase hex
305/// sha256 of `.gwm.toml`'s raw bytes.
306#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
307pub struct TrustEntry {
308 pub origin: String,
309 pub config_sha: String,
310 /// RFC3339 timestamp of when the entry was first recorded. Surfaced
311 /// by `gwm trust list` so users can audit who/when the trust was
312 /// granted before deciding to revoke.
313 pub trusted_at: DateTime<Utc>,
314 /// Best-effort `user@host` identifier captured at record time. Not
315 /// security-relevant on its own (it's local input), purely an audit
316 /// hint for multi-machine users sharing a ledger via dotfiles.
317 pub trusted_by: String,
318}
319
320impl TrustLedger {
321 /// Load the ledger from `path`. A missing file is NOT an error — it
322 /// is treated as an empty ledger, which is the right default for
323 /// the first ever invocation. A malformed file IS an error: silently
324 /// treating it as empty would re-prompt every previously trusted
325 /// repo and train the user to mash `y` (anti-habituation goal of the
326 /// whole feature).
327 pub fn load(path: &Path) -> Result<Self> {
328 match fs::read_to_string(path) {
329 Ok(raw) => {
330 let ledger: TrustLedger = toml::from_str(&raw)?;
331 Ok(ledger)
332 }
333 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
334 Err(e) => Err(e.into()),
335 }
336 }
337
338 /// Persist the ledger atomically: serialise, write to a uniquely-
339 /// named tmp file in the same directory, then rename(2). The
340 /// rename is the atomic step on POSIX; on Windows it is also a
341 /// single syscall but with slightly different semantics around
342 /// open file handles. `tempfile::NamedTempFile::persist` papers
343 /// over both.
344 ///
345 /// The tmp filename is randomised (`gwm-trust-<random>.tmp`) so
346 /// two `gwm` processes hitting `save` concurrently don't clobber
347 /// each other's intermediate write — pre-fix both raced on the
348 /// fixed name `trust.toml.tmp` and could corrupt the final
349 /// ledger if one process's rename interleaved with the other's
350 /// write.
351 ///
352 /// Parent directories are created on demand (`mkdir -p`) so the
353 /// first ever write on a fresh machine succeeds without the user
354 /// having to create `~/.config/gwm/` manually.
355 pub fn save(&self, path: &Path) -> Result<()> {
356 let parent = match path.parent() {
357 Some(p) if !p.as_os_str().is_empty() => {
358 fs::create_dir_all(p)?;
359 p.to_path_buf()
360 }
361 // No parent (e.g. relative `trust.toml` in CWD) or empty
362 // parent → write the tmp file in `.`.
363 _ => PathBuf::from("."),
364 };
365 let body = toml::to_string_pretty(self)?;
366 let mut tmp = Builder::new()
367 .prefix("gwm-trust-")
368 .suffix(".tmp")
369 .tempfile_in(&parent)?;
370 tmp.write_all(body.as_bytes())?;
371 // `persist` does the atomic rename and consumes the handle so
372 // the tempfile crate's drop-cleanup is short-circuited — no
373 // sidecar `.tmp` survives a successful save.
374 tmp.persist(path).map_err(|e| GwmError::Io(e.error))?;
375 Ok(())
376 }
377
378 /// Returns true iff there is an entry with both `origin` AND
379 /// `config_sha` matching verbatim. Hash drift on a known origin is
380 /// a deliberate `false` — that is the re-prompt-on-config-edit
381 /// behaviour spec'd by the issue.
382 pub fn lookup(&self, origin: &str, config_sha: &str) -> bool {
383 self
384 .entries
385 .iter()
386 .any(|e| e.origin == origin && e.config_sha == config_sha)
387 }
388
389 /// Record (or refresh) a trust grant. Always produces exactly one
390 /// entry per `origin`: any prior entry is dropped first, then a
391 /// fresh entry is pushed with `trusted_at = Utc::now()`. So:
392 ///
393 /// * Re-recording the same `(origin, config_sha)` keeps a single
394 /// entry but **refreshes the timestamp** — useful when a user
395 /// explicitly re-confirms trust without editing the config.
396 /// * Re-recording the same `origin` with a different
397 /// `config_sha` supersedes the old hash (drift case), keeping
398 /// the ledger bounded over a repo's lifetime — without this,
399 /// every `.gwm.toml` edit would leak a stale tuple that
400 /// `gwm trust list` would surface forever.
401 ///
402 /// In both cases `entries.len()` after a re-record is the same as
403 /// before; `record_is_idempotent_on_exact_match` and
404 /// `record_supersedes_drifted_hash_for_same_origin` pin both
405 /// halves down.
406 pub fn record(&mut self, origin: &str, config_sha: &str, trusted_by: &str) {
407 self.entries.retain(|e| e.origin != origin);
408 self.entries.push(TrustEntry {
409 origin: origin.to_string(),
410 config_sha: config_sha.to_string(),
411 trusted_at: Utc::now(),
412 trusted_by: trusted_by.to_string(),
413 });
414 }
415
416 /// Remove every entry matching `origin`. Returns the count so
417 /// `gwm trust revoke` can print a precise "removed N entries" line
418 /// instead of guessing.
419 pub fn revoke(&mut self, origin: &str) -> usize {
420 let before = self.entries.len();
421 self.entries.retain(|e| e.origin != origin);
422 before - self.entries.len()
423 }
424}
425
426/// SHA-256 of the raw bytes of `.gwm.toml`, lowercase hex. Whitespace-
427/// sensitive on purpose (see the module-level comment).
428pub fn hash_config(bytes: &[u8]) -> String {
429 let digest = Sha256::digest(bytes);
430 hex_lower(&digest)
431}
432
433/// Resolve the active ledger path. Order of precedence:
434/// 1. `GWM_TRUST_LEDGER` env var (the testability hook + power-user
435/// override for users with non-XDG dotfiles).
436/// 2. `dirs::config_dir()/gwm/trust.toml` (XDG on Linux, the
437/// `Application Support` equivalent on macOS, `%APPDATA%` on
438/// Windows).
439///
440/// Returns `Err(GwmError::Other(..))` only on the rare case where
441/// `dirs::config_dir()` cannot determine a home — extremely uncommon,
442/// but better surfaced than panicked away.
443pub fn default_ledger_path() -> Result<PathBuf> {
444 if let Ok(p) = std::env::var("GWM_TRUST_LEDGER") {
445 if !p.is_empty() {
446 return Ok(PathBuf::from(p));
447 }
448 }
449 let base = dirs::config_dir().ok_or_else(|| {
450 GwmError::Other("could not resolve user config directory — set GWM_TRUST_LEDGER to override".into())
451 })?;
452 Ok(base.join("gwm").join("trust.toml"))
453}
454
455/// Best-effort `user@host` audit string. Falls back to `"unknown"` on
456/// each half independently so we never panic in a CI shell with
457/// minimal env, and the resulting string is purely informational
458/// (never used for trust decisions).
459pub fn current_actor() -> String {
460 let user = std::env::var("USER")
461 .or_else(|_| std::env::var("USERNAME"))
462 .unwrap_or_else(|_| "unknown".into());
463 let host = current_hostname().unwrap_or_else(|| "unknown".into());
464 format!("{}@{}", user, host)
465}
466
467#[cfg(unix)]
468fn current_hostname() -> Option<String> {
469 // libc is already a pinned Unix dependency (used by bootstrap.rs's
470 // O_NOFOLLOW primitives), so reusing it for `gethostname(3)` keeps
471 // the dep tree flat — no need for an extra `gethostname`/`whoami`
472 // crate just for an audit-log string.
473 let mut buf = [0i8; 256];
474 let rc = unsafe { libc::gethostname(buf.as_mut_ptr().cast(), buf.len()) };
475 if rc != 0 {
476 return None;
477 }
478 // Find the NUL terminator. POSIX doesn't promise gethostname will
479 // null-terminate if the host name is exactly the buffer length, so
480 // we cap at buf.len() defensively.
481 let len = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
482 // SAFETY: buf[..len] is a valid byte slice; we re-interpret the
483 // i8 view as u8 (same layout) to feed it to String.
484 let bytes: Vec<u8> = buf[..len].iter().map(|&b| b as u8).collect();
485 String::from_utf8(bytes).ok()
486}
487
488#[cfg(not(unix))]
489fn current_hostname() -> Option<String> {
490 std::env::var("COMPUTERNAME")
491 .ok()
492 .or_else(|| std::env::var("HOSTNAME").ok())
493}
494
495fn hex_lower(bytes: &[u8]) -> String {
496 let mut s = String::with_capacity(bytes.len() * 2);
497 for b in bytes {
498 s.push_str(&format!("{:02x}", b));
499 }
500 s
501}