Add ACP agent backend and terminal controls

This commit is contained in:
2026-07-30 07:25:11 -05:00
parent dbfa8bcd48
commit ad24374f6d
84 changed files with 12151 additions and 157 deletions
+296
View File
@@ -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;