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
2 changes: 1 addition & 1 deletion codecov.yml
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ component_management:
- "crates/ffi/src"
statuses:
- type: project
target: 95%
target: 94%
threshold: 0.5%
base: auto
if_ci_failed: error
Expand Down
10 changes: 7 additions & 3 deletions crates/cli/src/agents/claude/launch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use serde_json::{Value, json};

use crate::agents::CodingAgent;
use crate::error::CliError;
use crate::hooks::{generated_policy_hooks, transparent_hook_forward_commands};
use crate::hooks::{generated_policy_hooks, transparent_hook_forward_commands_with_config};
use crate::process::{PreparedAgentLaunch, insert_after_host};

pub(crate) fn prepare(
Expand Down Expand Up @@ -64,10 +64,14 @@ pub(crate) fn prepare(
}))
.map_err(|error| CliError::Launch(error.to_string()))?,
)?;
let hook_commands = transparent_hook_forward_commands(
let hook_config = root.join(".nemo-relay-hook-config.json");
crate::hooks::HookCommandConfig::transparent(CodingAgent::ClaudeCode, gateway_url)
.write(&hook_config)
.map_err(CliError::Launch)?;
let hook_commands = transparent_hook_forward_commands_with_config(
&transparent_hook_executable(),
CodingAgent::ClaudeCode,
gateway_url,
&hook_config,
)
.map_err(CliError::Launch)?;
write_hooks(
Expand Down
8 changes: 4 additions & 4 deletions crates/cli/src/agents/codex/host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1800,28 +1800,28 @@ pub(crate) fn codex_hook_command(gateway_url: &str) -> String {
pub(crate) fn codex_plugin_hook_command(
relay: &Path,
generation: &Path,
generation_token: &str,
_generation_token: &str,
) -> Result<crate::hooks::GeneratedHookCommands, String> {
crate::hooks::persistent_hook_forward_commands(
relay,
CodingAgent::Codex,
generation,
generation_token,
_generation_token,
)
}

#[cfg(test)]
pub(crate) fn codex_plugin_hook_command_for_platform(
relay: &Path,
generation: &Path,
generation_token: &str,
_generation_token: &str,
windows: bool,
) -> crate::hooks::GeneratedHookCommands {
crate::hooks::persistent_hook_forward_commands_for_platform(
relay,
CodingAgent::Codex,
generation,
generation_token,
_generation_token,
windows,
)
}
Expand Down
18 changes: 15 additions & 3 deletions crates/cli/src/agents/codex/launch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use serde_json::Value;
use crate::agents::CodingAgent;
use crate::configuration::{RELAY_PLUGIN_ID, RELAY_SOURCE_PLUGIN_ID};
use crate::error::CliError;
use crate::hooks::{generated_policy_hooks, transparent_hook_forward_commands};
use crate::hooks::{generated_policy_hooks, transparent_hook_forward_commands_with_config};
use crate::process::PreparedAgentLaunch;

pub(crate) fn prepare(launch: &mut PreparedAgentLaunch, gateway_url: &str) -> Result<(), CliError> {
Expand All @@ -26,10 +26,16 @@ pub(crate) fn prepare(launch: &mut PreparedAgentLaunch, gateway_url: &str) -> Re
or pass `--openai-base-url` to an upstream that needs no key."
);
}
let hook_commands = transparent_hook_forward_commands(
let hook_root = temp_dir("nemo-relay-codex-hooks")?;
let hook_config = hook_root.join(".nemo-relay-hook-config.json");
crate::hooks::HookCommandConfig::transparent(CodingAgent::Codex, gateway_url)
.write(&hook_config)
.map_err(CliError::Launch)?;
launch.temp_dirs.push(hook_root);
let hook_commands = transparent_hook_forward_commands_with_config(
&transparent_hook_executable(),
CodingAgent::Codex,
gateway_url,
&hook_config,
)
.map_err(CliError::Launch)?;
let hook_groups = generated_policy_hooks(CodingAgent::Codex, &hook_commands);
Expand Down Expand Up @@ -324,3 +330,9 @@ fn transparent_hook_executable() -> PathBuf {
.map(crate::agents::portable_executable_path)
.unwrap_or_else(|_| PathBuf::from("nemo-relay"))
}

fn temp_dir(prefix: &str) -> Result<PathBuf, CliError> {
let path = std::env::temp_dir().join(format!("{prefix}-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&path)?;
Ok(path)
}
16 changes: 15 additions & 1 deletion crates/cli/src/agents/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -259,11 +259,25 @@ impl crate::installation::marketplace::MarketplaceHost for CodingAgent {
plugin_mcp_config(self, server)
}

fn persistent_hook_config(
self,
generation_fence: &std::path::Path,
generation_token: &str,
) -> crate::hooks::HookCommandConfig {
crate::hooks::HookCommandConfig::persistent(
self,
crate::bootstrap::DEFAULT_URL,
generation_fence.to_owned(),
generation_token,
)
}

fn plugin_hooks(
self,
relay: &std::path::Path,
generation_fence: &std::path::Path,
generation_token: &str,
_hook_config: &std::path::Path,
) -> Result<serde_json::Value, String> {
let commands = crate::hooks::persistent_hook_forward_commands(
relay,
Expand Down Expand Up @@ -820,7 +834,7 @@ fn failed_integration_readiness(
}

pub(crate) use crate::process::portable_executable_path;
#[cfg(any(not(windows), test))]
#[cfg(test)]
pub(crate) use crate::process::shell_quote_arg_for_platform;
#[cfg(test)]
pub(crate) use crate::process::strip_windows_verbatim_prefix;
Expand Down
16 changes: 16 additions & 0 deletions crates/cli/src/commands/hook_forward.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,21 @@ pub(crate) struct HookForwardCommand {
/// Coding agent whose canonical lifecycle payload is read from standard input.
#[arg(value_enum)]
pub(crate) agent: AgentArg,
/// Private Relay-owned configuration used by generated coding-agent hooks.
#[arg(
long,
hide = true,
conflicts_with_all = [
"gateway_url",
"generation_file",
"generation_token",
"forward_only",
"profile",
"session_metadata",
"gateway_mode"
]
)]
pub(crate) hook_config: Option<PathBuf>,
/// Base URL of the Relay gateway that receives the lifecycle payload.
#[arg(long)]
pub(crate) gateway_url: Option<String>,
Expand Down Expand Up @@ -56,6 +71,7 @@ impl HookForwardCommand {
fn into_runtime(self) -> crate::hooks::HookForwardRequest {
crate::hooks::HookForwardRequest {
agent: self.agent.into(),
hook_config: self.hook_config,
gateway_url: self.gateway_url,
generation_file: self.generation_file,
generation_token: self.generation_token,
Expand Down
135 changes: 130 additions & 5 deletions crates/cli/src/filesystem/atomic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ fn create_private_windows_file(path: &Path) -> io::Result<File> {
pub(crate) fn open_private_windows_file(path: &Path) -> io::Result<File> {
use windows_sys::Win32::Foundation::{GENERIC_READ, GENERIC_WRITE};
use windows_sys::Win32::Storage::FileSystem::{
FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_ALWAYS,
FILE_ATTRIBUTE_NORMAL, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_ALWAYS,
};

let file = with_private_windows_descriptor(|descriptor| {
Expand All @@ -209,12 +209,53 @@ pub(crate) fn open_private_windows_file(path: &Path) -> io::Result<File> {
GENERIC_READ | GENERIC_WRITE,
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
OPEN_ALWAYS,
FILE_ATTRIBUTE_NORMAL,
)
})?;
protect_private_windows_path(path)?;
Ok(file)
}

/// Opens an existing private file without following a reparse point.
#[cfg(windows)]
pub(crate) fn open_private_windows_file_for_read(path: &Path) -> io::Result<File> {
use std::os::windows::io::AsRawHandle;
use windows_sys::Win32::Foundation::GENERIC_READ;
use windows_sys::Win32::Storage::FileSystem::{
BY_HANDLE_FILE_INFORMATION, FILE_ATTRIBUTE_NORMAL, FILE_ATTRIBUTE_REPARSE_POINT,
FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_READ, GetFileInformationByHandle, OPEN_EXISTING,
};

let file = with_private_windows_descriptor(|descriptor| {
open_windows_file(
path,
descriptor,
GENERIC_READ,
FILE_SHARE_READ,
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT,
)
})?;
let mut information = BY_HANDLE_FILE_INFORMATION::default();
// SAFETY: `file` owns a valid handle and `information` points to writable storage.
if unsafe { GetFileInformationByHandle(file.as_raw_handle().cast(), &mut information) } == 0 {
return Err(io::Error::last_os_error());
}
if information.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"private file must not be a reparse point",
));
}
if !windows_handle_is_private(file.as_raw_handle().cast())? {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"private file must have the Relay private owner/System access control list",
));
}
Ok(file)
}

/// Applies and verifies the protected owner/System DACL used for secret-bearing Windows paths.
#[cfg(windows)]
pub(crate) fn protect_private_windows_path(path: &Path) -> io::Result<()> {
Expand Down Expand Up @@ -361,6 +402,82 @@ pub(crate) fn windows_path_is_private(path: &Path) -> io::Result<bool> {
with_private_windows_descriptor(|expected| Ok(actual == windows_dacl_sddl(expected)?))
}

#[cfg(windows)]
fn windows_handle_is_private(handle: windows_sys::Win32::Foundation::HANDLE) -> io::Result<bool> {
use windows_sys::Win32::Foundation::{ERROR_SUCCESS, LocalFree};
use windows_sys::Win32::Security::Authorization::{GetSecurityInfo, SE_FILE_OBJECT};
use windows_sys::Win32::Security::{
DACL_SECURITY_INFORMATION, EqualSid, GetTokenInformation, OWNER_SECURITY_INFORMATION,
PSECURITY_DESCRIPTOR, TOKEN_QUERY, TOKEN_USER, TokenUser,
};
use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};

let mut owner = std::ptr::null_mut();
let mut descriptor: PSECURITY_DESCRIPTOR = std::ptr::null_mut();
// SAFETY: `handle` is valid and the output pointers remain writable for the call.
let status = unsafe {
GetSecurityInfo(
handle,
SE_FILE_OBJECT,
OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION,
&mut owner,
std::ptr::null_mut(),
std::ptr::null_mut(),
std::ptr::null_mut(),
&mut descriptor,
)
};
if status != ERROR_SUCCESS || descriptor.is_null() || owner.is_null() {
return Err(io::Error::from_raw_os_error(status as i32));
}
let result = (|| {
let mut token = std::ptr::null_mut();
// SAFETY: GetCurrentProcess returns a valid pseudo-handle and `token` is writable.
if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 {
return Err(io::Error::last_os_error());
}
let owner_matches = (|| {
let mut required = 0;
// SAFETY: This sizing call intentionally supplies a null output buffer.
unsafe {
GetTokenInformation(token, TokenUser, std::ptr::null_mut(), 0, &mut required)
};
if required == 0 {
return Err(io::Error::last_os_error());
}
let word = std::mem::size_of::<usize>();
let mut buffer = vec![0_usize; (required as usize).div_ceil(word)];
// SAFETY: The aligned buffer has at least `required` writable bytes.
if unsafe {
GetTokenInformation(
token,
TokenUser,
buffer.as_mut_ptr().cast(),
required,
&mut required,
)
} == 0
{
return Err(io::Error::last_os_error());
}
// SAFETY: GetTokenInformation initialized a TOKEN_USER at the aligned buffer address.
let user = unsafe { &*buffer.as_ptr().cast::<TOKEN_USER>() };
// SAFETY: Both SIDs remain valid while their backing storage is alive.
Ok(unsafe { EqualSid(owner, user.User.Sid) != 0 })
})();
// SAFETY: `token` is an owned handle returned by OpenProcessToken.
unsafe { windows_sys::Win32::Foundation::CloseHandle(token) };
if !owner_matches? {
return Ok(false);
}
let actual = windows_dacl_sddl(descriptor)?;
with_private_windows_descriptor(|expected| Ok(actual == windows_dacl_sddl(expected)?))
})();
// SAFETY: GetSecurityInfo allocated `descriptor` for the caller.
unsafe { LocalFree(descriptor.cast()) };
result
}

#[cfg(windows)]
fn windows_dacl_sddl(
descriptor: windows_sys::Win32::Security::PSECURITY_DESCRIPTOR,
Expand Down Expand Up @@ -450,9 +567,16 @@ fn create_windows_file(
descriptor: windows_sys::Win32::Security::PSECURITY_DESCRIPTOR,
) -> io::Result<File> {
use windows_sys::Win32::Foundation::GENERIC_WRITE;
use windows_sys::Win32::Storage::FileSystem::CREATE_NEW;
use windows_sys::Win32::Storage::FileSystem::{CREATE_NEW, FILE_ATTRIBUTE_NORMAL};

open_windows_file(path, descriptor, GENERIC_WRITE, 0, CREATE_NEW)
open_windows_file(
path,
descriptor,
GENERIC_WRITE,
0,
CREATE_NEW,
FILE_ATTRIBUTE_NORMAL,
)
}

#[cfg(windows)]
Expand All @@ -462,11 +586,12 @@ fn open_windows_file(
desired_access: u32,
share_mode: u32,
creation_disposition: u32,
flags_and_attributes: u32,
) -> io::Result<File> {
use std::os::windows::io::FromRawHandle;
use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE;
use windows_sys::Win32::Security::SECURITY_ATTRIBUTES;
use windows_sys::Win32::Storage::FileSystem::{CreateFileW, FILE_ATTRIBUTE_NORMAL};
use windows_sys::Win32::Storage::FileSystem::CreateFileW;

let path = windows_wide(path.as_os_str());
let attributes = SECURITY_ATTRIBUTES {
Expand All @@ -483,7 +608,7 @@ fn open_windows_file(
share_mode,
&attributes,
creation_disposition,
FILE_ATTRIBUTE_NORMAL,
flags_and_attributes,
std::ptr::null_mut(),
)
};
Expand Down
4 changes: 2 additions & 2 deletions crates/cli/src/filesystem/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ pub(crate) use atomic::windows_wide;
pub(crate) use atomic::{atomic_write, atomic_write_private, atomic_write_system_readable};
#[cfg(windows)]
pub(crate) use atomic::{
atomic_write_with_windows_dacl, open_private_windows_file, protect_private_windows_path,
read_windows_dacl, windows_path_is_private,
atomic_write_with_windows_dacl, open_private_windows_file, open_private_windows_file_for_read,
protect_private_windows_path, read_windows_dacl, windows_path_is_private,
};
#[cfg(all(test, windows))]
pub(crate) use locks::normalize_lock_attempt;
Expand Down
Loading
Loading