Remove the openrouter-rs dependency in favor of a minimal in-tree OpenRouter chat-completions client, and drop the BOT_NAME and SANDBOX_ENABLED config options. Reviews now always run inside the sandbox, and the review prompt asks the model to read files with the available tools instead of embedding the diff.
247 lines
8.1 KiB
Rust
247 lines
8.1 KiB
Rust
//! 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 serde_json::{Value, json};
|
|
|
|
use super::Sandbox;
|
|
use crate::{gitea::WebhookType, open_router::Tool};
|
|
|
|
/// 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 {
|
|
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"));
|
|
}
|
|
}
|