1.2: Sandboxing #7

Merged
qpismont merged 17 commits from 1.2 into main 2026-09-20 16:58:36 +02:00
16 changed files with 1327 additions and 354 deletions
Showing only changes of commit 624bc1e028 - Show all commits
+1 -1
View File
@@ -20,5 +20,5 @@ RUST_BACKTRACE=1
METRICS_BIND_ADDR=
qpismont marked this conversation as resolved
Review

Les nouvelles variables doivent être documentées ici avec un commentaire indiquant leur rôle et une valeur d'exemple non sensible. Vérifier qu'aucune vraie clé/token n'a été committée et que le fichier reste aligné avec les variables réellement lues dans env.rs.

Les nouvelles variables doivent être documentées ici avec un commentaire indiquant leur rôle et une valeur d'exemple non sensible. Vérifier qu'aucune vraie clé/token n'a été committée et que le fichier reste aligné avec les variables réellement lues dans `env.rs`.
# Sandboxed tool execution
CONTAINER_RUNTIME=docker
# DOCKER_HOST=
SANDBOX_MAX_ITERATIONS=8
Generated
+133 -3
View File
1
@@ -295,6 +295,49 @@ dependencies = [
"objc2",
]
[[package]]
name = "bollard"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbe8358268799ebb3e4df23cb9d47f4c72bbc4f5247e2fa6a1bf7b6c0baea220"
dependencies = [
"base64 0.22.1",
"bollard-stubs",
"bytes",
"futures-core",
"futures-util",
"hex",
"http 1.5.0",
"http-body-util",
"hyper",
"hyper-named-pipe",
"hyper-util",
"hyperlocal",
"log",
"pin-project-lite",
"serde",
"serde_derive",
"serde_json",
"serde_urlencoded",
"thiserror",
"tokio",
"tokio-util",
"tower-service",
"url",
"winapi",
]
[[package]]
name = "bollard-stubs"
version = "1.53.1-rc.29.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce412eb6f7096743011dc3cb5c674caeb24ced61d8c498fe07cf7998a4fea889"
dependencies = [
"serde",
"serde_json",
"serde_repr",
]
[[package]]
name = "bumpalo"
version = "3.20.3"
@@ -432,11 +475,16 @@ name = "devcontainer-rs"
version = "0.1.0"
dependencies = [
"anyhow",
"bollard",
"bytes",
"futures-util",
"serde",
"serde_json",
"tar",
"tempfile",
"thiserror",
"tokio",
"tokio-stream",
]
[[package]]
@@ -488,7 +536,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -508,6 +556,16 @@ version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "filetime"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.11"
@@ -830,6 +888,20 @@ dependencies = [
"want",
]
[[package]]
name = "hyper-named-pipe"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fab3637d6b04a8037af8a266fdf6cf92ea957e8c53981a2bf6136572531025bf"
dependencies = [
"hex",
"hyper",
"hyper-util",
"pin-project-lite",
"tokio",
"tower-service",
]
[[package]]
name = "hyper-tls"
version = "0.6.0"
@@ -869,6 +941,21 @@ dependencies = [
"tracing",
]
[[package]]
name = "hyperlocal"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7"
dependencies = [
"hex",
"http-body-util",
"hyper",
"hyper-util",
"pin-project-lite",
"tokio",
"tower-service",
]
[[package]]
name = "icu_collections"
version = "2.3.0"
@@ -1777,7 +1864,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -2066,6 +2153,17 @@ dependencies = [
"serde_core",
]
[[package]]
name = "serde_repr"
version = "0.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.4",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
@@ -2179,6 +2277,17 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "tar"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]]
name = "tempfile"
version = "3.27.0"
@@ -2189,7 +2298,7 @@ dependencies = [
"getrandom 0.3.4",
"once_cell",
"rustix",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -2299,6 +2408,17 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-stream"
version = "0.1.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b"
dependencies = [
"futures-core",
"pin-project-lite",
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.19"
@@ -2796,6 +2916,16 @@ version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix",
]
[[package]]
name = "yoke"
version = "0.8.3"
+2
View File
1
@@ -8,6 +8,7 @@ resolver = "3"
[workspace.dependencies]
reqwest = { version = "0.13", default-features = false, features = ["json", "native-tls", "stream"] }
tokio = { version = "1.53", features = ["full"] }
tokio-stream = "0.1"
tokio-util = "0.7"
futures-util = "0.3"
serde_json = "1.0"
@@ -24,6 +25,7 @@ anyhow = { version = "1", features = ["backtrace"] }
thiserror = "2.0"
ring = "0.17"
hex = "0.4"
bytes = "1.1"
metrics = "0.24"
metrics-exporter-prometheus = { version = "0.18", default-features = false, features = ["http-listener"] }
+30 -3
View File
@@ -1,4 +1,4 @@
FROM rust:1.97-trixie as builder
FROM rust:1.98-trixie as builder
qpismont marked this conversation as resolved Outdated
Outdated
Review

Incohérence de toolchain : rust:1.97-trixie ici, alors que .devcontainer/Dockerfile et .woodpecker/tests.yml utilisent rust:1.98. Aligner les versions et écrire AS en majuscules (convention Dockerfile).

Incohérence de toolchain : `rust:1.97-trixie` ici, alors que `.devcontainer/Dockerfile` et `.woodpecker/tests.yml` utilisent `rust:1.98`. Aligner les versions et écrire `AS` en majuscules (convention Dockerfile).
Outdated
Review

Build de production : privilégier un build multi-étapes pour ne pas embarquer toolchain et sources dans l'image finale, exécuter le binaire avec un utilisateur non privilégié, et épingler les dépendances de l'étape de build. Ajouter un ENTRYPOINT/CMD explicite et un label de version.

Build de production : privilégier un build multi-étapes pour ne pas embarquer toolchain et sources dans l'image finale, exécuter le binaire avec un utilisateur non privilégié, et épingler les dépendances de l'étape de build. Ajouter un `ENTRYPOINT`/`CMD` explicite et un label de version.
Outdated
Review

Épingler l'image de base par digest plutôt que par tag flottant, et vérifier que l'image de sandbox ne contient pas de secrets ni d'outils inutiles (surface d'attaque réduite, utilisateur non-root).

Épingler l'image de base par digest plutôt que par tag flottant, et vérifier que l'image de sandbox ne contient pas de secrets ni d'outils inutiles (surface d'attaque réduite, utilisateur non-root).
WORKDIR /app
@@ -10,6 +10,33 @@ RUN cargo build --release --package herald-server
FROM debian:trixie-slim
# git clones the pull request. ca-certificates is what every HTTPS call needs
# (Gitea, OpenRouter, git). The shared libraries are the non-base ones the binary
qpismont marked this conversation as resolved Outdated
Outdated
Review

L'étage final debian:trixie-slim ne contient que le binaire, or herald-server a besoin de git (clone de la PR) et d'un binaire de runtime conteneur (docker/podman) pour exécuter la sandbox. Avec cette image, chaque revue échouera à l'exécution. Ajouter ces paquets (plus ca-certificates) ou documenter explicitement le montage du socket/runtime requis.

L'étage final `debian:trixie-slim` ne contient que le binaire, or herald-server a besoin de `git` (clone de la PR) et d'un binaire de runtime conteneur (`docker`/`podman`) pour exécuter la sandbox. Avec cette image, chaque revue échouera à l'exécution. Ajouter ces paquets (plus `ca-certificates`) ou documenter explicitement le montage du socket/runtime requis.
# links against, as reported by `ldd target/release/herald-server`; libc, libm
# and libgcc_s come from the base image.
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
git \
libssl3t64 \
libzstd1 \
zlib1g \
&& rm -rf /var/lib/apt/lists/*
# Herald drives the container daemon through its socket (DOCKER_HOST, default
# unix:///var/run/docker.sock), so neither docker nor podman is needed here: the
# compose file mounts the socket. Reaching that socket is what this user needs,
# and the socket is root-equivalent, so either run the container as root or give
# it the socket's group, e.g. `group_add: ["<gid of the host docker group>"]`.
RUN useradd --create-home --shell /usr/sbin/nologin --uid 10001 herald
WORKDIR /app
COPY --from=builder /app/target/release/herald-server .
CMD [ "./herald-server" ]
COPY --from=builder /app/target/release/herald-server ./herald-server
# git looks for its configuration under $HOME.
ENV HOME=/home/herald
USER herald
# Exec form, so the binary is PID 1 and receives the SIGTERM it handles to shut
# down gracefully.
CMD ["./herald-server"]
+12 -4
View File
@@ -37,8 +37,8 @@ Herald reads its configuration from environment variables (a `.env` file is supp
| `METRICS_BIND_ADDR` | *(optional)* Bind address for the Prometheus metrics endpoint (e.g. `0.0.0.0:9100`). If unset, the metrics exporter is disabled. |
| `SENTRY_DSN` | *(optional)* Sentry DSN for error tracking |
| `RUST_LOG` | *(optional)* Log level, defaults to `info` |
| `CONTAINER_RUNTIME` | *(optional)* Container runtime binary used for the sandbox (`docker` or `podman`). Defaults to `docker` |
| `SANDBOX_MAX_ITERATIONS` | *(optional)* Maximum number of tool-calling iterations per sandboxed review. Defaults to `8` |
qpismont marked this conversation as resolved
Review

La documentation du sandbox doit préciser ce qui est isolé et ce qui ne l'est pas (réseau, filesystem, credentials), comment le configurer via l'environnement, et rappeler que l'exécution de code issu d'une PR est une opération à risque. Ajouter si possible un exemple de variable d'environnement sécurisé.

La documentation du sandbox doit préciser ce qui est isolé et ce qui ne l'est pas (réseau, filesystem, credentials), comment le configurer via l'environnement, et rappeler que l'exécution de code issu d'une PR est une opération à risque. Ajouter si possible un exemple de variable d'environnement sécurisé.
Review

Documenter le nouveau fonctionnement (workspace multi-crates + sandboxing) : prérequis (Podman/Docker), variables d'environnement nécessaires et limites connues de l'isolation.

Documenter le nouveau fonctionnement (workspace multi-crates + sandboxing) : prérequis (Podman/Docker), variables d'environnement nécessaires et limites connues de l'isolation.
| `DOCKER_HOST` | *(optional)* Container daemon socket Herald drives, e.g. `unix:///run/user/1000/podman/podman.sock` for a rootless podman. Defaults to `unix:///var/run/docker.sock` |
## Sandboxed reviews
@@ -56,8 +56,16 @@ Herald reviews pull requests inside an ephemeral
4. posts the review, anchoring each comment on the added or removed line it
refers to, and removes the container and the temporary clone.
The container runtime is selected with `CONTAINER_RUNTIME` (`docker` or
`podman`). The repository must contain a `.devcontainer/devcontainer.json`.
Herald drives the container daemon through its socket: `DOCKER_HOST` (default
`unix:///var/run/docker.sock`), which covers both docker and podman's
Docker-compatible socket. The repository must contain a
`.devcontainer/devcontainer.json`.
qpismont marked this conversation as resolved Outdated
Outdated
Review

L'affirmation « Each sandbox is isolated » est plus forte que la réalité tant que les runArgs/containerEnv du dépôt analysé sont transmis tels quels à docker run (cf. container.rs). Soit durcir le code, soit nuancer la section en listant les limites de l'isolation.

L'affirmation « Each sandbox is isolated » est plus forte que la réalité tant que les `runArgs`/`containerEnv` du dépôt analysé sont transmis tels quels à `docker run` (cf. `container.rs`). Soit durcir le code, soit nuancer la section en listant les limites de l'isolation.
The `runArgs` of that file are read but deliberately **not** passed to the daemon:
they come from an untrusted pull request, and one of them (`--network host`) would
attach the container to another network and quietly
defeat the network cut described below. A devcontainer that relies on them
(`--gpus all`, `--cap-add`, `--shm-size`…) will not get them.
Each sandbox is isolated: it gets its own image tag, container and network. The
container starts with network access so the `postCreateCommand` /
@@ -70,7 +78,7 @@ network and image are removed when the review ends (including on failure).
The easiest way to get started is with the provided [Dev Container](https://containers.dev/) (VS Code or Zed with the dev container extension).
Open the project and reopen it in the container — the Rust toolchain is pre-installed.
Open the project and reopen it in the container — the Rust toolchain is pre-installed, along with rootless podman, so sandboxed reviews can be exercised locally: start its API socket with `podman system service --time=0 &` and set `DOCKER_HOST=unix:///run/user/$(id -u)/podman/podman.sock`.
**Without Dev Container**, you just need a Rust toolchain:
+5
View File
@@ -4,7 +4,12 @@ version = "0.1.0"
edition = "2024"
[dependencies]
bollard = "0.21"
bytes = { workspace = true }
futures-util = { workspace = true }
tar = "0.4"
tokio = { workspace = true }
tokio-stream = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
anyhow = { workspace = true }
File diff suppressed because it is too large Load Diff
+7 -5
View File
1
@@ -38,6 +38,12 @@ pub struct DevContainerSchema {
#[serde(rename = "remoteUser", default)]
pub remote_user: Option<String>,
/// Arguments passés à `docker run`.
///
/// Lus pour rester fidèle au format `devcontainer.json`, mais
/// **délibérément pas transmis** au runtime : ils viennent d'une pull
/// request non fiable et pourraient casser l'isolation de la sandbox (voir
/// [`DevContainer::run_args`]).
#[serde(rename = "runArgs", default)]
pub run_args: Vec<String>,
}
@@ -52,7 +58,6 @@ pub struct DevContainer {
pub post_create_command: Option<String>,
pub post_start_command: Option<String>,
pub remote_user: Option<String>,
pub run_args: Vec<String>,
}
#[derive(Debug, thiserror::Error)]
3
@@ -114,7 +119,6 @@ impl TryFrom<(DevContainerSchema, PathBuf)> for DevContainer {
post_create_command: schema.post_create_command,
post_start_command: schema.post_start_command,
remote_user: schema.remote_user,
run_args: schema.run_args,
})
}
}
1
@@ -203,8 +207,7 @@ mod tests {
"containerEnv": {
"RUST_LOG": "debug"
},
"remoteUser": "dev",
"runArgs": ["--userns=keep-id"]
"remoteUser": "dev"
}"#,
)
.unwrap();
@@ -217,7 +220,6 @@ mod tests {
assert_eq!(config.container_env.get("RUST_LOG").unwrap(), "debug");
assert_eq!(config.workspace_folder.as_deref(), Some("/workspace"));
assert_eq!(config.remote_user.as_deref(), Some("dev"));
assert_eq!(config.run_args, vec!["--userns=keep-id"]);
}
#[test]
+104 -4
View File
@@ -1,3 +1,5 @@
use std::str::FromStr;
qpismont marked this conversation as resolved Outdated
Outdated
Review

Action de review : ajouter des tests unitaires sur la construction du prompt, la gestion des erreurs et les cas limites (contenu vide, réponse LLM tronquée, échec du sandbox). Prévoir aussi une limite de taille de la réponse publiée dans Gitea pour éviter de dépasser les quotas de l'API.

Action de review : ajouter des tests unitaires sur la construction du prompt, la gestion des erreurs et les cas limites (contenu vide, réponse LLM tronquée, échec du sandbox). Prévoir aussi une limite de taille de la réponse publiée dans Gitea pour éviter de dépasser les quotas de l'API.
use tracing::{info, instrument, warn};
use crate::{
@@ -7,6 +9,7 @@ use crate::{
metrics,
open_router::{OpenRouterClient, Tool},
sandbox::{Sandbox, SandboxConfig, agent},
text::excerpt,
};
#[instrument(skip(gitea_api, open_router_client, sandbox_config, tools, review_payload))]
@@ -59,7 +62,7 @@ pub async fn exec_review(
.replace("{comment}", &review_payload.comment.body)
.replace("{changes}", &changes);
let (message, cost) = run_sandboxed_review(
let (mut review_result, cost) = run_sandboxed_review(
gitea_api,
open_router_client,
sandbox_config,
@@ -69,7 +72,6 @@ pub async fn exec_review(
)
.await?;
let mut review_result = serde_json::from_str::<ReviewResult>(&message)?;
resolve_review_sides(&mut review_result, &changed_lines);
review_result.cost = cost;
@@ -112,6 +114,10 @@ pub async fn exec_review(
/// Runs the review inside a sandbox container, letting the model explore the
/// repository with tools before answering.
///
/// The answer of the model is parsed by [`ReviewResult::from_str`], which the
/// agent loop enforces: an answer that is not a review is sent back to the model
/// for correction.
async fn run_sandboxed_review(
gitea_api: &GiteaAPI,
open_router_client: &OpenRouterClient,
@@ -119,7 +125,7 @@ async fn run_sandboxed_review(
tools: Vec<Tool>,
review_payload: &ReviewPayload,
bot_request: &str,
) -> anyhow::Result<(String, Option<f64>)> {
) -> anyhow::Result<(ReviewResult, Option<f64>)> {
let repo_url = gitea_api.repo_clone_url(&review_payload.repository.full_name);
let sandbox = Sandbox::create(
@@ -151,7 +157,7 @@ async fn run_sandboxed_review(
"Sandboxed review finished"
);
Ok((result.message, result.cost))
Ok((result.answer, result.cost))
}
fn review_result_to_markdown(review_result: &ReviewResult) -> String {
@@ -452,6 +458,61 @@ fn format_line_numbers(lines: &[u64]) -> String {
.join(", ")
}
/// Number of characters of a model answer kept in the logs when it cannot be
/// parsed.
const MAX_LOGGED_ANSWER: usize = 500;
impl FromStr for ReviewResult {
type Err = anyhow::Error;
/// Parses the review the model answered with.
///
/// This is the contract the agent loop enforces: a rejected answer is sent
/// back to the model, with the reason, so that it can correct itself.
///
/// The contract is a raw JSON object, but models sometimes wrap it in a
/// markdown code fence or surround it with a sentence: the object is then
/// extracted from the answer before failing, and the answer is logged so a
/// breach of the contract can be diagnosed.
fn from_str(message: &str) -> Result<Self, Self::Err> {
let error = match serde_json::from_str::<Self>(message) {
Ok(review_result) => return Ok(review_result),
Err(error) => error,
};
// A markdown code fence or a sentence around the object is tolerated, with
// a warning: the contract asks for a raw JSON object.
if let Some(json) = json_object(message)
&& let Ok(review_result) = serde_json::from_str::<Self>(json)
{
warn!(
"Model answer is not a raw JSON object, it was extracted from the surrounding text"
);
return Ok(review_result);
}
// The reason of the rejection is logged along with the answer: without it,
// a broken answer is impossible to diagnose.
warn!(
answer = %excerpt(message, MAX_LOGGED_ANSWER),
reason = %error,
"Model answer is not the expected JSON"
);
anyhow::bail!("the answer is not valid JSON: {error}")
}
}
/// Returns the outermost `{...}` of an answer, which ignores a markdown code
/// fence or any text around it.
fn json_object(message: &str) -> Option<&str> {
let start = message.find('{')?;
let end = message.rfind('}')?;
(start < end).then(|| &message[start..=end])
}
/// Resolves the side each review is anchored on and drops the reviews that do
/// not match a line the pull request changes.
///
@@ -824,6 +885,45 @@ mod tests {
assert_eq!(review_result.reviews[0].side, Some(ReviewSide::Removed));
}
#[test]
fn a_raw_answer_is_parsed() {
let answer = r#"{"reviews": [], "comment": "ok"}"#;
assert_eq!(answer.parse::<ReviewResult>().unwrap().comment, "ok");
}
#[test]
fn a_fenced_answer_is_extracted() {
let answer = concat!(
"Voici ma review :\n",
"```json\n",
"{\"reviews\": [], \"comment\": \"rien à signaler\"}\n",
"```\n"
);
assert_eq!(
answer.parse::<ReviewResult>().unwrap().comment,
"rien à signaler"
);
}
#[test]
fn a_sentence_around_the_object_is_ignored() {
let answer = r#"Rien à signaler. {"reviews": [], "comment": "ok"} Bonne journée !"#;
assert_eq!(answer.parse::<ReviewResult>().unwrap().comment, "ok");
}
#[test]
fn an_answer_without_json_is_rejected() {
assert!("Je n'ai rien relevé.".parse::<ReviewResult>().is_err());
}
#[test]
fn an_answer_that_is_an_object_but_not_a_review_is_rejected() {
assert!(r#"{"message": "LGTM"}"#.parse::<ReviewResult>().is_err());
}
#[test]
fn odd_sides_from_the_model_are_tolerated() {
let changed_lines = parse_changed_lines(DIFF);
+4 -1
View File
1
@@ -15,7 +15,8 @@ pub const SANDBOX_SYSTEM_PROMPT: &str = "
tools (ls, read_file, grep, find) to explore the code and gather the context
you need before answering. Paths are relative to the repository root.
When you have enough information, answer with the requested JSON only.
When you have enough information, answer with the requested JSON only, as a raw
JSON object: no markdown code fence, nothing before or after it.
";
pub const REVIEW_PROMPT: &str = "
@@ -39,6 +40,8 @@ pub const REVIEW_PROMPT: &str = "
Return your feedback, in french, with only this json format, reviews must contain each review
All fields are mandatory.
Answer with the raw json object only: no markdown code fence, no text before or
after it.
(filename field must contain the full path with extension; line must be one of the
listed line numbers for that file, and side must be \"added\" when the line comes
from the `added` list or \"removed\" when it comes from the `removed` list)
-4
View File
@@ -12,7 +12,6 @@ pub struct EnvConfig {
pub gitea_token: String,
pub gitea_timeout: u64,
pub metrics_bind_addr: Option<String>,
pub container_runtime: String,
pub sandbox_max_iterations: usize,
qpismont marked this conversation as resolved
Review

Nouvelles variables d'environnement : valider leur présence et leur format au démarrage (échouer tôt avec un message explicite) plutôt que de laisser une valeur par défaut silencieuse. Prévoir aussi une valeur par défaut sûre (fail-closed) pour tout réglage désactivant l'isolation.

Nouvelles variables d'environnement : valider leur présence et leur format au démarrage (échouer tôt avec un message explicite) plutôt que de laisser une valeur par défaut silencieuse. Prévoir aussi une valeur par défaut sûre (fail-closed) pour tout réglage désactivant l'isolation.
Review

Nouvelles variables d'environnement : documenter chacune dans .env.example et fournir une valeur par défaut sûre ; une variable manquante ne devrait pas désactiver silencieusement le sandboxing.

Nouvelles variables d'environnement : documenter chacune dans `.env.example` et fournir une valeur par défaut sûre ; une variable manquante ne devrait pas désactiver silencieusement le sandboxing.
}
@@ -27,8 +26,6 @@ pub fn load_config() -> anyhow::Result<EnvConfig> {
let gitea_token = try_get_env("GITEA_TOKEN")?;
let gitea_timeout = try_get_env("GITEA_TIMEOUT")?.parse()?;
let metrics_bind_addr = std::env::var("METRICS_BIND_ADDR").ok();
let container_runtime =
std::env::var("CONTAINER_RUNTIME").unwrap_or_else(|_| "docker".to_string());
let sandbox_max_iterations = std::env::var("SANDBOX_MAX_ITERATIONS")
.ok()
.and_then(|value| value.parse().ok())
1
@@ -45,7 +42,6 @@ pub fn load_config() -> anyhow::Result<EnvConfig> {
gitea_token,
gitea_timeout,
metrics_bind_addr,
container_runtime,
sandbox_max_iterations,
})
}
+34 -8
View File
@@ -1,3 +1,5 @@
use std::time::Duration;
qpismont marked this conversation as resolved Outdated
Outdated
Review

Nouveau point d'entrée du crate herald-server : vérifier que les modules déplacés (sandbox, open_router, consts, bot_actions) sont bien déclarés et que plus aucune référence au chemin racine src/... ne subsiste.

Nouveau point d'entrée du crate `herald-server` : vérifier que les modules déplacés (`sandbox`, `open_router`, `consts`, `bot_actions`) sont bien déclarés et que plus aucune référence au chemin racine `src/...` ne subsiste.
use crate::{
bot::Bot,
gitea::{GiteaAPI, WebhookType},
@@ -23,6 +25,13 @@ mod metrics;
mod open_router;
mod sandbox;
mod state;
mod text;
/// Délai laissé aux reviews en cours et aux serveurs pour s'arrêter proprement.
///
/// Sans lui, une review prise dans une sandbox récalcitrante garderait le processus
/// en vie jusqu'à ce que le superviseur le tue.
const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(60);
fn main() -> anyhow::Result<()> {
dotenv().ok();
1
@@ -79,14 +88,14 @@ async fn run() -> anyhow::Result<()> {
let shutdown = CancellationToken::new();
let sandbox = SandboxConfig {
runtime: devcontainer_rs::ContainerRuntime::new(config.container_runtime.clone()),
runtime: devcontainer_rs::ContainerRuntime::connect()?,
max_iterations: config.sandbox_max_iterations,
};
if !sandbox.runtime.available().await {
warn!(
runtime = sandbox.runtime.program(),
"Container runtime is not available, every review will fail"
endpoint = sandbox.runtime.endpoint(),
"Container daemon is not reachable, every review will fail"
);
}
@@ -119,11 +128,28 @@ async fn run() -> anyhow::Result<()> {
anyhow::Ok(())
};
tokio::try_join!(
bot.start(rx, shutdown.clone()),
api::start(app_state, shutdown.clone()),
signal
)?;
let shutdown_deadline = async {
shutdown.cancelled().await;
tokio::time::sleep(SHUTDOWN_TIMEOUT).await;
};
tokio::select! {
result = async {
tokio::try_join!(
bot.start(rx, shutdown.clone()),
api::start(app_state, shutdown.clone()),
signal
)
} => {
result?;
}
() = shutdown_deadline => {
warn!(
timeout = ?SHUTDOWN_TIMEOUT,
"Shutdown did not finish in time, exiting anyway"
);
}
}
info!("Shutdown complete");
+40 -8
View File
2
@@ -17,8 +17,24 @@ use tracing::instrument;
/// OpenRouter API root, version prefix included.
const BASE_URL: &str = "https://openrouter.ai/api/v1";
/// The model decides on its own which tool to call.
const TOOL_CHOICE_AUTO: &str = "auto";
/// What the model is allowed to do on a given turn.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolChoice {
/// The model decides whether to call a tool.
Auto,
/// The model must answer, without calling any tool. The tools stay declared,
/// so the conversation remains the same shape as on the other turns.
None,
}
impl ToolChoice {
fn as_str(self) -> &'static str {
match self {
Self::Auto => "auto",
Self::None => "none",
}
}
}
/// Result of a completion that may contain tool calls.
pub struct ToolChatResult {
@@ -250,13 +266,18 @@ impl OpenRouterClient {
/// Sends a conversation with tool definitions and returns either a final
/// message or the tool calls requested by the model.
///
/// The conversation is borrowed rather than taken by value: the caller keeps
/// appending to it between iterations, and a copy per iteration would be pure
/// waste.
#[instrument(skip(self, messages, tools), err)]
pub async fn chat_with_tools(
&self,
messages: Vec<Message>,
tools: Vec<Tool>,
messages: &[Message],
tools: &[Tool],
tool_choice: ToolChoice,
) -> anyhow::Result<ToolChatResult> {
let response = self.complete(&messages, &tools).await?;
let response = self.complete(messages, tools, tool_choice).await?;
let cost = response.usage.and_then(|usage| usage.cost);
let message = response
@@ -273,13 +294,18 @@ impl OpenRouterClient {
})
}
async fn complete(&self, messages: &[Message], tools: &[Tool]) -> anyhow::Result<ChatResponse> {
async fn complete(
&self,
messages: &[Message],
tools: &[Tool],
tool_choice: ToolChoice,
) -> anyhow::Result<ChatResponse> {
let request = ChatRequest {
model: &self.model,
messages,
reasoning: Reasoning { enabled: true },
tools,
tool_choice: TOOL_CHOICE_AUTO,
tool_choice: tool_choice.as_str(),
};
let response = self
@@ -364,7 +390,7 @@ mod tests {
messages: &messages,
reasoning: Reasoning { enabled: true },
tools: &tools,
tool_choice: TOOL_CHOICE_AUTO,
tool_choice: ToolChoice::Auto.as_str(),
})
.unwrap();
@@ -375,6 +401,12 @@ mod tests {
assert_eq!(request["messages"][0]["role"], "user");
}
#[test]
fn a_forced_answer_turn_disables_the_tools() {
assert_eq!(ToolChoice::None.as_str(), "none");
assert_eq!(ToolChoice::Auto.as_str(), "auto");
}
#[test]
fn response_parses_tool_calls_and_cost() {
let response: ChatResponse = serde_json::from_value(json!({
+200 -21
View File
2
@@ -5,18 +5,58 @@
//! back, until the model produces a final message or the iteration budget is
//! exhausted.
use std::str::FromStr;
use std::time::Instant;
use anyhow::Context;
use serde_json::Value;
use tracing::{debug, warn};
use tracing::{info, warn};
use crate::{
open_router::{Message, OpenRouterClient, Role, Tool, ToolCall},
open_router::{Message, OpenRouterClient, Role, Tool, ToolCall, ToolChoice},
sandbox::{Sandbox, tools},
text::{excerpt, truncate_bytes},
};
/// Number of characters of the arguments of a tool call kept in the logs.
const MAX_LOGGED_ARGUMENTS: usize = 200;
/// Number of characters of a tool result kept in the logs.
///
/// Sizes alone make a broken tool invisible: a command that fails returns a short
/// `error: …` instead of the expected content, which is exactly what this excerpt
/// makes obvious.
const MAX_LOGGED_OUTPUT: usize = 120;
/// Maximum number of bytes of a tool result handed to the model.
///
/// A result stays in the conversation and is re-sent on every following
/// iteration, so an unbounded one (a whole file, a match on every line) inflates
/// the context and the cost of the whole rest of the run — and can overflow the
/// model context window outright.
const MAX_TOOL_OUTPUT_BYTES: usize = 32 * 1024;
/// Asked of the model when its answer does not satisfy the caller's contract.
const RETRY_PROMPT: &str = "
Your answer is not valid: {error}
Answer again with the requested format only: a raw json object, without any
markdown code fence and without any text before or after it.
";
/// Asked of the model on the last turn, in place of one more exploration turn.
qpismont marked this conversation as resolved Outdated
Outdated
Review

messages.clone() et tool_definitions.clone() à chaque itération recopient toute la conversation à chaque tour (coût O(n²)) alors qu'un passage par référence (&[Message]) suffirait. Par ailleurs rien ne borne la taille du contenu renvoyé par les outils : ajouter un plafond par appel pour maîtriser le contexte et le coût.

`messages.clone()` et `tool_definitions.clone()` à chaque itération recopient toute la conversation à chaque tour (coût O(n²)) alors qu'un passage par référence (`&[Message]`) suffirait. Par ailleurs rien ne borne la taille du contenu renvoyé par les outils : ajouter un plafond par appel pour maîtriser le contexte et le coût.
///
/// Without it, a model still exploring on its last turn would ask for another
/// tool, and the run would end on the iteration budget with no answer at all.
const FINAL_PROMPT: &str = "
You are out of turns: this is your last one. Answer now, with the requested
format, from what you have already gathered, without calling any tool.
";
/// Final output of an agent run.
pub struct AgentResult {
pub message: String,
pub struct AgentResult<A> {
/// Answer of the model, parsed into the type the caller asked for.
pub answer: A,
pub cost: Option<f64>,
pub iterations: usize,
}
@@ -26,25 +66,64 @@ pub struct AgentResult {
///
/// `tool_definitions` is the set of tools the model may call; it is selected by
/// the caller based on the webhook action (see [`tools::for_webhook`]).
pub async fn run(
///
/// The answer of the model is parsed into `A`, which is how the caller states the
/// format it expects: while [`FromStr`] rejects the answer, the error is sent back
/// to the model so that it can correct itself, which costs an iteration. The
/// parsing happens here because that is where the conversation and the remaining
/// budget are at hand.
pub async fn run<A>(
open_router: &OpenRouterClient,
sandbox: &Sandbox,
tool_definitions: Vec<Tool>,
system_prompt: &str,
user_prompt: &str,
max_iterations: usize,
) -> anyhow::Result<AgentResult> {
) -> anyhow::Result<AgentResult<A>>
where
A: FromStr,
A::Err: std::fmt::Display,
{
let mut messages = vec![
Message::new(Role::System, system_prompt),
Message::new(Role::User, user_prompt),
];
info!(
tools = %tool_definitions
.iter()
.map(|tool| tool.function.name.as_str())
.collect::<Vec<_>>()
.join(", "),
max_iterations,
"Starting tool-calling loop"
);
let mut total_cost = 0.0_f64;
let mut has_cost = false;
let mut last_rejection: Option<String> = None;
for iteration in 1..=max_iterations {
// Le dernier tour n'est plus un tour d'exploration : le modèle doit rendre
// sa réponse, avec ce qu'il a déjà vu.
let last = iteration == max_iterations;
if last {
info!(iteration, "Last turn: asking for the final answer");
messages.push(Message::new(Role::User, FINAL_PROMPT));
}
let started = Instant::now();
let response = open_router
.chat_with_tools(messages.clone(), tool_definitions.clone())
.chat_with_tools(
&messages,
&tool_definitions,
if last {
ToolChoice::None
} else {
ToolChoice::Auto
},
)
.await?;
if let Some(cost) = response.cost {
@@ -52,12 +131,44 @@ pub async fn run(
has_cost = true;
}
if response.tool_calls.is_empty() {
return Ok(AgentResult {
message: response.message.unwrap_or_default(),
cost: has_cost.then_some(total_cost),
iterations: iteration,
});
info!(
iteration,
max_iterations,
last_turn = last,
tool_calls = response.tool_calls.len(),
answer_bytes = response.message.as_deref().map_or(0, str::len),
elapsed_ms = started.elapsed().as_millis() as u64,
"Model answered"
);
// Au dernier tour, les outils sont refusés : même si le fournisseur les
// renvoie malgré tout, on ne les exécute pas et on tente la réponse.
if last || response.tool_calls.is_empty() {
let answer = response.message.unwrap_or_default();
match answer.parse::<A>() {
Ok(parsed) => {
return Ok(AgentResult {
answer: parsed,
cost: has_cost.then_some(total_cost),
iterations: iteration,
});
}
Err(err) => {
warn!(iteration, %err, "Model answer was rejected, asking for another one");
// Keep the rejected answer in the history, so that the model
// sees what it has to fix.
messages.push(Message::new(Role::Assistant, answer));
messages.push(Message::new(
Role::User,
RETRY_PROMPT.replace("{error}", &err.to_string()),
));
last_rejection = Some(err.to_string());
continue;
}
}
}
messages.push(Message::assistant_with_tool_calls(
@@ -66,28 +177,71 @@ pub async fn run(
));
for call in &response.tool_calls {
debug!(tool = call.name(), "Executing tool call");
let content = execute(sandbox, call).await;
let started = Instant::now();
let (content, truncated) = execute(sandbox, call).await;
info!(
iteration,
tool = call.name(),
arguments = %excerpt(call.arguments_json(), MAX_LOGGED_ARGUMENTS),
output_bytes = content.len(),
truncated,
// Les sauts de ligne casseraient la lisibilité d'une ligne de log.
output = %excerpt(&content, MAX_LOGGED_OUTPUT).replace('\n', " "),
elapsed_ms = started.elapsed().as_millis() as u64,
"Tool call finished"
);
messages.push(Message::tool_response(call.id(), content));
}
}
warn!(max_iterations, "Agent reached the iteration limit");
anyhow::bail!("agent exceeded the maximum number of iterations ({max_iterations})")
warn!(
max_iterations,
"Agent used its last turn without a valid answer"
);
match last_rejection {
Some(error) => anyhow::bail!(
"agent exceeded the maximum number of iterations ({max_iterations}), \
last answer rejected: {error}"
),
None => anyhow::bail!("agent exceeded the maximum number of iterations ({max_iterations})"),
}
}
async fn execute(sandbox: &Sandbox, call: &ToolCall) -> String {
/// Runs a tool call and returns its result for the model, along with whether the
/// result had to be truncated.
async fn execute(sandbox: &Sandbox, call: &ToolCall) -> (String, bool) {
let args = match parse_args(call) {
Ok(args) => args,
Err(err) => return format!("error: {err}"),
Err(err) => return cap_output(format!("error: {err}")),
};
match tools::dispatch(sandbox, call.name(), &args).await {
Ok(output) => output,
Err(err) => format!("error: {err}"),
Ok(output) => cap_output(output),
Err(err) => cap_output(format!("error: {err}")),
}
}
/// Bounds [`MAX_TOOL_OUTPUT_BYTES`] of a tool result, telling the model what was
/// dropped so that it can narrow its request.
fn cap_output(mut output: String) -> (String, bool) {
let total = output.len();
if !truncate_bytes(&mut output, MAX_TOOL_OUTPUT_BYTES) {
return (output, false);
}
let kept = output.len();
output.push_str(&format!(
"\n… output truncated: {total} bytes in total, the first {kept} are shown. \
Narrow the request (path, pattern or line range) to see the rest."
));
(output, true)
}
fn parse_args(call: &ToolCall) -> anyhow::Result<Value> {
let raw = call.arguments_json().trim();
if raw.is_empty() {
@@ -134,4 +288,29 @@ mod tests {
let call = tool_call("ls", "not json");
assert!(parse_args(&call).is_err());
}
#[test]
fn a_small_tool_output_is_kept_as_is() {
let (content, truncated) = cap_output(String::from("src/main.rs"));
assert_eq!(content, "src/main.rs");
assert!(!truncated);
}
#[test]
fn a_large_tool_output_is_truncated_and_told_to_the_model() {
let (content, truncated) = cap_output("a".repeat(MAX_TOOL_OUTPUT_BYTES + 1));
assert!(truncated);
assert!(content.starts_with(&"a".repeat(MAX_TOOL_OUTPUT_BYTES)));
assert!(content.contains("output truncated"));
}
#[test]
fn a_truncated_tool_output_stays_valid_utf8() {
let (content, truncated) = cap_output("é".repeat(MAX_TOOL_OUTPUT_BYTES));
assert!(truncated);
assert!(content.starts_with('é'));
}
}
+101 -3
View File
3
@@ -25,7 +25,7 @@ const DEVCONTAINER_PATHS: [&str; 2] = [".devcontainer/devcontainer.json", ".devc
/// Sandbox-related runtime configuration.
#[derive(Clone)]
pub struct SandboxConfig {
/// Container runtime binary to drive (e.g. `docker`, `podman`).
/// 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,
@@ -35,6 +35,8 @@ pub struct SandboxConfig {
pub struct Sandbox {
// Owns the temporary directory; dropping it cleans up the clone.
_workspace: TempDir,
/// Path of the clone, as bind-mounted into the container.
repo_dir: PathBuf,
container: Container,
}
@@ -54,6 +56,7 @@ impl Sandbox {
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}`"))?;
@@ -63,10 +66,79 @@ impl Sandbox {
info!(image = %devcontainer.image_tag(), "Building and starting sandbox container");
let container = devcontainer.up(runtime, &repo_dir).await?;
Ok(Self {
let sandbox = Self {
_workspace: workspace,
repo_dir,
container,
})
};
sandbox.check_workspace().await?;
Review

[Élevé] Si check_workspace() échoue, le ? abandonne Sandbox après le démarrage du conteneur, mais Container n'a pas de Drop capable de supprimer les ressources asynchrones. Le conteneur, le réseau et l'image restent donc potentiellement sur le daemon à chaque échec de vérification. Nettoyer explicitement le conteneur avant de retourner l'erreur, avec conservation de l'erreur initiale même si le nettoyage échoue.

[Élevé] Si `check_workspace()` échoue, le `?` abandonne `Sandbox` après le démarrage du conteneur, mais `Container` n'a pas de `Drop` capable de supprimer les ressources asynchrones. Le conteneur, le réseau et l'image restent donc potentiellement sur le daemon à chaque échec de vérification. Nettoyer explicitement le conteneur avant de retourner l'erreur, avec conservation de l'erreur initiale même si le nettoyage échoue.
Ok(sandbox)
}
/// Vérifie que le clone est bien visible dans le container.
///
/// Sans ce contrôle, un montage vide — le daemon ne voit pas le clone, par
/// exemple quand Herald est dans un container sur une autre machine — 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() {
qpismont marked this conversation as resolved Outdated
Outdated
Review

Le chemin dest est converti en chaîne via display(), ce qui peut planter si le chemin contient des caractères non-UTF-8. Dans un contexte Linux, c’est très rare, mais il serait plus sûr d’utiliser to_string_lossy().

Le chemin `dest` est converti en chaîne via `display()`, ce qui peut planter si le chemin contient des caractères non-UTF-8. Dans un contexte Linux, c’est très rare, mais il serait plus sûr d’utiliser `to_string_lossy()`.
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).
1
@@ -141,6 +213,32 @@ async fn clone_pull_request(
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<()> {
+81
View File
@@ -0,0 +1,81 @@
//! Small text helpers shared by the modules that log what the model answered and
//! bound what the tools return to it.
/// First `limit` characters of `text`, for logs.
///
/// Cuts on character boundaries, so the excerpt stays valid UTF-8, and marks a
/// truncation with an ellipsis.
pub fn excerpt(text: &str, limit: usize) -> String {
let mut chars = text.chars();
let excerpt = chars.by_ref().take(limit).collect::<String>();
if chars.next().is_some() {
return format!("{excerpt}…");
}
excerpt
}
/// Truncates `text` in place to at most `limit` bytes, cutting on a character
/// boundary so the result stays valid UTF-8.
///
/// Returns `true` when something was dropped.
pub fn truncate_bytes(text: &mut String, limit: usize) -> bool {
if text.len() <= limit {
return false;
}
let mut end = limit;
while !text.is_char_boundary(end) {
end -= 1;
}
text.truncate(end);
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_short_text_is_kept_as_is() {
assert_eq!(excerpt("abc", 3), "abc");
}
#[test]
fn a_long_text_is_truncated_and_marked() {
assert_eq!(excerpt("abcdef", 3), "abc…");
}
#[test]
fn truncation_cuts_on_character_boundaries() {
assert_eq!(excerpt("ééé", 2), "éé…");
}
#[test]
fn a_short_text_is_not_truncated() {
let mut text = String::from("abc");
assert!(!truncate_bytes(&mut text, 3));
assert_eq!(text, "abc");
}
#[test]
fn a_long_text_is_truncated_within_the_limit() {
let mut text = String::from("abcdef");
assert!(truncate_bytes(&mut text, 4));
assert_eq!(text, "abcd");
}
#[test]
fn byte_truncation_never_splits_a_character() {
let mut text = String::from("ééé");
// The limit falls in the middle of the second `é`: it is dropped.
assert!(truncate_bytes(&mut text, 3));
assert_eq!(text, "é");
}
}