use std::path::PathBuf;
use serde_json::{json, Value};
use crate::core::types::HostDecisionAuthority;
use crate::rpc::handler::{HostHandler, HostOutcome};
use crate::rpc::message::RpcError;
use super::gate::{self, DangerousCommandGate};
use super::protocol::{
ClientCapabilities, FsCapabilities, FsReadParams, FsWriteParams, PermissionOption,
PermissionOptionKind, PermissionOutcome, PermissionRequestParams, PermissionToolCall,
TerminalCreateParams, TerminalExitStatus, TerminalIdParams, TerminalOutputResult, ToolKind,
};
use super::terminal::TerminalStore;
#[derive(Debug, Clone)]
pub(crate) enum HostRequestError {
Denied(String),
Failed(String),
}
impl HostRequestError {
pub(crate) fn message(&self) -> &str {
match self {
HostRequestError::Denied(m) | HostRequestError::Failed(m) => m,
}
}
}
pub(crate) trait AcpHostHandler: Send + Sync {
fn fs_read_text_file(&self, params: &FsReadParams) -> Result<String, HostRequestError> {
Err(HostRequestError::Denied(format!(
"fs/read_text_file not supported: {}",
params.path
)))
}
fn fs_write_text_file(&self, params: &FsWriteParams) -> Result<(), HostRequestError> {
Err(HostRequestError::Denied(format!(
"fs/write_text_file not supported: {}",
params.path
)))
}
fn terminal_create(&self, params: &TerminalCreateParams) -> Result<String, HostRequestError> {
let _ = params;
Err(HostRequestError::Denied("terminal/create not supported".to_string()))
}
fn terminal_output(
&self,
params: &TerminalIdParams,
) -> Result<TerminalOutputResult, HostRequestError> {
let _ = params;
Err(HostRequestError::Denied("terminal/output not supported".to_string()))
}
fn terminal_wait_for_exit(
&self,
params: &TerminalIdParams,
) -> Result<TerminalExitStatus, HostRequestError> {
let _ = params;
Err(HostRequestError::Denied("terminal/wait_for_exit not supported".to_string()))
}
fn terminal_kill(&self, params: &TerminalIdParams) -> Result<(), HostRequestError> {
let _ = params;
Err(HostRequestError::Denied("terminal/kill not supported".to_string()))
}
fn terminal_release(&self, params: &TerminalIdParams) -> Result<(), HostRequestError> {
let _ = params;
Err(HostRequestError::Denied("terminal/release not supported".to_string()))
}
fn request_permission(&self, params: &PermissionRequestParams) -> PermissionOutcome {
let _ = params;
PermissionOutcome::Cancelled
}
fn permission_refusal_reason(&self, params: &PermissionRequestParams) -> Option<String> {
let _ = params;
None
}
fn permission_blocked_by_gate(&self, tool_call: &PermissionToolCall) -> bool {
let _ = tool_call;
false
}
fn terminal_create_blocked_by_gate(&self, params: &TerminalCreateParams) -> bool {
let _ = params;
false
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HostPolicy {
Yolo,
Auto,
ReadOnly,
Deny,
}
impl Default for HostPolicy {
fn default() -> Self {
HostPolicy::Auto
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PermissionDeferral {
Disabled,
Enabled,
}
impl Default for PermissionDeferral {
fn default() -> Self {
PermissionDeferral::Disabled
}
}
impl HostPolicy {
pub(crate) fn client_capabilities(self) -> ClientCapabilities {
match self {
HostPolicy::Yolo | HostPolicy::Auto => ClientCapabilities {
fs: FsCapabilities { read_text_file: true, write_text_file: true },
terminal: true,
},
HostPolicy::ReadOnly => ClientCapabilities {
fs: FsCapabilities { read_text_file: true, write_text_file: false },
terminal: false,
},
HostPolicy::Deny => ClientCapabilities {
fs: FsCapabilities { read_text_file: false, write_text_file: false },
terminal: false,
},
}
}
fn allows_mutation(self) -> bool {
matches!(self, HostPolicy::Yolo | HostPolicy::Auto)
}
fn allows_read(self) -> bool {
!matches!(self, HostPolicy::Deny)
}
pub(crate) fn select_permission_option(
self,
tool_call: &PermissionToolCall,
options: &[PermissionOption],
) -> PermissionOutcome {
let prefer_allow = match self {
HostPolicy::Yolo | HostPolicy::Auto => true,
HostPolicy::ReadOnly => tool_call.kind.is_read_only(),
HostPolicy::Deny => false,
};
let preference: &[PermissionOptionKind] = if prefer_allow {
if matches!(self, HostPolicy::Yolo) {
&[PermissionOptionKind::AllowAlways, PermissionOptionKind::AllowOnce]
} else {
&[PermissionOptionKind::AllowOnce, PermissionOptionKind::AllowAlways]
}
} else {
&[PermissionOptionKind::RejectOnce, PermissionOptionKind::RejectAlways]
};
let outcome = select_offered_option(options, preference);
let outcome = if prefer_allow && outcome == PermissionOutcome::Cancelled {
match allow_ish_option(options) {
Some(option) => {
eprintln!(
"[gate4agent::acp::host] session/request_permission: no known \
PermissionOptionKind offered under {self:?} for toolCallId={:?}; \
falling back to allow-ish option optionId={:?} name={:?} kind={:?}",
tool_call.tool_call_id, option.option_id, option.name, option.kind
);
PermissionOutcome::Selected { option_id: option.option_id.clone() }
}
None => outcome,
}
} else {
outcome
};
if outcome == PermissionOutcome::Cancelled {
warn_permission_cancelled(self, tool_call, options);
}
outcome
}
}
fn allow_ish_option(options: &[PermissionOption]) -> Option<&PermissionOption> {
const ALLOW_ISH: [&str; 4] = ["allow", "approve", "always", "yes"];
options.iter().find(|option| {
let id = option.option_id.to_lowercase();
let name = option.name.to_lowercase();
ALLOW_ISH.iter().any(|needle| id.contains(needle) || name.contains(needle))
})
}
fn warn_permission_cancelled(policy: HostPolicy, tool_call: &PermissionToolCall, options: &[PermissionOption]) {
if options.is_empty() {
eprintln!(
"[gate4agent::acp::host] session/request_permission cancelled under {policy:?} for \
toolCallId={:?}: no options offered (params may have failed to parse, or the agent \
offered none)",
tool_call.tool_call_id
);
return;
}
let described: Vec<String> = options
.iter()
.map(|option| {
format!(
"(optionId={:?}, name={:?}, kind={:?})",
option.option_id, option.name, option.kind
)
})
.collect();
eprintln!(
"[gate4agent::acp::host] session/request_permission cancelled under {policy:?} for \
toolCallId={:?}: offered options were [{}]",
tool_call.tool_call_id,
described.join(", ")
);
}
fn warn_permission_params_parse_failed(context: &str, detail: &str) {
eprintln!(
"[gate4agent::acp::host] session/request_permission params failed to parse ({context}): {detail}"
);
}
pub(crate) fn select_offered_option(
options: &[PermissionOption],
preference: &[PermissionOptionKind],
) -> PermissionOutcome {
for kind in preference {
if let Some(option) = options.iter().find(|option| option.kind == *kind) {
return PermissionOutcome::Selected { option_id: option.option_id.clone() };
}
}
PermissionOutcome::Cancelled
}
pub(crate) struct PolicyHostHandler {
policy: HostPolicy,
working_dir: PathBuf,
terminals: TerminalStore,
dangerous_command_gate: DangerousCommandGate,
}
impl PolicyHostHandler {
pub(crate) fn new(
policy: HostPolicy,
working_dir: PathBuf,
dangerous_command_gate: DangerousCommandGate,
) -> Self {
Self { policy, working_dir, terminals: TerminalStore::new(), dangerous_command_gate }
}
fn terminal_create_cwd(&self, cwd_override: Option<&str>) -> PathBuf {
cwd_override
.map(PathBuf::from)
.unwrap_or_else(|| self.working_dir.clone())
}
}
fn windowed_read(content: &str, line: Option<u32>, limit: Option<u32>) -> String {
if line.is_none() && limit.is_none() {
return content.to_owned();
}
let start = line.unwrap_or(1).saturating_sub(1) as usize;
let lines = content.split_inclusive('\n').skip(start);
match limit {
Some(n) => lines.take(n as usize).collect(),
None => lines.collect(),
}
}
impl AcpHostHandler for PolicyHostHandler {
fn fs_read_text_file(&self, params: &FsReadParams) -> Result<String, HostRequestError> {
if !self.policy.allows_read() {
return Err(HostRequestError::Denied(format!(
"fs/read_text_file denied by host policy: {}",
params.path
)));
}
let content = std::fs::read_to_string(¶ms.path)
.map_err(|e| HostRequestError::Failed(e.to_string()))?;
Ok(windowed_read(&content, params.line, params.limit))
}
fn fs_write_text_file(&self, params: &FsWriteParams) -> Result<(), HostRequestError> {
if !self.policy.allows_mutation() {
return Err(HostRequestError::Denied(format!(
"fs/write_text_file denied by host policy: {}",
params.path
)));
}
std::fs::write(¶ms.path, ¶ms.content)
.map_err(|e| HostRequestError::Failed(e.to_string()))
}
fn terminal_create(&self, params: &TerminalCreateParams) -> Result<String, HostRequestError> {
if self.terminal_create_blocked_by_gate(params) {
let cwd = self.terminal_create_cwd(params.cwd.as_deref());
let verdict = gate::evaluate_command(¶ms.command, ¶ms.args, &cwd);
let refusal = verdict.refusal_message().unwrap_or_default();
return Err(HostRequestError::Denied(format!("terminal/create {refusal}")));
}
if !self.policy.allows_mutation() {
return Err(HostRequestError::Denied("terminal/create denied by host policy".to_string()));
}
self.terminals.create(&self.working_dir, params).map_err(HostRequestError::Failed)
}
fn terminal_output(
&self,
params: &TerminalIdParams,
) -> Result<TerminalOutputResult, HostRequestError> {
self.terminals.output(¶ms.terminal_id).map_err(HostRequestError::Failed)
}
fn terminal_wait_for_exit(
&self,
params: &TerminalIdParams,
) -> Result<TerminalExitStatus, HostRequestError> {
self.terminals.wait_for_exit(¶ms.terminal_id).map_err(HostRequestError::Failed)
}
fn terminal_kill(&self, params: &TerminalIdParams) -> Result<(), HostRequestError> {
self.terminals.kill(¶ms.terminal_id).map_err(HostRequestError::Failed)
}
fn terminal_release(&self, params: &TerminalIdParams) -> Result<(), HostRequestError> {
self.terminals.release(¶ms.terminal_id).map_err(HostRequestError::Failed)
}
fn request_permission(&self, params: &PermissionRequestParams) -> PermissionOutcome {
if self.permission_blocked_by_gate(¶ms.tool_call) {
return HostPolicy::Deny.select_permission_option(¶ms.tool_call, ¶ms.options);
}
self.policy.select_permission_option(¶ms.tool_call, ¶ms.options)
}
fn permission_refusal_reason(&self, params: &PermissionRequestParams) -> Option<String> {
if self.permission_blocked_by_gate(¶ms.tool_call) {
return gate::evaluate_permission_tool_call(¶ms.tool_call, &self.working_dir)
.refusal_message();
}
Some("session/request_permission denied by host policy".to_string())
}
fn permission_blocked_by_gate(&self, tool_call: &PermissionToolCall) -> bool {
self.dangerous_command_gate == DangerousCommandGate::Enforced
&& tool_call.kind == ToolKind::Execute
&& gate::evaluate_permission_tool_call(tool_call, &self.working_dir).is_blocked()
}
fn terminal_create_blocked_by_gate(&self, params: &TerminalCreateParams) -> bool {
self.dangerous_command_gate == DangerousCommandGate::Enforced
&& gate::evaluate_command(
¶ms.command,
¶ms.args,
&self.terminal_create_cwd(params.cwd.as_deref()),
)
.is_blocked()
}
}
pub(crate) struct AcpHostAdapter {
inner: std::sync::Arc<dyn AcpHostHandler>,
deferral: PermissionDeferral,
}
impl AcpHostAdapter {
pub(crate) fn new(
inner: std::sync::Arc<dyn AcpHostHandler>,
deferral: PermissionDeferral,
) -> Self {
Self { inner, deferral }
}
pub(crate) fn decision_authority(
&self,
method: &str,
params: Option<&Value>,
) -> HostDecisionAuthority {
let gate_blocked = match method {
"terminal/create" => params
.and_then(|v| serde_json::from_value::<TerminalCreateParams>(v.clone()).ok())
.is_some_and(|p| self.inner.terminal_create_blocked_by_gate(&p)),
"session/request_permission" => match params {
Some(v) => match serde_json::from_value::<PermissionRequestParams>(v.clone()) {
Ok(p) => self.inner.permission_blocked_by_gate(&p.tool_call),
Err(e) => {
warn_permission_params_parse_failed("decision_authority", &e.to_string());
false
}
},
None => false,
},
_ => false,
};
if gate_blocked {
HostDecisionAuthority::Gate
} else {
HostDecisionAuthority::Policy
}
}
pub(crate) fn permission_refusal_reason(&self, params: Option<&Value>) -> Option<String> {
let parsed = match serde_json::from_value::<PermissionRequestParams>(params?.clone()) {
Ok(p) => p,
Err(e) => {
warn_permission_params_parse_failed("permission_refusal_reason", &e.to_string());
return None;
}
};
self.inner.permission_refusal_reason(&parsed)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum HostCallOutcome {
Denied,
Granted,
GrantedButFailed { error: String },
}
pub(crate) struct HostCallResult {
pub(crate) response: Result<Value, RpcError>,
pub(crate) outcome: HostCallOutcome,
}
fn host_call_result_from_error(code: i32, err: HostRequestError) -> HostCallResult {
let message = err.message().to_string();
let outcome = match err {
HostRequestError::Denied(_) => HostCallOutcome::Denied,
HostRequestError::Failed(_) => HostCallOutcome::GrantedButFailed { error: message.clone() },
};
HostCallResult { response: Err(RpcError { code, message, data: None }), outcome }
}
pub(crate) enum HostDispatchOutcome {
Immediate { response: Result<Value, RpcError>, outcome: HostCallOutcome },
Deferred,
}
impl AcpHostAdapter {
fn dispatch(&self, method: &str, params: Option<Value>) -> HostCallResult {
match method {
"fs/read_text_file" => {
let p: FsReadParams = match parse_params(params) {
Ok(p) => p,
Err(e) => return HostCallResult { response: Err(e), outcome: HostCallOutcome::Denied },
};
match self.inner.fs_read_text_file(&p) {
Ok(content) => HostCallResult {
response: Ok(json!({ "content": content })),
outcome: HostCallOutcome::Granted,
},
Err(err) => host_call_result_from_error(RpcError::PERMISSION_DENIED, err),
}
}
"fs/write_text_file" => {
let p: FsWriteParams = match parse_params(params) {
Ok(p) => p,
Err(e) => return HostCallResult { response: Err(e), outcome: HostCallOutcome::Denied },
};
match self.inner.fs_write_text_file(&p) {
Ok(()) => {
HostCallResult { response: Ok(json!({})), outcome: HostCallOutcome::Granted }
}
Err(err) => host_call_result_from_error(RpcError::PERMISSION_DENIED, err),
}
}
"terminal/create" => {
let p: TerminalCreateParams = match parse_params(params) {
Ok(p) => p,
Err(e) => return HostCallResult { response: Err(e), outcome: HostCallOutcome::Denied },
};
match self.inner.terminal_create(&p) {
Ok(terminal_id) => HostCallResult {
response: Ok(json!({ "terminalId": terminal_id })),
outcome: HostCallOutcome::Granted,
},
Err(err) => host_call_result_from_error(RpcError::PERMISSION_DENIED, err),
}
}
"terminal/output" => {
let p: TerminalIdParams = match parse_params(params) {
Ok(p) => p,
Err(e) => return HostCallResult { response: Err(e), outcome: HostCallOutcome::Denied },
};
match self.inner.terminal_output(&p) {
Ok(result) => HostCallResult {
response: Ok(serde_json::to_value(result).unwrap_or(Value::Null)),
outcome: HostCallOutcome::Granted,
},
Err(err) => host_call_result_from_error(RpcError::NOT_FOUND, err),
}
}
"terminal/wait_for_exit" => {
let p: TerminalIdParams = match parse_params(params) {
Ok(p) => p,
Err(e) => return HostCallResult { response: Err(e), outcome: HostCallOutcome::Denied },
};
match self.inner.terminal_wait_for_exit(&p) {
Ok(result) => HostCallResult {
response: Ok(serde_json::to_value(result).unwrap_or(Value::Null)),
outcome: HostCallOutcome::Granted,
},
Err(err) => host_call_result_from_error(RpcError::NOT_FOUND, err),
}
}
"terminal/kill" => {
let p: TerminalIdParams = match parse_params(params) {
Ok(p) => p,
Err(e) => return HostCallResult { response: Err(e), outcome: HostCallOutcome::Denied },
};
match self.inner.terminal_kill(&p) {
Ok(()) => {
HostCallResult { response: Ok(json!({})), outcome: HostCallOutcome::Granted }
}
Err(err) => host_call_result_from_error(RpcError::NOT_FOUND, err),
}
}
"terminal/release" => {
let p: TerminalIdParams = match parse_params(params) {
Ok(p) => p,
Err(e) => return HostCallResult { response: Err(e), outcome: HostCallOutcome::Denied },
};
match self.inner.terminal_release(&p) {
Ok(()) => {
HostCallResult { response: Ok(json!({})), outcome: HostCallOutcome::Granted }
}
Err(err) => host_call_result_from_error(RpcError::NOT_FOUND, err),
}
}
"session/request_permission" => {
let p: PermissionRequestParams = match parse_params(params) {
Ok(p) => p,
Err(e) => {
warn_permission_params_parse_failed("dispatch", &e.message);
return HostCallResult { response: Err(e), outcome: HostCallOutcome::Denied };
}
};
let outcome = self.inner.request_permission(&p);
HostCallResult {
response: Ok(serde_json::to_value(outcome).unwrap_or(Value::Null)),
outcome: HostCallOutcome::Granted,
}
}
other => HostCallResult {
response: Err(RpcError::method_not_found(other)),
outcome: HostCallOutcome::Denied,
},
}
}
pub(crate) fn dispatch_deferrable(&self, method: &str, params: Option<Value>) -> HostDispatchOutcome {
if method != "session/request_permission" || self.deferral == PermissionDeferral::Disabled {
let result = self.dispatch(method, params);
return HostDispatchOutcome::Immediate { response: result.response, outcome: result.outcome };
}
let parsed = match params.clone() {
Some(v) => match serde_json::from_value::<PermissionRequestParams>(v) {
Ok(p) => Some(p),
Err(e) => {
warn_permission_params_parse_failed("dispatch_deferrable", &e.to_string());
None
}
},
None => None,
};
let Some(parsed) = parsed else {
let result = self.dispatch(method, params);
return HostDispatchOutcome::Immediate { response: result.response, outcome: result.outcome };
};
if self.inner.permission_blocked_by_gate(&parsed.tool_call) {
let result = self.dispatch(method, params);
return HostDispatchOutcome::Immediate { response: result.response, outcome: result.outcome };
}
HostDispatchOutcome::Deferred
}
}
impl HostHandler for AcpHostAdapter {
fn handle(&self, method: &str, params: Option<Value>) -> Result<Value, RpcError> {
self.dispatch(method, params).response
}
fn handle_deferrable(&self, method: &str, params: Option<Value>) -> HostOutcome {
match self.dispatch_deferrable(method, params) {
HostDispatchOutcome::Immediate { response, .. } => HostOutcome::Immediate(response),
HostDispatchOutcome::Deferred => HostOutcome::Deferred,
}
}
}
fn parse_params<T: serde::de::DeserializeOwned>(params: Option<Value>) -> Result<T, RpcError> {
let v = params.unwrap_or(Value::Null);
serde_json::from_value(v).map_err(|e| RpcError {
code: RpcError::INVALID_PARAMS,
message: format!("Invalid params: {}", e),
data: None,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::acp::protocol::ToolCallLocation;
use serde_json::json;
use std::sync::Arc;
struct Bare;
impl AcpHostHandler for Bare {}
#[test]
fn trait_defaults_deny_fs_read() {
let params = FsReadParams { path: "/etc/passwd".to_owned(), session_id: String::new(), line: None, limit: None };
assert!(Bare.fs_read_text_file(¶ms).is_err());
}
#[test]
fn trait_defaults_deny_terminal_create() {
let params = TerminalCreateParams {
session_id: String::new(),
command: "ls".to_owned(),
args: vec![],
env: vec![],
cwd: None,
output_byte_limit: None,
};
assert!(Bare.terminal_create(¶ms).is_err());
}
#[test]
fn trait_defaults_cancel_permission() {
let params = PermissionRequestParams {
session_id: "s1".to_owned(),
tool_call: PermissionToolCall::default(),
options: vec![PermissionOption {
option_id: "a1".to_owned(),
name: "Allow".to_owned(),
kind: PermissionOptionKind::AllowOnce,
}],
};
assert_eq!(Bare.request_permission(¶ms), PermissionOutcome::Cancelled);
}
fn adapter(policy: HostPolicy) -> AcpHostAdapter {
AcpHostAdapter::new(
Arc::new(PolicyHostHandler::new(policy, std::env::temp_dir(), DangerousCommandGate::Enforced)),
PermissionDeferral::Disabled,
)
}
fn adapter_with_gate(policy: HostPolicy, gate: DangerousCommandGate) -> AcpHostAdapter {
AcpHostAdapter::new(
Arc::new(PolicyHostHandler::new(policy, std::env::temp_dir(), gate)),
PermissionDeferral::Disabled,
)
}
fn adapter_with_deferral(
policy: HostPolicy,
gate: DangerousCommandGate,
deferral: PermissionDeferral,
) -> AcpHostAdapter {
AcpHostAdapter::new(Arc::new(PolicyHostHandler::new(policy, std::env::temp_dir(), gate)), deferral)
}
#[test]
fn yolo_and_auto_declare_full_capabilities() {
for policy in [HostPolicy::Yolo, HostPolicy::Auto] {
let caps = policy.client_capabilities();
assert!(caps.fs.read_text_file);
assert!(caps.fs.write_text_file);
assert!(caps.terminal);
}
}
#[test]
fn read_only_declares_read_but_not_write_or_terminal() {
let caps = HostPolicy::ReadOnly.client_capabilities();
assert!(caps.fs.read_text_file);
assert!(!caps.fs.write_text_file);
assert!(!caps.terminal);
}
#[test]
fn deny_declares_nothing() {
let caps = HostPolicy::Deny.client_capabilities();
assert!(!caps.fs.read_text_file);
assert!(!caps.fs.write_text_file);
assert!(!caps.terminal);
}
#[test]
fn default_policy_is_auto() {
assert_eq!(HostPolicy::default(), HostPolicy::Auto);
}
#[test]
fn auto_reads_and_writes_a_real_file() {
let dir = std::env::temp_dir();
let path = dir.join("gate4agent_host_test_rw.txt");
std::fs::write(&path, "before").unwrap();
let handler =
PolicyHostHandler::new(HostPolicy::Auto, dir.clone(), DangerousCommandGate::Enforced);
let read = handler
.fs_read_text_file(&FsReadParams {
path: path.to_string_lossy().into_owned(),
session_id: String::new(),
line: None,
limit: None,
})
.unwrap();
assert_eq!(read, "before");
handler
.fs_write_text_file(&FsWriteParams {
path: path.to_string_lossy().into_owned(),
session_id: String::new(),
content: "after".to_owned(),
})
.unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "after");
std::fs::remove_file(&path).ok();
}
#[test]
fn read_only_reads_but_refuses_to_write() {
let dir = std::env::temp_dir();
let path = dir.join("gate4agent_host_test_readonly.txt");
std::fs::write(&path, "content").unwrap();
let handler =
PolicyHostHandler::new(HostPolicy::ReadOnly, dir, DangerousCommandGate::Enforced);
assert!(handler
.fs_read_text_file(&FsReadParams {
path: path.to_string_lossy().into_owned(),
session_id: String::new(),
line: None,
limit: None,
})
.is_ok());
assert!(handler
.fs_write_text_file(&FsWriteParams {
path: path.to_string_lossy().into_owned(),
session_id: String::new(),
content: "nope".to_owned(),
})
.is_err());
std::fs::remove_file(&path).ok();
}
#[test]
fn deny_refuses_read_and_write() {
let dir = std::env::temp_dir();
let handler = PolicyHostHandler::new(HostPolicy::Deny, dir, DangerousCommandGate::Enforced);
assert!(handler
.fs_read_text_file(&FsReadParams {
path: "/etc/passwd".to_owned(),
session_id: String::new(),
line: None,
limit: None,
})
.is_err());
assert!(handler
.fs_write_text_file(&FsWriteParams {
path: "/tmp/should-not-be-written".to_owned(),
session_id: String::new(),
content: "x".to_owned(),
})
.is_err());
}
#[test]
fn windowed_read_applies_line_and_limit() {
let content = "one\ntwo\nthree\nfour\n";
assert_eq!(windowed_read(content, None, None), content);
assert_eq!(windowed_read(content, Some(2), None), "two\nthree\nfour\n");
assert_eq!(windowed_read(content, Some(2), Some(1)), "two\n");
}
fn options_all_four() -> Vec<PermissionOption> {
vec![
PermissionOption { option_id: "ao".to_owned(), name: "Allow once".to_owned(), kind: PermissionOptionKind::AllowOnce },
PermissionOption { option_id: "aa".to_owned(), name: "Allow always".to_owned(), kind: PermissionOptionKind::AllowAlways },
PermissionOption { option_id: "ro".to_owned(), name: "Reject once".to_owned(), kind: PermissionOptionKind::RejectOnce },
PermissionOption { option_id: "ra".to_owned(), name: "Reject always".to_owned(), kind: PermissionOptionKind::RejectAlways },
]
}
fn tool_call(kind: crate::acp::protocol::ToolKind) -> PermissionToolCall {
PermissionToolCall {
tool_call_id: "tc1".to_owned(),
title: "test".to_owned(),
kind,
locations: vec![ToolCallLocation { path: "/repo/file.txt".to_owned(), line: None }],
raw_input: Value::Null,
}
}
#[test]
fn yolo_prefers_allow_always() {
let outcome = HostPolicy::Yolo
.select_permission_option(&tool_call(crate::acp::protocol::ToolKind::Execute), &options_all_four());
assert_eq!(outcome, PermissionOutcome::Selected { option_id: "aa".to_owned() });
}
#[test]
fn auto_prefers_allow_once_over_allow_always() {
let outcome = HostPolicy::Auto
.select_permission_option(&tool_call(crate::acp::protocol::ToolKind::Execute), &options_all_four());
assert_eq!(outcome, PermissionOutcome::Selected { option_id: "ao".to_owned() });
}
#[test]
fn read_only_allows_a_read_kind_tool_call() {
let outcome = HostPolicy::ReadOnly
.select_permission_option(&tool_call(crate::acp::protocol::ToolKind::Read), &options_all_four());
assert_eq!(outcome, PermissionOutcome::Selected { option_id: "ao".to_owned() });
}
#[test]
fn read_only_rejects_an_execute_kind_tool_call() {
let outcome = HostPolicy::ReadOnly
.select_permission_option(&tool_call(crate::acp::protocol::ToolKind::Execute), &options_all_four());
assert_eq!(outcome, PermissionOutcome::Selected { option_id: "ro".to_owned() });
}
#[test]
fn deny_always_rejects() {
let outcome = HostPolicy::Deny
.select_permission_option(&tool_call(crate::acp::protocol::ToolKind::Read), &options_all_four());
assert_eq!(outcome, PermissionOutcome::Selected { option_id: "ro".to_owned() });
}
#[test]
fn deny_cancels_when_the_agent_offers_only_allow_options() {
let options = vec![
PermissionOption { option_id: "ao".to_owned(), name: "Allow once".to_owned(), kind: PermissionOptionKind::AllowOnce },
];
let outcome = HostPolicy::Deny
.select_permission_option(&tool_call(crate::acp::protocol::ToolKind::Execute), &options);
assert_eq!(outcome, PermissionOutcome::Cancelled);
}
#[test]
fn auto_falls_back_to_allow_always_when_agent_omits_allow_once() {
let options = vec![
PermissionOption { option_id: "aa".to_owned(), name: "Allow always".to_owned(), kind: PermissionOptionKind::AllowAlways },
PermissionOption { option_id: "ro".to_owned(), name: "Reject once".to_owned(), kind: PermissionOptionKind::RejectOnce },
];
let outcome = HostPolicy::Auto
.select_permission_option(&tool_call(crate::acp::protocol::ToolKind::Execute), &options);
assert_eq!(outcome, PermissionOutcome::Selected { option_id: "aa".to_owned() });
}
fn allow_ish_unknown_option() -> Vec<PermissionOption> {
vec![PermissionOption {
option_id: "always_allow".to_owned(),
name: "Always allow".to_owned(),
kind: PermissionOptionKind::Unknown,
}]
}
#[test]
fn auto_falls_back_to_allow_ish_unknown_option_when_no_known_kind_offered() {
let outcome = HostPolicy::Auto.select_permission_option(
&tool_call(crate::acp::protocol::ToolKind::Execute),
&allow_ish_unknown_option(),
);
assert_eq!(outcome, PermissionOutcome::Selected { option_id: "always_allow".to_owned() });
}
#[test]
fn yolo_falls_back_to_allow_ish_unknown_option_when_no_known_kind_offered() {
let outcome = HostPolicy::Yolo.select_permission_option(
&tool_call(crate::acp::protocol::ToolKind::Execute),
&allow_ish_unknown_option(),
);
assert_eq!(outcome, PermissionOutcome::Selected { option_id: "always_allow".to_owned() });
}
#[test]
fn known_kinds_still_win_over_the_allow_ish_fallback() {
let outcome = HostPolicy::Yolo
.select_permission_option(&tool_call(crate::acp::protocol::ToolKind::Execute), &options_all_four());
assert_eq!(outcome, PermissionOutcome::Selected { option_id: "aa".to_owned() });
let outcome = HostPolicy::Auto
.select_permission_option(&tool_call(crate::acp::protocol::ToolKind::Execute), &options_all_four());
assert_eq!(outcome, PermissionOutcome::Selected { option_id: "ao".to_owned() });
}
#[test]
fn allow_ish_fallback_never_fires_under_read_only_for_a_non_read_call() {
let outcome = HostPolicy::ReadOnly.select_permission_option(
&tool_call(crate::acp::protocol::ToolKind::Execute),
&allow_ish_unknown_option(),
);
assert_eq!(outcome, PermissionOutcome::Cancelled);
}
#[test]
fn allow_ish_fallback_never_fires_under_deny() {
let outcome = HostPolicy::Deny.select_permission_option(
&tool_call(crate::acp::protocol::ToolKind::Execute),
&allow_ish_unknown_option(),
);
assert_eq!(outcome, PermissionOutcome::Cancelled);
}
#[test]
fn adapter_selects_allow_ish_option_when_agent_offers_only_an_unknown_kind() {
let result = adapter(HostPolicy::Auto).handle(
"session/request_permission",
Some(json!({
"sessionId": "s1",
"toolCall": {"toolCallId": "tc1", "kind": "execute"},
"options": [
{"optionId": "always_allow", "name": "Always allow", "kind": "allow_forever"}
]
})),
);
let value = result.unwrap();
assert_eq!(value["outcome"], "selected");
assert_eq!(value["optionId"], "always_allow");
}
#[test]
fn adapter_dispatches_fs_read_success() {
let dir = std::env::temp_dir();
let path = dir.join("gate4agent_host_adapter_read.txt");
std::fs::write(&path, "adapter content").unwrap();
let result = adapter(HostPolicy::Auto)
.handle("fs/read_text_file", Some(json!({"path": path.to_string_lossy()})));
assert_eq!(result.unwrap()["content"], "adapter content");
std::fs::remove_file(&path).ok();
}
#[test]
fn adapter_dispatches_fs_read_denied_under_deny() {
let result = adapter(HostPolicy::Deny)
.handle("fs/read_text_file", Some(json!({"path": "/etc/passwd"})));
assert_eq!(result.unwrap_err().code, RpcError::PERMISSION_DENIED);
}
#[test]
fn adapter_dispatches_permission_request() {
let result = adapter(HostPolicy::Yolo).handle(
"session/request_permission",
Some(json!({
"sessionId": "s1",
"toolCall": {"toolCallId": "tc1", "kind": "execute"},
"options": [
{"optionId": "ao", "name": "Allow once", "kind": "allow_once"}
]
})),
);
let value = result.unwrap();
assert_eq!(value["outcome"], "selected");
assert_eq!(value["optionId"], "ao");
}
#[test]
fn adapter_unknown_method_returns_method_not_found() {
let result = adapter(HostPolicy::Auto).handle("unknown/method", None);
assert_eq!(result.unwrap_err().code, RpcError::METHOD_NOT_FOUND);
}
#[test]
fn adapter_invalid_params_returns_invalid_params_error() {
let result = adapter(HostPolicy::Auto).handle("terminal/create", Some(json!({})));
assert_eq!(result.unwrap_err().code, RpcError::INVALID_PARAMS);
}
#[test]
fn adapter_terminal_lifecycle_end_to_end() {
let handler = adapter(HostPolicy::Auto);
#[cfg(windows)]
let create_params = json!({"command": "cmd", "args": ["/C", "echo lifecycle"]});
#[cfg(not(windows))]
let create_params = json!({"command": "sh", "args": ["-c", "echo lifecycle"]});
let created = handler.handle("terminal/create", Some(create_params)).unwrap();
let terminal_id = created["terminalId"].as_str().unwrap().to_owned();
let waited = handler
.handle("terminal/wait_for_exit", Some(json!({"terminalId": terminal_id})))
.unwrap();
assert_eq!(waited["exitCode"], 0);
let output = handler
.handle("terminal/output", Some(json!({"terminalId": terminal_id})))
.unwrap();
assert!(output["output"].as_str().unwrap().contains("lifecycle"));
handler
.handle("terminal/release", Some(json!({"terminalId": terminal_id})))
.unwrap();
let after_release =
handler.handle("terminal/output", Some(json!({"terminalId": terminal_id})));
assert_eq!(after_release.unwrap_err().code, RpcError::NOT_FOUND);
}
#[test]
fn adapter_terminal_create_denied_under_read_only() {
let result = adapter(HostPolicy::ReadOnly)
.handle("terminal/create", Some(json!({"command": "echo", "args": []})));
assert_eq!(result.unwrap_err().code, RpcError::PERMISSION_DENIED);
}
fn dangerous_terminal_params() -> TerminalCreateParams {
TerminalCreateParams {
session_id: String::new(),
command: "rm".to_owned(),
args: vec!["-rf".to_owned(), "/".to_owned()],
env: vec![],
cwd: None,
output_byte_limit: None,
}
}
fn dangerous_execute_tool_call() -> PermissionToolCall {
PermissionToolCall {
tool_call_id: "tc1".to_owned(),
title: "Run rm -rf /".to_owned(),
kind: crate::acp::protocol::ToolKind::Execute,
locations: vec![],
raw_input: json!({"command": "rm", "args": ["-rf", "/"]}),
}
}
#[test]
fn dangerous_command_gate_blocks_terminal_create_even_under_yolo_policy() {
let handler =
PolicyHostHandler::new(HostPolicy::Yolo, std::env::temp_dir(), DangerousCommandGate::Enforced);
let err = handler.terminal_create(&dangerous_terminal_params()).unwrap_err();
assert!(matches!(err, HostRequestError::Denied(_)), "gate block must be Denied, was: {err:?}");
let message = err.message();
assert!(message.contains("dangerous-command gate"), "message was: {message}");
assert!(message.contains("filesystem-wipe"), "message was: {message}");
assert!(message.contains("rule="), "refusal must name the rule: {message}");
assert!(message.contains("argument="), "refusal must name the offending argument: {message}");
}
#[test]
fn dangerous_command_gate_disabled_skips_straight_to_policy_for_terminal_create() {
let handler =
PolicyHostHandler::new(HostPolicy::Deny, std::env::temp_dir(), DangerousCommandGate::Disabled);
let err = handler.terminal_create(&dangerous_terminal_params()).unwrap_err();
assert!(matches!(err, HostRequestError::Denied(_)), "policy refusal must be Denied, was: {err:?}");
assert_eq!(err.message(), "terminal/create denied by host policy");
}
#[test]
fn dangerous_command_gate_blocks_execute_permission_request_even_under_yolo_policy() {
let handler =
PolicyHostHandler::new(HostPolicy::Yolo, std::env::temp_dir(), DangerousCommandGate::Enforced);
let params = PermissionRequestParams {
session_id: "s1".to_owned(),
tool_call: dangerous_execute_tool_call(),
options: options_all_four(),
};
let outcome = handler.request_permission(¶ms);
assert_eq!(outcome, PermissionOutcome::Selected { option_id: "ro".to_owned() });
}
#[test]
fn dangerous_command_gate_disabled_leaves_execute_permission_request_to_policy() {
let handler =
PolicyHostHandler::new(HostPolicy::Yolo, std::env::temp_dir(), DangerousCommandGate::Disabled);
let params = PermissionRequestParams {
session_id: "s1".to_owned(),
tool_call: dangerous_execute_tool_call(),
options: options_all_four(),
};
let outcome = handler.request_permission(¶ms);
assert_eq!(outcome, PermissionOutcome::Selected { option_id: "aa".to_owned() });
}
#[test]
fn dangerous_command_gate_allows_a_benign_terminal_create_under_yolo() {
let handler =
PolicyHostHandler::new(HostPolicy::Yolo, std::env::temp_dir(), DangerousCommandGate::Enforced);
let params = TerminalCreateParams {
session_id: String::new(),
command: "echo".to_owned(),
args: vec!["hello".to_owned()],
env: vec![],
cwd: None,
output_byte_limit: None,
};
assert!(handler.terminal_create(¶ms).is_ok());
}
#[test]
fn adapter_dispatches_terminal_create_blocked_by_gate_as_permission_denied() {
let result = adapter_with_gate(HostPolicy::Auto, DangerousCommandGate::Enforced)
.handle("terminal/create", Some(json!({"command": "rm", "args": ["-rf", "/"]})));
let error = result.unwrap_err();
assert_eq!(error.code, RpcError::PERMISSION_DENIED);
assert!(error.message.contains("filesystem-wipe"), "message was: {}", error.message);
}
#[test]
fn permission_refusal_reason_is_the_gate_verdict_text_when_gate_blocked() {
let handler =
PolicyHostHandler::new(HostPolicy::Yolo, std::env::temp_dir(), DangerousCommandGate::Enforced);
let params = PermissionRequestParams {
session_id: "s1".to_owned(),
tool_call: dangerous_execute_tool_call(),
options: options_all_four(),
};
let reason = handler
.permission_refusal_reason(¶ms)
.expect("a gate-blocked call must explain why");
assert!(reason.contains("dangerous-command gate"), "reason was: {reason}");
assert!(reason.contains("rule="), "reason must name the rule: {reason}");
assert!(reason.contains("argument="), "reason must name the offending argument: {reason}");
}
#[test]
fn permission_refusal_reason_is_the_fixed_policy_sentence_when_not_gate_blocked() {
let handler =
PolicyHostHandler::new(HostPolicy::Deny, std::env::temp_dir(), DangerousCommandGate::Enforced);
let params = PermissionRequestParams {
session_id: "s1".to_owned(),
tool_call: PermissionToolCall {
tool_call_id: "tc1".to_owned(),
title: "Read a file".to_owned(),
kind: crate::acp::protocol::ToolKind::Read,
locations: vec![],
raw_input: json!({}),
},
options: options_all_four(),
};
assert_eq!(
handler.permission_refusal_reason(¶ms),
Some("session/request_permission denied by host policy".to_string())
);
}
#[test]
fn adapter_permission_refusal_reason_reparses_raw_params_and_carries_the_gate_text() {
let handler = adapter_with_gate(HostPolicy::Yolo, DangerousCommandGate::Enforced);
let params = PermissionRequestParams {
session_id: "s1".to_owned(),
tool_call: dangerous_execute_tool_call(),
options: options_all_four(),
};
let raw = serde_json::to_value(¶ms).expect("PermissionRequestParams serializes");
let reason = handler
.permission_refusal_reason(Some(&raw))
.expect("gate-blocked call must carry a reason through the adapter");
assert!(reason.contains("dangerous-command gate"), "reason was: {reason}");
}
#[test]
fn adapter_permission_refusal_reason_is_none_for_unparsable_params() {
let handler = adapter_with_gate(HostPolicy::Yolo, DangerousCommandGate::Enforced);
assert_eq!(handler.permission_refusal_reason(Some(&json!("not an object"))), None);
}
#[test]
fn gate_refusal_text_reaches_both_terminal_create_and_permission_request_paths() {
let handler =
PolicyHostHandler::new(HostPolicy::Yolo, std::env::temp_dir(), DangerousCommandGate::Enforced);
let terminal_reason = handler.terminal_create(&dangerous_terminal_params()).unwrap_err();
let terminal_reason = terminal_reason.message();
let permission_params = PermissionRequestParams {
session_id: "s1".to_owned(),
tool_call: dangerous_execute_tool_call(),
options: options_all_four(),
};
let permission_reason = handler
.permission_refusal_reason(&permission_params)
.expect("gate-blocked permission request must carry a reason");
assert!(
terminal_reason.contains("rule=filesystem-wipe"),
"terminal reason was: {terminal_reason}"
);
assert!(
permission_reason.contains("rule=filesystem-wipe"),
"permission reason was: {permission_reason}"
);
}
#[test]
fn handle_deferrable_never_defers_when_disabled() {
let handler = adapter(HostPolicy::Auto);
let outcome = handler.handle_deferrable(
"session/request_permission",
Some(json!({
"sessionId": "s1",
"toolCall": {"toolCallId": "tc1", "kind": "read"},
"options": []
})),
);
assert!(matches!(outcome, HostOutcome::Immediate(_)));
}
#[test]
fn handle_deferrable_never_defers_methods_other_than_request_permission() {
let handler =
adapter_with_deferral(HostPolicy::Auto, DangerousCommandGate::Enforced, PermissionDeferral::Enabled);
let outcome = handler.handle_deferrable("fs/read_text_file", Some(json!({"path": "/etc/passwd"})));
assert!(matches!(outcome, HostOutcome::Immediate(_)));
}
#[test]
fn handle_deferrable_defers_a_non_blocked_permission_request_when_enabled() {
let handler =
adapter_with_deferral(HostPolicy::Auto, DangerousCommandGate::Enforced, PermissionDeferral::Enabled);
let outcome = handler.handle_deferrable(
"session/request_permission",
Some(json!({
"sessionId": "s1",
"toolCall": {"toolCallId": "tc1", "kind": "read"},
"options": [{"optionId": "ao", "name": "Allow once", "kind": "allow_once"}]
})),
);
assert!(matches!(outcome, HostOutcome::Deferred));
}
#[test]
fn handle_deferrable_decides_a_gate_blocked_request_immediately_even_when_enabled() {
let handler =
adapter_with_deferral(HostPolicy::Yolo, DangerousCommandGate::Enforced, PermissionDeferral::Enabled);
let params = PermissionRequestParams {
session_id: "s1".to_owned(),
tool_call: dangerous_execute_tool_call(),
options: options_all_four(),
};
let outcome = handler.handle_deferrable(
"session/request_permission",
Some(serde_json::to_value(¶ms).expect("PermissionRequestParams serializes")),
);
match outcome {
HostOutcome::Immediate(Ok(value)) => {
assert_eq!(value["outcome"], "selected");
assert_eq!(value["optionId"], "ro");
}
other => panic!("expected an immediate gate-blocked decision, got {other:?}"),
}
}
#[test]
fn handle_deferrable_falls_through_to_handle_on_unparsable_params() {
let handler =
adapter_with_deferral(HostPolicy::Auto, DangerousCommandGate::Enforced, PermissionDeferral::Enabled);
let outcome = handler.handle_deferrable("session/request_permission", Some(json!("not an object")));
match outcome {
HostOutcome::Immediate(Err(err)) => assert_eq!(err.code, RpcError::INVALID_PARAMS),
other => panic!("expected an immediate INVALID_PARAMS error, got {other:?}"),
}
}
#[test]
fn decision_authority_is_gate_for_a_gate_blocked_terminal_create() {
let handler = adapter_with_gate(HostPolicy::Yolo, DangerousCommandGate::Enforced);
let params = json!({"command": "rm", "args": ["-rf", "/"]});
assert_eq!(
handler.decision_authority("terminal/create", Some(¶ms)),
HostDecisionAuthority::Gate
);
}
#[test]
fn decision_authority_is_policy_for_a_benign_terminal_create() {
let handler = adapter_with_gate(HostPolicy::Yolo, DangerousCommandGate::Enforced);
let params = json!({"command": "echo", "args": ["hello"]});
assert_eq!(
handler.decision_authority("terminal/create", Some(¶ms)),
HostDecisionAuthority::Policy
);
}
#[test]
fn decision_authority_is_policy_for_terminal_create_when_gate_disabled() {
let handler = adapter_with_gate(HostPolicy::Yolo, DangerousCommandGate::Disabled);
let params = json!({"command": "rm", "args": ["-rf", "/"]});
assert_eq!(
handler.decision_authority("terminal/create", Some(¶ms)),
HostDecisionAuthority::Policy
);
}
#[test]
fn decision_authority_is_gate_for_a_gate_blocked_permission_request() {
let handler = adapter_with_gate(HostPolicy::Yolo, DangerousCommandGate::Enforced);
let params = PermissionRequestParams {
session_id: "s1".to_owned(),
tool_call: dangerous_execute_tool_call(),
options: options_all_four(),
};
let value = serde_json::to_value(¶ms).expect("PermissionRequestParams serializes");
assert_eq!(
handler.decision_authority("session/request_permission", Some(&value)),
HostDecisionAuthority::Gate
);
}
#[test]
fn decision_authority_is_policy_for_a_non_blocked_permission_request() {
let handler = adapter_with_gate(HostPolicy::Yolo, DangerousCommandGate::Enforced);
let params = PermissionRequestParams {
session_id: "s1".to_owned(),
tool_call: tool_call(crate::acp::protocol::ToolKind::Read),
options: options_all_four(),
};
let value = serde_json::to_value(¶ms).expect("PermissionRequestParams serializes");
assert_eq!(
handler.decision_authority("session/request_permission", Some(&value)),
HostDecisionAuthority::Policy
);
}
#[test]
fn decision_authority_is_policy_for_every_other_method() {
let handler = adapter_with_gate(HostPolicy::Yolo, DangerousCommandGate::Enforced);
for method in [
"fs/read_text_file",
"fs/write_text_file",
"terminal/output",
"terminal/wait_for_exit",
"terminal/kill",
"terminal/release",
] {
assert_eq!(
handler.decision_authority(method, None),
HostDecisionAuthority::Policy,
"method was: {method}"
);
}
}
}