Skip to main content

klasp_core/
trigger.rs

1//! Trigger pattern matching for git commit/push.
2//!
3//! Design: [docs/design.md §6]. The regex is a Rust port of fallow's POSIX
4//! ERE pattern, compiled once via `OnceLock`. Edge cases the regex
5//! deliberately misses (`bash -c "git push"`, `eval "git commit"`,
6//! `GIT_DIR=… git commit`, aliases like `gp`) are documented in design §6
7//! and treated as non-goals for v0.1; klasp gates honest agents, not
8//! adversarial ones.
9
10use std::sync::OnceLock;
11
12use regex::Regex;
13
14/// The git event a tool-call command was classified as.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum GitEvent {
17    Commit,
18    Push,
19}
20
21/// Stateless namespace for trigger classification. The regex itself is held
22/// in a process-wide `OnceLock`.
23pub struct Trigger;
24
25fn pattern() -> &'static Regex {
26    static RE: OnceLock<Regex> = OnceLock::new();
27    RE.get_or_init(|| {
28        // Anchor on a non-word boundary so `forgit commit` and `mygit push`
29        // don't match. The first capture group disambiguates commit vs push.
30        Regex::new(r"(?:^|[\s;|&()])git\s+(commit|push)(?:\s|$)")
31            .expect("trigger regex must compile")
32    })
33}
34
35impl Trigger {
36    /// Classify a shell command. Returns `Some(GitEvent)` if the command
37    /// represents a git commit or push that should run the gate, `None`
38    /// otherwise.
39    pub fn classify(cmd: &str) -> Option<GitEvent> {
40        let captures = pattern().captures(cmd)?;
41        match captures.get(1)?.as_str() {
42            "commit" => Some(GitEvent::Commit),
43            "push" => Some(GitEvent::Push),
44            _ => None,
45        }
46    }
47}
48
49#[cfg(test)]
50mod tests {
51    use super::*;
52
53    #[test]
54    fn matches_bare_commit() {
55        assert_eq!(Trigger::classify("git commit"), Some(GitEvent::Commit));
56    }
57
58    #[test]
59    fn matches_commit_with_flags() {
60        assert_eq!(
61            Trigger::classify("git commit -m 'wip'"),
62            Some(GitEvent::Commit),
63        );
64    }
65
66    #[test]
67    fn matches_bare_push() {
68        assert_eq!(Trigger::classify("git push"), Some(GitEvent::Push));
69    }
70
71    #[test]
72    fn matches_push_with_flags() {
73        assert_eq!(
74            Trigger::classify("git push origin main"),
75            Some(GitEvent::Push),
76        );
77    }
78
79    #[test]
80    fn matches_chained_with_double_amp() {
81        assert_eq!(
82            Trigger::classify("cargo test && git push"),
83            Some(GitEvent::Push),
84        );
85    }
86
87    #[test]
88    fn matches_chained_with_semicolon() {
89        assert_eq!(
90            Trigger::classify("cargo fmt; git commit"),
91            Some(GitEvent::Commit),
92        );
93    }
94
95    #[test]
96    fn matches_chained_with_pipe() {
97        assert_eq!(
98            Trigger::classify("echo hi | git commit -F -"),
99            Some(GitEvent::Commit),
100        );
101    }
102
103    #[test]
104    fn matches_subshell_parens() {
105        // Subshell-grouped invocation: `(git commit ...)` is a common idiom
106        // when chaining `cd dir && (git commit ...) && other`. The leading
107        // `(` is in the boundary-character set so the regex anchors cleanly.
108        assert_eq!(
109            Trigger::classify("(git commit -m 'wip')"),
110            Some(GitEvent::Commit),
111        );
112    }
113
114    #[test]
115    fn matches_subshell_push() {
116        assert_eq!(
117            Trigger::classify("(cd subdir && git push origin main)"),
118            Some(GitEvent::Push),
119        );
120    }
121
122    #[test]
123    fn rejects_forgit() {
124        assert_eq!(Trigger::classify("forgit commit"), None);
125    }
126
127    #[test]
128    fn rejects_mygit() {
129        assert_eq!(Trigger::classify("mygit push"), None);
130    }
131
132    #[test]
133    fn rejects_committed_substring() {
134        // Hypothetical command that mentions the substring "commit" but isn't
135        // a git commit invocation.
136        assert_eq!(Trigger::classify("git committed-files-tool"), None);
137        // Bare `git committed` — the `(?:\s|$)` tail anchor must reject the
138        // `t` after `commit` here too, not just whatever-follows-a-dash.
139        assert_eq!(Trigger::classify("git committed"), None);
140    }
141
142    #[test]
143    fn rejects_unrelated_git_subcommand() {
144        assert_eq!(Trigger::classify("git status"), None);
145        assert_eq!(Trigger::classify("git log"), None);
146    }
147
148    #[test]
149    fn rejects_plain_text() {
150        assert_eq!(Trigger::classify("ls -la"), None);
151        assert_eq!(Trigger::classify(""), None);
152    }
153
154    /// Documented limitation: `git -c key=value commit` puts a flag between
155    /// `git` and the subcommand, so the simple regex doesn't recognise it.
156    /// Pinned as `#[ignore]` so the limitation lives in code; v0.2 may
157    /// upgrade the trigger language and lift this. See [docs/design.md §6,
158    /// §10].
159    #[test]
160    #[ignore = "design §6 known limitation; tracked for v0.2"]
161    fn matches_git_dash_c_commit() {
162        assert_eq!(
163            Trigger::classify("git -c user.email=x@y.z commit"),
164            Some(GitEvent::Commit),
165        );
166    }
167
168    /// Deliberate non-goal per [docs/design.md §6]: a `bash -c "git push"`
169    /// payload hides the trigger inside a quoted argument the regex never
170    /// inspects. Honest agents don't do this; adversarial ones can bypass
171    /// klasp trivially anyway (`bash -c "$(echo ... | base64 -d)"`).
172    #[test]
173    #[ignore = "design §6 deliberate non-goal; klasp gates honest agents"]
174    fn deliberately_misses_bash_c_quoted() {
175        assert_eq!(
176            Trigger::classify(r#"bash -c "git push""#),
177            Some(GitEvent::Push),
178        );
179    }
180
181    /// Deliberate non-goal per [docs/design.md §6]: `eval` defers
182    /// classification to a runtime-constructed string the regex can't see.
183    #[test]
184    #[ignore = "design §6 deliberate non-goal; klasp gates honest agents"]
185    fn deliberately_misses_eval_quoted() {
186        assert_eq!(
187            Trigger::classify(r#"eval "git commit""#),
188            Some(GitEvent::Commit),
189        );
190    }
191
192    /// Deliberate non-goal per [docs/design.md §6]: env-var-prefixed
193    /// invocations such as `GIT_DIR=/elsewhere git push` are uncommon outside
194    /// scripts. v0.2 may add a leading-env-assignment skip.
195    #[test]
196    #[ignore = "design §6 deliberate non-goal; v0.2 candidate"]
197    fn deliberately_misses_env_prefixed() {
198        assert_eq!(
199            Trigger::classify("GIT_DIR=/elsewhere git push"),
200            Some(GitEvent::Push),
201        );
202    }
203
204    /// Deliberate non-goal per [docs/design.md §6]: shell aliases such as
205    /// `gp = git push` resolve at the shell layer. The regex inspects the
206    /// raw tool-input command, not the post-alias-expansion form.
207    #[test]
208    #[ignore = "design §6 deliberate non-goal; shell aliases are out of scope"]
209    fn deliberately_misses_alias() {
210        assert_eq!(Trigger::classify("gp"), Some(GitEvent::Push));
211    }
212}