Add ACP agent backend and terminal controls
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
//! Conservative recognition of commands that can open an SSH-backed terminal.
|
||||
//!
|
||||
//! This is a narrow transport-boundary guard, not a network sandbox. Commands
|
||||
//! that are otherwise authorized can still access the network through tools
|
||||
//! other than the recognized SSH launch forms below.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// Returns whether a shell command appears capable of opening an SSH-backed
|
||||
/// terminal, including common wrappers and compound command lists.
|
||||
pub fn is_potential_remote_ssh_command(command: &str) -> bool {
|
||||
command_segments(command)
|
||||
.iter()
|
||||
.any(|segment| segment_starts_remote_ssh(segment, 0))
|
||||
}
|
||||
|
||||
fn command_segments(command: &str) -> Vec<String> {
|
||||
#[derive(Clone, Copy, Eq, PartialEq)]
|
||||
enum Quote {
|
||||
None,
|
||||
Single,
|
||||
Double,
|
||||
}
|
||||
|
||||
let mut quote = Quote::None;
|
||||
let mut escaped = false;
|
||||
let mut current = String::new();
|
||||
let mut segments = Vec::new();
|
||||
for character in command.chars() {
|
||||
if escaped {
|
||||
current.push(character);
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
match quote {
|
||||
Quote::None => match character {
|
||||
'\\' => {
|
||||
current.push(character);
|
||||
escaped = true;
|
||||
}
|
||||
'\'' => {
|
||||
current.push(character);
|
||||
quote = Quote::Single;
|
||||
}
|
||||
'"' => {
|
||||
current.push(character);
|
||||
quote = Quote::Double;
|
||||
}
|
||||
';' | '\n' | '|' | '&' | '(' | ')' | '`' => {
|
||||
push_segment(&mut segments, &mut current);
|
||||
}
|
||||
_ => current.push(character),
|
||||
},
|
||||
Quote::Single => {
|
||||
current.push(character);
|
||||
if character == '\'' {
|
||||
quote = Quote::None;
|
||||
}
|
||||
}
|
||||
Quote::Double => {
|
||||
current.push(character);
|
||||
match character {
|
||||
'\\' => escaped = true,
|
||||
'"' => quote = Quote::None,
|
||||
// Backticks remain command substitutions inside double
|
||||
// quotes, so inspect the enclosed command independently.
|
||||
'`' => push_segment(&mut segments, &mut current),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
push_segment(&mut segments, &mut current);
|
||||
segments
|
||||
}
|
||||
|
||||
fn push_segment(segments: &mut Vec<String>, current: &mut String) {
|
||||
if !current.trim().is_empty() {
|
||||
segments.push(std::mem::take(current));
|
||||
} else {
|
||||
current.clear();
|
||||
}
|
||||
}
|
||||
|
||||
fn segment_starts_remote_ssh(segment: &str, depth: usize) -> bool {
|
||||
if depth > 4 {
|
||||
return false;
|
||||
}
|
||||
let tokens = shell_words::split(segment).unwrap_or_else(|_| {
|
||||
segment
|
||||
.split_whitespace()
|
||||
.map(|token| token.trim_matches(['\'', '"', '`', '(', ')']).to_owned())
|
||||
.filter(|token| !token.is_empty())
|
||||
.collect()
|
||||
});
|
||||
command_tokens_start_remote_ssh(&tokens, depth)
|
||||
}
|
||||
|
||||
fn command_tokens_start_remote_ssh(tokens: &[String], depth: usize) -> bool {
|
||||
let mut index = skip_assignments(tokens, 0);
|
||||
loop {
|
||||
let Some(executable) = tokens.get(index).map(|token| executable_name(token)) else {
|
||||
return false;
|
||||
};
|
||||
match executable {
|
||||
"command" => {
|
||||
index += 1;
|
||||
if tokens
|
||||
.get(index)
|
||||
.is_some_and(|option| matches!(option.as_str(), "-v" | "-V"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
index = skip_flag_only_options(tokens, index);
|
||||
}
|
||||
"env" => {
|
||||
index = skip_env_prefix(tokens, index + 1);
|
||||
}
|
||||
"sudo" => {
|
||||
index = skip_sudo_prefix(tokens, index + 1);
|
||||
}
|
||||
"exec" | "nohup" | "setsid" | "time" => {
|
||||
index = skip_flag_only_options(tokens, index + 1);
|
||||
}
|
||||
"timeout" => {
|
||||
index = skip_timeout_prefix(tokens, index + 1);
|
||||
}
|
||||
"{" => {
|
||||
index += 1;
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
index = skip_assignments(tokens, index);
|
||||
}
|
||||
|
||||
let executable = executable_name(&tokens[index]);
|
||||
if executable == "ssh" {
|
||||
return true;
|
||||
}
|
||||
if executable == "gcloud" {
|
||||
return tokens[index + 1..]
|
||||
.windows(2)
|
||||
.any(|pair| pair[0] == "compute" && pair[1] == "ssh");
|
||||
}
|
||||
if executable == "eb" {
|
||||
return tokens[index + 1..]
|
||||
.first()
|
||||
.is_some_and(|command| command == "ssh");
|
||||
}
|
||||
if executable == "doctl" {
|
||||
return tokens[index + 1..]
|
||||
.windows(2)
|
||||
.any(|pair| pair[0] == "compute" && pair[1] == "ssh");
|
||||
}
|
||||
if matches!(executable, "sh" | "bash" | "dash" | "zsh" | "ksh" | "fish") {
|
||||
return shell_command_payload(tokens, index + 1)
|
||||
.is_some_and(|payload| segment_starts_remote_ssh(payload, depth + 1));
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn executable_name(token: &str) -> &str {
|
||||
Path::new(token)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or(token)
|
||||
}
|
||||
|
||||
fn skip_assignments(tokens: &[String], mut index: usize) -> usize {
|
||||
while tokens.get(index).is_some_and(|token| is_assignment(token)) {
|
||||
index += 1;
|
||||
}
|
||||
index
|
||||
}
|
||||
|
||||
fn is_assignment(token: &str) -> bool {
|
||||
let Some((name, _)) = token.split_once('=') else {
|
||||
return false;
|
||||
};
|
||||
let mut characters = name.chars();
|
||||
characters
|
||||
.next()
|
||||
.is_some_and(|character| character == '_' || character.is_ascii_alphabetic())
|
||||
&& characters.all(|character| character == '_' || character.is_ascii_alphanumeric())
|
||||
}
|
||||
|
||||
fn skip_flag_only_options(tokens: &[String], mut index: usize) -> usize {
|
||||
while let Some(option) = tokens.get(index) {
|
||||
if option == "--" {
|
||||
return index + 1;
|
||||
}
|
||||
if !option.starts_with('-') || option == "-" {
|
||||
break;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
index
|
||||
}
|
||||
|
||||
fn skip_env_prefix(tokens: &[String], mut index: usize) -> usize {
|
||||
while let Some(option) = tokens.get(index) {
|
||||
if option == "--" {
|
||||
index += 1;
|
||||
break;
|
||||
}
|
||||
if matches!(
|
||||
option.as_str(),
|
||||
"-u" | "--unset" | "-C" | "--chdir" | "-S" | "--split-string"
|
||||
) {
|
||||
index = (index + 2).min(tokens.len());
|
||||
continue;
|
||||
}
|
||||
if option.starts_with('-') && option != "-" {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
skip_assignments(tokens, index)
|
||||
}
|
||||
|
||||
fn skip_sudo_prefix(tokens: &[String], mut index: usize) -> usize {
|
||||
while let Some(option) = tokens.get(index) {
|
||||
if option == "--" {
|
||||
return index + 1;
|
||||
}
|
||||
if matches!(
|
||||
option.as_str(),
|
||||
"-u" | "--user"
|
||||
| "-g"
|
||||
| "--group"
|
||||
| "-h"
|
||||
| "--host"
|
||||
| "-p"
|
||||
| "--prompt"
|
||||
| "-C"
|
||||
| "--chdir"
|
||||
| "-R"
|
||||
| "--chroot"
|
||||
| "-r"
|
||||
| "--role"
|
||||
| "-t"
|
||||
| "--type"
|
||||
) {
|
||||
index = (index + 2).min(tokens.len());
|
||||
continue;
|
||||
}
|
||||
if option.starts_with('-') && option != "-" {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
index
|
||||
}
|
||||
|
||||
fn skip_timeout_prefix(tokens: &[String], mut index: usize) -> usize {
|
||||
while let Some(option) = tokens.get(index) {
|
||||
if option == "--" {
|
||||
index += 1;
|
||||
break;
|
||||
}
|
||||
if matches!(option.as_str(), "-k" | "--kill-after" | "-s" | "--signal") {
|
||||
index = (index + 2).min(tokens.len());
|
||||
continue;
|
||||
}
|
||||
if option.starts_with('-') && option != "-" {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
// The first non-option is timeout's duration, not its child executable.
|
||||
(index + usize::from(index < tokens.len())).min(tokens.len())
|
||||
}
|
||||
|
||||
fn shell_command_payload(tokens: &[String], mut index: usize) -> Option<&str> {
|
||||
while let Some(option) = tokens.get(index) {
|
||||
if option == "--" {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if option.starts_with('-') && option.contains('c') {
|
||||
return tokens.get(index + 1).map(String::as_str);
|
||||
}
|
||||
if !option.starts_with('-') {
|
||||
return None;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "remote_command_tests.rs"]
|
||||
mod tests;
|
||||
Reference in New Issue
Block a user