Skip to main content

chio_guards/
input_injection.rs

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