Add sandbox
ci/woodpecker/push/tests Pipeline was canceled

This commit is contained in:
2026-09-17 13:16:15 +00:00
parent 536c55f27b
commit 99d1c2feef
16 changed files with 1537 additions and 12 deletions
+2 -1
View File
@@ -27,4 +27,5 @@ hex = { workspace = true }
bytes = { workspace = true }
metrics = { workspace = true }
metrics-exporter-prometheus = { workspace = true }
devcontainer-rs = { path = "../devcontainer-rs" }
devcontainer-rs = { path = "../devcontainer-rs" }
tempfile = "3"
+8
View File
@@ -2,6 +2,7 @@ use crate::{
gitea::{GiteaAPI, WebhookType},
metrics,
open_router::OpenRouterClient,
sandbox::SandboxConfig,
};
use serde::Deserialize;
use std::{collections::HashSet, sync::Arc};
@@ -31,6 +32,7 @@ pub struct Bot {
http_client: reqwest::Client,
max_concurrent: usize,
open_router_model: String,
sandbox: SandboxConfig,
actions_handled: Arc<Mutex<HashSet<u64>>>,
}
@@ -42,6 +44,7 @@ impl Bot {
http_client: reqwest::Client,
max_concurrent: usize,
open_router_model: String,
sandbox: SandboxConfig,
) -> Self {
Self {
bot_name,
@@ -50,6 +53,7 @@ impl Bot {
http_client,
max_concurrent,
open_router_model,
sandbox,
actions_handled: Arc::new(Mutex::new(HashSet::new())),
}
}
@@ -113,12 +117,16 @@ impl Bot {
}
};
let tools = crate::sandbox::tools::for_webhook(&webhook);
let exec_result = match webhook {
WebhookType::Review(review_payload) => crate::bot_actions::review::exec_review(
&self.gitea_api,
&self.open_router_client,
&self.http_client,
&self.open_router_model,
&self.sandbox,
tools,
review_payload,
),
}
+75 -6
View File
@@ -1,22 +1,33 @@
use futures_util::stream::TryStreamExt;
use openrouter_rs::types::Tool;
use tokio::io::AsyncReadExt;
use tokio_util::io::StreamReader;
use tracing::instrument;
use tracing::{info, instrument, warn};
use crate::{
bot::ReviewResult,
consts::{BOT_PROCESS_MSG, MAX_DIFF_SIZE, REVIEW_PROMPT},
consts::{BOT_PROCESS_MSG, MAX_DIFF_SIZE, REVIEW_PROMPT, SANDBOX_SYSTEM_PROMPT},
gitea::{GiteaAPI, ReviewPayload},
metrics,
open_router::OpenRouterClient,
sandbox::{Sandbox, SandboxConfig, agent},
};
#[instrument(skip(gitea_api, open_router_client, http_client, review_payload))]
#[instrument(skip(
gitea_api,
open_router_client,
http_client,
sandbox_config,
tools,
review_payload
))]
pub async fn exec_review(
gitea_api: &GiteaAPI,
open_router_client: &OpenRouterClient,
http_client: &reqwest::Client,
model: &str,
sandbox_config: &SandboxConfig,
tools: Vec<Tool>,
review_payload: ReviewPayload,
) -> anyhow::Result<()> {
tracing::info!(
@@ -45,10 +56,24 @@ pub async fn exec_review(
.replace("{comment}", &review_payload.comment.body)
.replace("{diff}", &diff_for_llm);
let chat_result = open_router_client.chat(&bot_request).await?;
let mut review_result = serde_json::from_str::<ReviewResult>(&chat_result.message)?;
let (message, cost) = if sandbox_config.enabled {
run_sandboxed_review(
gitea_api,
open_router_client,
sandbox_config,
tools,
&review_payload,
&bot_request,
)
.await?
} else {
let chat_result = open_router_client.chat(&bot_request).await?;
(chat_result.message, chat_result.cost)
};
review_result.cost = chat_result.cost;
let mut review_result = serde_json::from_str::<ReviewResult>(&message)?;
review_result.cost = cost;
if let Some(cost) = review_result.cost {
metrics::openrouter_cost_usd(cost);
}
@@ -86,6 +111,50 @@ pub async fn exec_review(
}
}
/// Runs the review inside a sandbox container, letting the model explore the
/// repository with tools before answering.
async fn run_sandboxed_review(
gitea_api: &GiteaAPI,
open_router_client: &OpenRouterClient,
sandbox_config: &SandboxConfig,
tools: Vec<Tool>,
review_payload: &ReviewPayload,
bot_request: &str,
) -> anyhow::Result<(String, Option<f64>)> {
let repo_url = gitea_api.repo_clone_url(&review_payload.repository.full_name);
let sandbox = Sandbox::create(
&sandbox_config.runtime,
&repo_url,
gitea_api.token(),
review_payload.pull_request.number,
)
.await?;
let result = agent::run(
open_router_client,
&sandbox,
tools,
SANDBOX_SYSTEM_PROMPT,
bot_request,
sandbox_config.max_iterations,
)
.await;
if let Err(err) = sandbox.cleanup().await {
warn!(%err, "Failed to clean up sandbox container");
}
let result = result?;
info!(
iterations = result.iterations,
cost = ?result.cost,
"Sandboxed review finished"
);
Ok((result.message, result.cost))
}
fn review_result_to_markdown(review_result: &ReviewResult) -> String {
if review_result.reviews.is_empty() {
return String::from("No issues found. ✅");
+10
View File
@@ -8,6 +8,16 @@ pub const BOT_PROCESS_MSG: &str = "
Review in progress with the model \"{model}\"...
";
pub const SANDBOX_SYSTEM_PROMPT: &str = "
You are a senior software engineer reviewing a pull request.
The repository is checked out in your working directory. Use the provided
tools (ls, read_file, grep, find) to explore the code and gather the context
you need before answering. Paths are relative to the repository root.
When you have enough information, answer with the requested JSON only.
";
pub const REVIEW_PROMPT: &str = "
You are a senior software engineer reviewing code changes.
+15
View File
@@ -12,6 +12,9 @@ pub struct EnvConfig {
pub gitea_token: String,
pub gitea_timeout: u64,
pub metrics_bind_addr: Option<String>,
pub container_runtime: String,
pub sandbox_enabled: bool,
pub sandbox_max_iterations: usize,
}
pub fn load_config() -> anyhow::Result<EnvConfig> {
@@ -25,6 +28,15 @@ pub fn load_config() -> anyhow::Result<EnvConfig> {
let gitea_token = try_get_env("GITEA_TOKEN")?;
let gitea_timeout = try_get_env("GITEA_TIMEOUT")?.parse()?;
let metrics_bind_addr = std::env::var("METRICS_BIND_ADDR").ok();
let container_runtime =
std::env::var("CONTAINER_RUNTIME").unwrap_or_else(|_| "docker".to_string());
let sandbox_enabled = std::env::var("SANDBOX_ENABLED")
.map(|value| matches!(value.as_str(), "1" | "true" | "yes"))
.unwrap_or(false);
let sandbox_max_iterations = std::env::var("SANDBOX_MAX_ITERATIONS")
.ok()
.and_then(|value| value.parse().ok())
.unwrap_or(8);
Ok(EnvConfig {
http_port,
@@ -37,6 +49,9 @@ pub fn load_config() -> anyhow::Result<EnvConfig> {
gitea_token,
gitea_timeout,
metrics_bind_addr,
container_runtime,
sandbox_enabled,
sandbox_max_iterations,
})
}
+16
View File
@@ -9,6 +9,7 @@ use crate::{bot::ReviewResult, errors::AppError};
#[derive(Clone)]
pub struct GiteaAPI {
base_url: String,
token: String,
client: reqwest::Client,
}
@@ -22,6 +23,7 @@ impl GiteaAPI {
Ok(Self {
base_url: String::from(base_url),
token: String::from(token),
client: reqwest::Client::builder()
.timeout(Duration::from_secs(timeout))
.default_headers(default_headers)
@@ -29,6 +31,20 @@ impl GiteaAPI {
})
}
/// API token used to authenticate against Gitea.
pub fn token(&self) -> &str {
&self.token
}
/// HTTPS clone URL for a repository, suitable for `git clone`.
pub fn repo_clone_url(&self, full_name: &str) -> String {
format!(
"{}/{}.git",
self.base_url.trim_end_matches('/'),
full_name.trim_start_matches('/')
)
}
#[instrument(skip(self))]
pub async fn get_authorized_user(&self) -> anyhow::Result<User> {
let url = format!("{}/api/v1/user", self.base_url);
+16
View File
@@ -2,6 +2,7 @@ use crate::{
bot::Bot,
gitea::{GiteaAPI, WebhookType},
open_router::OpenRouterClient,
sandbox::SandboxConfig,
state::AppState,
};
@@ -20,6 +21,7 @@ mod errors;
mod gitea;
mod metrics;
mod open_router;
mod sandbox;
mod state;
fn main() -> anyhow::Result<()> {
@@ -76,6 +78,19 @@ async fn run() -> anyhow::Result<()> {
let shutdown = CancellationToken::new();
let sandbox = SandboxConfig {
enabled: config.sandbox_enabled,
runtime: devcontainer_rs::ContainerRuntime::new(config.container_runtime.clone()),
max_iterations: config.sandbox_max_iterations,
};
if sandbox.enabled && !sandbox.runtime.available().await {
warn!(
runtime = sandbox.runtime.program(),
"Sandbox is enabled but the container runtime is not available"
);
}
let bot = Bot::new(
gitea_user.login,
gitea_api,
@@ -83,6 +98,7 @@ async fn run() -> anyhow::Result<()> {
reqwest::Client::new(),
config.bot_max_concurrent,
config.open_router_model.clone(),
sandbox,
);
let (tx, rx) = tokio::sync::mpsc::channel::<WebhookType>(config.bot_max_concurrent * 2);
+43 -1
View File
@@ -1,6 +1,10 @@
use std::time::Duration;
use openrouter_rs::{Message, api::chat::ChatCompletionRequest};
use openrouter_rs::{
Message,
api::chat::ChatCompletionRequest,
types::{Tool, ToolCall},
};
use tracing::instrument;
pub struct ChatResult {
@@ -8,6 +12,12 @@ pub struct ChatResult {
pub cost: Option<f64>,
}
pub struct ToolChatResult {
pub message: Option<String>,
pub tool_calls: Vec<ToolCall>,
pub cost: Option<f64>,
}
#[derive(Clone)]
pub struct OpenRouterClient {
client: openrouter_rs::OpenRouterClient,
@@ -47,4 +57,36 @@ impl OpenRouterClient {
cost: response.usage.and_then(|u| u.cost),
})
}
/// Sends a conversation with tool definitions and returns either a final
/// message or the tool calls requested by the model.
#[instrument(skip(self, messages, tools), err)]
pub async fn chat_with_tools(
&self,
messages: Vec<Message>,
tools: Vec<Tool>,
) -> anyhow::Result<ToolChatResult> {
let request = ChatCompletionRequest::builder()
.model(&self.model)
.enable_reasoning()
.messages(messages)
.tools(tools)
.tool_choice_auto()
.build()?;
let response = self.client.chat().create(&request).await?;
let choice = response
.choices
.first()
.ok_or_else(|| anyhow::anyhow!("No choices in response"))?;
Ok(ToolChatResult {
message: choice.content().map(String::from),
tool_calls: choice
.tool_calls()
.map(<[ToolCall]>::to_vec)
.unwrap_or_default(),
cost: response.usage.and_then(|u| u.cost),
})
}
}
+129
View File
@@ -0,0 +1,129 @@
//! Tool-calling loop driving the model against a [`Sandbox`].
//!
//! The loop sends the conversation and the available tools to OpenRouter,
//! executes any requested tool call inside the sandbox and feeds the results
//! back, until the model produces a final message or the iteration budget is
//! exhausted.
use anyhow::Context;
use openrouter_rs::{
Message,
types::{Role, Tool, ToolCall},
};
use serde_json::Value;
use tracing::{debug, warn};
use crate::{
open_router::OpenRouterClient,
sandbox::{Sandbox, tools},
};
/// Final output of an agent run.
pub struct AgentResult {
pub message: String,
pub cost: Option<f64>,
pub iterations: usize,
}
/// Runs the tool-calling loop until the model answers or `max_iterations` is
/// reached.
///
/// `tool_definitions` is the set of tools the model may call; it is selected by
/// the caller based on the webhook action (see [`tools::for_webhook`]).
pub async fn run(
open_router: &OpenRouterClient,
sandbox: &Sandbox,
tool_definitions: Vec<Tool>,
system_prompt: &str,
user_prompt: &str,
max_iterations: usize,
) -> anyhow::Result<AgentResult> {
let mut messages = vec![
Message::new(Role::System, system_prompt),
Message::new(Role::User, user_prompt),
];
let mut total_cost = 0.0_f64;
let mut has_cost = false;
for iteration in 1..=max_iterations {
let response = open_router
.chat_with_tools(messages.clone(), tool_definitions.clone())
.await?;
if let Some(cost) = response.cost {
total_cost += cost;
has_cost = true;
}
if response.tool_calls.is_empty() {
return Ok(AgentResult {
message: response.message.unwrap_or_default(),
cost: has_cost.then_some(total_cost),
iterations: iteration,
});
}
messages.push(Message::assistant_with_tool_calls(
response.message.unwrap_or_default(),
response.tool_calls.clone(),
));
for call in &response.tool_calls {
debug!(tool = call.name(), "Executing tool call");
let content = execute(sandbox, call).await;
messages.push(Message::tool_response(call.id(), content));
}
}
warn!(max_iterations, "Agent reached the iteration limit");
anyhow::bail!("agent exceeded the maximum number of iterations ({max_iterations})")
}
async fn execute(sandbox: &Sandbox, call: &ToolCall) -> String {
let args = match parse_args(call) {
Ok(args) => args,
Err(err) => return format!("error: {err}"),
};
match tools::dispatch(sandbox, call.name(), &args).await {
Ok(output) => output,
Err(err) => format!("error: {err}"),
}
}
fn parse_args(call: &ToolCall) -> anyhow::Result<Value> {
let raw = call.arguments_json().trim();
if raw.is_empty() {
return Ok(Value::Object(serde_json::Map::new()));
}
serde_json::from_str(raw)
.with_context(|| format!("invalid arguments for tool `{}`", call.name()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_args_accepts_empty_arguments() {
let call = ToolCall::new("id", "ls", "");
assert_eq!(
parse_args(&call).unwrap(),
Value::Object(serde_json::Map::new())
);
}
#[test]
fn parse_args_parses_json_object() {
let call = ToolCall::new("id", "ls", r#"{"path":"src"}"#);
assert_eq!(parse_args(&call).unwrap()["path"], "src");
}
#[test]
fn parse_args_rejects_invalid_json() {
let call = ToolCall::new("id", "ls", "not json");
assert!(parse_args(&call).is_err());
}
}
+197
View File
@@ -0,0 +1,197 @@
//! Sandboxed tool execution for the AI bot.
//!
//! A [`Sandbox`] clones a pull request into a temporary directory, builds and
//! starts its devcontainer (via `devcontainer-rs`) and exposes command
//! execution inside the resulting container. The [`tools`] module maps model
//! tool calls to commands run in that container, and [`agent`] drives the
//! tool-calling loop against OpenRouter.
pub mod agent;
pub mod tools;
use std::{
path::{Path, PathBuf},
process::Stdio,
};
use anyhow::Context;
use devcontainer_rs::{Container, ContainerRuntime, ExecOutput};
use tempfile::TempDir;
use tracing::{info, instrument};
/// Devcontainer locations recognized within a repository, in priority order.
const DEVCONTAINER_PATHS: [&str; 2] = [".devcontainer/devcontainer.json", ".devcontainer.json"];
/// Sandbox-related runtime configuration.
#[derive(Clone)]
pub struct SandboxConfig {
/// Whether the bot should run its tools inside a sandbox container.
pub enabled: bool,
/// Container runtime binary to drive (e.g. `docker`, `podman`).
pub runtime: ContainerRuntime,
/// Maximum number of tool-calling iterations per agent run.
pub max_iterations: usize,
}
/// A cloned repository running inside an ephemeral devcontainer.
pub struct Sandbox {
// Owns the temporary directory; dropping it cleans up the clone.
_workspace: TempDir,
container: Container,
}
impl Sandbox {
/// Clones the pull request head, builds the devcontainer and starts it.
///
/// The clone is PR-aware: it fetches `refs/pull/<number>/head`, which works
/// for both same-repository and forked pull requests.
#[instrument(skip(runtime, token), fields(pr = pull_request_number))]
pub async fn create(
runtime: &ContainerRuntime,
repo_url: &str,
token: &str,
pull_request_number: u64,
) -> anyhow::Result<Self> {
let workspace = tempfile::tempdir().context("failed to create sandbox workspace")?;
let repo_dir = workspace.path().join("repo");
clone_pull_request(repo_url, token, pull_request_number, &repo_dir).await?;
let devcontainer_path = find_devcontainer(&repo_dir)
.with_context(|| format!("no devcontainer found in `{repo_url}`"))?;
let devcontainer = devcontainer_rs::parse(&devcontainer_path).await?;
info!(image = %devcontainer.image_tag(), "Building and starting sandbox container");
let container = devcontainer.up(runtime, &repo_dir).await?;
Ok(Self {
_workspace: workspace,
container,
})
}
/// Executes a command in the container as an argv vector (no shell).
pub async fn exec(&self, cmd: &[&str]) -> anyhow::Result<ExecOutput> {
Ok(self.container.exec(cmd).await?)
}
/// Path of the repository inside the container.
pub fn workspace_folder(&self) -> &str {
self.container.workspace_folder()
}
/// Stops and removes the container. The temporary clone is removed on drop.
pub async fn cleanup(self) -> anyhow::Result<()> {
self.container.remove().await?;
Ok(())
}
}
fn find_devcontainer(repo_dir: &Path) -> Option<PathBuf> {
DEVCONTAINER_PATHS
.iter()
.map(|relative| repo_dir.join(relative))
.find(|candidate| candidate.is_file())
}
async fn clone_pull_request(
repo_url: &str,
token: &str,
pull_request_number: u64,
dest: &Path,
) -> anyhow::Result<()> {
let dest = dest.display().to_string();
run_git(
token,
&[
"clone".to_string(),
"--depth".to_string(),
"1".to_string(),
repo_url.to_string(),
dest.clone(),
],
)
.await?;
run_git(
token,
&[
"-C".to_string(),
dest.clone(),
"fetch".to_string(),
"--depth".to_string(),
"1".to_string(),
"origin".to_string(),
format!("refs/pull/{pull_request_number}/head"),
],
)
.await?;
run_git(
token,
&[
"-C".to_string(),
dest,
"checkout".to_string(),
"FETCH_HEAD".to_string(),
],
)
.await?;
Ok(())
}
/// Runs git with the token injected through `http.extraHeader`, keeping the
/// secret out of the process arguments.
async fn run_git(token: &str, args: &[String]) -> anyhow::Result<()> {
let output = tokio::process::Command::new("git")
.args(args)
.env("GIT_CONFIG_COUNT", "1")
.env("GIT_CONFIG_KEY_0", "http.extraHeader")
.env(
"GIT_CONFIG_VALUE_0",
format!("Authorization: token {token}"),
)
.env("GIT_TERMINAL_PROMPT", "0")
.stdin(Stdio::null())
.output()
.await
.context("failed to spawn git")?;
if !output.status.success() {
anyhow::bail!(
"git {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&output.stderr).trim()
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn find_devcontainer_prefers_dot_devcontainer_dir() {
let dir = tempfile::tempdir().unwrap();
let nested = dir.path().join(".devcontainer");
std::fs::create_dir(&nested).unwrap();
std::fs::write(nested.join("devcontainer.json"), "{}").unwrap();
std::fs::write(dir.path().join(".devcontainer.json"), "{}").unwrap();
assert_eq!(
find_devcontainer(dir.path()),
Some(nested.join("devcontainer.json"))
);
}
#[test]
fn find_devcontainer_returns_none_when_absent() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(find_devcontainer(dir.path()), None);
}
}
+248
View File
@@ -0,0 +1,248 @@
//! Tool definitions exposed to the model and their execution inside a
//! [`Sandbox`].
//!
//! Every tool is read-only and confined to the repository workspace: paths are
//! resolved relative to the container workspace folder and rejected if they
//! escape it. Commands are executed as argv vectors (never through a shell),
//! so tool arguments cannot be used for shell injection.
use std::path::Path;
use anyhow::{Context, bail};
use devcontainer_rs::{ExecOutput, normalize};
use openrouter_rs::types::Tool;
use serde_json::{Value, json};
use super::Sandbox;
use crate::gitea::WebhookType;
/// Tools available to the model for a given webhook action.
///
/// The match is exhaustive on [`WebhookType`], so adding a new action forces a
/// decision here about which tools that action may use.
pub fn for_webhook(webhook: &WebhookType) -> Vec<Tool> {
match webhook {
WebhookType::Review(_) => review_tools(),
}
}
/// Read-only tools used to explore a repository during a review.
fn review_tools() -> Vec<Tool> {
vec![
Tool::new(
"ls",
"List the entries of a directory inside the repository.",
json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Directory path relative to the repository root. Defaults to the repository root."
}
}
}),
),
Tool::new(
"read_file",
"Read the content of a text file inside the repository.",
json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "File path relative to the repository root."
},
"start_line": {
"type": "integer",
"description": "First line to read (1-based, inclusive). Defaults to the first line."
},
"end_line": {
"type": "integer",
"description": "Last line to read (1-based, inclusive). Defaults to the last line."
}
},
"required": ["path"]
}),
),
Tool::new(
"grep",
"Search for a regular expression across the repository files.",
json!({
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "Extended regular expression to search for."
},
"path": {
"type": "string",
"description": "File or directory to search in, relative to the repository root. Defaults to the repository root."
}
},
"required": ["pattern"]
}),
),
Tool::new(
"find",
"Find files by name pattern inside the repository.",
json!({
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "Glob pattern matched against file names, e.g. `*.rs`."
},
"path": {
"type": "string",
"description": "Directory to search in, relative to the repository root. Defaults to the repository root."
}
},
"required": ["pattern"]
}),
),
]
}
/// Executes a tool call and returns its textual result.
///
/// Errors are returned as `Err` so the caller can decide whether to surface
/// them to the model or abort.
pub async fn dispatch(sandbox: &Sandbox, name: &str, args: &Value) -> anyhow::Result<String> {
match name {
"ls" => ls(sandbox, args).await,
"read_file" => read_file(sandbox, args).await,
"grep" => grep(sandbox, args).await,
"find" => find(sandbox, args).await,
other => bail!("unknown tool `{other}`"),
}
}
async fn ls(sandbox: &Sandbox, args: &Value) -> anyhow::Result<String> {
let path = resolve(sandbox, optional_str(args, "path").unwrap_or("."))?;
let output = sandbox.exec(&["ls", "-la", "--", &path]).await?;
into_stdout(output)
}
async fn read_file(sandbox: &Sandbox, args: &Value) -> anyhow::Result<String> {
let path = resolve(sandbox, required_str(args, "path")?)?;
let start = args.get("start_line").and_then(Value::as_u64);
let end = args.get("end_line").and_then(Value::as_u64);
let output = if start.is_none() && end.is_none() {
sandbox.exec(&["cat", "--", &path]).await?
} else {
let start = start.unwrap_or(1);
let end = end
.map(|line| line.to_string())
.unwrap_or_else(|| "$".to_string());
let range = format!("{start},{end}p");
sandbox.exec(&["sed", "-n", &range, "--", &path]).await?
};
into_stdout(output)
}
async fn grep(sandbox: &Sandbox, args: &Value) -> anyhow::Result<String> {
let pattern = required_str(args, "pattern")?;
let path = resolve(sandbox, optional_str(args, "path").unwrap_or("."))?;
let output = sandbox
.exec(&["grep", "-rn", "-E", "--", pattern, &path])
.await?;
// grep exits with 1 when there is no match, which is not an error.
match output.status {
0 | 1 => Ok(output.stdout),
_ => bail!("grep failed: {}", output.stderr.trim()),
}
}
async fn find(sandbox: &Sandbox, args: &Value) -> anyhow::Result<String> {
let pattern = required_str(args, "pattern")?;
let path = resolve(sandbox, optional_str(args, "path").unwrap_or("."))?;
let output = sandbox
.exec(&["find", &path, "-type", "f", "-name", pattern])
.await?;
into_stdout(output)
}
/// Resolves a tool path against the container workspace folder, rejecting any
/// path that escapes it.
fn resolve(sandbox: &Sandbox, path: &str) -> anyhow::Result<String> {
let workspace = sandbox.workspace_folder();
let candidate = Path::new(workspace).join(path);
let normalized =
normalize(&candidate).with_context(|| format!("path `{path}` escapes the workspace"))?;
if !normalized.starts_with(workspace) {
bail!("path `{path}` escapes the workspace");
}
Ok(normalized.display().to_string())
}
fn required_str<'a>(args: &'a Value, key: &str) -> anyhow::Result<&'a str> {
args.get(key)
.and_then(Value::as_str)
.with_context(|| format!("`{key}` is required"))
}
fn optional_str<'a>(args: &'a Value, key: &str) -> Option<&'a str> {
args.get(key).and_then(Value::as_str)
}
fn into_stdout(output: ExecOutput) -> anyhow::Result<String> {
if output.success() {
Ok(output.stdout)
} else {
bail!(
"command failed (status {}): {}",
output.status,
output.stderr.trim()
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::gitea::{Comment, PullRequest, Repository, ReviewPayload};
fn review_webhook() -> WebhookType {
WebhookType::Review(ReviewPayload {
action: "created".to_string(),
pull_request: PullRequest {
diff_url: "https://example.com/diff".to_string(),
number: 1,
title: "My PR".to_string(),
},
repository: Repository {
full_name: "owner/repo".to_string(),
},
comment: Comment {
id: 1,
body: "@bot review".to_string(),
},
})
}
#[test]
fn review_webhook_exposes_read_only_tools() {
let names: Vec<String> = for_webhook(&review_webhook())
.into_iter()
.map(|tool| tool.function.name)
.collect();
assert_eq!(names, vec!["ls", "read_file", "grep", "find"]);
}
#[test]
fn required_str_reports_missing_key() {
let err = required_str(&json!({}), "path").unwrap_err();
assert!(err.to_string().contains("path"));
}
}