Skip to main content

klasp_core/
protocol.rs

1//! Gate wire protocol — versioned, env-var-keyed.
2//!
3//! Design: [docs/design.md §3.3, §7]. The schema version is held in the
4//! `KLASP_GATE_SCHEMA` environment variable exported by the generated hook
5//! script, **not** in the JSON stdin payload — this defends against a
6//! malicious agent that crafts a `tool_input` field claiming an arbitrary
7//! schema version.
8//!
9//! Versioning is independent of klasp's semver: most binary releases will
10//! not bump the schema. Bumping is reserved for genuine wire-format changes
11//! (renamed fields, required-field additions, exit-code semantics).
12
13use serde::Deserialize;
14
15/// Wire-protocol schema version. Bump only when the JSON shape, exit-code
16/// semantics, or env-var contract changes — *never* for cosmetic releases.
17///
18/// v2 (v0.2.5): adds `parallel`, `all_fail`/`majority_fail` policies, JUnit/SARIF
19/// output, and monorepo config discovery. Old shims with `KLASP_GATE_SCHEMA=1`
20/// fail-open with a notice; `klasp install` regenerates the shim.
21pub const GATE_SCHEMA_VERSION: u32 = 2;
22
23/// Plugin subprocess protocol version. Separate from [`GATE_SCHEMA_VERSION`] so
24/// plugin upgrades and gate upgrades evolve independently.
25///
26/// `0` is the explicit experimental tier — this protocol **may break in any
27/// v0.3.x release**. It graduates to `1` only at v1.0 after real plugin authors
28/// have stressed it. See `docs/plugin-protocol.md` for the full spec.
29pub const PLUGIN_PROTOCOL_VERSION: u32 = 0;
30
31/// `klasp gate --format json` output schema version.
32///
33/// Stable from v0.3 forward — within a v0.3.x release series additions are
34/// allowed but removals and renames are not. See `docs/output-schema.md` for
35/// the full spec and stability commitment.
36///
37/// This constant is separate from `GATE_SCHEMA_VERSION` (the stdin wire
38/// protocol) — `KLASP_OUTPUT_SCHEMA` governs the machine-readable JSON that
39/// `klasp gate --format json` writes to stdout or `--output`.
40pub const KLASP_OUTPUT_SCHEMA: u32 = 1;
41
42/// The Claude Code `PreToolUse` payload klasp consumes from stdin.
43#[derive(Debug, Deserialize, PartialEq, Eq)]
44pub struct GateInput {
45    pub tool_name: String,
46    pub tool_input: ToolInput,
47}
48
49/// The subset of Claude Code's `tool_input` klasp inspects. Only the `Bash`
50/// tool's `command` field matters in v0.1; future fields can be added behind
51/// `#[serde(default)]` without bumping the schema.
52#[derive(Debug, Deserialize, PartialEq, Eq)]
53pub struct ToolInput {
54    #[serde(default)]
55    pub command: Option<String>,
56}
57
58#[derive(Debug, thiserror::Error, PartialEq, Eq)]
59pub enum GateError {
60    #[error("could not parse gate input as JSON: {0}")]
61    Parse(String),
62    #[error(
63        "klasp-gate: schema mismatch (script={script}, binary={binary}). \
64         Re-run `klasp install` to update the hook."
65    )]
66    SchemaMismatch { script: u32, binary: u32 },
67    #[error(
68        "KLASP_GATE_SCHEMA is not set. Re-run `klasp install` to regenerate \
69         the hook script."
70    )]
71    SchemaMissing,
72}
73
74pub struct GateProtocol;
75
76impl GateProtocol {
77    /// Parse the JSON payload Claude Code writes to the hook's stdin.
78    pub fn parse(stdin: &str) -> Result<GateInput, GateError> {
79        serde_json::from_str(stdin).map_err(|e| GateError::Parse(e.to_string()))
80    }
81
82    /// Read `KLASP_GATE_SCHEMA` from the environment and parse it as a `u32`.
83    ///
84    /// Returns `GateError::SchemaMissing` when the variable is not set, and
85    /// `GateError::Parse` when the value cannot be parsed as an integer.
86    /// Designed to be composed with `check_schema_env`.
87    pub fn read_schema_from_env() -> Result<u32, GateError> {
88        match std::env::var("KLASP_GATE_SCHEMA") {
89            Err(std::env::VarError::NotPresent) => Err(GateError::SchemaMissing),
90            Err(e) => Err(GateError::Parse(format!("KLASP_GATE_SCHEMA env var: {e}"))),
91            Ok(s) => s
92                .parse::<u32>()
93                .map_err(|e| GateError::Parse(format!("KLASP_GATE_SCHEMA = {s:?}: {e}"))),
94        }
95    }
96
97    /// Compare the env-var schema (set by the shim) with the binary's
98    /// compiled-in schema. The shim's value is read from the environment
99    /// by the caller and passed in here as a `u32` — this function never
100    /// touches the environment itself, keeping it pure and testable.
101    pub fn check_schema_env(env_value: u32) -> Result<(), GateError> {
102        if env_value == GATE_SCHEMA_VERSION {
103            Ok(())
104        } else {
105            Err(GateError::SchemaMismatch {
106                script: env_value,
107                binary: GATE_SCHEMA_VERSION,
108            })
109        }
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    #[test]
118    fn parses_minimal_claude_payload() {
119        let stdin = r#"{
120            "tool_name": "Bash",
121            "tool_input": { "command": "git commit -m 'wip'" }
122        }"#;
123        let input = GateProtocol::parse(stdin).expect("should parse");
124        assert_eq!(input.tool_name, "Bash");
125        assert_eq!(
126            input.tool_input.command.as_deref(),
127            Some("git commit -m 'wip'")
128        );
129    }
130
131    #[test]
132    fn parses_payload_without_command() {
133        let stdin = r#"{ "tool_name": "Read", "tool_input": {} }"#;
134        let input = GateProtocol::parse(stdin).expect("should parse");
135        assert_eq!(input.tool_name, "Read");
136        assert!(input.tool_input.command.is_none());
137    }
138
139    #[test]
140    fn parses_payload_ignoring_extra_fields() {
141        // Forward-compat: unknown future fields must not break parsing.
142        let stdin = r#"{
143            "tool_name": "Bash",
144            "tool_input": { "command": "ls", "extra": 42 },
145            "session_id": "abc"
146        }"#;
147        let input = GateProtocol::parse(stdin).expect("should parse");
148        assert_eq!(input.tool_input.command.as_deref(), Some("ls"));
149    }
150
151    #[test]
152    fn fails_on_malformed_json() {
153        let err = GateProtocol::parse("{ not json").expect_err("should fail");
154        assert!(matches!(err, GateError::Parse(_)));
155    }
156
157    #[test]
158    fn fails_on_missing_tool_input() {
159        let err = GateProtocol::parse(r#"{ "tool_name": "Bash" }"#).expect_err("should fail");
160        assert!(matches!(err, GateError::Parse(_)));
161    }
162
163    #[test]
164    fn schema_match_passes() {
165        assert!(GateProtocol::check_schema_env(GATE_SCHEMA_VERSION).is_ok());
166    }
167
168    #[test]
169    fn schema_mismatch_returns_error() {
170        let err = GateProtocol::check_schema_env(GATE_SCHEMA_VERSION + 1).expect_err("mismatch");
171        match err {
172            GateError::SchemaMismatch { script, binary } => {
173                assert_eq!(script, GATE_SCHEMA_VERSION + 1);
174                assert_eq!(binary, GATE_SCHEMA_VERSION);
175            }
176            other => panic!("expected SchemaMismatch, got {other:?}"),
177        }
178    }
179
180    #[test]
181    fn schema_zero_is_mismatch() {
182        // The shim must always export KLASP_GATE_SCHEMA; a zero value should
183        // be treated as a mismatch (the binary starts at schema 1).
184        let err = GateProtocol::check_schema_env(0).expect_err("zero should be mismatch");
185        assert!(matches!(err, GateError::SchemaMismatch { .. }));
186    }
187
188    #[test]
189    fn schema_missing_env_returns_schema_missing() {
190        // Note: env-var tests are inherently racy in multi-threaded test
191        // runners. This test is self-contained: it saves, unsets, asserts,
192        // then restores the variable. It assumes no other test in this
193        // process concurrently reads KLASP_GATE_SCHEMA.
194        let saved = std::env::var("KLASP_GATE_SCHEMA").ok();
195        // SAFETY: single-threaded access pattern; see note above.
196        unsafe {
197            std::env::remove_var("KLASP_GATE_SCHEMA");
198        }
199        let result = GateProtocol::read_schema_from_env();
200        if let Some(v) = saved {
201            // SAFETY: restoring a previously-set env var.
202            unsafe {
203                std::env::set_var("KLASP_GATE_SCHEMA", v);
204            }
205        }
206        assert!(
207            matches!(result, Err(GateError::SchemaMissing)),
208            "expected SchemaMissing, got {result:?}",
209        );
210    }
211}