Skip to main content

chio_guards/
input_injection.rs

1//! InputInjectionCapabilityGuard - fine-grained control over `input.inject`
2//! actions.
3//!
4//! Roadmap phase 5.2.  Ported from ClawdStrike's
5//! `guards/input_injection_capability.rs` and adapted to Chio's synchronous
6//! [`chio_kernel::Guard`] trait.
7//!
8//! The guard applies to tool calls that represent an **input injection**
9//! action on a remote / desktop session.  It claims two detection surfaces:
10//!
11//! 1. `tool_name == "input.inject"` (or an `action_type`/`custom_type`
12//!    argument equal to `input.inject`);
13//! 2. arbitrary tool names where the arguments explicitly carry an
14//!    `input_type` / `inputType` field together with metadata consistent
15//!    with an injection flow (e.g., `keyboard`, `mouse`, `touch`).
16//!
17//! Enforcement:
18//!
19//! - the `input_type` value must be in the configured allowlist (default
20//!   `{keyboard, mouse, touch}`).  Missing `input_type` is denied
21//!   (fail-closed);
22//! - when `require_postcondition_probe = true`, the arguments must carry a
23//!   non-empty `postcondition_probe_hash` / `postconditionProbeHash`
24//!   string.  This binds every input injection to a later verification
25//!   step (a screenshot hash, typically) so the agent cannot act blindly.
26//!
27//! Non-injection actions pass through with [`Verdict::Allow`].
28
29use std::collections::HashSet;
30
31use serde::{Deserialize, Serialize};
32use serde_json::Value;
33
34use chio_kernel::{Guard, GuardContext, KernelError, Verdict};
35
36/// Default allowlist of input types.
37pub fn default_allowed_input_types() -> Vec<String> {
38    vec![
39        "keyboard".to_string(),
40        "mouse".to_string(),
41        "touch".to_string(),
42    ]
43}
44
45/// Configuration for [`InputInjectionCapabilityGuard`].
46#[derive(Clone, Debug, Deserialize, Serialize)]
47#[serde(deny_unknown_fields)]
48pub struct InputInjectionCapabilityConfig {
49    /// Enable/disable the guard.
50    #[serde(default = "default_true")]
51    pub enabled: bool,
52    /// Allowed input-type strings.
53    #[serde(default = "default_allowed_input_types")]
54    pub allowed_input_types: Vec<String>,
55    /// When true, the arguments must carry a non-empty
56    /// `postcondition_probe_hash` / `postconditionProbeHash` string.
57    #[serde(default)]
58    pub require_postcondition_probe: bool,
59    /// When true, the guard runs in strict mode and denies actions that
60    /// look like input injection but are missing `input_type` entirely.
61    /// When false, such actions pass through with [`Verdict::Allow`]
62    /// (useful for deployments where `input.inject` arrives through a
63    /// different dispatch path).
64    #[serde(default = "default_true")]
65    pub strict: bool,
66}
67
68fn default_true() -> bool {
69    true
70}
71
72impl Default for InputInjectionCapabilityConfig {
73    fn default() -> Self {
74        Self {
75            enabled: true,
76            allowed_input_types: default_allowed_input_types(),
77            require_postcondition_probe: false,
78            strict: true,
79        }
80    }
81}
82
83/// Fine-grained gate for `input.inject` CUA actions.
84pub struct InputInjectionCapabilityGuard {
85    enabled: bool,
86    allowed_types: HashSet<String>,
87    require_postcondition_probe: bool,
88    strict: bool,
89}
90
91impl InputInjectionCapabilityGuard {
92    /// Build a guard with default configuration.
93    pub fn new() -> Self {
94        Self::with_config(InputInjectionCapabilityConfig::default())
95    }
96
97    /// Build a guard with an explicit configuration.
98    pub fn with_config(config: InputInjectionCapabilityConfig) -> Self {
99        Self {
100            enabled: config.enabled,
101            allowed_types: config.allowed_input_types.into_iter().collect(),
102            require_postcondition_probe: config.require_postcondition_probe,
103            strict: config.strict,
104        }
105    }
106
107    /// Determine whether this tool call is an input-injection candidate.
108    fn is_injection(tool_name: &str, arguments: &Value) -> bool {
109        if tool_name == "input.inject" || tool_name == "input_inject" {
110            return true;
111        }
112        for key in ["action_type", "actionType", "custom_type", "customType"] {
113            if let Some(v) = arguments.get(key).and_then(|v| v.as_str()) {
114                if v == "input.inject" {
115                    return true;
116                }
117            }
118        }
119        // Fallback: explicit `input_type` field with a recognised value
120        // indicates an injection flow even when dispatched by a generic
121        // tool name.
122        arguments
123            .get("input_type")
124            .or_else(|| arguments.get("inputType"))
125            .and_then(|v| v.as_str())
126            .is_some()
127            && (tool_name == "keyboard"
128                || tool_name == "mouse"
129                || tool_name == "touch"
130                || tool_name == "input")
131    }
132
133    /// Read `input_type` / `inputType` from arguments.
134    fn input_type(arguments: &Value) -> Option<&str> {
135        arguments
136            .get("input_type")
137            .or_else(|| arguments.get("inputType"))
138            .and_then(|v| v.as_str())
139    }
140
141    /// Return `true` if a non-empty postcondition probe hash is present.
142    fn has_postcondition_probe(arguments: &Value) -> bool {
143        arguments
144            .get("postcondition_probe_hash")
145            .or_else(|| arguments.get("postconditionProbeHash"))
146            .and_then(|v| v.as_str())
147            .is_some_and(|s| !s.is_empty())
148    }
149}
150
151impl Default for InputInjectionCapabilityGuard {
152    fn default() -> Self {
153        Self::new()
154    }
155}
156
157impl Guard for InputInjectionCapabilityGuard {
158    fn name(&self) -> &str {
159        "input-injection-capability"
160    }
161
162    fn evaluate(&self, ctx: &GuardContext) -> Result<Verdict, KernelError> {
163        if !self.enabled {
164            return Ok(Verdict::Allow);
165        }
166
167        if !Self::is_injection(&ctx.request.tool_name, &ctx.request.arguments) {
168            return Ok(Verdict::Allow);
169        }
170
171        // 1. Validate input_type.
172        match Self::input_type(&ctx.request.arguments) {
173            Some(it) => {
174                if !self.allowed_types.contains(it) {
175                    return Ok(Verdict::Deny);
176                }
177            }
178            None => {
179                // Missing input_type on an injection-flagged call.
180                return Ok(if self.strict {
181                    Verdict::Deny
182                } else {
183                    Verdict::Allow
184                });
185            }
186        }
187
188        // 2. Postcondition probe.
189        if self.require_postcondition_probe
190            && !Self::has_postcondition_probe(&ctx.request.arguments)
191        {
192            return Ok(Verdict::Deny);
193        }
194
195        Ok(Verdict::Allow)
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn detects_explicit_input_inject_tool() {
205        let args = serde_json::json!({"input_type": "keyboard"});
206        assert!(InputInjectionCapabilityGuard::is_injection(
207            "input.inject",
208            &args
209        ));
210    }
211
212    #[test]
213    fn detects_action_type_argument() {
214        let args = serde_json::json!({"action_type": "input.inject", "input_type": "mouse"});
215        assert!(InputInjectionCapabilityGuard::is_injection(
216            "generic", &args
217        ));
218    }
219
220    #[test]
221    fn ignores_unrelated_tools() {
222        let args = serde_json::json!({"path": "/tmp/x"});
223        assert!(!InputInjectionCapabilityGuard::is_injection(
224            "read_file",
225            &args
226        ));
227    }
228
229    #[test]
230    fn input_type_accepts_camel_case() {
231        let args = serde_json::json!({"inputType": "keyboard"});
232        assert_eq!(
233            InputInjectionCapabilityGuard::input_type(&args),
234            Some("keyboard")
235        );
236    }
237
238    #[test]
239    fn postcondition_probe_detected_both_cases() {
240        let snake = serde_json::json!({"postcondition_probe_hash": "sha256:abc"});
241        let camel = serde_json::json!({"postconditionProbeHash": "sha256:def"});
242        assert!(InputInjectionCapabilityGuard::has_postcondition_probe(
243            &snake
244        ));
245        assert!(InputInjectionCapabilityGuard::has_postcondition_probe(
246            &camel
247        ));
248    }
249
250    #[test]
251    fn postcondition_probe_empty_string_is_missing() {
252        let empty = serde_json::json!({"postcondition_probe_hash": ""});
253        assert!(!InputInjectionCapabilityGuard::has_postcondition_probe(
254            &empty
255        ));
256    }
257}