1.2: Sandboxing #7

Merged
qpismont merged 17 commits from 1.2 into main 2026-09-20 16:58:36 +02:00
8 changed files with 630 additions and 335 deletions
Showing only changes of commit fa03fd8589 - Show all commits
+11 -2
View File
2
@@ -51,11 +51,20 @@ Herald reviews pull requests inside an ephemeral
`GITEA_TOKEN` (so private repositories work), tells the model which files and `GITEA_TOKEN` (so private repositories work), tells the model which files and
lines changed — additions and deletions, with the line numbers of the new and lines changed — additions and deletions, with the line numbers of the new and
old versions of the file respectively — then lets it explore the repository old versions of the file respectively — then lets it explore the repository
with read-only tools (`ls`, `read_file`, `grep`, `find`) run inside the with read-only tools (`ls`, `file_size`, `read_file`, `grep`, `find`) run inside
container: the code itself is not sent, so the model reads it at those lines, the container: the code itself is not sent, so the model reads it at those lines,
4. posts the review, anchoring each comment on the added or removed line it 4. posts the review, anchoring each comment on the added or removed line it
refers to, and removes the container and the temporary clone. refers to, and removes the container and the temporary clone.
Generated files are left out of the changes handed to the model: lockfiles
(`Cargo.lock`, `package-lock.json`, `yarn.lock`, `go.sum`…) are machine-written
dependency churn whose thousands of lines would drown the code under review, and
they are never a place where a comment belongs.
Each comment is tagged with a severity — `bug`, `security`, `performance` or
`maintainability` — shown at the start of the comment, and the summary also lists
what the pull request does well.
Herald drives the container daemon through its socket: `DOCKER_HOST` (default Herald drives the container daemon through its socket: `DOCKER_HOST` (default
`unix:///var/run/docker.sock`), which covers both docker and podman's `unix:///var/run/docker.sock`), which covers both docker and podman's
Docker-compatible socket. The repository must contain a Docker-compatible socket. The repository must contain a
+56
View File
1
@@ -23,6 +23,8 @@ pub struct ReviewItem {
pub line: Option<u64>, pub line: Option<u64>,
#[serde(default, deserialize_with = "deserialize_side")] #[serde(default, deserialize_with = "deserialize_side")]
pub side: Option<ReviewSide>, pub side: Option<ReviewSide>,
#[serde(default, deserialize_with = "deserialize_severity")]
pub severity: Option<ReviewSeverity>,
pub message: String, pub message: String,
} }
@@ -48,6 +50,46 @@ impl ReviewSide {
} }
} }
/// What kind of problem a review reports.
///
/// The categories are the ones the prompt asks for; a review whose severity is
/// missing or unreadable falls back to [`ReviewSeverity::Maintainability`], the
/// least alarming bucket, instead of failing the whole review.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReviewSeverity {
/// Wrong behaviour.
Bug,
/// A vulnerability.
Security,
/// A resource problem.
Performance,
/// Everything else: readability, structure, naming, tests.
Maintainability,
}
impl ReviewSeverity {
/// Reads the severity the model asked for, tolerating casing and synonyms.
fn parse(raw: &str) -> Option<Self> {
match raw.trim().to_ascii_lowercase().as_str() {
"bug" | "bugs" | "correctness" | "error" => Some(Self::Bug),
"security" | "vulnerability" => Some(Self::Security),
"performance" | "perf" => Some(Self::Performance),
"maintainability" | "quality" | "style" => Some(Self::Maintainability),
_ => None,
}
}
/// Lower-case label used in the review markdown.
pub fn label(self) -> &'static str {
match self {
Self::Bug => "bug",
Self::Security => "security",
Self::Performance => "performance",
Self::Maintainability => "maintainability",
}
}
}
/// Reads the side the model asked for. An unreadable value is ignored instead of /// Reads the side the model asked for. An unreadable value is ignored instead of
/// failing the whole review: the side is then resolved from the changed lines. /// failing the whole review: the side is then resolved from the changed lines.
fn deserialize_side<'de, D>(deserializer: D) -> Result<Option<ReviewSide>, D::Error> fn deserialize_side<'de, D>(deserializer: D) -> Result<Option<ReviewSide>, D::Error>
@@ -62,6 +104,20 @@ where
.and_then(ReviewSide::parse)) .and_then(ReviewSide::parse))
} }
/// Reads the severity the model asked for, ignoring an unreadable value: the
/// review is then reported as [`ReviewSeverity::Maintainability`].
fn deserialize_severity<'de, D>(deserializer: D) -> Result<Option<ReviewSeverity>, D::Error>
where
D: Deserializer<'de>,
{
let raw = Option::<serde_json::Value>::deserialize(deserializer)?;
Ok(raw
.as_ref()
.and_then(serde_json::Value::as_str)
.and_then(ReviewSeverity::parse))
}
#[derive(Clone)] #[derive(Clone)]
pub struct Bot { pub struct Bot {
bot_name: String, bot_name: String,
+203 -5
View File
@@ -3,7 +3,7 @@ use std::str::FromStr;
use tracing::{info, instrument, warn}; use tracing::{info, instrument, warn};
use crate::{ use crate::{
bot::{ReviewResult, ReviewSide}, bot::{ReviewItem, ReviewResult, ReviewSeverity, ReviewSide},
consts::{BOT_PROCESS_MSG, REVIEW_PROMPT, SANDBOX_SYSTEM_PROMPT}, consts::{BOT_PROCESS_MSG, REVIEW_PROMPT, SANDBOX_SYSTEM_PROMPT},
gitea::{GiteaAPI, PullRequestFile, ReviewPayload}, gitea::{GiteaAPI, PullRequestFile, ReviewPayload},
metrics, metrics,
@@ -53,6 +53,7 @@ pub async fn exec_review(
}; };
let mut changed_lines = parse_changed_lines(&git_diff); let mut changed_lines = parse_changed_lines(&git_diff);
drop_generated_files(&mut changed_lines);
resolve_filenames(&mut changed_lines, &files); resolve_filenames(&mut changed_lines, &files);
let changes = format_changes(&files, &changed_lines); let changes = format_changes(&files, &changed_lines);
@@ -126,11 +127,9 @@ async fn run_sandboxed_review(
review_payload: &ReviewPayload, review_payload: &ReviewPayload,
bot_request: &str, bot_request: &str,
) -> anyhow::Result<(ReviewResult, Option<f64>)> { ) -> anyhow::Result<(ReviewResult, Option<f64>)> {
let repo_url = gitea_api.repo_clone_url(&review_payload.repository.full_name);
let sandbox = Sandbox::create( let sandbox = Sandbox::create(
&sandbox_config.runtime, &sandbox_config.runtime,
&repo_url, &review_payload.repository.clone_url,
gitea_api.token(), gitea_api.token(),
review_payload.pull_request.number, review_payload.pull_request.number,
) )
@@ -172,6 +171,12 @@ fn review_result_to_markdown(review_result: &ReviewResult) -> String {
review_result.reviews.len() review_result.reviews.len()
)); ));
let breakdown = severity_breakdown(&review_result.reviews);
if !breakdown.is_empty() {
md.push_str(&breakdown);
md.push('\n');
}
if !review_result.comment.is_empty() { if !review_result.comment.is_empty() {
md.push_str("\n---\n\n"); md.push_str("\n---\n\n");
md.push_str("### Summary\n\n"); md.push_str("### Summary\n\n");
@@ -188,6 +193,30 @@ fn review_result_to_markdown(review_result: &ReviewResult) -> String {
md md
} }
/// Counts the reviews per severity, most severe first.
///
/// A review whose severity the model omitted or wrote unreadably is counted as
/// [`ReviewSeverity::Maintainability`], the same fallback used when posting it.
fn severity_breakdown(reviews: &[ReviewItem]) -> String {
[
ReviewSeverity::Bug,
ReviewSeverity::Security,
ReviewSeverity::Performance,
ReviewSeverity::Maintainability,
]
.into_iter()
.filter_map(|severity| {
let count = reviews
.iter()
.filter(|review| review.severity.unwrap_or(ReviewSeverity::Maintainability) == severity)
.count();
(count > 0).then(|| format!("- {} {}", count, severity.label()))
})
.collect::<Vec<_>>()
.join("\n")
}
/// The lines a pull request changed, per file. /// The lines a pull request changed, per file.
/// ///
/// Line numbers are the ones Gitea expects to place a review comment: /// Line numbers are the ones Gitea expects to place a review comment:
@@ -386,18 +415,61 @@ fn resolve_filenames(changed_lines: &mut ChangedLines, files: &[PullRequestFile]
} }
} }
/// Basenames of generated files that are never reviewed.
///
/// Their diffs are machine-written dependency churn: they run to thousands of
/// lines, flood the prompt with line numbers and drown the code the model should
/// look at. A lockfile is also never a place where a review comment belongs.
const IGNORED_FILE_NAMES: [&str; 16] = [
"Cargo.lock",
"package-lock.json",
"npm-shrinkwrap.json",
"yarn.lock",
"pnpm-lock.yaml",
"bun.lock",
"bun.lockb",
"composer.lock",
"Gemfile.lock",
"poetry.lock",
"uv.lock",
"Pipfile.lock",
"go.sum",
"packages.lock.json",
"flake.lock",
"pubspec.lock",
];
/// Whether a changed path is a generated file that is never reviewed.
///
/// The comparison is on the basename, so a lockfile nested in a workspace (for
/// example `crates/foo/Cargo.lock`) is matched too.
fn is_ignored(filename: &str) -> bool {
let basename = filename.rsplit('/').next().unwrap_or(filename);
IGNORED_FILE_NAMES.contains(&basename)
}
/// Drops the generated files from the changed lines.
///
/// Removing them here keeps the prompt free of their line numbers, and also makes
/// [`resolve_review_sides`] reject any review the model anchors on one of them.
fn drop_generated_files(changed_lines: &mut ChangedLines) {
changed_lines.retain(|file| !is_ignored(&file.filename));
}
/// Renders the changes for the model: the files the pull request touches, then /// Renders the changes for the model: the files the pull request touches, then
/// the lines to review per file. /// the lines to review per file.
fn format_changes(files: &[PullRequestFile], changed_lines: &ChangedLines) -> String { fn format_changes(files: &[PullRequestFile], changed_lines: &ChangedLines) -> String {
let mut sections = Vec::new(); let mut sections = Vec::new();
if !files.is_empty() {
let described = files let described = files
.iter() .iter()
.filter(|file| !is_ignored(&file.filename))
.map(describe_file) .map(describe_file)
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(", "); .join(", ");
if !described.is_empty() {
sections.push(format!("Files changed by the pull request: {described}")); sections.push(format!("Files changed by the pull request: {described}"));
} }
@@ -666,6 +738,7 @@ mod tests {
filename: String::from(filename), filename: String::from(filename),
line, line,
side, side,
severity: None,
message: String::from("message"), message: String::from("message"),
} }
} }
@@ -799,6 +872,56 @@ mod tests {
assert_eq!(format_changes(&[], &changed_lines), expected); assert_eq!(format_changes(&[], &changed_lines), expected);
} }
#[test]
fn generated_lockfiles_are_ignored() {
assert!(is_ignored("Cargo.lock"));
assert!(is_ignored("crates/herald-server/Cargo.lock"));
assert!(is_ignored("package-lock.json"));
assert!(is_ignored("web/yarn.lock"));
assert!(is_ignored("go.sum"));
assert!(!is_ignored("src/main.rs"));
assert!(!is_ignored("Cargo.toml"));
assert!(!is_ignored("docs/lockfiles.md"));
}
#[test]
fn generated_files_are_dropped_from_the_changed_lines() {
const DIFF_WITH_LOCKFILE: &str = concat!(
"diff --git a/Cargo.lock b/Cargo.lock\n",
"--- a/Cargo.lock\n",
"+++ b/Cargo.lock\n",
"@@ -1,1 +1,2 @@\n",
" [[package]]\n",
"+name = \"x\"\n",
"diff --git a/src/foo.rs b/src/foo.rs\n",
"--- a/src/foo.rs\n",
"+++ b/src/foo.rs\n",
"@@ -1,1 +1,2 @@\n",
" fn a() {}\n",
"+fn b() {}\n",
);
let mut changed_lines = parse_changed_lines(DIFF_WITH_LOCKFILE);
drop_generated_files(&mut changed_lines);
assert_eq!(format_changed_lines(&changed_lines), "src/foo.rs: added 2");
}
#[test]
fn a_generated_file_is_not_described_to_the_model() {
let files = vec![
pull_request_file("Cargo.lock", None, "modified"),
pull_request_file("src/foo.rs", None, "modified"),
];
let changed_lines = parse_changed_lines(DIFF);
let changes = format_changes(&files, &changed_lines);
assert!(!changes.contains("Cargo.lock"));
assert!(changes.contains("src/foo.rs (modified)"));
}
#[test] #[test]
fn a_renamed_file_is_described_with_its_previous_path() { fn a_renamed_file_is_described_with_its_previous_path() {
let file = pull_request_file("src/new.rs", Some("src/old.rs"), "renamed"); let file = pull_request_file("src/new.rs", Some("src/old.rs"), "renamed");
@@ -960,4 +1083,79 @@ mod tests {
] ]
); );
} }
#[test]
fn severities_are_read_from_the_model() {
let result: ReviewResult = serde_json::from_str(
r#"{
"reviews": [
{ "filename": "a", "line": 1, "severity": "Bug", "message": "x" },
{ "filename": "b", "line": 2, "severity": "SECURITY", "message": "x" },
{ "filename": "c", "line": 3, "severity": "perf", "message": "x" },
{ "filename": "d", "line": 4, "severity": "banana", "message": "x" }
],
"comment": ""
}"#,
)
.unwrap();
let severities = result
.reviews
.iter()
.map(|review| review.severity)
.collect::<Vec<_>>();
assert_eq!(
severities,
vec![
Some(ReviewSeverity::Bug),
Some(ReviewSeverity::Security),
Some(ReviewSeverity::Performance),
None,
]
);
}
#[test]
fn the_breakdown_counts_severities_most_severe_first() {
let reviews = vec![
ReviewItem {
severity: Some(ReviewSeverity::Maintainability),
..review("a", Some(1), None)
},
ReviewItem {
severity: Some(ReviewSeverity::Bug),
..review("b", Some(2), None)
},
ReviewItem {
severity: Some(ReviewSeverity::Bug),
..review("c", Some(3), None)
},
// A severity the model left out counts as maintainability.
review("d", Some(4), None),
];
assert_eq!(severity_breakdown(&reviews), "- 2 bug\n- 2 maintainability");
}
#[test]
fn the_markdown_breaks_the_issues_down_by_severity() {
let mut result = review_result(vec![
ReviewItem {
severity: Some(ReviewSeverity::Bug),
..review("a", Some(1), None)
},
ReviewItem {
severity: Some(ReviewSeverity::Performance),
..review("b", Some(2), None)
},
]);
result.comment = String::from("Le découpage en crates est propre.");
let markdown = review_result_to_markdown(&result);
assert!(markdown.contains("- 1 bug"));
assert!(markdown.contains("- 1 performance"));
assert!(markdown.contains("Le découpage en crates est propre."));
}
} }
+42 -21
View File
1
@@ -9,50 +9,71 @@ pub const BOT_PROCESS_MSG: &str = "
"; ";
pub const SANDBOX_SYSTEM_PROMPT: &str = " pub const SANDBOX_SYSTEM_PROMPT: &str = "
You are a senior software engineer reviewing a pull request. You are a senior software engineer reviewing a pull request inside an
isolated sandbox.
The repository is checked out in your working directory. Use the provided The repository is checked out in your working directory. Explore it with the
tools (ls, read_file, grep, find, file_size) to explore the code and gather the context provided read-only tools (ls, file_size, read_file, grep, find); paths are
you need before answering. Paths are relative to the repository root. relative to the repository root. Every line you read is prefixed with its
absolute line number: that is the number you must cite to anchor a comment.
When you have enough information, answer with the requested JSON only, as a raw Read before you assert: a review anchored on a line you did not read is
JSON object: no markdown code fence, nothing before or after it. worthless, so never comment on code you have not seen. Gather enough context
to be confident, but do not re-read what you already have.
When you answer, send the requested JSON only: a raw JSON object, no markdown
code fence, nothing before or after it.
"; ";
pub const REVIEW_PROMPT: &str = " pub const REVIEW_PROMPT: &str = "
You are a senior software engineer reviewing code changes. You are a senior software engineer reviewing a pull request.
Check good practices and code quality. Judge the changes for correctness, security, resource use and
maintainability. Report real problems; do not invent issues, and do not
report pure formatting a formatter would fix.
Be exhaustive: one review per distinct issue, and cover every changed file
that has something to report. Do not stop at the first few findings, and do
not merge several issues into a single review.
This is the pull request subject: \"{subject}\" This is the pull request subject: \"{subject}\"
This is the user comment: \"{comment}\" This is the user comment: \"{comment}\"
If the user asks about something precise, address that first.
The pull request changes these files and lines: The pull request changes these files and lines:
{changes} {changes}
`added` line numbers refer to the new version of the file, `removed` line The code is not provided. Read the files you need with the available tools
numbers to the old version, as they appear in the diff. before answering.
The code is not provided: read the files you need with the available tools `added` line numbers refer to the new version of the file, which is what your
before answering. Review only the listed lines. working directory contains now. `removed` line numbers refer to the old
version, which is not in your working directory: you cannot read a removed
line, only the code around where it used to be.
Return your feedback, in french, with only this json format, reviews must contain each review Every review must anchor on one of the listed line numbers:
All fields are mandatory. - filename: the full path exactly as listed above,
Answer with the raw json object only: no markdown code fence, no text before or - line: one of the numbers listed for that file,
after it. - side: \"added\" for a number from the `added` list, \"removed\" for one from
(filename field must contain the full path with extension; line must be one of the the `removed` list,
listed line numbers for that file, and side must be \"added\" when the line comes - severity: exactly one of \"bug\", \"security\", \"performance\" or
from the `added` list or \"removed\" when it comes from the `removed` list) \"maintainability\" — a bug is wrong behaviour, security a vulnerability,
and comment must contain a final summary: performance a resource problem, maintainability everything else.
Answer in french, with the raw json object only: no markdown code fence, no
text before or after it. All fields are mandatory. The `comment` field must
hold a short summary that lists the issues, and also what the pull request
does well: the author should get the compliments too.
{ {
\"reviews\": [ \"reviews\": [
{ {
\"filename\": \"\", \"filename\": \"\",
\"line\": , \"line\": 0,
\"side\": \"\", \"side\": \"\",
\"severity\": \"\",
\"message\": \"\" \"message\": \"\"
} }
], ],
+21 -16
View File
@@ -8,7 +8,7 @@ use tokio_util::io::StreamReader;
use tracing::{instrument, warn}; use tracing::{instrument, warn};
use crate::{ use crate::{
bot::{ReviewResult, ReviewSide}, bot::{ReviewResult, ReviewSeverity, ReviewSide},
consts::MAX_DIFF_SIZE, consts::MAX_DIFF_SIZE,
errors::AppError, errors::AppError,
}; };
@@ -49,15 +49,6 @@ impl GiteaAPI {
&self.token &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))] #[instrument(skip(self))]
pub async fn get_authorized_user(&self) -> anyhow::Result<User> { pub async fn get_authorized_user(&self) -> anyhow::Result<User> {
let url = format!("{}/api/v1/user", self.base_url); let url = format!("{}/api/v1/user", self.base_url);
@@ -231,7 +222,11 @@ impl GiteaAPI {
.filter_map(|review| { .filter_map(|review| {
let line = review.line?; let line = review.line?;
let path = review.filename.clone(); let path = review.filename.clone();
let body = review.message.clone(); let severity = review
.severity
.unwrap_or(ReviewSeverity::Maintainability)
.label();
let body = format!("**[{severity}]** {}", review.message);
// A line removed by the pull request only exists in the old // A line removed by the pull request only exists in the old
// version of the file, so it is anchored with `old_position`. // version of the file, so it is anchored with `old_position`.
@@ -333,6 +328,7 @@ pub struct User {
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
pub struct Repository { pub struct Repository {
pub full_name: String, pub full_name: String,
pub clone_url: String,
} }
/// A file changed by a pull request, as reported by the API. /// A file changed by a pull request, as reported by the API.
@@ -390,7 +386,8 @@ mod tests {
"title": "My PR" "title": "My PR"
}, },
"repository": { "repository": {
"full_name": "owner/repo" "full_name": "owner/repo",
"clone_url": "https://github.com/owner/repo.git"
}, },
"comment": { "comment": {
"id": 7, "id": 7,
@@ -453,7 +450,8 @@ mod tests {
"title": "My PR" "title": "My PR"
}, },
"repository": { "repository": {
"full_name": "owner/repo" "full_name": "owner/repo",
"clone_url": "https://github.com/owner/repo.git"
}, },
"comment": { "comment": {
"id": 1, "id": 1,
@@ -485,7 +483,8 @@ mod tests {
"title": "My PR" "title": "My PR"
}, },
"repository": { "repository": {
"full_name": "owner/repo" "full_name": "owner/repo",
"clone_url": "https://github.com/owner/repo.git"
}, },
"comment": { "comment": {
"id": 12, "id": 12,
@@ -501,6 +500,10 @@ mod tests {
assert_eq!(payload.action, "created"); assert_eq!(payload.action, "created");
assert_eq!(payload.comment.id, 12); assert_eq!(payload.comment.id, 12);
assert_eq!(payload.comment.body, "Needs work"); assert_eq!(payload.comment.body, "Needs work");
assert_eq!(
payload.repository.clone_url,
"https://github.com/owner/repo.git"
);
} }
#[test] #[test]
@@ -521,7 +524,8 @@ mod tests {
"title": "My PR" "title": "My PR"
}, },
"repository": { "repository": {
"full_name": "owner/repo" "full_name": "owner/repo",
"clone_url": "https://github.com/owner/repo.git"
}, },
"comment": { "comment": {
"id": 1, "id": 1,
@@ -548,7 +552,8 @@ mod tests {
"title": "My PR" "title": "My PR"
}, },
"repository": { "repository": {
"full_name": "owner/repo" "full_name": "owner/repo",
"clone_url": "https://github.com/owner/repo.git"
}, },
"comment": { "comment": {
"id": 1, "id": 1,
@@ -0,0 +1,288 @@
//! Le [`Sandbox`] : clone d'une pull request exécuté dans un devcontainer
//! éphémère, et sa configuration.
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 {
/// Client du daemon de containers qui exécute la sandbox.
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,
/// Path of the clone, as copied into the container.
repo_dir: PathBuf,
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,
clone_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(clone_url, token, pull_request_number, &repo_dir).await?;
make_readable(&repo_dir).await?;
let devcontainer_path = find_devcontainer(&repo_dir)
.with_context(|| format!("no devcontainer found in `{clone_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?;
let sandbox = Self {
_workspace: workspace,
repo_dir,
container,
};
if let Err(err) = sandbox.check_workspace().await {
let _ = sandbox.container.remove().await;
return Err(err);
}
Ok(sandbox)
}
/// Vérifie que le clone est bien visible dans le container.
///
/// Sans ce contrôle, un workspace vide — par exemple un daemon qui n'a pas pu
/// recevoir le clone — fait échouer chaque outil ; le modèle enchaîne alors les
/// appels ratés jusqu'au budget d'itérations, sans jamais pouvoir reviewer quoi
/// que ce soit.
async fn check_workspace(&self) -> anyhow::Result<()> {
let workspace_folder = self.workspace_folder();
let probe = self
.container
.exec(&["ls", "-A", "--", workspace_folder])
.await
.with_context(|| format!("failed to list `{workspace_folder}` in the sandbox"))?;
if !probe.success() {
let details = self.diagnose_workspace().await;
anyhow::bail!(
"the sandbox cannot list `{workspace_folder}`: {} ({details})",
probe.stderr.trim()
);
}
if probe.stdout.trim().is_empty() {
anyhow::bail!(
"the sandbox workspace `{workspace_folder}` is empty: the container daemon does not see the clone"
);
}
info!(
clone = %self.repo_dir.display(),
workspace = %workspace_folder,
entries = probe.stdout.lines().count(),
"Sandbox workspace is readable"
);
Ok(())
}
/// Rassemble de quoi expliquer un refus d'accès au workspace.
///
/// Un `EACCES` a deux causes possibles, indistinguables dans le message de
/// `ls` : les permissions du clone, ou un confinement du noyau qui bloque
/// l'accès. L'identité de l'utilisateur d'exec et les permissions du point de
/// montage permettent de trancher.
async fn diagnose_workspace(&self) -> String {
let mut details = Vec::new();
if let Ok(output) = self.container.exec(&["id"]).await {
details.push(output.stdout.trim().to_string());
}
if let Ok(output) = self
.container
.exec(&["ls", "-ld", "--", self.workspace_folder()])
.await
&& output.success()
{
details.push(output.stdout.trim().to_string());
}
details.join(", ")
}
/// 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(())
}
/// Rend le clone lisible par les utilisateurs du container sandbox.
///
/// Le container peut ne pas avoir les mêmes uid que Herald (podman rootless
/// mappe les uid à travers des plages subuid), et l'umask de l'opérateur peut être
/// restrictif : sans cela, les outils de la sandbox échouent en `Permission
/// denied` sur des fichiers que Herald vient de cloner lui-même.
async fn make_readable(repo_dir: &Path) -> anyhow::Result<()> {
let output = tokio::process::Command::new("chmod")
.args(["-R", "a+rX"])
.arg(repo_dir)
.stdin(Stdio::null())
.output()
.await
.context("failed to spawn chmod")?;
if !output.status.success() {
anyhow::bail!(
"chmod failed on `{}`: {}",
repo_dir.display(),
String::from_utf8_lossy(&output.stderr).trim()
);
}
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);
}
}
+2 -285
View File
2
@@ -7,290 +7,7 @@
//! tool-calling loop against OpenRouter. //! tool-calling loop against OpenRouter.
pub mod agent; pub mod agent;
mod instance;
pub mod tools; pub mod tools;
use std::{ pub use instance::{Sandbox, SandboxConfig};
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 {
/// Client du daemon de containers qui exécute la sandbox.
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,
/// Path of the clone, as copied into the container.
repo_dir: PathBuf,
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?;
make_readable(&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?;
let sandbox = Self {
_workspace: workspace,
repo_dir,
container,
};
if let Err(err) = sandbox.check_workspace().await {
let _ = sandbox.container.remove().await;
return Err(err);
}
Ok(sandbox)
}
/// Vérifie que le clone est bien visible dans le container.
///
/// Sans ce contrôle, un workspace vide — par exemple un daemon qui n'a pas pu
/// recevoir le clone — fait échouer chaque outil ; le modèle enchaîne alors les
/// appels ratés jusqu'au budget d'itérations, sans jamais pouvoir reviewer quoi
/// que ce soit.
async fn check_workspace(&self) -> anyhow::Result<()> {
let workspace_folder = self.workspace_folder();
let probe = self
.container
.exec(&["ls", "-A", "--", workspace_folder])
.await
.with_context(|| format!("failed to list `{workspace_folder}` in the sandbox"))?;
if !probe.success() {
let details = self.diagnose_workspace().await;
anyhow::bail!(
"the sandbox cannot list `{workspace_folder}`: {} ({details})",
probe.stderr.trim()
);
}
if probe.stdout.trim().is_empty() {
anyhow::bail!(
"the sandbox workspace `{workspace_folder}` is empty: the container daemon does not see the clone"
);
}
info!(
clone = %self.repo_dir.display(),
workspace = %workspace_folder,
entries = probe.stdout.lines().count(),
"Sandbox workspace is readable"
);
Ok(())
}
/// Rassemble de quoi expliquer un refus d'accès au workspace.
///
/// Un `EACCES` a deux causes possibles, indistinguables dans le message de
/// `ls` : les permissions du clone, ou un confinement du noyau qui bloque
/// l'accès. L'identité de l'utilisateur d'exec et les permissions du point de
/// montage permettent de trancher.
async fn diagnose_workspace(&self) -> String {
let mut details = Vec::new();
if let Ok(output) = self.container.exec(&["id"]).await {
details.push(output.stdout.trim().to_string());
}
if let Ok(output) = self
.container
.exec(&["ls", "-ld", "--", self.workspace_folder()])
.await
&& output.success()
{
details.push(output.stdout.trim().to_string());
}
details.join(", ")
}
/// 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(())
}
/// Rend le clone lisible par les utilisateurs du container sandbox.
///
/// Le container peut ne pas avoir les mêmes uid que Herald (podman rootless
/// mappe les uid à travers des plages subuid), et l'umask de l'opérateur peut être
/// restrictif : sans cela, les outils de la sandbox échouent en `Permission
/// denied` sur des fichiers que Herald vient de cloner lui-même.
async fn make_readable(repo_dir: &Path) -> anyhow::Result<()> {
let output = tokio::process::Command::new("chmod")
.args(["-R", "a+rX"])
.arg(repo_dir)
.stdin(Stdio::null())
.output()
.await
.context("failed to spawn chmod")?;
if !output.status.success() {
anyhow::bail!(
"chmod failed on `{}`: {}",
repo_dir.display(),
String::from_utf8_lossy(&output.stderr).trim()
);
}
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);
}
}
+2 -1
View File
7
@@ -259,6 +259,7 @@ mod tests {
}, },
repository: Repository { repository: Repository {
full_name: "owner/repo".to_string(), full_name: "owner/repo".to_string(),
clone_url: "https://github.com/owner/repo.git".to_string(),
}, },
comment: Comment { comment: Comment {
id: 1, id: 1,
@@ -274,7 +275,7 @@ mod tests {
.map(|tool| tool.function.name) .map(|tool| tool.function.name)
.collect(); .collect();
assert_eq!(names, vec!["ls", "read_file", "grep", "find", "file_size"]); assert_eq!(names, vec!["ls", "file_size", "read_file", "grep", "find"]);
} }
#[test] #[test]