Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion deployment/systemd/guard.service
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 31 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -973,6 +980,7 @@ async fn main() -> Result<()> {
require_approval,
wait_approval,
reevaluate,
hostkey,
binary,
args,
}) => {
Expand All @@ -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),
Expand Down Expand Up @@ -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<SshHostKeyCliMode> 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<server::RevertSpec> {
Expand Down Expand Up @@ -2084,6 +2111,7 @@ async fn run_exec(
env_vars: HashMap<String, String>,
secret_vars: HashMap<String, String>,
gating: GatingOptions,
hostkey: server::SshHostKeyMode,
) -> Result<()> {
let config = client_config::ClientConfig::load().ok().unwrap_or_default();

Expand All @@ -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);
}
Expand Down
53 changes: 53 additions & 0 deletions src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,24 @@ struct GuardVerbArgs {
params: std::collections::BTreeMap<String, String>,
}

#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
enum McpSshHostKeyMode {
OnlyExisting,
AcceptNew,
AcceptAll,
}

impl From<McpSshHostKeyMode> 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)]
Expand Down Expand Up @@ -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<McpSshHostKeyMode>,
}

#[derive(Debug, Clone)]
Expand Down Expand Up @@ -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());
}
Expand Down Expand Up @@ -792,6 +817,11 @@ impl<E: GuardExecutor, A: GuardAdmin> McpServer<E, A> {
"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" },
Expand Down Expand Up @@ -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]
Expand Down
Loading
Loading