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
+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"));
}
}