Skip to main content

khive_pack_git/
write_handlers.rs

1//! `git.commit` / `git.branch` / `git.push` verb handlers (ADR-108, amended
2//! by the ADR-108 Amendment).
3//!
4//! Thin, 1:1 wrappers over system `git`, shelled via
5//! `std::process::Command::args` (never a shell string) with every
6//! caller-supplied value validated and assembled into an argv vector by
7//! `crate::write_argv` before it reaches the process boundary. `repo` is an
8//! ordinary verb argument like every other khive verb — the Gate (ADR-018)
9//! still decides allow/deny before any of these functions run, but it is no
10//! longer the only enforcement point: `enforce_write_policy` below is a
11//! handler-level precondition, fail-closed independent of Gate
12//! configuration, resolved by `crate::write_policy` against the operator's
13//! `[git_write]` allowlist (ADR-108 Amendment).
14//!
15//! `enforce_write_policy` returns the **canonical** repo path on success, and
16//! every git invocation for that call uses it from that point on — never the
17//! raw caller-supplied `repo` (ADR-108 review r2 High finding: reusing the
18//! raw path after only canonicalizing it for the comparison is a symlink
19//! TOCTOU). The check and the mutation are additionally serialized per-repo
20//! via the private `repo_write_lock` helper so a concurrent khive-mediated write to the same
21//! repo cannot interleave between the policy check and the git command it
22//! guards.
23//!
24//! Every write attempt — allowed or denied, whether git itself then
25//! succeeds or fails — appends exactly one `git.write`-shaped `Event` (kind
26//! `Audit`, ADR-108 rule 2) via `emit_write_audit`, carrying
27//! `repo`/`branch`/`decision` and, on success, `sha`. This is in addition
28//! to, not a replacement for, the dispatch-level gate-check audit that
29//! fires for every verb.
30
31use std::collections::HashMap;
32use std::path::{Path, PathBuf};
33use std::process::Command;
34use std::sync::{Arc, Mutex as StdMutex, OnceLock};
35
36use serde_json::{json, Value};
37use tokio::sync::Mutex as AsyncMutex;
38
39use khive_runtime::{NamespaceToken, RuntimeError};
40use khive_storage::event::Event;
41use khive_types::{EventKind, EventOutcome, SubstrateKind};
42
43use crate::write_argv::{
44    build_add_argv, build_branch_argv, build_commit_argv, build_push_argv, reject_force,
45    validate_repo_path, GitArgError,
46};
47use crate::write_policy::{GitWritePolicy, GitWritePolicyError};
48use crate::GitPack;
49
50fn to_invalid_input(e: GitArgError) -> RuntimeError {
51    RuntimeError::InvalidInput(e.to_string())
52}
53
54fn to_policy_denied(e: GitWritePolicyError) -> RuntimeError {
55    RuntimeError::InvalidInput(e.to_string())
56}
57
58/// Process-wide per-repo advisory lock registry, keyed by canonical repo
59/// path. Guards the window between `enforce_write_policy`'s check and the
60/// mutating git command it authorizes, so two concurrent khive-mediated
61/// writes to the same repo (e.g. two overlapping `git.commit` calls) cannot
62/// interleave a check from one call with the mutation of another (ADR-108
63/// review r2 High finding).
64static REPO_LOCKS: OnceLock<StdMutex<HashMap<PathBuf, Arc<AsyncMutex<()>>>>> = OnceLock::new();
65
66fn repo_write_lock(repo: &Path) -> Arc<AsyncMutex<()>> {
67    let key = std::fs::canonicalize(repo).unwrap_or_else(|_| repo.to_path_buf());
68    let registry = REPO_LOCKS.get_or_init(|| StdMutex::new(HashMap::new()));
69    let mut guard = registry.lock().unwrap_or_else(|e| e.into_inner());
70    guard
71        .entry(key)
72        .or_insert_with(|| Arc::new(AsyncMutex::new(())))
73        .clone()
74}
75
76fn parse_repo_param(params: &Value) -> Result<PathBuf, RuntimeError> {
77    match params.get("repo") {
78        Some(Value::String(raw)) => Ok(PathBuf::from(raw)),
79        None | Some(Value::Null) => Err(RuntimeError::InvalidInput("repo is required".into())),
80        Some(other) => Err(RuntimeError::InvalidInput(format!(
81            "repo must be a string, got {other:?}"
82        ))),
83    }
84}
85
86fn parse_optional_string<'a>(
87    params: &'a Value,
88    name: &str,
89) -> Result<Option<&'a str>, RuntimeError> {
90    match params.get(name) {
91        None | Some(Value::Null) => Ok(None),
92        Some(Value::String(value)) => Ok(Some(value)),
93        Some(other) => Err(RuntimeError::InvalidInput(format!(
94            "{name} must be a string, got {other:?}"
95        ))),
96    }
97}
98
99fn parse_paths_param(params: &Value) -> Result<Vec<String>, RuntimeError> {
100    match params.get("paths") {
101        None | Some(Value::Null) => Ok(Vec::new()),
102        Some(Value::Array(arr)) => arr
103            .iter()
104            .map(|v| {
105                v.as_str().map(str::to_string).ok_or_else(|| {
106                    RuntimeError::InvalidInput("paths entries must be strings".into())
107                })
108            })
109            .collect(),
110        Some(other) => Err(RuntimeError::InvalidInput(format!(
111            "paths must be an array of strings, got {other:?}"
112        ))),
113    }
114}
115
116/// Parses the `force` argument. `true` is caught by [`reject_force`]
117/// downstream; any non-boolean value (a string, number, array, object) is
118/// rejected loudly here rather than silently coerced to `false` — an
119/// explicit but malformed `force` argument must never be interpreted as "no
120/// force requested" (ADR-108: "an explicit force arg is rejected loudly").
121fn parse_force_param(params: &Value) -> Result<Option<bool>, RuntimeError> {
122    match params.get("force") {
123        None | Some(Value::Null) => Ok(None),
124        Some(Value::Bool(b)) => Ok(Some(*b)),
125        Some(other) => Err(RuntimeError::InvalidInput(format!(
126            "force must be a boolean, got {other:?}; force-push is never permitted through this verb"
127        ))),
128    }
129}
130
131/// Runs `git -C <repo> <argv...>`, argv-only (no shell), returning stdout on
132/// success or a `RuntimeError` carrying git's stderr on failure.
133///
134/// Every invocation disables repo-configured hooks via
135/// `-c core.hooksPath=/dev/null` (ADR-108 Amendment), mirroring
136/// `crate::cache`'s hardened clone/fetch invocations: this function runs in
137/// the daemon's own credential context, so a hook script committed into an
138/// allowlisted repo (e.g. `.git/hooks/pre-commit`) must never get a chance
139/// to execute as a side effect of a khive-mediated write. `GIT_CONFIG_GLOBAL`
140/// / `GIT_CONFIG_SYSTEM` are deliberately left untouched here, unlike the
141/// test harness's hermetic `git_command` helper: these are real,
142/// operator-owned repos, and a commit/push needs the operator's actual
143/// author identity and credential helpers (SSH keys, `credential.helper`)
144/// configured in global/system git config to work at all — neutralizing
145/// that config would break the legitimate write path along with the attack
146/// surface it does not itself pose (hooks are the RCE risk; identity/
147/// credential config is not). Unit-test builds override both config sources
148/// below so handler tests remain hermetic; that override is not compiled into
149/// production builds.
150fn run_git(repo: &Path, argv: &[String]) -> Result<String, RuntimeError> {
151    let mut command = Command::new("git");
152    command
153        .arg("-c")
154        .arg("core.hooksPath=/dev/null")
155        .arg("-C")
156        .arg(repo)
157        .args(argv);
158    #[cfg(test)]
159    command
160        .env("GIT_CONFIG_GLOBAL", "/dev/null")
161        .env("GIT_CONFIG_SYSTEM", "/dev/null");
162    let output = command
163        .output()
164        .map_err(|e| RuntimeError::InvalidInput(format!("spawning git {argv:?}: {e}")))?;
165    if !output.status.success() {
166        return Err(RuntimeError::InvalidInput(format!(
167            "git {argv:?} failed: {}",
168            String::from_utf8_lossy(&output.stderr).trim()
169        )));
170    }
171    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
172}
173
174/// Reads the repo's currently checked-out branch via `git symbolic-ref`.
175/// `git.commit` has no explicit branch argument — the branch it actually
176/// writes to is whatever is checked out — so this is what
177/// `enforce_write_policy` checks the allowlist against for that verb.
178/// Errors (e.g. detached HEAD) surface as an ordinary handler error.
179fn current_branch(repo: &Path) -> Result<String, RuntimeError> {
180    let out = run_git(
181        repo,
182        &[
183            "symbolic-ref".to_string(),
184            "--short".to_string(),
185            "HEAD".to_string(),
186        ],
187    )?;
188    Ok(out.trim().to_string())
189}
190
191struct WritePreflightError {
192    error: RuntimeError,
193    branch: Option<String>,
194    outcome: EventOutcome,
195}
196
197impl WritePreflightError {
198    fn denied(error: RuntimeError, branch: Option<&str>) -> Self {
199        Self {
200            error,
201            branch: branch.map(str::to_string),
202            outcome: EventOutcome::Denied,
203        }
204    }
205
206    fn runtime(error: RuntimeError) -> Self {
207        Self {
208            error,
209            branch: None,
210            outcome: EventOutcome::Error,
211        }
212    }
213}
214
215struct CommitPreflight {
216    branch: String,
217    add_argv: Option<Vec<String>>,
218    commit_argv: Vec<String>,
219}
220
221fn prepare_commit(repo: &Path, params: &Value) -> Result<CommitPreflight, WritePreflightError> {
222    validate_repo_path(repo)
223        .map_err(to_invalid_input)
224        .map_err(|e| WritePreflightError::denied(e, None))?;
225    let branch = current_branch(repo).map_err(WritePreflightError::runtime)?;
226    let message = params
227        .get("message")
228        .and_then(Value::as_str)
229        .ok_or_else(|| RuntimeError::InvalidInput("git.commit requires message".into()))
230        .map_err(|e| WritePreflightError::denied(e, Some(&branch)))?;
231    let paths =
232        parse_paths_param(params).map_err(|e| WritePreflightError::denied(e, Some(&branch)))?;
233    let author = parse_optional_string(params, "author")
234        .map_err(|e| WritePreflightError::denied(e, Some(&branch)))?;
235    let add_argv = if paths.is_empty() {
236        None
237    } else {
238        Some(
239            build_add_argv(&paths)
240                .map_err(to_invalid_input)
241                .map_err(|e| WritePreflightError::denied(e, Some(&branch)))?,
242        )
243    };
244    let commit_argv = build_commit_argv(message, &paths, author)
245        .map_err(to_invalid_input)
246        .map_err(|e| WritePreflightError::denied(e, Some(&branch)))?;
247    Ok(CommitPreflight {
248        branch,
249        add_argv,
250        commit_argv,
251    })
252}
253
254struct BranchPreflight {
255    name: String,
256    from: Option<String>,
257    argv: Vec<String>,
258}
259
260fn prepare_branch(repo: &Path, params: &Value) -> Result<BranchPreflight, WritePreflightError> {
261    validate_repo_path(repo)
262        .map_err(to_invalid_input)
263        .map_err(|e| WritePreflightError::denied(e, None))?;
264    let name = params
265        .get("name")
266        .and_then(Value::as_str)
267        .ok_or_else(|| RuntimeError::InvalidInput("git.branch requires name".into()))
268        .map_err(|e| WritePreflightError::denied(e, None))?;
269    let from = parse_optional_string(params, "from")
270        .map_err(|e| WritePreflightError::denied(e, Some(name)))?;
271    let argv = build_branch_argv(name, from)
272        .map_err(to_invalid_input)
273        .map_err(|e| WritePreflightError::denied(e, Some(name)))?;
274    Ok(BranchPreflight {
275        name: name.to_string(),
276        from: from.map(str::to_string),
277        argv,
278    })
279}
280
281struct PushPreflight {
282    branch: String,
283    remote: String,
284    argv: Vec<String>,
285}
286
287fn prepare_push(repo: &Path, params: &Value) -> Result<PushPreflight, WritePreflightError> {
288    validate_repo_path(repo)
289        .map_err(to_invalid_input)
290        .map_err(|e| WritePreflightError::denied(e, None))?;
291    let branch = params
292        .get("branch")
293        .and_then(Value::as_str)
294        .ok_or_else(|| RuntimeError::InvalidInput("git.push requires branch".into()))
295        .map_err(|e| WritePreflightError::denied(e, None))?;
296    let remote = parse_optional_string(params, "remote")
297        .map_err(|e| WritePreflightError::denied(e, Some(branch)))?
298        .unwrap_or("origin");
299    let force =
300        parse_force_param(params).map_err(|e| WritePreflightError::denied(e, Some(branch)))?;
301    reject_force(force)
302        .map_err(to_invalid_input)
303        .map_err(|e| WritePreflightError::denied(e, Some(branch)))?;
304    let argv = build_push_argv(remote, branch)
305        .map_err(to_invalid_input)
306        .map_err(|e| WritePreflightError::denied(e, Some(branch)))?;
307    Ok(PushPreflight {
308        branch: branch.to_string(),
309        remote: remote.to_string(),
310        argv,
311    })
312}
313
314impl GitPack {
315    /// Handler-level fail-closed precondition (ADR-108 Amendment), enforced
316    /// before any of the three write verbs mutate a repository: the write
317    /// verbs are unavailable unless a `[git_write]` policy is configured,
318    /// and even then `repo`/`branch` must resolve to an allowlisted entry.
319    /// This runs in addition to, not instead of, the Gate (ADR-018) —
320    /// deliberately not dependent on Gate configuration, the same
321    /// enforcement class as [`crate::write_argv::reject_force`]'s
322    /// unconditional force-push denial.
323    ///
324    /// Reads the already-resolved `[git_write]` policy from
325    /// `RuntimeConfig::git_write` (threaded in at boot from `--config` /
326    /// `KHIVE_CONFIG` discovery, see `crate::write_policy`'s module doc) —
327    /// never re-runs config discovery itself, so an explicit `--config` path
328    /// reaches this check even when not also exported as `KHIVE_CONFIG`.
329    ///
330    /// On success, returns the **canonical** repo path — callers must use it
331    /// for every subsequent git invocation, never the raw `repo` argument
332    /// (see the module doc's TOCTOU note).
333    fn enforce_write_policy(&self, repo: &Path, branch: &str) -> Result<PathBuf, RuntimeError> {
334        let policy = GitWritePolicy::from_config(&self.runtime().config().git_write);
335        policy.check(repo, branch).map_err(to_policy_denied)
336    }
337
338    async fn audit_early_failure(
339        &self,
340        token: &NamespaceToken,
341        verb: &str,
342        repo: &Path,
343        branch: Option<&str>,
344        outcome: EventOutcome,
345        error: RuntimeError,
346    ) -> RuntimeError {
347        self.emit_write_audit(token, verb, repo, branch, "deny", outcome, None)
348            .await;
349        error
350    }
351
352    async fn parse_audited_repo(
353        &self,
354        token: &NamespaceToken,
355        verb: &str,
356        params: &Value,
357    ) -> Result<PathBuf, RuntimeError> {
358        match parse_repo_param(params) {
359            Ok(repo) => Ok(repo),
360            Err(error) => Err(self
361                .audit_early_failure(
362                    token,
363                    verb,
364                    Path::new("<invalid-repo>"),
365                    None,
366                    EventOutcome::Denied,
367                    error,
368                )
369                .await),
370        }
371    }
372
373    pub(crate) async fn handle_commit(
374        &self,
375        token: &NamespaceToken,
376        params: Value,
377    ) -> Result<Value, RuntimeError> {
378        let repo = self
379            .parse_audited_repo(token, "git.commit", &params)
380            .await?;
381        let lock = repo_write_lock(&repo);
382        let _guard = lock.lock().await;
383        let CommitPreflight {
384            branch,
385            add_argv,
386            commit_argv,
387        } = match prepare_commit(&repo, &params) {
388            Ok(preflight) => preflight,
389            Err(failure) => {
390                return Err(self
391                    .audit_early_failure(
392                        token,
393                        "git.commit",
394                        &repo,
395                        failure.branch.as_deref(),
396                        failure.outcome,
397                        failure.error,
398                    )
399                    .await)
400            }
401        };
402
403        let canonical_repo = match self.enforce_write_policy(&repo, &branch) {
404            Ok(p) => p,
405            Err(e) => {
406                self.emit_write_audit(
407                    token,
408                    "git.commit",
409                    &repo,
410                    Some(&branch),
411                    "deny",
412                    EventOutcome::Denied,
413                    None,
414                )
415                .await;
416                return Err(e);
417            }
418        };
419
420        let exec: Result<String, RuntimeError> = (|| {
421            if let Some(add_argv) = &add_argv {
422                run_git(&canonical_repo, add_argv)?;
423            }
424            run_git(&canonical_repo, &commit_argv)?;
425            let sha = run_git(
426                &canonical_repo,
427                &["rev-parse".to_string(), "HEAD".to_string()],
428            )?
429            .trim()
430            .to_string();
431            Ok(sha)
432        })();
433
434        match exec {
435            Ok(sha) => {
436                self.emit_write_audit(
437                    token,
438                    "git.commit",
439                    &canonical_repo,
440                    Some(&branch),
441                    "allow",
442                    EventOutcome::Success,
443                    Some(&sha),
444                )
445                .await;
446                Ok(json!({
447                    "repo": canonical_repo.display().to_string(),
448                    "sha": sha,
449                }))
450            }
451            Err(e) => {
452                self.emit_write_audit(
453                    token,
454                    "git.commit",
455                    &canonical_repo,
456                    Some(&branch),
457                    "allow",
458                    EventOutcome::Error,
459                    None,
460                )
461                .await;
462                Err(e)
463            }
464        }
465    }
466
467    pub(crate) async fn handle_branch(
468        &self,
469        token: &NamespaceToken,
470        params: Value,
471    ) -> Result<Value, RuntimeError> {
472        let repo = self
473            .parse_audited_repo(token, "git.branch", &params)
474            .await?;
475        let lock = repo_write_lock(&repo);
476        let _guard = lock.lock().await;
477        let BranchPreflight { name, from, argv } = match prepare_branch(&repo, &params) {
478            Ok(preflight) => preflight,
479            Err(failure) => {
480                return Err(self
481                    .audit_early_failure(
482                        token,
483                        "git.branch",
484                        &repo,
485                        failure.branch.as_deref(),
486                        failure.outcome,
487                        failure.error,
488                    )
489                    .await)
490            }
491        };
492
493        let canonical_repo = match self.enforce_write_policy(&repo, &name) {
494            Ok(p) => p,
495            Err(e) => {
496                self.emit_write_audit(
497                    token,
498                    "git.branch",
499                    &repo,
500                    Some(&name),
501                    "deny",
502                    EventOutcome::Denied,
503                    None,
504                )
505                .await;
506                return Err(e);
507            }
508        };
509
510        match run_git(&canonical_repo, &argv) {
511            Ok(_) => {
512                self.emit_write_audit(
513                    token,
514                    "git.branch",
515                    &canonical_repo,
516                    Some(&name),
517                    "allow",
518                    EventOutcome::Success,
519                    None,
520                )
521                .await;
522                Ok(json!({
523                    "repo": canonical_repo.display().to_string(),
524                    "name": name,
525                    "from": from,
526                }))
527            }
528            Err(e) => {
529                self.emit_write_audit(
530                    token,
531                    "git.branch",
532                    &canonical_repo,
533                    Some(&name),
534                    "allow",
535                    EventOutcome::Error,
536                    None,
537                )
538                .await;
539                Err(e)
540            }
541        }
542    }
543
544    pub(crate) async fn handle_push(
545        &self,
546        token: &NamespaceToken,
547        params: Value,
548    ) -> Result<Value, RuntimeError> {
549        let repo = self.parse_audited_repo(token, "git.push", &params).await?;
550        let lock = repo_write_lock(&repo);
551        let _guard = lock.lock().await;
552        let PushPreflight {
553            branch,
554            remote,
555            argv,
556        } = match prepare_push(&repo, &params) {
557            Ok(preflight) => preflight,
558            Err(failure) => {
559                return Err(self
560                    .audit_early_failure(
561                        token,
562                        "git.push",
563                        &repo,
564                        failure.branch.as_deref(),
565                        failure.outcome,
566                        failure.error,
567                    )
568                    .await)
569            }
570        };
571
572        let canonical_repo = match self.enforce_write_policy(&repo, &branch) {
573            Ok(p) => p,
574            Err(e) => {
575                self.emit_write_audit(
576                    token,
577                    "git.push",
578                    &repo,
579                    Some(&branch),
580                    "deny",
581                    EventOutcome::Denied,
582                    None,
583                )
584                .await;
585                return Err(e);
586            }
587        };
588
589        match run_git(&canonical_repo, &argv) {
590            Ok(_) => {
591                self.emit_write_audit(
592                    token,
593                    "git.push",
594                    &canonical_repo,
595                    Some(&branch),
596                    "allow",
597                    EventOutcome::Success,
598                    None,
599                )
600                .await;
601                Ok(json!({
602                    "repo": canonical_repo.display().to_string(),
603                    "remote": remote,
604                    "branch": branch,
605                }))
606            }
607            Err(e) => {
608                self.emit_write_audit(
609                    token,
610                    "git.push",
611                    &canonical_repo,
612                    Some(&branch),
613                    "allow",
614                    EventOutcome::Error,
615                    None,
616                )
617                .await;
618                Err(e)
619            }
620        }
621    }
622
623    /// Appends exactly one supplementary audit `Event` (ADR-108 rule 2) per
624    /// write attempt, on every exit path — handler-allowlist-denied,
625    /// git-failed, and success alike — carrying `repo`/`branch`/`decision`
626    /// and, on success, `sha`. This is in addition to, not a replacement
627    /// for, the dispatch-level gate-check audit
628    /// (`khive-runtime::pack::VerbRegistry::dispatch_with_identity`, fired
629    /// automatically for every verb): that audit only ever records the
630    /// Gate's own allow/deny decision, so without this call a
631    /// handler-allowlist denial left only a Gate "Allow" audit paired with
632    /// an errored outcome — a misleading trail (ADR-108 review r2 finding).
633    /// `decision` is `"allow"` when this handler's own precondition passed
634    /// (`enforce_write_policy` returned `Ok`, regardless of whether git
635    /// itself then succeeded) and `"deny"` when it did not. Best-effort: a
636    /// store failure is logged and swallowed, exactly like every other audit
637    /// write in this codebase (ADR-018 "audit storage failures don't
638    /// propagate") — it must never fail a write that git itself completed.
639    #[allow(clippy::too_many_arguments)]
640    async fn emit_write_audit(
641        &self,
642        token: &NamespaceToken,
643        verb: &str,
644        repo: &Path,
645        branch: Option<&str>,
646        decision: &str,
647        outcome: EventOutcome,
648        sha: Option<&str>,
649    ) {
650        let Ok(store) = self.runtime().events(token) else {
651            return;
652        };
653        let mut payload = json!({
654            "repo": repo.display().to_string(),
655            "decision": decision,
656        });
657        if let Some(b) = branch {
658            payload["branch"] = json!(b);
659        }
660        if let Some(s) = sha {
661            payload["sha"] = json!(s);
662        }
663        let event = Event::new(
664            token.namespace().as_str(),
665            verb,
666            EventKind::Audit,
667            SubstrateKind::Event,
668            token.actor().id.clone(),
669        )
670        .with_outcome(outcome)
671        .with_payload(payload);
672        if let Err(e) = store.append_event(event).await {
673            tracing::warn!(
674                verb,
675                error = %e,
676                "git write audit event store write failed (non-fatal)"
677            );
678        }
679    }
680}