From 29eb75d00cba779f9b2fdb3e4cd6b04fd1b88ad4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=B6rg=C3=A6sis?= Date: Thu, 2 Jul 2026 20:40:44 +0000 Subject: [PATCH] Add SSH host-key mode (--hostkey) for first-contact workflows Adds an SshHostKeyMode (only-existing / accept-new / accept-all) on ExecuteRequest, a --hostkey CLI flag, and a matching guard_run MCP tool argument. When binary==ssh, the daemon folds the mode into the command once (right after verb rendering) by prepending the corresponding -o options, so the policy decision, the evaluator, the audit record, and the spawned process all act on the identical command. only-existing (default) injects nothing and preserves ssh's strict host-key checking. accept-new injects StrictHostKeyChecking=accept-new and UpdateHostKeys=yes, both of which the read-only fast-path allow-list already vets, so a fixed diagnostic still qualifies. accept-all injects StrictHostKeyChecking=no and UserKnownHostsFile=/dev/null; it forfeits the deterministic fast path both because that value fails the allow-list and via an explicit guard, so giving up host authentication always goes through the evaluator. The daemon spawns ssh, so the systemd unit now pins HOME to the state directory and pre-creates .ssh for accept-new to record keys into. Tested: injection per mode, non-ssh no-op, fast-path reconciliation (accept-new keeps it, accept-all forfeits), and MCP arg/schema. --- deployment/systemd/guard.service | 6 +- src/main.rs | 33 ++++- src/mcp.rs | 53 ++++++++ src/server.rs | 202 ++++++++++++++++++++++++++++++- 4 files changed, 289 insertions(+), 5 deletions(-) diff --git a/deployment/systemd/guard.service b/deployment/systemd/guard.service index 275a454..b5283c9 100644 --- a/deployment/systemd/guard.service +++ b/deployment/systemd/guard.service @@ -9,9 +9,13 @@ User=guard Group=guard WorkingDirectory=/var/lib/guard EnvironmentFile=-/etc/default/guard +# The daemon spawns ssh itself, so `guard run ssh --hostkey accept-new` needs a +# writable HOME/.ssh for ssh to record newly accepted host keys. Pin HOME to the +# state directory and have systemd pre-create .ssh (owned by the guard user). +Environment=HOME=/var/lib/guard RuntimeDirectory=guard RuntimeDirectoryMode=0755 -StateDirectory=guard +StateDirectory=guard guard/.ssh ExecStart=/usr/local/bin/guard server start --socket /run/guard/guard.sock --state-db /var/lib/guard/state.db Restart=on-failure RestartSec=5 diff --git a/src/main.rs b/src/main.rs index 46cf47b..4513d79 100644 --- a/src/main.rs +++ b/src/main.rs @@ -74,6 +74,13 @@ enum MainArgs { /// should be allowed. #[arg(long = "reevaluate", action = ArgAction::SetTrue)] reevaluate: bool, + /// SSH host-key policy for a guarded `ssh` command. `only-existing` + /// (default) keeps ssh's strict checking; `accept-new` trusts a new + /// host on first contact but still rejects a changed key; `accept-all` + /// gives up host verification and never rides the deterministic fast + /// path. Only affects `ssh`. + #[arg(long = "hostkey", value_enum, default_value = "only-existing")] + hostkey: SshHostKeyCliMode, /// Binary to execute binary: String, /// Arguments to pass to the binary @@ -973,6 +980,7 @@ async fn main() -> Result<()> { require_approval, wait_approval, reevaluate, + hostkey, binary, args, }) => { @@ -985,7 +993,7 @@ async fn main() -> Result<()> { wait_approval, reevaluate, }; - run_exec(binary, args, env_vars, secret_vars, gating).await + run_exec(binary, args, env_vars, secret_vars, gating, hostkey.into()).await } Ok(MainArgs::Server(cmd)) => run_server(cmd).await, Ok(MainArgs::Profile(cmd)) => handle_profile(cmd), @@ -2052,6 +2060,25 @@ struct GatingOptions { reevaluate: bool, } +/// CLI spelling of the ssh host-key mode. Kebab-case value names +/// (`only-existing`, `accept-new`, `accept-all`) are derived by clap. +#[derive(Clone, Copy, Debug, clap::ValueEnum)] +enum SshHostKeyCliMode { + OnlyExisting, + AcceptNew, + AcceptAll, +} + +impl From for server::SshHostKeyMode { + fn from(value: SshHostKeyCliMode) -> Self { + match value { + SshHostKeyCliMode::OnlyExisting => Self::OnlyExisting, + SshHostKeyCliMode::AcceptNew => Self::AcceptNew, + SshHostKeyCliMode::AcceptAll => Self::AcceptAll, + } + } +} + /// Parse a `--revert "binary arg1 arg2"` string into a structured RevertSpec /// (no shell is ever run; this only splits the operator's command into argv). fn parse_revert(spec: &str) -> Result { @@ -2084,6 +2111,7 @@ async fn run_exec( env_vars: HashMap, secret_vars: HashMap, gating: GatingOptions, + hostkey: server::SshHostKeyMode, ) -> Result<()> { let config = client_config::ClientConfig::load().ok().unwrap_or_default(); @@ -2101,7 +2129,8 @@ async fn run_exec( gating.require_approval, gating.wait_approval, ) - .with_reevaluate(gating.reevaluate); + .with_reevaluate(gating.reevaluate) + .with_hostkey(hostkey); if let Some(token) = config.auth_token { client = client.with_auth(token); } diff --git a/src/mcp.rs b/src/mcp.rs index 54f1ea4..c53f8a5 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -84,6 +84,24 @@ struct GuardVerbArgs { params: std::collections::BTreeMap, } +#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +enum McpSshHostKeyMode { + OnlyExisting, + AcceptNew, + AcceptAll, +} + +impl From for server::SshHostKeyMode { + fn from(value: McpSshHostKeyMode) -> Self { + match value { + McpSshHostKeyMode::OnlyExisting => Self::OnlyExisting, + McpSshHostKeyMode::AcceptNew => Self::AcceptNew, + McpSshHostKeyMode::AcceptAll => Self::AcceptAll, + } + } +} + #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] struct GuardToolArgs { #[serde(default)] @@ -115,6 +133,10 @@ struct GuardToolArgs { /// that should be allowed. #[serde(default)] reevaluate: bool, + /// SSH host-key policy for a guarded `ssh` command. Defaults to + /// only-existing (ssh's strict checking) when omitted. + #[serde(default)] + hostkey: Option, } #[derive(Debug, Clone)] @@ -231,6 +253,9 @@ impl GuardExecutor for ClientExecutor { args.wait_approval, ) .with_reevaluate(args.reevaluate); + if let Some(mode) = args.hostkey { + client = client.with_hostkey(mode.into()); + } if let Some(token) = &self.auth_token { client = client.with_auth(token.clone()); } @@ -792,6 +817,11 @@ impl McpServer { "items": { "type": "string" }, "description": "Arguments to pass to the binary." }, + "hostkey": { + "type": "string", + "enum": ["only-existing", "accept-new", "accept-all"], + "description": "SSH host-key policy for guarded ssh commands. only-existing (default) keeps ssh's strict checking; accept-new trusts a new host on first contact but rejects a changed key; accept-all gives up host verification." + }, "env": { "type": "object", "additionalProperties": { "type": "string" }, @@ -1302,6 +1332,29 @@ mod tests { response["result"]["tools"][0]["inputSchema"]["required"], json!(["binary", "args"]) ); + assert_eq!( + response["result"]["tools"][0]["inputSchema"]["properties"]["hostkey"]["enum"], + json!(["only-existing", "accept-new", "accept-all"]) + ); + } + + #[test] + fn guard_tool_args_accepts_hostkey_mode() { + let parsed: GuardToolArgs = serde_json::from_value(json!({ + "binary": "ssh", + "args": ["host01", "id"], + "hostkey": "accept-new" + })) + .unwrap(); + assert_eq!(parsed.hostkey, Some(McpSshHostKeyMode::AcceptNew)); + + // Omitting it defaults to None (only-existing behavior server-side). + let without: GuardToolArgs = serde_json::from_value(json!({ + "binary": "ssh", + "args": ["host01", "id"] + })) + .unwrap(); + assert_eq!(without.hostkey, None); } #[tokio::test] diff --git a/src/server.rs b/src/server.rs index bfb43f9..dce0e19 100644 --- a/src/server.rs +++ b/src/server.rs @@ -179,6 +179,42 @@ impl std::fmt::Display for CallerIdentity { } } +/// How ssh should treat the remote host key for a guarded ssh command. +/// Default (`OnlyExisting`) preserves ssh's own strict behavior: the daemon +/// injects nothing, so a first-contact host still fails closed. The relaxed +/// modes are opt-in and only ever apply when `binary == "ssh"`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum SshHostKeyMode { + /// Only connect to hosts already in known_hosts (no injection). + OnlyExisting, + /// Trust-on-first-use: accept and record an unknown host key, but still + /// refuse if a known key changed (`StrictHostKeyChecking=accept-new`). + AcceptNew, + /// Accept any host key without recording it (`StrictHostKeyChecking=no`, + /// `UserKnownHostsFile=/dev/null`). This gives up host authentication and + /// is intentionally excluded from the deterministic fast path. + AcceptAll, +} + +impl SshHostKeyMode { + /// The ssh `-o` options this mode injects ahead of the caller's args. + /// `OnlyExisting` injects nothing so the default is a no-op. + fn ssh_options(self) -> &'static [(&'static str, &'static str)] { + match self { + Self::OnlyExisting => &[], + Self::AcceptNew => &[ + ("StrictHostKeyChecking", "accept-new"), + ("UpdateHostKeys", "yes"), + ], + Self::AcceptAll => &[ + ("StrictHostKeyChecking", "no"), + ("UserKnownHostsFile", "/dev/null"), + ], + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ExecuteRequest { pub binary: String, @@ -226,6 +262,37 @@ pub struct ExecuteRequest { /// Safe for any caller: its only effect is "ask the LLM again." #[serde(default)] pub reevaluate: bool, + /// SSH host-key behavior for first-contact workflows. Only applied when + /// `binary == "ssh"`; the default (`None`/`OnlyExisting`) preserves ssh's + /// existing strict host-key checking. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ssh_hostkey: Option, +} + +impl ExecuteRequest { + /// Prepend the ssh `-o` options implied by the requested host-key mode so + /// the policy decision, the evaluator, the audit log, and the spawned + /// process all see the identical command. A no-op for non-ssh binaries and + /// for `OnlyExisting`/absent modes, which keep ssh's strict default. + fn apply_ssh_hostkey_options(&mut self) { + if self.binary != "ssh" { + return; + } + let options = match self.ssh_hostkey { + Some(mode) => mode.ssh_options(), + None => return, + }; + if options.is_empty() { + return; + } + let mut injected = Vec::with_capacity(self.args.len() + options.len() * 2); + for (key, value) in options { + injected.push("-o".to_string()); + injected.push(format!("{key}={value}")); + } + injected.append(&mut self.args); + self.args = injected; + } } /// A structured rollback command (no shell). Each arg is a single argv element. @@ -3517,6 +3584,12 @@ async fn execute_command_inner( } } + // Fold the requested ssh host-key mode into the command now that the verb + // (if any) has been rendered. From here on, request.args carries any + // injected `-o` options, so the policy decision, the evaluator, the audit + // record, and the spawned process all act on the same command. + request.apply_ssh_hostkey_options(); + // Check recursion depth let depth: u32 = std::env::var("GUARD_DEPTH") .ok() @@ -3789,8 +3862,14 @@ async fn execute_command_inner( // read-only commands. Like a trusted verb, it is a deterministic allow // that precedes the evaluator; it never applies when the caller injected // env/secrets (which could change the command's meaning) and is disabled - // in paranoid mode. - if request.env.is_empty() && request.secrets.is_empty() { + // in paranoid mode. `accept-all` host-key mode is excluded explicitly: + // its injected `StrictHostKeyChecking=no` already fails the ssh option + // allow-list, but keeping the guard here documents that giving up host + // authentication never rides the fast path even if the diagnostic is fixed. + if request.env.is_empty() + && request.secrets.is_empty() + && !matches!(request.ssh_hostkey, Some(SshHostKeyMode::AcceptAll)) + { if let Some(reason) = deterministic_safe_allow_reason(config, &request.binary, &request.args) { @@ -5713,6 +5792,7 @@ async fn execute_snapshot( revert: None, confirm_within_secs: None, reevaluate: false, + ssh_hostkey: None, require_approval: None, wait_approval_secs: None, verb: None, @@ -5819,6 +5899,7 @@ async fn run_provisional_revert(config: &ServerConfig, p: &Provisional) -> Execu revert: None, confirm_within_secs: None, reevaluate: false, + ssh_hostkey: None, require_approval: None, wait_approval_secs: None, verb: None, @@ -6638,6 +6719,7 @@ pub struct Client { wait_approval_secs: Option, verb: Option, reevaluate: bool, + ssh_hostkey: Option, } impl Client { @@ -6654,6 +6736,7 @@ impl Client { wait_approval_secs: None, verb: None, reevaluate: false, + ssh_hostkey: None, } } @@ -6671,6 +6754,14 @@ impl Client { self } + /// Set the ssh host-key mode carried onto each `guard run` request. Only + /// affects ssh commands; the daemon injects the corresponding `-o` options + /// server-side before evaluation and execution. + pub fn with_hostkey(mut self, mode: SshHostKeyMode) -> Self { + self.ssh_hostkey = Some(mode); + self + } + pub fn with_auth(mut self, token: String) -> Self { self.auth_token = Some(token); self @@ -6887,6 +6978,7 @@ impl Client { wait_approval_secs: self.wait_approval_secs, verb: self.verb.clone(), reevaluate: self.reevaluate, + ssh_hostkey: self.ssh_hostkey, } } @@ -7362,6 +7454,7 @@ mod tests { revert: None, confirm_within_secs: None, reevaluate: false, + ssh_hostkey: None, require_approval: None, wait_approval_secs: None, verb: None, @@ -7442,6 +7535,7 @@ mod tests { revert: None, confirm_within_secs: None, reevaluate: false, + ssh_hostkey: None, require_approval: None, wait_approval_secs: None, verb: None, @@ -7500,6 +7594,7 @@ mod tests { revert: None, confirm_within_secs: None, reevaluate: false, + ssh_hostkey: None, require_approval: None, wait_approval_secs: None, verb: None, @@ -7535,6 +7630,7 @@ mod tests { revert: None, confirm_within_secs: None, reevaluate: false, + ssh_hostkey: None, require_approval: None, wait_approval_secs: None, verb: None, @@ -7718,6 +7814,94 @@ mod tests { assert!(reason.is_some(), "fixed ssh diagnostic should be allowed"); } + fn ssh_request(mode: Option, argv: &[&str]) -> ExecuteRequest { + ExecuteRequest { + binary: "ssh".to_string(), + args: args(argv), + auth_token: None, + env: HashMap::new(), + secrets: HashMap::new(), + stream: false, + session_token: None, + revert: None, + confirm_within_secs: None, + require_approval: None, + wait_approval_secs: None, + verb: None, + reevaluate: false, + ssh_hostkey: mode, + } + } + + #[test] + fn apply_ssh_hostkey_injects_options_by_mode() { + // OnlyExisting / absent: no change, ssh keeps its strict default. + for mode in [None, Some(SshHostKeyMode::OnlyExisting)] { + let mut req = ssh_request(mode, &["host01", "id"]); + req.apply_ssh_hostkey_options(); + assert_eq!(req.args, args(&["host01", "id"]), "mode {mode:?}"); + } + + // AcceptNew prepends accept-new + UpdateHostKeys ahead of the host. + let mut req = ssh_request(Some(SshHostKeyMode::AcceptNew), &["host01", "id"]); + req.apply_ssh_hostkey_options(); + assert_eq!( + req.args, + args(&[ + "-o", + "StrictHostKeyChecking=accept-new", + "-o", + "UpdateHostKeys=yes", + "host01", + "id", + ]) + ); + + // AcceptAll gives up host verification. + let mut req = ssh_request(Some(SshHostKeyMode::AcceptAll), &["host01", "id"]); + req.apply_ssh_hostkey_options(); + assert_eq!( + req.args, + args(&[ + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "host01", + "id", + ]) + ); + } + + #[test] + fn apply_ssh_hostkey_is_noop_for_non_ssh() { + let mut req = ssh_request(Some(SshHostKeyMode::AcceptAll), &["get", "pods"]); + req.binary = "kubectl".to_string(); + req.apply_ssh_hostkey_options(); + assert_eq!(req.args, args(&["get", "pods"])); + } + + #[test] + fn accept_new_hostkey_keeps_fixed_diagnostic_on_fast_path() { + // The options accept-new injects are allow-listed, so a fixed + // diagnostic still qualifies for the deterministic fast path. + let (cfg, _buf) = make_test_config(); + let mut req = ssh_request(Some(SshHostKeyMode::AcceptNew), &["host01", "id"]); + req.apply_ssh_hostkey_options(); + assert!(deterministic_safe_allow_reason(&cfg, "ssh", &req.args).is_some()); + } + + #[test] + fn accept_all_hostkey_forfeits_fast_path() { + // accept-all injects StrictHostKeyChecking=no, which the option + // allow-list rejects, so even a fixed diagnostic forfeits to the + // evaluator rather than auto-allowing over an unauthenticated channel. + let (cfg, _buf) = make_test_config(); + let mut req = ssh_request(Some(SshHostKeyMode::AcceptAll), &["host01", "id"]); + req.apply_ssh_hostkey_options(); + assert!(deterministic_safe_allow_reason(&cfg, "ssh", &req.args).is_none()); + } + #[test] fn safe_allow_rejects_ssh_arbitrary_remote_command() { let (cfg, _buf) = make_test_config(); @@ -7951,6 +8135,7 @@ mod tests { revert: None, confirm_within_secs: None, reevaluate: false, + ssh_hostkey: None, require_approval: None, wait_approval_secs: None, verb: Some(VerbInvocation { @@ -7999,6 +8184,7 @@ mod tests { revert: None, confirm_within_secs: None, reevaluate: false, + ssh_hostkey: None, require_approval: None, wait_approval_secs: None, verb: Some(VerbInvocation { @@ -8049,6 +8235,7 @@ mod tests { revert: None, confirm_within_secs: None, reevaluate: false, + ssh_hostkey: None, require_approval: None, wait_approval_secs: None, verb: None, @@ -8101,6 +8288,7 @@ mod tests { revert: None, confirm_within_secs: None, reevaluate: false, + ssh_hostkey: None, require_approval: None, wait_approval_secs: None, verb: None, @@ -8376,6 +8564,7 @@ mod tests { revert: None, confirm_within_secs: None, reevaluate: false, + ssh_hostkey: None, require_approval: None, wait_approval_secs: None, verb: None, @@ -8448,6 +8637,7 @@ mod tests { revert: None, confirm_within_secs: None, reevaluate: false, + ssh_hostkey: None, require_approval: None, wait_approval_secs: None, verb: None, @@ -8500,6 +8690,7 @@ mod tests { revert: None, confirm_within_secs: None, reevaluate: false, + ssh_hostkey: None, require_approval: None, wait_approval_secs: None, verb: None, @@ -8764,6 +8955,7 @@ mod tests { revert: Some(revert), confirm_within_secs: None, reevaluate: false, + ssh_hostkey: None, require_approval: None, wait_approval_secs: None, verb: None, @@ -9130,6 +9322,7 @@ mod tests { revert: None, confirm_within_secs: None, reevaluate: false, + ssh_hostkey: None, require_approval: None, wait_approval_secs: None, verb: None, @@ -9217,6 +9410,7 @@ mod tests { revert: None, confirm_within_secs: None, reevaluate: false, + ssh_hostkey: None, require_approval: None, wait_approval_secs: None, verb: None, @@ -9298,6 +9492,7 @@ mod tests { revert: None, confirm_within_secs: None, reevaluate: false, + ssh_hostkey: None, require_approval: None, wait_approval_secs: None, verb: None, @@ -9378,6 +9573,7 @@ mod tests { revert: None, confirm_within_secs: None, reevaluate: false, + ssh_hostkey: None, require_approval: None, wait_approval_secs: None, verb: None, @@ -9450,6 +9646,7 @@ mod tests { revert: None, confirm_within_secs: None, reevaluate: false, + ssh_hostkey: None, require_approval: None, wait_approval_secs: None, verb: None, @@ -9526,6 +9723,7 @@ mod tests { revert: None, confirm_within_secs: None, reevaluate: false, + ssh_hostkey: None, require_approval: None, wait_approval_secs: None, verb: None,