fix(channels): execute tool calls in channel runtime (#302)

* fix(channels): execute tool calls in channel runtime (#302)

* chore(fmt): align repo formatting with rustfmt 1.92
This commit is contained in:
Chummy 2026-02-16 18:07:01 +08:00 committed by GitHub
parent efabe9703f
commit 9d29f30a31
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 483 additions and 127 deletions

View file

@ -88,7 +88,12 @@ impl AuditEvent {
}
/// Set the actor
pub fn with_actor(mut self, channel: String, user_id: Option<String>, username: Option<String>) -> Self {
pub fn with_actor(
mut self,
channel: String,
user_id: Option<String>,
username: Option<String>,
) -> Self {
self.actor = Some(Actor {
channel,
user_id,
@ -98,7 +103,13 @@ impl AuditEvent {
}
/// Set the action
pub fn with_action(mut self, command: String, risk_level: String, approved: bool, allowed: bool) -> Self {
pub fn with_action(
mut self,
command: String,
risk_level: String,
approved: bool,
allowed: bool,
) -> Self {
self.action = Some(Action {
command: Some(command),
risk_level: Some(risk_level),
@ -109,7 +120,13 @@ impl AuditEvent {
}
/// Set the result
pub fn with_result(mut self, success: bool, exit_code: Option<i32>, duration_ms: u64, error: Option<String>) -> Self {
pub fn with_result(
mut self,
success: bool,
exit_code: Option<i32>,
duration_ms: u64,
error: Option<String>,
) -> Self {
self.result = Some(ExecutionResult {
success,
exit_code,
@ -179,7 +196,12 @@ impl AuditLogger {
) -> Result<()> {
let event = AuditEvent::new(AuditEventType::CommandExecution)
.with_actor(channel.to_string(), None, None)
.with_action(command.to_string(), risk_level.to_string(), approved, allowed)
.with_action(
command.to_string(),
risk_level.to_string(),
approved,
allowed,
)
.with_result(success, None, duration_ms, None);
self.log(&event)
@ -224,8 +246,11 @@ mod tests {
#[test]
fn audit_event_with_actor() {
let event = AuditEvent::new(AuditEventType::CommandExecution)
.with_actor("telegram".to_string(), Some("123".to_string()), Some("@alice".to_string()));
let event = AuditEvent::new(AuditEventType::CommandExecution).with_actor(
"telegram".to_string(),
Some("123".to_string()),
Some("@alice".to_string()),
);
assert!(event.actor.is_some());
let actor = event.actor.as_ref().unwrap();
@ -236,8 +261,12 @@ mod tests {
#[test]
fn audit_event_with_action() {
let event = AuditEvent::new(AuditEventType::CommandExecution)
.with_action("ls -la".to_string(), "low".to_string(), false, true);
let event = AuditEvent::new(AuditEventType::CommandExecution).with_action(
"ls -la".to_string(),
"low".to_string(),
false,
true,
);
assert!(event.action.is_some());
let action = event.action.as_ref().unwrap();

View file

@ -35,14 +35,23 @@ impl BubblewrapSandbox {
impl Sandbox for BubblewrapSandbox {
fn wrap_command(&self, cmd: &mut Command) -> std::io::Result<()> {
let program = cmd.get_program().to_string_lossy().to_string();
let args: Vec<String> = cmd.get_args().map(|s| s.to_string_lossy().to_string()).collect();
let args: Vec<String> = cmd
.get_args()
.map(|s| s.to_string_lossy().to_string())
.collect();
let mut bwrap_cmd = Command::new("bwrap");
bwrap_cmd.args([
"--ro-bind", "/usr", "/usr",
"--dev", "/dev",
"--proc", "/proc",
"--bind", "/tmp", "/tmp",
"--ro-bind",
"/usr",
"/usr",
"--dev",
"/dev",
"--proc",
"/proc",
"--bind",
"/tmp",
"/tmp",
"--unshare-all",
"--die-with-parent",
]);

View file

@ -25,7 +25,9 @@ pub fn create_sandbox(config: &SecurityConfig) -> Arc<dyn Sandbox> {
}
}
}
tracing::warn!("Landlock requested but not available, falling back to application-layer");
tracing::warn!(
"Landlock requested but not available, falling back to application-layer"
);
Arc::new(super::traits::NoopSandbox)
}
SandboxBackend::Firejail => {
@ -35,7 +37,9 @@ pub fn create_sandbox(config: &SecurityConfig) -> Arc<dyn Sandbox> {
return Arc::new(sandbox);
}
}
tracing::warn!("Firejail requested but not available, falling back to application-layer");
tracing::warn!(
"Firejail requested but not available, falling back to application-layer"
);
Arc::new(super::traits::NoopSandbox)
}
SandboxBackend::Bubblewrap => {
@ -48,7 +52,9 @@ pub fn create_sandbox(config: &SecurityConfig) -> Arc<dyn Sandbox> {
}
}
}
tracing::warn!("Bubblewrap requested but not available, falling back to application-layer");
tracing::warn!(
"Bubblewrap requested but not available, falling back to application-layer"
);
Arc::new(super::traits::NoopSandbox)
}
SandboxBackend::Docker => {
@ -138,7 +144,7 @@ mod tests {
fn auto_mode_detects_something() {
let config = SecurityConfig {
sandbox: SandboxConfig {
enabled: None, // Auto-detect
enabled: None, // Auto-detect
backend: SandboxBackend::Auto,
firejail_args: Vec::new(),
},

View file

@ -56,14 +56,21 @@ impl DockerSandbox {
impl Sandbox for DockerSandbox {
fn wrap_command(&self, cmd: &mut Command) -> std::io::Result<()> {
let program = cmd.get_program().to_string_lossy().to_string();
let args: Vec<String> = cmd.get_args().map(|s| s.to_string_lossy().to_string()).collect();
let args: Vec<String> = cmd
.get_args()
.map(|s| s.to_string_lossy().to_string())
.collect();
let mut docker_cmd = Command::new("docker");
docker_cmd.args([
"run", "--rm",
"--memory", "512m",
"--cpus", "1.0",
"--network", "none",
"run",
"--rm",
"--memory",
"512m",
"--cpus",
"1.0",
"--network",
"none",
]);
docker_cmd.arg(&self.image);
docker_cmd.arg(&program);

View file

@ -41,20 +41,23 @@ impl Sandbox for FirejailSandbox {
fn wrap_command(&self, cmd: &mut Command) -> std::io::Result<()> {
// Prepend firejail to the command
let program = cmd.get_program().to_string_lossy().to_string();
let args: Vec<String> = cmd.get_args().map(|s| s.to_string_lossy().to_string()).collect();
let args: Vec<String> = cmd
.get_args()
.map(|s| s.to_string_lossy().to_string())
.collect();
// Build firejail wrapper with security flags
let mut firejail_cmd = Command::new("firejail");
firejail_cmd.args([
"--private=home", // New home directory
"--private-dev", // Minimal /dev
"--nosound", // No audio
"--no3d", // No 3D acceleration
"--novideo", // No video devices
"--nowheel", // No input devices
"--notv", // No TV devices
"--noprofile", // Skip profile loading
"--quiet", // Suppress warnings
"--private=home", // New home directory
"--private-dev", // Minimal /dev
"--nosound", // No audio
"--no3d", // No 3D acceleration
"--novideo", // No video devices
"--nowheel", // No input devices
"--notv", // No TV devices
"--noprofile", // Skip profile loading
"--quiet", // Suppress warnings
]);
// Add the original command
@ -100,7 +103,10 @@ mod tests {
let result = FirejailSandbox::new();
match result {
Ok(_) => println!("Firejail is installed"),
Err(e) => assert!(e.kind() == std::io::ErrorKind::NotFound || e.kind() == std::io::ErrorKind::Unsupported),
Err(e) => assert!(
e.kind() == std::io::ErrorKind::NotFound
|| e.kind() == std::io::ErrorKind::Unsupported
),
}
}

View file

@ -26,8 +26,7 @@ impl LandlockSandbox {
/// Create a Landlock sandbox with a specific workspace directory
pub fn with_workspace(workspace_dir: Option<std::path::PathBuf>) -> std::io::Result<Self> {
// Test if Landlock is available by trying to create a minimal ruleset
let test_ruleset = Ruleset::new()
.set_access_fs(AccessFS::read_file | AccessFS::write_file);
let test_ruleset = Ruleset::new().set_access_fs(AccessFS::read_file | AccessFS::write_file);
match test_ruleset.create() {
Ok(_) => Ok(Self { workspace_dir }),
@ -48,30 +47,35 @@ impl LandlockSandbox {
/// Apply Landlock restrictions to the current process
fn apply_restrictions(&self) -> std::io::Result<()> {
let mut ruleset = Ruleset::new()
.set_access_fs(
AccessFS::read_file
| AccessFS::write_file
| AccessFS::read_dir
| AccessFS::remove_dir
| AccessFS::remove_file
| AccessFS::make_char
| AccessFS::make_sock
| AccessFS::make_fifo
| AccessFS::make_block
| AccessFS::make_reg
| AccessFS::make_sym
);
let mut ruleset = Ruleset::new().set_access_fs(
AccessFS::read_file
| AccessFS::write_file
| AccessFS::read_dir
| AccessFS::remove_dir
| AccessFS::remove_file
| AccessFS::make_char
| AccessFS::make_sock
| AccessFS::make_fifo
| AccessFS::make_block
| AccessFS::make_reg
| AccessFS::make_sym,
);
// Allow workspace directory (read/write)
if let Some(ref workspace) = self.workspace_dir {
if workspace.exists() {
ruleset = ruleset.add_path(workspace, AccessFS::read_file | AccessFS::write_file | AccessFS::read_dir)?;
ruleset = ruleset.add_path(
workspace,
AccessFS::read_file | AccessFS::write_file | AccessFS::read_dir,
)?;
}
}
// Allow /tmp for general operations
ruleset = ruleset.add_path(Path::new("/tmp"), AccessFS::read_file | AccessFS::write_file)?;
ruleset = ruleset.add_path(
Path::new("/tmp"),
AccessFS::read_file | AccessFS::write_file,
)?;
// Allow /usr and /bin for executing commands
ruleset = ruleset.add_path(Path::new("/usr"), AccessFS::read_file | AccessFS::read_dir)?;
@ -193,7 +197,10 @@ mod tests {
// Result depends on platform and feature flag
match result {
Ok(sandbox) => assert!(sandbox.is_available()),
Err(_) => assert!(!cfg!(all(feature = "sandbox-landlock", target_os = "linux"))),
Err(_) => assert!(!cfg!(all(
feature = "sandbox-landlock",
target_os = "linux"
))),
}
}
}

View file

@ -1,7 +1,7 @@
pub mod audit;
pub mod detect;
#[cfg(feature = "sandbox-bubblewrap")]
pub mod bubblewrap;
pub mod detect;
pub mod docker;
#[cfg(target_os = "linux")]
pub mod firejail;

View file

@ -61,7 +61,10 @@ mod tests {
let mut cmd = Command::new("echo");
cmd.arg("test");
let original_program = cmd.get_program().to_string_lossy().to_string();
let original_args: Vec<String> = cmd.get_args().map(|s| s.to_string_lossy().to_string()).collect();
let original_args: Vec<String> = cmd
.get_args()
.map(|s| s.to_string_lossy().to_string())
.collect();
let sandbox = NoopSandbox;
assert!(sandbox.wrap_command(&mut cmd).is_ok());
@ -69,7 +72,9 @@ mod tests {
// Command should be unchanged
assert_eq!(cmd.get_program().to_string_lossy(), original_program);
assert_eq!(
cmd.get_args().map(|s| s.to_string_lossy().to_string()).collect::<Vec<_>>(),
cmd.get_args()
.map(|s| s.to_string_lossy().to_string())
.collect::<Vec<_>>(),
original_args
);
}