use std::path::Path;
use serde_json::{Value, json};
use crate::agents::CodingAgent;
#[cfg(any(windows, test))]
use base64::Engine;
pub(crate) fn generated_hooks(agent: CodingAgent, command: &str) -> Value {
if agent.uses_direct_hook_entries() {
direct_hooks(agent.hook_events(), command)
} else {
grouped_hooks(agent.hook_events(), command)
}
}
pub(crate) fn persistent_hook_forward_command(
relay: &Path,
agent: CodingAgent,
generation_file: &Path,
generation_token: &str,
) -> Result<String, String> {
hook_command(
relay,
&persistent_hook_arguments(agent, generation_file, generation_token),
)
}
pub(crate) fn transparent_hook_forward_command(
relay: &Path,
agent: CodingAgent,
gateway_url: &str,
) -> Result<String, String> {
hook_command(relay, &transparent_hook_arguments(agent, gateway_url))
}
#[cfg(test)]
pub(crate) fn transparent_hook_forward_command_for_platform(
relay: &Path,
agent: CodingAgent,
gateway_url: &str,
windows: bool,
) -> String {
hook_command_for_platform(
relay,
&transparent_hook_arguments(agent, gateway_url),
windows,
)
}
#[cfg(test)]
pub(crate) fn persistent_hook_forward_command_for_platform(
relay: &Path,
agent: CodingAgent,
generation_file: &Path,
generation_token: &str,
windows: bool,
) -> String {
hook_command_for_platform(
relay,
&persistent_hook_arguments(agent, generation_file, generation_token),
windows,
)
}
pub(super) fn transparent_hook_arguments(agent: CodingAgent, gateway_url: &str) -> Vec<String> {
vec![
"hook-forward".into(),
agent.as_arg().into(),
"--gateway-url".into(),
gateway_url.into(),
"--transparent-run".into(),
]
}
pub(super) fn persistent_hook_arguments(
agent: CodingAgent,
generation_file: &Path,
generation_token: &str,
) -> Vec<String> {
vec![
"hook-forward".into(),
agent.as_arg().into(),
"--gateway-url".into(),
crate::bootstrap::DEFAULT_URL.into(),
"--generation-file".into(),
generation_file.display().to_string(),
"--generation-token".into(),
generation_token.into(),
]
}
pub(super) fn hook_command(relay: &Path, arguments: &[String]) -> Result<String, String> {
#[cfg(windows)]
{
return encoded_windows_hook_command(&windows_powershell_launcher()?, relay, arguments);
}
#[cfg(not(windows))]
{
Ok(posix_hook_command(relay, arguments))
}
}
#[cfg(test)]
pub(super) fn hook_command_for_platform(
relay: &Path,
arguments: &[String],
windows: bool,
) -> String {
if windows {
return encoded_windows_hook_command(
"C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe",
relay,
arguments,
)
.expect("test hook command must fit within the Windows command-line limit");
}
posix_hook_command(relay, arguments)
}
#[cfg(any(not(windows), test))]
pub(super) fn posix_hook_command(relay: &Path, arguments: &[String]) -> String {
std::iter::once(relay.display().to_string())
.chain(arguments.iter().cloned())
.map(|argument| crate::agents::shell_quote_arg_for_platform(&argument, false))
.collect::<Vec<_>>()
.join(" ")
}
#[cfg(any(windows, test))]
const MAX_WINDOWS_HOOK_COMMAND_UTF16_UNITS: usize = 8_000;
#[cfg(any(windows, test))]
pub(crate) fn encoded_windows_hook_command(
powershell: &str,
relay: &Path,
arguments: &[String],
) -> Result<String, String> {
const PREFIX: &str = "$ErrorActionPreference='Stop'; & ";
const SUFFIX: &str = "; if ($null -eq $LASTEXITCODE) { exit 1 }; exit $LASTEXITCODE";
let invocation = std::iter::once(relay.display().to_string())
.chain(arguments.iter().cloned())
.map(|argument| format!("'{}'", argument.replace('\'', "''")))
.collect::<Vec<_>>()
.join(" ");
let script = format!("{PREFIX}{invocation}{SUFFIX}");
let bytes = script
.encode_utf16()
.flat_map(u16::to_le_bytes)
.collect::<Vec<_>>();
let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
let command =
format!("{powershell} -NoLogo -NoProfile -NonInteractive -EncodedCommand {encoded}");
if command.encode_utf16().count() > MAX_WINDOWS_HOOK_COMMAND_UTF16_UNITS {
return Err(format!(
"generated Windows coding-agent hook command exceeds the {MAX_WINDOWS_HOOK_COMMAND_UTF16_UNITS}-character safety limit; shorten the Relay or plugin installation path"
));
}
Ok(command)
}
#[cfg(windows)]
pub(super) fn windows_powershell_launcher() -> Result<String, String> {
let powershell = windows_powershell_path()?;
if !Path::new(&powershell).is_file() {
return Err(format!(
"trusted Windows PowerShell launcher is missing at {powershell}; install Windows PowerShell before configuring coding-agent hooks"
));
}
Ok(powershell)
}
#[cfg(windows)]
pub(crate) fn windows_powershell_path() -> Result<String, String> {
use std::os::windows::ffi::OsStringExt;
use windows_sys::Win32::System::SystemInformation::GetSystemDirectoryW;
let mut buffer = vec![0_u16; 260];
let length = loop {
let length = unsafe { GetSystemDirectoryW(buffer.as_mut_ptr(), buffer.len() as u32) };
if length == 0 {
return Err(format!(
"failed to resolve the trusted Windows system directory: {}",
std::io::Error::last_os_error()
));
}
if (length as usize) < buffer.len() {
break length as usize;
}
buffer.resize(length as usize + 1, 0);
};
let system = std::path::PathBuf::from(std::ffi::OsString::from_wide(&buffer[..length]));
let powershell = system.join("WindowsPowerShell/v1.0/powershell.exe");
let powershell = powershell
.into_os_string()
.into_string()
.map_err(|_| "trusted Windows PowerShell path is not valid Unicode".to_string())?
.replace('\\', "/");
if !safe_windows_launcher_token(&powershell) {
return Err(format!(
"trusted Windows PowerShell path {powershell} contains characters that cannot be represented safely in coding-agent hook commands"
));
}
Ok(powershell)
}
#[cfg(any(windows, test))]
pub(super) fn safe_windows_launcher_token(launcher: &str) -> bool {
!launcher.is_empty()
&& launcher.chars().all(|character| {
character.is_ascii_alphanumeric() || matches!(character, '/' | ':' | '.' | '_' | '-')
})
&& launcher
.to_ascii_lowercase()
.ends_with("/system32/windowspowershell/v1.0/powershell.exe")
}
#[cfg(any(windows, test))]
pub(crate) fn decode_windows_hook_command(command: &str) -> Option<Vec<String>> {
const COMMAND_SEPARATOR: &str = " -NoLogo -NoProfile -NonInteractive -EncodedCommand ";
const SCRIPT_PREFIX: &str = "$ErrorActionPreference='Stop'; & ";
const SCRIPT_SUFFIX: &str = "; if ($null -eq $LASTEXITCODE) { exit 1 }; exit $LASTEXITCODE";
if command.encode_utf16().count() > MAX_WINDOWS_HOOK_COMMAND_UTF16_UNITS {
return None;
}
let (launcher, encoded) = command.split_once(COMMAND_SEPARATOR)?;
if !safe_windows_launcher_token(launcher) {
return None;
}
#[cfg(windows)]
if !launcher.eq_ignore_ascii_case(&windows_powershell_path().ok()?) {
return None;
}
if encoded.is_empty() || encoded.chars().any(char::is_whitespace) {
return None;
}
let bytes = base64::engine::general_purpose::STANDARD
.decode(encoded)
.ok()?;
let pairs = bytes.chunks_exact(2);
if !pairs.remainder().is_empty() {
return None;
}
let script = String::from_utf16(
&pairs
.map(|pair| u16::from_le_bytes([pair[0], pair[1]]))
.collect::<Vec<_>>(),
)
.ok()?;
let invocation = script
.strip_prefix(SCRIPT_PREFIX)?
.strip_suffix(SCRIPT_SUFFIX)?;
parse_powershell_single_quoted_arguments(invocation)
}
#[cfg(any(windows, test))]
pub(super) fn parse_powershell_single_quoted_arguments(mut raw: &str) -> Option<Vec<String>> {
let mut arguments = Vec::new();
while !raw.is_empty() {
raw = raw.strip_prefix('\'')?;
let mut argument = String::new();
loop {
let quote = raw.find('\'')?;
argument.push_str(&raw[..quote]);
raw = &raw[quote + 1..];
if let Some(rest) = raw.strip_prefix('\'') {
argument.push('\'');
raw = rest;
} else {
break;
}
}
arguments.push(argument);
if raw.is_empty() {
break;
}
raw = raw.strip_prefix(' ')?;
if raw.is_empty() {
return None;
}
}
(!arguments.is_empty()).then_some(arguments)
}
pub(super) fn direct_hooks(events: &[&str], command: &str) -> Value {
let hooks: serde_json::Map<String, Value> = events
.iter()
.map(|event| {
(
(*event).to_string(),
json!([{
"command": command,
"timeout": 30
}]),
)
})
.collect();
json!({ "hooks": Value::Object(hooks) })
}
pub(super) fn grouped_hooks(events: &[&str], command: &str) -> Value {
let hooks: serde_json::Map<String, Value> = events
.iter()
.map(|event| {
let mut group = serde_json::Map::new();
if event_matches_tools(event) {
group.insert("matcher".into(), json!("*"));
}
group.insert(
"hooks".into(),
json!([{
"type": "command",
"command": command,
"timeout": 30
}]),
);
(
(*event).to_string(),
Value::Array(vec![Value::Object(group)]),
)
})
.collect();
json!({ "hooks": Value::Object(hooks) })
}
pub(crate) fn event_matches_tools(event: &str) -> bool {
matches!(
event,
"PreToolUse" | "PostToolUse" | "PostToolUseFailure" | "PermissionRequest"
)
}