Skip to main content

khive_pack_git/
write_policy.rs

1//! Handler-level git-write policy allowlist (ADR-108 Amendment).
2//!
3//! `git.commit` / `git.branch` / `git.push` fail closed at the handler when
4//! no policy artifact is configured, or the artifact is empty — the same
5//! enforcement class as [`crate::write_argv::reject_force`]'s unconditional
6//! force-push denial: deliberately not dependent on Gate configuration. The
7//! Rego/Gate policy path (ADR-018) still runs on top of this, unchanged;
8//! this module only adds a handler-level precondition that must also pass.
9//!
10//! The policy is a closed allowlist of `(repo_path, branch_patterns)`
11//! entries sourced from the `[git_write]` section of the standard khive
12//! config file (`khive_runtime::engine_config::KhiveConfig`). Discovery
13//! (`--config`/`KHIVE_CONFIG`, project `khive.toml`, db-anchored
14//! `.khive/config.toml`, `~/.khive/config.toml`) happens exactly once, at
15//! boot, in the transport layer (`khive-mcp::serve`) via
16//! `khive_runtime::runtime_config_from_khive_config`, which threads the
17//! resolved `[git_write]` section into `RuntimeConfig::git_write`. This
18//! module never re-reads `KHIVE_CONFIG` or re-runs discovery itself —
19//! [`GitWritePolicy::from_config`] is a pure, I/O-free conversion from the
20//! already-resolved `RuntimeConfig::git_write` the handler reads via
21//! `self.runtime().config().git_write` (ADR-108 review r2: a handler-level
22//! reload ignored an explicit `--config` path that was not also exported as
23//! `KHIVE_CONFIG`, so `kkernel mcp --config /path` silently failed closed).
24//! An allowlisted repo is operator-declared trusted provenance — this is
25//! the concrete boundary ADR-108's Open Question 4 (fork-content write
26//! capability stays unbuilt) resolves to: only repos an operator has
27//! explicitly named ever accept a khive-mediated write.
28
29use std::path::{Path, PathBuf};
30
31use khive_runtime::engine_config::GitWriteSectionConfig;
32
33/// One allowlisted `(repo_path, branch_patterns)` entry.
34#[derive(Debug, Clone)]
35pub struct GitWritePolicyEntry {
36    /// Absolute repo path as configured. Canonicalized at check time, not
37    /// load time, so the check reflects the filesystem state at the moment
38    /// of the write, not at daemon startup.
39    pub repo_path: PathBuf,
40    /// Non-empty list of exact branch names or single-`*`-wildcard globs
41    /// (e.g. `"main"`, `"release-*"`, `"*"`).
42    pub branch_patterns: Vec<String>,
43}
44
45/// The parsed `[git_write]` allowlist.
46///
47/// `allowed.is_empty()` is the fail-closed default — both "no `[git_write]`
48/// section at all" and "`[git_write]` present with an empty `allowed` list"
49/// collapse to the same empty policy, and [`GitWritePolicy::check`] denies
50/// unconditionally in that state.
51#[derive(Debug, Clone, Default)]
52pub struct GitWritePolicy {
53    pub allowed: Vec<GitWritePolicyEntry>,
54}
55
56/// Why a git-write policy check failed.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub enum GitWritePolicyError {
59    /// No `[git_write]` policy artifact is configured, or it is empty.
60    NotConfigured,
61    /// `repo`, canonicalized, does not exactly match any allowlisted
62    /// entry's canonicalized `repo_path`.
63    RepoNotAllowlisted(String),
64    /// `repo` is allowlisted but `branch` matches none of that entry's
65    /// `branch_patterns`.
66    BranchNotAllowed { repo: String, branch: String },
67}
68
69impl std::fmt::Display for GitWritePolicyError {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        match self {
72            Self::NotConfigured => write!(
73                f,
74                "this verb is unavailable until a git-write policy is configured \
75                 ([git_write] in khive config, ADR-108 Amendment)"
76            ),
77            Self::RepoNotAllowlisted(repo) => write!(
78                f,
79                "repo {repo:?} is not in the configured [git_write] allowlist"
80            ),
81            Self::BranchNotAllowed { repo, branch } => write!(
82                f,
83                "branch {branch:?} in repo {repo:?} does not match any allowed \
84                 branch pattern for that repo's [git_write] entry"
85            ),
86        }
87    }
88}
89
90impl std::error::Error for GitWritePolicyError {}
91
92impl GitWritePolicy {
93    /// Deny-by-default check: fails with [`GitWritePolicyError::NotConfigured`]
94    /// when the policy is empty, otherwise requires `repo` to canonicalize
95    /// to an allowlisted entry and `branch` to match one of that entry's
96    /// patterns. On success, returns the **canonical** repo path — callers
97    /// must use this returned path for every subsequent git invocation for
98    /// the call, never the raw `repo` argument (ADR-108 review r2 High
99    /// finding): using the raw caller path after only canonicalizing it for
100    /// the comparison is a symlink TOCTOU -- a symlink that resolved to an
101    /// allowlisted repo at check time can be retargeted to an
102    /// unallowlisted repo before the mutating git command runs, which would
103    /// then silently operate on the retargeted path if handlers kept using
104    /// `repo` instead of the resolved identity this function already
105    /// computed.
106    ///
107    /// `repo` is canonicalized before comparison, and so is every
108    /// allowlisted entry's `repo_path` — a symlink that resolves to an
109    /// allowlisted repo's real path is accepted (it names the same repo);
110    /// a symlink that resolves anywhere else is denied exactly as if the
111    /// caller had passed that other path directly. Canonicalization never
112    /// widens what is reachable, only normalizes how the same repo can be
113    /// spelled.
114    pub fn check(&self, repo: &Path, branch: &str) -> Result<PathBuf, GitWritePolicyError> {
115        if self.allowed.is_empty() {
116            return Err(GitWritePolicyError::NotConfigured);
117        }
118        let canonical_repo = std::fs::canonicalize(repo).unwrap_or_else(|_| repo.to_path_buf());
119        let entry = self.allowed.iter().find(|e| {
120            std::fs::canonicalize(&e.repo_path)
121                .map(|c| c == canonical_repo)
122                .unwrap_or(false)
123        });
124        let Some(entry) = entry else {
125            return Err(GitWritePolicyError::RepoNotAllowlisted(
126                repo.display().to_string(),
127            ));
128        };
129        if entry
130            .branch_patterns
131            .iter()
132            .any(|pattern| glob_match(pattern, branch))
133        {
134            Ok(canonical_repo)
135        } else {
136            Err(GitWritePolicyError::BranchNotAllowed {
137                repo: repo.display().to_string(),
138                branch: branch.to_string(),
139            })
140        }
141    }
142}
143
144/// Minimal glob: `*` matches any run of characters (including empty); every
145/// other character must match literally. No `?`, character classes, or
146/// escaping — deliberately kept to the smallest grammar that expresses
147/// "exact name" (no `*`) and "prefix/suffix/contains" (exactly one `*`)
148/// branch policies. ADR-108 authorizes exact name or a SINGLE wildcard only;
149/// `KhiveConfig::validate` already rejects a multi-star pattern at config
150/// load, but this guard defends the invariant here too, for any
151/// `GitWritePolicy` built directly (e.g. by a future non-TOML config path)
152/// rather than through `KhiveConfig::validate` — a pattern with two or more
153/// `*` never matches anything instead of silently widening to a broader
154/// grammar than the ADR authorizes.
155fn glob_match(pattern: &str, value: &str) -> bool {
156    if pattern.matches('*').count() > 1 {
157        return false;
158    }
159    if !pattern.contains('*') {
160        return pattern == value;
161    }
162    let parts: Vec<&str> = pattern.split('*').collect();
163    let mut rest = value;
164    let last = parts.len() - 1;
165    for (i, part) in parts.iter().enumerate() {
166        if part.is_empty() {
167            continue;
168        }
169        if i == 0 {
170            if !rest.starts_with(part) {
171                return false;
172            }
173            rest = &rest[part.len()..];
174        } else if i == last {
175            if !rest.ends_with(part) {
176                return false;
177            }
178        } else {
179            match rest.find(part) {
180                Some(pos) => rest = &rest[pos + part.len()..],
181                None => return false,
182            }
183        }
184    }
185    true
186}
187
188impl GitWritePolicy {
189    /// Pure, I/O-free conversion from an already-resolved `[git_write]`
190    /// section (`RuntimeConfig::git_write`, threaded in at boot from
191    /// `KhiveConfig` — see the module doc). Performs no discovery and reads
192    /// no environment variables: the handler passes in whatever
193    /// `self.runtime().config().git_write` already holds.
194    pub fn from_config(section: &GitWriteSectionConfig) -> Self {
195        let allowed = section
196            .allowed
197            .iter()
198            .map(|entry| GitWritePolicyEntry {
199                repo_path: PathBuf::from(&entry.repo),
200                branch_patterns: entry.branches.clone(),
201            })
202            .collect();
203        GitWritePolicy { allowed }
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    fn entry(repo: &Path, branches: &[&str]) -> GitWritePolicyEntry {
212        GitWritePolicyEntry {
213            repo_path: repo.to_path_buf(),
214            branch_patterns: branches.iter().map(|s| s.to_string()).collect(),
215        }
216    }
217
218    #[test]
219    fn empty_policy_denies_not_configured() {
220        let policy = GitWritePolicy::default();
221        let dir = tempfile::tempdir().unwrap();
222        assert_eq!(
223            policy.check(dir.path(), "main"),
224            Err(GitWritePolicyError::NotConfigured)
225        );
226    }
227
228    #[test]
229    fn allowlisted_repo_and_branch_succeeds() {
230        let dir = tempfile::tempdir().unwrap();
231        let policy = GitWritePolicy {
232            allowed: vec![entry(dir.path(), &["main", "release-*"])],
233        };
234        assert!(policy.check(dir.path(), "main").is_ok());
235        assert!(policy.check(dir.path(), "release-1.2.3").is_ok());
236    }
237
238    #[test]
239    fn non_allowlisted_repo_denied() {
240        let dir = tempfile::tempdir().unwrap();
241        let other = tempfile::tempdir().unwrap();
242        let policy = GitWritePolicy {
243            allowed: vec![entry(dir.path(), &["main"])],
244        };
245        let err = policy.check(other.path(), "main").unwrap_err();
246        assert!(matches!(err, GitWritePolicyError::RepoNotAllowlisted(_)));
247    }
248
249    #[test]
250    fn branch_outside_patterns_denied() {
251        let dir = tempfile::tempdir().unwrap();
252        let policy = GitWritePolicy {
253            allowed: vec![entry(dir.path(), &["main"])],
254        };
255        let err = policy.check(dir.path(), "feat/x").unwrap_err();
256        assert!(matches!(err, GitWritePolicyError::BranchNotAllowed { .. }));
257    }
258
259    #[test]
260    fn glob_match_exact_no_wildcard() {
261        assert!(glob_match("main", "main"));
262        assert!(!glob_match("main", "mainx"));
263    }
264
265    #[test]
266    fn glob_match_prefix_and_suffix_and_contains() {
267        assert!(glob_match("feat/*", "feat/x"));
268        assert!(!glob_match("feat/*", "fix/x"));
269        assert!(glob_match("*-stable", "v1-stable"));
270        assert!(glob_match("*", "anything"));
271        assert!(glob_match("rel-*-final", "rel-1.2-final"));
272        assert!(!glob_match("rel-*-final", "rel-1.2"));
273    }
274
275    // ADR-108: exact name or a SINGLE wildcard only -- a pattern with two or
276    // more `*` must never match, even if it slipped past config validation.
277    #[test]
278    fn glob_match_rejects_multi_star_patterns() {
279        assert!(!glob_match("**", "anything"));
280        assert!(!glob_match("**", ""));
281        assert!(!glob_match("rel-*-*-final", "rel-1.2-final"));
282    }
283
284    #[test]
285    fn glob_match_single_star_boundary() {
286        assert!(glob_match("a*b", "a-mid-b"));
287        assert!(!glob_match("a*b*c", "a-x-b-y-c"));
288    }
289
290    #[test]
291    fn check_returns_canonical_repo_path_on_success() {
292        let dir = tempfile::tempdir().unwrap();
293        let policy = GitWritePolicy {
294            allowed: vec![entry(dir.path(), &["main"])],
295        };
296        let canonical = policy.check(dir.path(), "main").expect("allowed");
297        assert_eq!(canonical, std::fs::canonicalize(dir.path()).unwrap());
298    }
299
300    #[test]
301    fn from_config_converts_section() {
302        use khive_runtime::engine_config::GitWriteEntryConfig;
303        let section = GitWriteSectionConfig {
304            allowed: vec![GitWriteEntryConfig {
305                repo: "/abs/path".to_string(),
306                branches: vec!["main".to_string()],
307            }],
308        };
309        let policy = GitWritePolicy::from_config(&section);
310        assert_eq!(policy.allowed.len(), 1);
311        assert_eq!(policy.allowed[0].repo_path, PathBuf::from("/abs/path"));
312        assert_eq!(policy.allowed[0].branch_patterns, vec!["main".to_string()]);
313    }
314
315    #[cfg(unix)]
316    #[test]
317    fn symlink_resolving_to_allowlisted_repo_succeeds() {
318        let real = tempfile::tempdir().unwrap();
319        let parent = tempfile::tempdir().unwrap();
320        let link = parent.path().join("link-to-real");
321        std::os::unix::fs::symlink(real.path(), &link).unwrap();
322
323        let policy = GitWritePolicy {
324            allowed: vec![entry(real.path(), &["main"])],
325        };
326        assert!(
327            policy.check(&link, "main").is_ok(),
328            "a symlink that canonicalizes to an allowlisted repo must be accepted"
329        );
330    }
331
332    #[cfg(unix)]
333    #[test]
334    fn symlink_resolving_elsewhere_denied() {
335        let real = tempfile::tempdir().unwrap();
336        let decoy = tempfile::tempdir().unwrap();
337        let parent = tempfile::tempdir().unwrap();
338        let link = parent.path().join("link-to-decoy");
339        std::os::unix::fs::symlink(decoy.path(), &link).unwrap();
340
341        let policy = GitWritePolicy {
342            allowed: vec![entry(real.path(), &["main"])],
343        };
344        let err = policy.check(&link, "main").unwrap_err();
345        assert!(
346            matches!(err, GitWritePolicyError::RepoNotAllowlisted(_)),
347            "a symlink resolving outside the allowlist must not be usable as a bypass"
348        );
349    }
350
351    /// TOCTOU regression (ADR-108 review r2 High finding): a symlink pointing
352    /// at the allowlisted repo when `check()` runs, then retargeted to a
353    /// decoy repo before execution, must not let the retargeted path be
354    /// used. `check()` returns the canonical path resolved at check time;
355    /// callers that use ONLY that returned path (never the raw symlink
356    /// again) are unaffected by any later retarget, because the returned
357    /// `PathBuf` is an ordinary absolute path string, not a live symlink
358    /// traversal — retargeting `link` after this point has no way to change
359    /// what the already-returned canonical path resolves to.
360    #[cfg(unix)]
361    #[test]
362    fn canonical_path_returned_at_check_time_is_immune_to_later_retarget() {
363        let real = tempfile::tempdir().unwrap();
364        let decoy = tempfile::tempdir().unwrap();
365        let parent = tempfile::tempdir().unwrap();
366        let link = parent.path().join("link");
367        std::os::unix::fs::symlink(real.path(), &link).unwrap();
368
369        let policy = GitWritePolicy {
370            allowed: vec![entry(real.path(), &["main"])],
371        };
372        let canonical_at_check = policy.check(&link, "main").expect("allowed at check time");
373        assert_eq!(
374            canonical_at_check,
375            std::fs::canonicalize(real.path()).unwrap()
376        );
377
378        // Retarget the symlink to the decoy repo -- simulates an attacker
379        // racing the check.
380        std::fs::remove_file(&link).unwrap();
381        std::os::unix::fs::symlink(decoy.path(), &link).unwrap();
382
383        // The path a correct caller would now use for execution is the
384        // value already returned by `check()`, not `link` -- it still names
385        // the real (allowlisted) repo, unaffected by the retarget.
386        assert_eq!(
387            canonical_at_check,
388            std::fs::canonicalize(real.path()).unwrap()
389        );
390        assert_ne!(
391            canonical_at_check,
392            std::fs::canonicalize(decoy.path()).unwrap()
393        );
394    }
395}