//! 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 { match webhook { WebhookType::Review(_) => review_tools(), } } /// Read-only tools used to explore a repository during a review. fn review_tools() -> Vec { 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 { 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 { 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 { 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 { 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::>() .join("\n") } async fn grep(sandbox: &Sandbox, args: &Value) -> anyhow::Result { 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 { 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 { 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 { 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 = 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), ""); } }