This commit is contained in:
1 parent
9f03384bc9
commit
f5f0d3999e
30 files changed
+96
-93
No files matched your search
@@ -0,0 +1,303 @@
|
||||
//! 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(
|
||||
"file_size",
|
||||
"Get the size of a file inside the repository in bytes.",
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "File path relative to the repository root."
|
||||
}
|
||||
}
|
||||
}),
|
||||
),
|
||||
Tool::new(
|
||||
"read_file",
|
||||
"Read the content of a text file inside the repository. Every line is \
|
||||
prefixed with its absolute line number, even when only a range is read.",
|
||||
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,
|
||||
"file_size" => file_size(sandbox, args).await,
|
||||
"grep" => grep(sandbox, args).await,
|
||||
"find" => find(sandbox, args).await,
|
||||
other => bail!("unknown tool `{other}`"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn file_size(sandbox: &Sandbox, args: &Value) -> anyhow::Result<String> {
|
||||
let path = resolve(sandbox, required_str(args, "path")?)?;
|
||||
let size = sandbox.exec(&["du", "-b", "--", &path]).await?;
|
||||
into_stdout(size)
|
||||
}
|
||||
|
||||
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 (first_line, output) = if start.is_none() && end.is_none() {
|
||||
(1, 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");
|
||||
|
||||
(
|
||||
start,
|
||||
sandbox.exec(&["sed", "-n", &range, "--", &path]).await?,
|
||||
)
|
||||
};
|
||||
|
||||
Ok(number_lines(&into_stdout(output)?, first_line))
|
||||
}
|
||||
|
||||
/// Préfixe chaque ligne par son numéro.
|
||||
///
|
||||
/// Le modèle doit citer une ligne précise pour ancrer son commentaire : sans
|
||||
/// numéros, il les compte lui-même et se décale de quelques lignes, ce qui place le
|
||||
/// commentaire à côté du code visé.
|
||||
fn number_lines(content: &str, first_line: u64) -> String {
|
||||
content
|
||||
.lines()
|
||||
.enumerate()
|
||||
.map(|(offset, line)| format!("{}:{line}", first_line + offset as u64))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
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(),
|
||||
clone_url: "https://github.com/owner/repo.git".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", "file_size", "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"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_are_numbered_from_the_first_one() {
|
||||
assert_eq!(number_lines("a\nb\n", 1), "1:a\n2:b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_range_keeps_the_absolute_line_numbers() {
|
||||
// Un extrait lu à partir de la ligne 12 doit garder la numérotation du
|
||||
// fichier : sinon le modèle citerait des lignes décalées.
|
||||
assert_eq!(number_lines("x\ny", 12), "12:x\n13:y");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_read_stays_empty() {
|
||||
assert_eq!(number_lines("", 1), "");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user