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.
17pub const GATE_SCHEMA_VERSION: u32 = 1;
18
19/// The Claude Code `PreToolUse` payload klasp consumes from stdin.
20#[derive(Debug, Deserialize, PartialEq, Eq)]
21pub struct GateInput {
22    pub tool_name: String,
23    pub tool_input: ToolInput,
24}
25
26/// The subset of Claude Code's `tool_input` klasp inspects. Only the `Bash`
27/// tool's `command` field matters in v0.1; future fields can be added behind
28/// `#[serde(default)]` without bumping the schema.
29#[derive(Debug, Deserialize, PartialEq, Eq)]
30pub struct ToolInput {
31    #[serde(default)]
32    pub command: Option<String>,
33}
34
35#[derive(Debug, thiserror::Error, PartialEq, Eq)]
36pub enum GateError {
37    #[error("could not parse gate input as JSON: {0}")]
38    Parse(String),
39    #[error(
40        "klasp-gate: schema mismatch (script={script}, binary={binary}). \
41         Re-run `klasp install` to update the hook."
42    )]
43    SchemaMismatch { script: u32, binary: u32 },
44    #[error(
45        "KLASP_GATE_SCHEMA is not set. Re-run `klasp install` to regenerate \
46         the hook script."
47    )]
48    SchemaMissing,
49}
50
51pub struct GateProtocol;
52
53impl GateProtocol {
54    /// Parse the JSON payload Claude Code writes to the hook's stdin.
55    pub fn parse(stdin: &str) -> Result<GateInput, GateError> {
56        serde_json::from_str(stdin).map_err(|e| GateError::Parse(e.to_string()))
57    }
58
59    /// Read `KLASP_GATE_SCHEMA` from the environment and parse it as a `u32`.
60    ///
61    /// Returns `GateError::SchemaMissing` when the variable is not set, and
62    /// `GateError::Parse` when the value cannot be parsed as an integer.
63    /// Designed to be composed with `check_schema_env`.
64    pub fn read_schema_from_env() -> Result<u32, GateError> {
65        match std::env::var("KLASP_GATE_SCHEMA") {
66            Err(std::env::VarError::NotPresent) => Err(GateError::SchemaMissing),
67            Err(e) => Err(GateError::Parse(format!("KLASP_GATE_SCHEMA env var: {e}"))),
68            Ok(s) => s
69                .parse::<u32>()
70                .map_err(|e| GateError::Parse(format!("KLASP_GATE_SCHEMA = {s:?}: {e}"))),
71        }
72    }
73
74    /// Compare the env-var schema (set by the shim) with the binary's
75    /// compiled-in schema. The shim's value is read from the environment
76    /// by the caller and passed in here as a `u32` — this function never
77    /// touches the environment itself, keeping it pure and testable.
78    pub fn check_schema_env(env_value: u32) -> Result<(), GateError> {
79        if env_value == GATE_SCHEMA_VERSION {
80            Ok(())
81        } else {
82            Err(GateError::SchemaMismatch {
83                script: env_value,
84                binary: GATE_SCHEMA_VERSION,
85            })
86        }
87    }
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93
94    #[test]
95    fn parses_minimal_claude_payload() {
96        let stdin = r#"{
97            "tool_name": "Bash",
98            "tool_input": { "command": "git commit -m 'wip'" }
99        }"#;
100        let input = GateProtocol::parse(stdin).expect("should parse");
101        assert_eq!(input.tool_name, "Bash");
102        assert_eq!(
103            input.tool_input.command.as_deref(),
104            Some("git commit -m 'wip'")
105        );
106    }
107
108    #[test]
109    fn parses_payload_without_command() {
110        let stdin = r#"{ "tool_name": "Read", "tool_input": {} }"#;
111        let input = GateProtocol::parse(stdin).expect("should parse");
112        assert_eq!(input.tool_name, "Read");
113        assert!(input.tool_input.command.is_none());
114    }
115
116    #[test]
117    fn parses_payload_ignoring_extra_fields() {
118        // Forward-compat: unknown future fields must not break parsing.
119        let stdin = r#"{
120            "tool_name": "Bash",
121            "tool_input": { "command": "ls", "extra": 42 },
122            "session_id": "abc"
123        }"#;
124        let input = GateProtocol::parse(stdin).expect("should parse");
125        assert_eq!(input.tool_input.command.as_deref(), Some("ls"));
126    }
127
128    #[test]
129    fn fails_on_malformed_json() {
130        let err = GateProtocol::parse("{ not json").expect_err("should fail");
131        assert!(matches!(err, GateError::Parse(_)));
132    }
133
134    #[test]
135    fn fails_on_missing_tool_input() {
136        let err = GateProtocol::parse(r#"{ "tool_name": "Bash" }"#).expect_err("should fail");
137        assert!(matches!(err, GateError::Parse(_)));
138    }
139
140    #[test]
141    fn schema_match_passes() {
142        assert!(GateProtocol::check_schema_env(GATE_SCHEMA_VERSION).is_ok());
143    }
144
145    #[test]
146    fn schema_mismatch_returns_error() {
147        let err = GateProtocol::check_schema_env(GATE_SCHEMA_VERSION + 1).expect_err("mismatch");
148        match err {
149            GateError::SchemaMismatch { script, binary } => {
150                assert_eq!(script, GATE_SCHEMA_VERSION + 1);
151                assert_eq!(binary, GATE_SCHEMA_VERSION);
152            }
153            other => panic!("expected SchemaMismatch, got {other:?}"),
154        }
155    }
156
157    #[test]
158    fn schema_zero_is_mismatch() {
159        // The shim must always export KLASP_GATE_SCHEMA; a zero value should
160        // be treated as a mismatch (the binary starts at schema 1).
161        let err = GateProtocol::check_schema_env(0).expect_err("zero should be mismatch");
162        assert!(matches!(err, GateError::SchemaMismatch { .. }));
163    }
164
165    #[test]
166    fn schema_missing_env_returns_schema_missing() {
167        // Note: env-var tests are inherently racy in multi-threaded test
168        // runners. This test is self-contained: it saves, unsets, asserts,
169        // then restores the variable. It assumes no other test in this
170        // process concurrently reads KLASP_GATE_SCHEMA.
171        let saved = std::env::var("KLASP_GATE_SCHEMA").ok();
172        // SAFETY: single-threaded access pattern; see note above.
173        unsafe {
174            std::env::remove_var("KLASP_GATE_SCHEMA");
175        }
176        let result = GateProtocol::read_schema_from_env();
177        if let Some(v) = saved {
178            // SAFETY: restoring a previously-set env var.
179            unsafe {
180                std::env::set_var("KLASP_GATE_SCHEMA", v);
181            }
182        }
183        assert!(
184            matches!(result, Err(GateError::SchemaMissing)),
185            "expected SchemaMissing, got {result:?}",
186        );
187    }
188}