mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-19 13:08:15 +02:00
Lot 7 : outils fichiers et shell exécutés par la façade
Quand la façade tient la conversation (LOKI_CHAT=rust), read, grep, glob, write, edit, bash et ask s'exécutent dans rust/loki-rs/src/tools au lieu de repartir au Go par /internal/v1/tool/exec : mêmes garde-fous dans le même ordre (commande catastrophique, « & » final en mode Code, bac à sable du mode Code, dossier mémoire interdit, lu avant d'écrire), même diff pour l'UI, même nettoyage de la sortie du shell. Le jeton de l'API interne est retiré de l'environnement du shell de l'agent. L'état partagé reste au Go, en un seul exemplaire (sous-agents et tâches le lisent aussi) : POST /internal/v1/tool/context (dossier de travail, dossier mémoire, cible distante), /tool/tracker (lecture notée, contrôle avant écriture), /tool/wrote (note après écriture, diagnostics LSP). Repli sur le Go pour une cible distante ou un contexte indisponible. Parité : internal/loki/parity_tools_test.go passe 58 appels à execTool sur une arborescence de test et range le journal dans tests/parity_tools ; tests/tools.rs le retrouve à l'identique avec exec_local. Écarts assumés consignés dans docs/superpowers/specs/2026-10-15-rust-migration-lot7-outils-design.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
This commit is contained in:
18 files changed
+3560
-2
No files matched your search
@@ -2,8 +2,10 @@
|
||||
//!
|
||||
//! - envoi au moteur par [`EngineHttp`] (clé de CET endpoint), pannes
|
||||
//! classées et formulées comme `friendlyLLMError` ;
|
||||
//! - outils exécutés par le Go (`/internal/v1/tool/exec`), avec ce qu'il tire
|
||||
//! du résultat (capture de la bulle, aperçu du navigateur, image relayée) ;
|
||||
//! - outils fichiers et shell exécutés ici ([`crate::tools`], état partagé
|
||||
//! tenu par le Go) ; les autres par le Go (`/internal/v1/tool/exec`), avec
|
||||
//! ce qu'il tire du résultat (capture de la bulle, aperçu du navigateur,
|
||||
//! image relayée) ;
|
||||
//! - résultats complets (« voir plus ») confiés à l'écrivain des discussions ;
|
||||
//! - compaction par [`Compactor`] : résumé demandé au moteur
|
||||
//! ([`EngineSummarizer`]), blocs archivés pour recall par le Go
|
||||
@@ -390,6 +392,21 @@ impl TurnHost for GoTurnHost {
|
||||
}
|
||||
|
||||
async fn exec_tool(&self, name: &str, args: &Map<String, Value>, label: &str) -> ToolOutcome {
|
||||
// Outils fichiers et shell : ici (lot 7), l'état partagé restant au Go.
|
||||
if crate::tools::is_local(name) {
|
||||
let st = crate::tools::GoToolState {
|
||||
store: self.shared.store.clone(),
|
||||
caps: self.caps.clone(),
|
||||
};
|
||||
let code = self
|
||||
.caps
|
||||
.get("Code")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
if let Some(out) = crate::tools::exec_local(&st, code, name, args, label).await {
|
||||
return out;
|
||||
}
|
||||
}
|
||||
match self
|
||||
.shared
|
||||
.store
|
||||
|
||||
@@ -24,5 +24,6 @@ pub mod presets;
|
||||
pub mod proxy;
|
||||
pub mod store;
|
||||
pub mod sysinfo;
|
||||
pub mod tools;
|
||||
pub mod ui;
|
||||
pub mod update;
|
||||
@@ -0,0 +1,188 @@
|
||||
//! Petit diff ligne à ligne pour l'interface — port de `chat_diff.go`.
|
||||
//!
|
||||
//! Quand l'agent modifie un fichier (`edit`) ou en crée un (`write`), l'UI
|
||||
//! affiche les lignes ajoutées (vert) et retirées (rouge). LCS classique sur
|
||||
//! les lignes, avec des garde-fous pour ne jamais transformer un gros
|
||||
//! remplacement en pavé. Les compteurs +N / -N sont calculés AVANT la coupe
|
||||
//! des lignes affichées.
|
||||
|
||||
use crate::chat::types::DiffLine;
|
||||
|
||||
/// Au-delà (partie modifiée), comparaison sans détail (LCS trop lente).
|
||||
const DIFF_MAX_LINES: usize = 400;
|
||||
/// Lignes envoyées à l'UI (le reste est résumé).
|
||||
const DIFF_MAX_SHOWN: usize = 120;
|
||||
/// Lignes identiques gardées autour d'un changement.
|
||||
const DIFF_CONTEXT: usize = 3;
|
||||
const DIFF_OMIT_PREFIX: &str = "…";
|
||||
|
||||
fn line(op: &str, text: &str) -> DiffLine {
|
||||
DiffLine {
|
||||
op: op.into(),
|
||||
text: text.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `lineDiff` : lignes à afficher (bornées), lignes ajoutées, retirées.
|
||||
pub fn line_diff(old_text: &str, new_text: &str) -> (Vec<DiffLine>, i64, i64) {
|
||||
let a = split_lines(old_text);
|
||||
let b = split_lines(new_text);
|
||||
let mut pre = 0;
|
||||
while pre < a.len() && pre < b.len() && a[pre] == b[pre] {
|
||||
pre += 1;
|
||||
}
|
||||
let mut suf = 0;
|
||||
while suf < a.len() - pre && suf < b.len() - pre && a[a.len() - 1 - suf] == b[b.len() - 1 - suf]
|
||||
{
|
||||
suf += 1;
|
||||
}
|
||||
let (ma, mb) = (&a[pre..a.len() - suf], &b[pre..b.len() - suf]);
|
||||
let mid: Vec<DiffLine> = if ma.len() > DIFF_MAX_LINES || mb.len() > DIFF_MAX_LINES {
|
||||
ma.iter()
|
||||
.map(|l| line("-", l))
|
||||
.chain(mb.iter().map(|l| line("+", l)))
|
||||
.collect()
|
||||
} else {
|
||||
lcs_diff(ma, mb)
|
||||
};
|
||||
let added = mid.iter().filter(|d| d.op == "+").count() as i64;
|
||||
let removed = mid.iter().filter(|d| d.op == "-").count() as i64;
|
||||
|
||||
let mut out = Vec::new();
|
||||
if pre > DIFF_CONTEXT {
|
||||
out.push(line(" ", &omitted(pre - DIFF_CONTEXT)));
|
||||
}
|
||||
for l in &a[pre.saturating_sub(DIFF_CONTEXT)..pre] {
|
||||
out.push(line(" ", l));
|
||||
}
|
||||
out.extend(mid);
|
||||
for l in &a[a.len() - suf..a.len() - suf + suf.min(DIFF_CONTEXT)] {
|
||||
out.push(line(" ", l));
|
||||
}
|
||||
if suf > DIFF_CONTEXT {
|
||||
out.push(line(" ", &omitted(suf - DIFF_CONTEXT)));
|
||||
}
|
||||
(cap_lines(out), added, removed)
|
||||
}
|
||||
|
||||
/// Diff exact par plus longue sous-séquence commune.
|
||||
fn lcs_diff(a: &[String], b: &[String]) -> Vec<DiffLine> {
|
||||
let (n, m) = (a.len(), b.len());
|
||||
let mut lcs = vec![vec![0usize; m + 1]; n + 1];
|
||||
for i in (0..n).rev() {
|
||||
for j in (0..m).rev() {
|
||||
lcs[i][j] = if a[i] == b[j] {
|
||||
lcs[i + 1][j + 1] + 1
|
||||
} else {
|
||||
lcs[i + 1][j].max(lcs[i][j + 1])
|
||||
};
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let (mut i, mut j) = (0, 0);
|
||||
while i < n && j < m {
|
||||
if a[i] == b[j] {
|
||||
out.push(line(" ", &a[i]));
|
||||
i += 1;
|
||||
j += 1;
|
||||
} else if lcs[i + 1][j] >= lcs[i][j + 1] {
|
||||
out.push(line("-", &a[i]));
|
||||
i += 1;
|
||||
} else {
|
||||
out.push(line("+", &b[j]));
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
out.extend(a[i..].iter().map(|l| line("-", l)));
|
||||
out.extend(b[j..].iter().map(|l| line("+", l)));
|
||||
out
|
||||
}
|
||||
|
||||
/// `addedDiff` : contenu entièrement nouveau, toutes ses lignes en ajout.
|
||||
pub fn added_diff(text: &str) -> (Vec<DiffLine>, i64) {
|
||||
let ls = split_lines(text);
|
||||
let n = ls.len() as i64;
|
||||
(cap_lines(ls.iter().map(|l| line("+", l)).collect()), n)
|
||||
}
|
||||
|
||||
/// Un saut de ligne final termine la dernière ligne, il n'en ouvre pas une
|
||||
/// vide. Les fins CRLF comptent comme LF.
|
||||
fn split_lines(s: &str) -> Vec<String> {
|
||||
let s = s.replace("\r\n", "\n");
|
||||
let s = s.strip_suffix('\n').unwrap_or(&s);
|
||||
if s.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
s.split('\n').map(str::to_string).collect()
|
||||
}
|
||||
|
||||
fn omitted(n: usize) -> String {
|
||||
if n == 1 {
|
||||
format!("{DIFF_OMIT_PREFIX}(1 ligne identique)")
|
||||
} else {
|
||||
format!("{DIFF_OMIT_PREFIX}({n} lignes identiques)")
|
||||
}
|
||||
}
|
||||
|
||||
fn cap_lines(mut d: Vec<DiffLine>) -> Vec<DiffLine> {
|
||||
if d.len() <= DIFF_MAX_SHOWN {
|
||||
return d;
|
||||
}
|
||||
let cut = d.len() - DIFF_MAX_SHOWN;
|
||||
d.truncate(DIFF_MAX_SHOWN);
|
||||
d.push(line(
|
||||
" ",
|
||||
&format!("{DIFF_OMIT_PREFIX}({cut} lignes de plus)"),
|
||||
));
|
||||
d
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ops(d: &[DiffLine]) -> Vec<String> {
|
||||
d.iter().map(|l| format!("{}{}", l.op, l.text)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn une_ligne_changee_dans_un_gros_bloc() {
|
||||
let old: String = (1..=20).map(|i| format!("l{i}\n")).collect();
|
||||
let new = old.replace("l10\n", "dix\n");
|
||||
let (d, a, r) = line_diff(&old, &new);
|
||||
assert_eq!((a, r), (1, 1));
|
||||
assert_eq!(
|
||||
ops(&d),
|
||||
[
|
||||
" …(6 lignes identiques)",
|
||||
" l7",
|
||||
" l8",
|
||||
" l9",
|
||||
"-l10",
|
||||
"+dix",
|
||||
" l11",
|
||||
" l12",
|
||||
" l13",
|
||||
" …(7 lignes identiques)"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ajout_et_coupe() {
|
||||
let text: String = (0..130).map(|i| format!("{i}\n")).collect();
|
||||
let (d, n) = added_diff(&text);
|
||||
assert_eq!(n, 130);
|
||||
assert_eq!(d.len(), 121);
|
||||
assert_eq!(d[120].text, "…(10 lignes de plus)");
|
||||
assert_eq!(added_diff("").1, 0);
|
||||
assert_eq!(added_diff("a\r\nb\r\n").1, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gros_remplacement_sans_lcs() {
|
||||
let old: String = (0..401).map(|i| format!("a{i}\n")).collect();
|
||||
let (_, a, r) = line_diff(&old, "b\n");
|
||||
assert_eq!((a, r), (1, 401));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,785 @@
|
||||
//! Outils fichiers de l'agent — port de `code_tools.go` (read, grep, glob,
|
||||
//! ask) et de `fileWrite` / `fileEdit` (`chat_tools.go`).
|
||||
//!
|
||||
//! Fonctions synchrones (disque) : l'appelant les lance hors du runtime
|
||||
//! (`spawn_blocking`) et se charge de ce qui est partagé avec le Go (suivi
|
||||
//! « lu avant d'écrire », diagnostics LSP — voir `tools::exec_local`).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, LazyLock, Mutex};
|
||||
|
||||
use regex::Regex;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use super::policy::{
|
||||
code_path_allowed, guard_tool_only_path, refused_path_result, resolve_agent_path, to_slash,
|
||||
};
|
||||
|
||||
/// Lignes par appel (offset/limit pour la suite).
|
||||
pub const READ_MAX_LINES: usize = 1000;
|
||||
pub const READ_DEF_LINES: usize = 400;
|
||||
/// Une ligne minifiée ne doit pas manger le contexte.
|
||||
const READ_MAX_LINE_LEN: usize = 500;
|
||||
const GREP_MAX_HITS: usize = 60;
|
||||
const GLOB_MAX_HITS: usize = 200;
|
||||
|
||||
/// `skipDirs` : dépendances et artefacts, jamais du code à lire.
|
||||
const SKIP_DIRS: [&str; 11] = [
|
||||
".git",
|
||||
"node_modules",
|
||||
"vendor",
|
||||
"dist",
|
||||
"build",
|
||||
"target",
|
||||
"__pycache__",
|
||||
".venv",
|
||||
"venv",
|
||||
".next",
|
||||
".cache",
|
||||
];
|
||||
|
||||
fn skip_dir(name: &str) -> bool {
|
||||
SKIP_DIRS.contains(&name)
|
||||
}
|
||||
|
||||
/// Texte d'erreur du système à la manière de Go (`no such file or
|
||||
/// directory`) : le message de l'OS, sans « (os error N) », minuscule en tête
|
||||
/// hors Windows.
|
||||
pub fn os_err_text(e: &std::io::Error) -> String {
|
||||
let s = e.to_string();
|
||||
let s = match s.rfind(" (os error ") {
|
||||
Some(i) => s[..i].to_string(),
|
||||
None => s,
|
||||
};
|
||||
if cfg!(windows) {
|
||||
return s;
|
||||
}
|
||||
let mut c = s.chars();
|
||||
match c.next() {
|
||||
Some(f) => f.to_lowercase().chain(c).collect(),
|
||||
None => s,
|
||||
}
|
||||
}
|
||||
|
||||
/// `*PathError` de Go : « op chemin: texte ».
|
||||
fn path_err(op: &str, p: &Path, e: &std::io::Error) -> String {
|
||||
format!("[erreur] {op} {}: {}", p.display(), os_err_text(e))
|
||||
}
|
||||
|
||||
/// `os.ReadFile`, erreurs au format Go (`open …` / `read …`).
|
||||
fn read_file(p: &Path) -> Result<Vec<u8>, String> {
|
||||
let mut f = fs::File::open(p).map_err(|e| path_err("open", p, &e))?;
|
||||
let mut b = Vec::new();
|
||||
f.read_to_end(&mut b).map_err(|e| path_err("read", p, &e))?;
|
||||
Ok(b)
|
||||
}
|
||||
|
||||
/// `isBinary` : un NUL dans les premiers 8 Kio.
|
||||
pub fn is_binary(b: &[u8]) -> bool {
|
||||
b[..b.len().min(8192)].contains(&0)
|
||||
}
|
||||
|
||||
/// Ligne bornée à `READ_MAX_LINE_LEN` caractères (suivie de « … » si coupée).
|
||||
fn cap_line(l: &str) -> String {
|
||||
match l.char_indices().nth(READ_MAX_LINE_LEN) {
|
||||
Some((i, _)) => format!("{}…", &l[..i]),
|
||||
None => l.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Entier positif d'un argument JSON (`args["x"].(float64)` puis `int(v)`).
|
||||
pub fn arg_int(args: &Map<String, Value>, k: &str) -> Option<i64> {
|
||||
args.get(k).and_then(Value::as_f64).map(|v| v as i64)
|
||||
}
|
||||
|
||||
pub fn arg_str<'a>(args: &'a Map<String, Value>, k: &str) -> &'a str {
|
||||
args.get(k).and_then(Value::as_str).unwrap_or("")
|
||||
}
|
||||
|
||||
/// Ce qu'un outil fichier rend : le résultat, et le chemin LU à noter dans
|
||||
/// le suivi (read réussi).
|
||||
pub struct ReadOut {
|
||||
pub result: String,
|
||||
pub noted: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// `toolRead` : fichier avec numéros de ligne.
|
||||
pub fn tool_read(cwd: &Path, args: &Map<String, Value>, code: bool) -> ReadOut {
|
||||
let err = |result: String| ReadOut {
|
||||
result,
|
||||
noted: None,
|
||||
};
|
||||
let file = arg_str(args, "file");
|
||||
if file.trim().is_empty() {
|
||||
return err("[erreur] chemin vide".into());
|
||||
}
|
||||
let path = resolve_agent_path(cwd, file);
|
||||
if code && !code_path_allowed(cwd, &path) {
|
||||
return err(refused_path_result(file));
|
||||
}
|
||||
let offset = arg_int(args, "offset").filter(|v| *v > 0).unwrap_or(1) as usize;
|
||||
let limit = (arg_int(args, "limit")
|
||||
.filter(|v| *v > 0)
|
||||
.unwrap_or(READ_DEF_LINES as i64) as usize)
|
||||
.min(READ_MAX_LINES);
|
||||
let b = match read_file(&path) {
|
||||
Ok(b) => b,
|
||||
Err(e) => return err(e),
|
||||
};
|
||||
if is_binary(&b) {
|
||||
let size = fs::metadata(&path)
|
||||
.map(|m| m.len())
|
||||
.unwrap_or(b.len() as u64);
|
||||
return err(format!(
|
||||
"[binaire] {file} ({size} octets) — pas un fichier texte"
|
||||
));
|
||||
}
|
||||
let text = String::from_utf8_lossy(&b);
|
||||
let lines: Vec<&str> = text.split('\n').collect();
|
||||
let mut total = lines.len();
|
||||
// Fichier fini par \n : la dernière entrée vide n'est pas une ligne.
|
||||
if total > 0 && lines[total - 1].is_empty() {
|
||||
total -= 1;
|
||||
}
|
||||
if offset > total {
|
||||
return err(format!("[erreur] offset {offset} > {total} lignes"));
|
||||
}
|
||||
let end = (offset - 1 + limit).min(total);
|
||||
let mut out = String::new();
|
||||
for (i, l) in lines.iter().enumerate().take(end).skip(offset - 1) {
|
||||
out.push_str(&format!("{}: {}\n", i + 1, cap_line(l)));
|
||||
}
|
||||
if end < total {
|
||||
out.push_str(&format!(
|
||||
"… ({} lignes restantes — relis avec offset={})\n",
|
||||
total - end,
|
||||
end + 1
|
||||
));
|
||||
}
|
||||
ReadOut {
|
||||
result: out.trim_end_matches('\n').to_string(),
|
||||
noted: Some(path),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parcours à la `filepath.WalkDir` : ordre lexical, liens non suivis,
|
||||
/// dossiers ignorés (`skipDirs`, la racine comprise). `visit` rend `false`
|
||||
/// pour tout arrêter.
|
||||
fn walk(root: &Path, visit: &mut dyn FnMut(&Path, &fs::Metadata) -> bool) {
|
||||
let Ok(meta) = fs::symlink_metadata(root) else {
|
||||
return;
|
||||
};
|
||||
walk_entry(root, &meta, visit);
|
||||
}
|
||||
|
||||
fn walk_entry(
|
||||
path: &Path,
|
||||
meta: &fs::Metadata,
|
||||
visit: &mut dyn FnMut(&Path, &fs::Metadata) -> bool,
|
||||
) -> bool {
|
||||
if !meta.is_dir() {
|
||||
return visit(path, meta);
|
||||
}
|
||||
if path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.is_some_and(skip_dir)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
let Ok(rd) = fs::read_dir(path) else {
|
||||
return true; // dossier illisible : on continue ailleurs
|
||||
};
|
||||
let mut entries: Vec<(String, PathBuf)> = rd
|
||||
.filter_map(Result::ok)
|
||||
.map(|e| (e.file_name().to_string_lossy().into_owned(), e.path()))
|
||||
.collect();
|
||||
entries.sort();
|
||||
for (_, p) in entries {
|
||||
let Ok(m) = fs::symlink_metadata(&p) else {
|
||||
continue;
|
||||
};
|
||||
if !walk_entry(&p, &m, visit) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Chemin relatif à la racine du parcours, en `/` (`.` pour la racine).
|
||||
fn rel_slash(root: &Path, p: &Path) -> String {
|
||||
match p.strip_prefix(root) {
|
||||
Ok(r) if r.as_os_str().is_empty() => ".".into(),
|
||||
Ok(r) => to_slash(r),
|
||||
Err(_) => to_slash(p),
|
||||
}
|
||||
}
|
||||
|
||||
/// `toolGrep` : motif regexp dans les fichiers.
|
||||
pub fn tool_grep(cwd: &Path, args: &Map<String, Value>, code: bool) -> String {
|
||||
let pattern = arg_str(args, "pattern");
|
||||
if pattern.trim().is_empty() {
|
||||
return "[erreur] motif vide".into();
|
||||
}
|
||||
let re = match Regex::new(pattern) {
|
||||
Ok(re) => re,
|
||||
Err(e) => return format!("[erreur] motif invalide : {e}"),
|
||||
};
|
||||
let p = arg_str(args, "path");
|
||||
let root = if !p.trim().is_empty() && p != "." {
|
||||
resolve_agent_path(cwd, p)
|
||||
} else {
|
||||
cwd.to_path_buf()
|
||||
};
|
||||
if code && !code_path_allowed(cwd, &root) {
|
||||
return refused_path_result(&root.display().to_string());
|
||||
}
|
||||
let glob_pat = arg_str(args, "glob");
|
||||
let mut hits: Vec<String> = Vec::new();
|
||||
walk(&root, &mut |path, _| {
|
||||
if !glob_pat.is_empty() {
|
||||
let name = path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
if !go_match(glob_pat, &name).unwrap_or(false) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if hits.len() >= GREP_MAX_HITS {
|
||||
return false;
|
||||
}
|
||||
let Ok(b) = fs::read(path) else {
|
||||
return true;
|
||||
};
|
||||
if is_binary(&b) {
|
||||
return true;
|
||||
}
|
||||
let rel = rel_slash(&root, path);
|
||||
let text = String::from_utf8_lossy(&b);
|
||||
for (i, line) in text.split('\n').enumerate() {
|
||||
if re.is_match(line) {
|
||||
hits.push(format!("{rel}:{}: {}", i + 1, cap_line(line).trim()));
|
||||
if hits.len() >= GREP_MAX_HITS {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
});
|
||||
if hits.is_empty() {
|
||||
return "[aucun résultat]".into();
|
||||
}
|
||||
let mut out = hits.join("\n");
|
||||
if hits.len() >= GREP_MAX_HITS {
|
||||
out.push_str(&format!(
|
||||
"\n… (plafond de {GREP_MAX_HITS} résultats atteint — affine le motif)"
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `toolGlob` : fichiers d'un motif (`**` compris), plus récents d'abord.
|
||||
pub fn tool_glob(cwd: &Path, args: &Map<String, Value>) -> String {
|
||||
let pattern = arg_str(args, "pattern");
|
||||
if pattern.trim().is_empty() {
|
||||
return "[erreur] motif vide".into();
|
||||
}
|
||||
let re = match glob_to_regex(pattern) {
|
||||
Ok(re) => re,
|
||||
Err(e) => return format!("[erreur] motif invalide : {e}"),
|
||||
};
|
||||
let mut hits: Vec<(String, std::time::SystemTime)> = Vec::new();
|
||||
walk(cwd, &mut |path, meta| {
|
||||
let rel = rel_slash(cwd, path);
|
||||
if re.is_match(&rel) {
|
||||
hits.push((rel, meta.modified().unwrap_or(std::time::UNIX_EPOCH)));
|
||||
}
|
||||
true
|
||||
});
|
||||
if hits.is_empty() {
|
||||
return "[aucun fichier]".into();
|
||||
}
|
||||
hits.sort_by_key(|h| std::cmp::Reverse(h.1));
|
||||
hits.truncate(GLOB_MAX_HITS);
|
||||
hits.into_iter().map(|h| h.0).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
|
||||
/// `globToRegexp` : glob (avec `**`) → expression sur des chemins en `/`.
|
||||
/// `**` = n'importe quelle profondeur, `*` = tout sauf `/`, `?` = un
|
||||
/// caractère sauf `/`.
|
||||
pub fn glob_to_regex(pattern: &str) -> Result<Regex, regex::Error> {
|
||||
let p = if cfg!(windows) {
|
||||
pattern.replace('\\', "/")
|
||||
} else {
|
||||
pattern.to_string()
|
||||
};
|
||||
let p = p.strip_prefix("./").unwrap_or(&p);
|
||||
let mut b = String::from("^");
|
||||
let mut rest = p;
|
||||
while let Some(c) = rest.chars().next() {
|
||||
if rest.starts_with("**/") {
|
||||
b.push_str("(?:[^/]+/)*");
|
||||
rest = &rest[3..];
|
||||
continue;
|
||||
}
|
||||
if rest.starts_with("**") {
|
||||
b.push_str(".*");
|
||||
rest = &rest[2..];
|
||||
continue;
|
||||
}
|
||||
match c {
|
||||
'*' => b.push_str("[^/]*"),
|
||||
'?' => b.push_str("[^/]"),
|
||||
'.' | '+' | '(' | ')' | '|' | '[' | ']' | '{' | '}' | '^' | '$' | '\\' => {
|
||||
b.push('\\');
|
||||
b.push(c);
|
||||
}
|
||||
_ => b.push(c),
|
||||
}
|
||||
rest = &rest[c.len_utf8()..];
|
||||
}
|
||||
b.push('$');
|
||||
Regex::new(&b)
|
||||
}
|
||||
|
||||
/// `filepath.Match` de Go (`*`, `?`, `[classes]`, `\` d'échappement hors
|
||||
/// Windows), sur un nom sans séparateur. `None` : motif mal formé.
|
||||
pub fn go_match(pattern: &str, name: &str) -> Option<bool> {
|
||||
let sep = std::path::MAIN_SEPARATOR;
|
||||
let mut pattern = pattern;
|
||||
let mut name = name;
|
||||
'pat: while !pattern.is_empty() {
|
||||
let (star, chunk, rest) = scan_chunk(pattern);
|
||||
pattern = rest;
|
||||
if star && chunk.is_empty() {
|
||||
return Some(!name.contains(sep));
|
||||
}
|
||||
let (t, ok, err) = match_chunk(chunk, name);
|
||||
if ok && (t.is_empty() || !pattern.is_empty()) {
|
||||
name = t;
|
||||
continue;
|
||||
}
|
||||
if err {
|
||||
return None;
|
||||
}
|
||||
if star {
|
||||
for (i, ch) in name.char_indices() {
|
||||
if ch == sep {
|
||||
break;
|
||||
}
|
||||
let skip = i + ch.len_utf8();
|
||||
// Go avance d'un OCTET ; sur du texte valide, les positions
|
||||
// intermédiaires d'un caractère ne peuvent rien faire correspondre.
|
||||
let (t, ok, err) = match_chunk(chunk, &name[skip..]);
|
||||
if ok {
|
||||
if pattern.is_empty() && !t.is_empty() {
|
||||
continue;
|
||||
}
|
||||
name = t;
|
||||
continue 'pat;
|
||||
}
|
||||
if err {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
while !pattern.is_empty() {
|
||||
let (_, chunk, rest) = scan_chunk(pattern);
|
||||
pattern = rest;
|
||||
if match_chunk(chunk, "").2 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
return Some(false);
|
||||
}
|
||||
Some(name.is_empty())
|
||||
}
|
||||
|
||||
fn scan_chunk(mut pattern: &str) -> (bool, &str, &str) {
|
||||
let mut star = false;
|
||||
while let Some(r) = pattern.strip_prefix('*') {
|
||||
pattern = r;
|
||||
star = true;
|
||||
}
|
||||
let b = pattern.as_bytes();
|
||||
let mut inrange = false;
|
||||
let mut i = 0;
|
||||
while i < b.len() {
|
||||
match b[i] {
|
||||
b'\\' if !cfg!(windows) => {
|
||||
if i + 1 < b.len() {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
b'[' => inrange = true,
|
||||
b']' => inrange = false,
|
||||
b'*' if !inrange => break,
|
||||
_ => {}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
(star, &pattern[..i], &pattern[i..])
|
||||
}
|
||||
|
||||
/// `matchChunk` : (reste du nom, correspond ?, motif mal formé ?).
|
||||
fn match_chunk<'a>(chunk: &str, s: &'a str) -> (&'a str, bool, bool) {
|
||||
let sep = std::path::MAIN_SEPARATOR;
|
||||
let mut chunk = chunk;
|
||||
let mut s = s;
|
||||
let mut failed = false;
|
||||
while let Some(c0) = chunk.chars().next() {
|
||||
if !failed && s.is_empty() {
|
||||
failed = true;
|
||||
}
|
||||
match c0 {
|
||||
'[' => {
|
||||
let mut r = '\0';
|
||||
if !failed {
|
||||
let ch = s.chars().next().unwrap_or('\0');
|
||||
r = ch;
|
||||
s = &s[ch.len_utf8()..];
|
||||
}
|
||||
chunk = &chunk[1..];
|
||||
let mut negated = false;
|
||||
if let Some(rest) = chunk.strip_prefix('^') {
|
||||
negated = true;
|
||||
chunk = rest;
|
||||
}
|
||||
let mut matched = false;
|
||||
let mut nrange = 0;
|
||||
loop {
|
||||
if nrange > 0
|
||||
&& let Some(rest) = chunk.strip_prefix(']')
|
||||
{
|
||||
chunk = rest;
|
||||
break;
|
||||
}
|
||||
let Some((lo, rest)) = get_esc(chunk) else {
|
||||
return ("", false, true);
|
||||
};
|
||||
chunk = rest;
|
||||
let mut hi = lo;
|
||||
if let Some(rest) = chunk.strip_prefix('-') {
|
||||
let Some((h, rest)) = get_esc(rest) else {
|
||||
return ("", false, true);
|
||||
};
|
||||
hi = h;
|
||||
chunk = rest;
|
||||
}
|
||||
if lo <= r && r <= hi {
|
||||
matched = true;
|
||||
}
|
||||
nrange += 1;
|
||||
}
|
||||
if matched == negated {
|
||||
failed = true;
|
||||
}
|
||||
}
|
||||
'?' => {
|
||||
if !failed {
|
||||
let ch = s.chars().next().unwrap_or('\0');
|
||||
if ch == sep {
|
||||
failed = true;
|
||||
}
|
||||
s = &s[ch.len_utf8()..];
|
||||
}
|
||||
chunk = &chunk[1..];
|
||||
}
|
||||
_ => {
|
||||
let mut lit = c0;
|
||||
if c0 == '\\' && !cfg!(windows) {
|
||||
chunk = &chunk[1..];
|
||||
match chunk.chars().next() {
|
||||
Some(c) => lit = c,
|
||||
None => return ("", false, true),
|
||||
}
|
||||
}
|
||||
if !failed {
|
||||
// Go compare OCTET par octet : même chose, caractère
|
||||
// entier contre caractère entier, sur du texte valide.
|
||||
match s.chars().next() {
|
||||
Some(c) if c == lit => s = &s[c.len_utf8()..],
|
||||
Some(c) => {
|
||||
failed = true;
|
||||
s = &s[c.len_utf8()..];
|
||||
}
|
||||
None => failed = true,
|
||||
}
|
||||
}
|
||||
chunk = &chunk[lit.len_utf8()..];
|
||||
}
|
||||
}
|
||||
}
|
||||
if failed {
|
||||
("", false, false)
|
||||
} else {
|
||||
(s, true, false)
|
||||
}
|
||||
}
|
||||
|
||||
/// `getEsc` : un caractère de classe (échappé ou non) ; `None` : motif mal
|
||||
/// formé (vide, `-` ou `]` en tête, ou rien après).
|
||||
fn get_esc(chunk: &str) -> Option<(char, &str)> {
|
||||
let mut chunk = chunk;
|
||||
match chunk.chars().next() {
|
||||
None | Some('-') | Some(']') => return None,
|
||||
Some('\\') if !cfg!(windows) => {
|
||||
chunk = &chunk[1..];
|
||||
chunk.chars().next()?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let r = chunk.chars().next()?;
|
||||
let rest = &chunk[r.len_utf8()..];
|
||||
if rest.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some((r, rest))
|
||||
}
|
||||
|
||||
/// Verrou par fichier : sérialise deux écritures du même fichier.
|
||||
fn file_mu(path: &Path) -> Arc<Mutex<()>> {
|
||||
static MAP: LazyLock<Mutex<HashMap<PathBuf, Arc<Mutex<()>>>>> =
|
||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
MAP.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.entry(super::policy::clean(path))
|
||||
.or_default()
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// `os.WriteFile` : écrit, crée au besoin (0644) ; un fichier existant garde
|
||||
/// son mode (un script 0755 réécrit reste exécutable).
|
||||
fn write_file(path: &Path, content: &[u8]) -> Result<(), String> {
|
||||
let mut opts = fs::OpenOptions::new();
|
||||
opts.write(true).create(true).truncate(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
opts.mode(0o644);
|
||||
}
|
||||
let mut f = opts.open(path).map_err(|e| path_err("open", path, &e))?;
|
||||
std::io::Write::write_all(&mut f, content).map_err(|e| path_err("write", path, &e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `fileWrite` : écrit le contenu tel quel, dossiers parents compris.
|
||||
pub fn file_write(cwd: &Path, memory_dir: &Path, path: &str, content: &str) -> String {
|
||||
if path.trim().is_empty() {
|
||||
return "[erreur] chemin vide".into();
|
||||
}
|
||||
let path = resolve_agent_path(cwd, path);
|
||||
if let Some(msg) = guard_tool_only_path(&path, memory_dir) {
|
||||
return msg;
|
||||
}
|
||||
let mu = file_mu(&path);
|
||||
let _g = mu.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(dir) = path.parent()
|
||||
&& !dir.as_os_str().is_empty()
|
||||
&& dir != Path::new(".")
|
||||
&& let Err(e) = fs::create_dir_all(dir)
|
||||
{
|
||||
return path_err("mkdir", dir, &e);
|
||||
}
|
||||
let existed = fs::metadata(&path).is_ok();
|
||||
if let Err(e) = write_file(&path, content.as_bytes()) {
|
||||
return e;
|
||||
}
|
||||
let verb = if existed { "réécrit" } else { "créé" };
|
||||
format!("[ok] {} {verb} ({} octets)", path.display(), content.len())
|
||||
}
|
||||
|
||||
/// `fileEdit` : UN remplacement exact (`old` doit apparaître une seule fois).
|
||||
/// Un fichier en CRLF est comparé en LF et réécrit en CRLF.
|
||||
pub fn file_edit(cwd: &Path, memory_dir: &Path, path: &str, old: &str, new: &str) -> String {
|
||||
if path.trim().is_empty() {
|
||||
return "[erreur] chemin vide".into();
|
||||
}
|
||||
if old.is_empty() {
|
||||
return "[erreur] old vide".into();
|
||||
}
|
||||
let path = resolve_agent_path(cwd, path);
|
||||
if let Some(msg) = guard_tool_only_path(&path, memory_dir) {
|
||||
return msg;
|
||||
}
|
||||
let mu = file_mu(&path);
|
||||
let _g = mu.lock().unwrap_or_else(|e| e.into_inner());
|
||||
// Octets, pas texte : un fichier hors UTF-8 (latin-1…) garde intact ce que
|
||||
// le remplacement ne touche pas.
|
||||
let content = match read_file(&path) {
|
||||
Ok(b) => b,
|
||||
Err(e) => return e,
|
||||
};
|
||||
let (old, new) = (old.as_bytes(), new.as_bytes());
|
||||
let crlf = contains(&content, b"\r\n");
|
||||
let search = if crlf && !contains(old, b"\r\n") {
|
||||
replace_all(&content, b"\r\n", b"\n")
|
||||
} else {
|
||||
content.clone()
|
||||
};
|
||||
let n = count(&search, old);
|
||||
if n == 0 {
|
||||
if !new.is_empty() && contains(&search, new) {
|
||||
return "[ok] déjà à jour — le fichier contient déjà cette modification".into();
|
||||
}
|
||||
return "[erreur] old introuvable dans le fichier".into();
|
||||
}
|
||||
if n > 1 {
|
||||
return format!(
|
||||
"[erreur] old apparaît {n} fois — ajoute du contexte pour le rendre unique"
|
||||
);
|
||||
}
|
||||
let i = find(&search, old).unwrap_or(0);
|
||||
let mut updated = [&search[..i], new, &search[i + old.len()..]].concat();
|
||||
if search != content {
|
||||
updated = replace_all(&updated, b"\n", b"\r\n");
|
||||
}
|
||||
if let Err(e) = write_file(&path, &updated) {
|
||||
return e;
|
||||
}
|
||||
format!("[ok] {} modifié (1 remplacement)", path.display())
|
||||
}
|
||||
|
||||
fn find(h: &[u8], n: &[u8]) -> Option<usize> {
|
||||
if n.is_empty() {
|
||||
return Some(0);
|
||||
}
|
||||
h.windows(n.len()).position(|w| w == n)
|
||||
}
|
||||
|
||||
fn contains(h: &[u8], n: &[u8]) -> bool {
|
||||
find(h, n).is_some()
|
||||
}
|
||||
|
||||
/// Occurrences sans chevauchement (`strings.Count`, `old` non vide).
|
||||
fn count(h: &[u8], n: &[u8]) -> usize {
|
||||
let (mut c, mut i) = (0, 0);
|
||||
while let Some(j) = find(&h[i..], n) {
|
||||
c += 1;
|
||||
i += j + n.len();
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
fn replace_all(h: &[u8], from: &[u8], to: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(h.len());
|
||||
let mut i = 0;
|
||||
while let Some(j) = find(&h[i..], from) {
|
||||
out.extend_from_slice(&h[i..i + j]);
|
||||
out.extend_from_slice(to);
|
||||
i += j + from.len();
|
||||
}
|
||||
out.extend_from_slice(&h[i..]);
|
||||
out
|
||||
}
|
||||
|
||||
/// `toolAsk` : la question part vers l'UI par un événement dédié ; ici, dire
|
||||
/// au modèle de clore son tour.
|
||||
pub fn tool_ask(args: &Map<String, Value>) -> String {
|
||||
if arg_str(args, "question").trim().is_empty() {
|
||||
return "[erreur] question vide".into();
|
||||
}
|
||||
"[question posée à l'utilisateur] Termine ton tour MAINTENANT sans appeler d'autre outil : sa réponse arrivera comme prochain message.".into()
|
||||
}
|
||||
|
||||
/// `askOptions` : options non vides, 4 au plus.
|
||||
pub fn ask_options(args: &Map<String, Value>) -> Vec<String> {
|
||||
args.get("options")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.take(4)
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn args(v: Value) -> Map<String, Value> {
|
||||
v.as_object().unwrap().clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filepath_match_de_go() {
|
||||
for (p, n, want) in [
|
||||
("*.go", "a.go", Some(true)),
|
||||
("*.go", "a.rs", Some(false)),
|
||||
("a?c", "abc", Some(true)),
|
||||
("[a-c]x", "bx", Some(true)),
|
||||
("[^a-c]x", "bx", Some(false)),
|
||||
("*", "", Some(true)),
|
||||
("*.é", "été.é", Some(true)),
|
||||
("[", "a", None),
|
||||
("a[", "b", None),
|
||||
("\\*", "*", Some(true)),
|
||||
] {
|
||||
assert_eq!(go_match(p, n), want, "{p} {n}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glob_en_regex() {
|
||||
let re = glob_to_regex("src/**/*.rs").unwrap();
|
||||
assert!(re.is_match("src/a.rs") && re.is_match("src/x/y/a.rs"));
|
||||
assert!(!re.is_match("src/a.rs.bak") && !re.is_match("a.rs"));
|
||||
assert!(glob_to_regex("./**").unwrap().is_match("a/b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lecture_numerotee() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
fs::write(d.path().join("a.txt"), "un\ndeux\ntrois\n").unwrap();
|
||||
let r = tool_read(
|
||||
d.path(),
|
||||
&args(json!({"file":"a.txt","offset":2,"limit":1})),
|
||||
false,
|
||||
);
|
||||
assert_eq!(
|
||||
r.result,
|
||||
"2: deux\n… (1 lignes restantes — relis avec offset=3)"
|
||||
);
|
||||
assert!(r.noted.is_some());
|
||||
let r = tool_read(d.path(), &args(json!({"file":"absent"})), false);
|
||||
assert!(
|
||||
r.result.starts_with("[erreur] open ")
|
||||
&& r.result.ends_with(": no such file or directory")
|
||||
|| cfg!(windows)
|
||||
);
|
||||
assert!(r.noted.is_none());
|
||||
assert_eq!(
|
||||
tool_read(d.path(), &args(json!({"file":" "})), false).result,
|
||||
"[erreur] chemin vide"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edition_crlf_et_deja_fait() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let mem = d.path().join("memory");
|
||||
fs::write(d.path().join("w.txt"), "a\r\nb\r\n").unwrap();
|
||||
assert_eq!(
|
||||
file_edit(d.path(), &mem, "w.txt", "a\nb", "a\nc"),
|
||||
format!(
|
||||
"[ok] {} modifié (1 remplacement)",
|
||||
d.path().join("w.txt").display()
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(d.path().join("w.txt")).unwrap(),
|
||||
"a\r\nc\r\n"
|
||||
);
|
||||
assert!(file_edit(d.path(), &mem, "w.txt", "a\nb", "a\nc").starts_with("[ok] déjà à jour"));
|
||||
assert!(file_write(d.path(), &mem, "memory/x.md", "x").starts_with("[refusé]"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
//! Outils de l'agent exécutés par la façade (lot 7 de la migration).
|
||||
//!
|
||||
//! Quand la façade tient la conversation (`LOKI_CHAT=rust`), les outils
|
||||
//! fichiers et le shell s'exécutent ici : `read`, `grep`, `glob`, `write`,
|
||||
//! `edit`, `bash`, `ask`. Les autres (mémoire, web, tâches, navigateur, MCP,
|
||||
//! sous-agents, critères…) restent au Go (`/internal/v1/tool/exec`), de même
|
||||
//! que `write`/`edit`/`bash` quand l'agent vise un poste distant.
|
||||
//!
|
||||
//! Ce que ces outils partagent avec le reste de Loki reste au Go, en un seul
|
||||
//! exemplaire ([`ToolState`]) : le dossier de travail de la discussion, le
|
||||
//! dossier mémoire, la cible distante, le suivi « lu avant d'écrire » (aussi
|
||||
//! tenu par les sous-agents et les tâches, qui tournent là-bas) et les
|
||||
//! diagnostics LSP. Les garde-fous sont ceux du Go, dans le même ordre.
|
||||
|
||||
pub mod diff;
|
||||
pub mod files;
|
||||
pub mod policy;
|
||||
pub mod shell;
|
||||
|
||||
use std::future::Future;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::chat::turn::ToolOutcome;
|
||||
use crate::chat::types::AskEvent;
|
||||
use crate::store::Store;
|
||||
|
||||
/// Ce que les outils doivent savoir avant de s'exécuter.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct ToolCtx {
|
||||
/// Dossier de travail (`agentCwd`).
|
||||
pub cwd: PathBuf,
|
||||
/// Dossier interdit au shell et à write/edit (`memoryDir`).
|
||||
pub memory_dir: PathBuf,
|
||||
/// Poste distant visé ("" : cette machine).
|
||||
pub target: String,
|
||||
/// Mode Code : chemins bornés au dossier, lu avant d'écrire.
|
||||
pub code: bool,
|
||||
}
|
||||
|
||||
/// L'état partagé avec le Go.
|
||||
pub trait ToolState: Send + Sync {
|
||||
/// Le contexte du moment ; `Err` : outils locaux indisponibles.
|
||||
fn context(&self) -> impl Future<Output = Result<ToolCtx, String>> + Send;
|
||||
/// `trackerNoteRead`.
|
||||
fn note_read(&self, path: &Path) -> impl Future<Output = ()> + Send;
|
||||
/// `trackerCheck` : le refus, ou `None`.
|
||||
fn check(&self, path: &Path, for_write: bool) -> impl Future<Output = Option<String>> + Send;
|
||||
/// Après une écriture réussie : `trackerNoteWrite` puis le bloc LSP.
|
||||
fn wrote(&self, path: &Path) -> impl Future<Output = String> + Send;
|
||||
}
|
||||
|
||||
/// Outils exécutés ici (hors cible distante pour write/edit/bash).
|
||||
pub fn is_local(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"read" | "grep" | "glob" | "write" | "edit" | "bash" | "ask"
|
||||
)
|
||||
}
|
||||
|
||||
fn outcome(result: String) -> ToolOutcome {
|
||||
ToolOutcome {
|
||||
result,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
async fn blocking<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> Result<T, String> {
|
||||
tokio::task::spawn_blocking(f)
|
||||
.await
|
||||
.map_err(|e| format!("[erreur] outil interrompu : {e}"))
|
||||
}
|
||||
|
||||
/// `codeWriteGuard` : en mode Code, borne de chemin puis « lu avant
|
||||
/// d'écrire ». `None` : l'écriture peut avoir lieu.
|
||||
async fn code_write_guard<S: ToolState>(
|
||||
st: &S,
|
||||
ctx: &ToolCtx,
|
||||
file: &str,
|
||||
for_write: bool,
|
||||
) -> Option<String> {
|
||||
if !ctx.code {
|
||||
return None;
|
||||
}
|
||||
let path = policy::resolve_agent_path(&ctx.cwd, file);
|
||||
if !policy::code_path_allowed(&ctx.cwd, &path) {
|
||||
return Some(policy::refused_path_result(file));
|
||||
}
|
||||
st.check(&path, for_write).await
|
||||
}
|
||||
|
||||
/// Exécute un outil ici ; `None` : c'est au Go de le faire (outil non porté,
|
||||
/// cible distante, contexte indisponible).
|
||||
pub async fn exec_local<S: ToolState>(
|
||||
st: &S,
|
||||
code: bool,
|
||||
name: &str,
|
||||
args: &Map<String, Value>,
|
||||
label: &str,
|
||||
) -> Option<ToolOutcome> {
|
||||
if !is_local(name) {
|
||||
return None;
|
||||
}
|
||||
let mut ctx = match st.context().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!("[outils] contexte indisponible, exécution par le Go : {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
ctx.code = code;
|
||||
let remote = !ctx.target.is_empty();
|
||||
let out = match name {
|
||||
"read" => {
|
||||
let (cwd, a) = (ctx.cwd.clone(), args.clone());
|
||||
let r = match blocking(move || files::tool_read(&cwd, &a, code)).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => return Some(outcome(e)),
|
||||
};
|
||||
if let Some(p) = &r.noted {
|
||||
st.note_read(p).await;
|
||||
}
|
||||
outcome(r.result)
|
||||
}
|
||||
"grep" => {
|
||||
let (cwd, a) = (ctx.cwd.clone(), args.clone());
|
||||
outcome(
|
||||
blocking(move || files::tool_grep(&cwd, &a, code))
|
||||
.await
|
||||
.unwrap_or_else(|e| e),
|
||||
)
|
||||
}
|
||||
"glob" => {
|
||||
let (cwd, a) = (ctx.cwd.clone(), args.clone());
|
||||
outcome(
|
||||
blocking(move || files::tool_glob(&cwd, &a))
|
||||
.await
|
||||
.unwrap_or_else(|e| e),
|
||||
)
|
||||
}
|
||||
"ask" => {
|
||||
let result = files::tool_ask(args);
|
||||
let ask = (!result.starts_with("[erreur]")).then(|| AskEvent {
|
||||
question: files::arg_str(args, "question").to_string(),
|
||||
options: files::ask_options(args),
|
||||
});
|
||||
ToolOutcome {
|
||||
result,
|
||||
ask,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
"write" if !remote => {
|
||||
let content = files::arg_str(args, "content").to_string();
|
||||
if let Some(msg) = code_write_guard(st, &ctx, label, true).await {
|
||||
return Some(outcome(msg));
|
||||
}
|
||||
let (cwd, mem, l, c) = (
|
||||
ctx.cwd.clone(),
|
||||
ctx.memory_dir.clone(),
|
||||
label.to_string(),
|
||||
content.clone(),
|
||||
);
|
||||
let mut result = match blocking(move || files::file_write(&cwd, &mem, &l, &c)).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => return Some(outcome(e)),
|
||||
};
|
||||
let mut o = ToolOutcome::default();
|
||||
if !result.starts_with("[erreur]") {
|
||||
(o.diff, o.added) = diff::added_diff(&content);
|
||||
result += &st.wrote(&policy::resolve_agent_path(&ctx.cwd, label)).await;
|
||||
}
|
||||
o.result = result;
|
||||
o
|
||||
}
|
||||
"edit" if !remote => {
|
||||
let (old, new) = (
|
||||
files::arg_str(args, "old").to_string(),
|
||||
files::arg_str(args, "new").to_string(),
|
||||
);
|
||||
if let Some(msg) = code_write_guard(st, &ctx, label, false).await {
|
||||
return Some(outcome(msg));
|
||||
}
|
||||
let (cwd, mem, l, o2, n2) = (
|
||||
ctx.cwd.clone(),
|
||||
ctx.memory_dir.clone(),
|
||||
label.to_string(),
|
||||
old.clone(),
|
||||
new.clone(),
|
||||
);
|
||||
let mut result =
|
||||
match blocking(move || files::file_edit(&cwd, &mem, &l, &o2, &n2)).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => return Some(outcome(e)),
|
||||
};
|
||||
let mut o = ToolOutcome::default();
|
||||
if !result.starts_with("[erreur]") {
|
||||
(o.diff, o.added, o.removed) = diff::line_diff(&old, &new);
|
||||
result += &st.wrote(&policy::resolve_agent_path(&ctx.cwd, label)).await;
|
||||
}
|
||||
o.result = result;
|
||||
o
|
||||
}
|
||||
"bash" if !remote || policy::dangerous_command(label).is_some() => {
|
||||
let timeout = files::arg_int(args, "timeout").unwrap_or(0);
|
||||
// Refusées AVANT toute exécution, cible distante comprise.
|
||||
if let Some(reason) = policy::dangerous_command(label) {
|
||||
return Some(outcome(policy::refused_command_result(reason)));
|
||||
}
|
||||
// Mode Code : « cmd & » laisse un orphelin sans sortie ni arrêt.
|
||||
if code && shell::trailing_background(label) {
|
||||
return Some(outcome("[refusé] commande terminée par « & » : lance-la avec bash_bg (sortie consultable avec bash_tail), pas en arrière-plan dans bash.".into()));
|
||||
}
|
||||
outcome(shell::run_shell(&ctx.cwd, &ctx.memory_dir, label, timeout).await)
|
||||
}
|
||||
_ => return None, // cible distante : le Go passe par le poste
|
||||
};
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// [`ToolState`] servi par l'API interne du Go.
|
||||
#[derive(Clone)]
|
||||
pub struct GoToolState {
|
||||
pub store: Store,
|
||||
/// Capacités du tour (pour les diagnostics LSP).
|
||||
pub caps: Value,
|
||||
}
|
||||
|
||||
impl ToolState for GoToolState {
|
||||
async fn context(&self) -> Result<ToolCtx, String> {
|
||||
let (status, v) = self
|
||||
.store
|
||||
.internal_post("/internal/v1/tool/context", &json!({}))
|
||||
.await?;
|
||||
if !status.is_success() {
|
||||
return Err(format!("contexte des outils : {status}"));
|
||||
}
|
||||
let s = |k: &str| v.get(k).and_then(Value::as_str).unwrap_or("").to_string();
|
||||
Ok(ToolCtx {
|
||||
cwd: PathBuf::from(s("cwd")),
|
||||
memory_dir: PathBuf::from(s("memory_dir")),
|
||||
target: s("target"),
|
||||
code: false,
|
||||
})
|
||||
}
|
||||
|
||||
async fn note_read(&self, path: &Path) {
|
||||
let body = json!({ "op": "read", "path": path });
|
||||
if let Err(e) = self
|
||||
.store
|
||||
.internal_post("/internal/v1/tool/tracker", &body)
|
||||
.await
|
||||
{
|
||||
eprintln!("[outils] lecture non notée : {e}");
|
||||
}
|
||||
}
|
||||
|
||||
async fn check(&self, path: &Path, for_write: bool) -> Option<String> {
|
||||
let body = json!({ "op": "check", "path": path, "for_write": for_write });
|
||||
match self
|
||||
.store
|
||||
.internal_post("/internal/v1/tool/tracker", &body)
|
||||
.await
|
||||
{
|
||||
Ok((st, v)) if st.is_success() => v
|
||||
.get("refusal")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|r| !r.is_empty())
|
||||
.map(str::to_string),
|
||||
// Sans le suivi, écrire serait écrire à l'aveugle : refus.
|
||||
Ok((st, _)) => Some(format!("[erreur] suivi des lectures indisponible : {st}")),
|
||||
Err(e) => Some(format!("[erreur] suivi des lectures indisponible : {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn wrote(&self, path: &Path) -> String {
|
||||
let body = json!({ "path": path, "caps": self.caps });
|
||||
match self
|
||||
.store
|
||||
.internal_post("/internal/v1/tool/wrote", &body)
|
||||
.await
|
||||
{
|
||||
Ok((st, v)) if st.is_success() => v
|
||||
.get("lsp")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
Ok(_) | Err(_) => String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
//! Chemins et garde-fous des outils de l'agent — port de `code_policy.go`,
|
||||
//! `chat_workspace.go` (`resolveAgentPath`) et `chat_scripts.go` (dossier
|
||||
//! mémoire réservé à ses outils).
|
||||
//!
|
||||
//! - [`dangerous_command`] : commandes CATASTROPHIQUES refusées (effacement
|
||||
//! de disque, arrêt machine, fork bomb), mode chat compris ;
|
||||
//! - [`code_path_allowed`] : en mode Code, les chemins des outils fichiers
|
||||
//! sont bornés au dossier de la discussion, liens symboliques résolus ;
|
||||
//! - [`guard_tool_only_path`] / [`guard_tool_only_command`] : le dossier
|
||||
//! mémoire n'est jamais touché par write/edit/bash, seulement par mem_*.
|
||||
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use regex::Regex;
|
||||
|
||||
/// `filepath.Clean` : forme lexicale la plus courte (`.` et `..` résolus sans
|
||||
/// toucher au disque, séparateurs doublés retirés). Vide → `.`.
|
||||
pub fn clean(p: &Path) -> PathBuf {
|
||||
let mut out: Vec<Component> = Vec::new();
|
||||
let mut rooted = false;
|
||||
for c in p.components() {
|
||||
match c {
|
||||
Component::Prefix(_) => out.push(c),
|
||||
Component::RootDir => {
|
||||
rooted = true;
|
||||
out.push(c);
|
||||
}
|
||||
Component::CurDir => {}
|
||||
Component::ParentDir => match out.last() {
|
||||
Some(Component::Normal(_)) => {
|
||||
out.pop();
|
||||
}
|
||||
// `/..` reste `/`.
|
||||
_ if rooted => {}
|
||||
_ => out.push(c),
|
||||
},
|
||||
Component::Normal(_) => out.push(c),
|
||||
}
|
||||
}
|
||||
if out.is_empty() {
|
||||
return PathBuf::from(".");
|
||||
}
|
||||
out.iter().collect()
|
||||
}
|
||||
|
||||
/// `os.UserHomeDir` : `$HOME` (`%USERPROFILE%` sous Windows).
|
||||
fn home_dir() -> Option<PathBuf> {
|
||||
let var = if cfg!(windows) { "USERPROFILE" } else { "HOME" };
|
||||
std::env::var_os(var)
|
||||
.filter(|h| !h.is_empty())
|
||||
.map(PathBuf::from)
|
||||
}
|
||||
|
||||
/// `resolveAgentPath` : chemin fourni par le modèle. Absolu → inchangé ;
|
||||
/// `~/x` → dans le home ; relatif → dans le dossier de travail `cwd`.
|
||||
pub fn resolve_agent_path(cwd: &Path, p: &str) -> PathBuf {
|
||||
let p = p.trim();
|
||||
if p.is_empty() {
|
||||
return PathBuf::new();
|
||||
}
|
||||
if (p == "~" || p.starts_with("~/") || p.starts_with("~\\"))
|
||||
&& let Some(home) = home_dir()
|
||||
{
|
||||
let rest = &p[1..];
|
||||
let rest = rest.strip_prefix('/').unwrap_or(rest);
|
||||
return clean(&home.join(from_slash(rest)));
|
||||
}
|
||||
let path = Path::new(p);
|
||||
if path.is_absolute() {
|
||||
return path.to_path_buf();
|
||||
}
|
||||
clean(&cwd.join(from_slash(p)))
|
||||
}
|
||||
|
||||
/// `filepath.FromSlash`.
|
||||
fn from_slash(p: &str) -> PathBuf {
|
||||
if cfg!(windows) {
|
||||
PathBuf::from(p.replace('/', "\\"))
|
||||
} else {
|
||||
PathBuf::from(p)
|
||||
}
|
||||
}
|
||||
|
||||
/// `filepath.ToSlash`.
|
||||
pub fn to_slash(p: &Path) -> String {
|
||||
let s = p.to_string_lossy();
|
||||
if cfg!(windows) {
|
||||
s.replace('\\', "/")
|
||||
} else {
|
||||
s.into_owned()
|
||||
}
|
||||
}
|
||||
|
||||
/// `filepath.EvalSymlinks`, à défaut le chemin tel quel.
|
||||
fn eval_symlinks(p: &Path) -> PathBuf {
|
||||
match std::fs::canonicalize(p) {
|
||||
Ok(c) => strip_verbatim(c),
|
||||
Err(_) => p.to_path_buf(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `canonicalize` rend `\\?\C:\…` sous Windows ; `EvalSymlinks` non.
|
||||
fn strip_verbatim(p: PathBuf) -> PathBuf {
|
||||
if cfg!(windows)
|
||||
&& let Some(s) = p.to_str()
|
||||
&& let Some(rest) = s.strip_prefix(r"\\?\")
|
||||
&& !rest.starts_with("UNC\\")
|
||||
{
|
||||
return PathBuf::from(rest);
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// `p` est `root` lui-même ou dedans (lexicalement, chemins nettoyés).
|
||||
fn within(root: &Path, p: &Path) -> bool {
|
||||
clean(p).starts_with(clean(root))
|
||||
}
|
||||
|
||||
/// `sameOrWithin` : liens symboliques résolus des deux côtés.
|
||||
fn same_or_within(root: &Path, p: &Path) -> bool {
|
||||
within(&eval_symlinks(root), &eval_symlinks(p))
|
||||
}
|
||||
|
||||
/// `codePathAllowed` : un chemin (déjà résolu) est-il DANS le dossier de la
|
||||
/// discussion `ws` ? Le fichier peut ne pas exister encore (write) : c'est son
|
||||
/// ancêtre existant le plus proche qui doit y être, liens résolus ; et le
|
||||
/// chemin final ne doit pas en ressortir par des `..` lexicaux.
|
||||
pub fn code_path_allowed(ws: &Path, abs: &Path) -> bool {
|
||||
if ws.as_os_str().is_empty() {
|
||||
return false;
|
||||
}
|
||||
let abs = clean(abs);
|
||||
let mut probe = abs.clone();
|
||||
loop {
|
||||
if std::fs::metadata(&probe).is_ok() {
|
||||
break;
|
||||
}
|
||||
match probe.parent() {
|
||||
Some(parent) if parent != probe => probe = parent.to_path_buf(),
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
same_or_within(ws, &probe) && within(ws, &abs)
|
||||
}
|
||||
|
||||
/// `refusedPathResult` : refus d'un chemin hors du bac à sable du mode Code.
|
||||
pub fn refused_path_result(path: &str) -> String {
|
||||
format!(
|
||||
"[refusé] {path} est hors du dossier de cette discussion. \
|
||||
En mode code, tous les fichiers vivent dans le dossier de travail — utilise des chemins relatifs."
|
||||
)
|
||||
}
|
||||
|
||||
/// `dangerousPatterns` : motifs de commandes refusées, et pourquoi.
|
||||
/// Volontairement ÉTROIT : la perte de données irréversible et l'arrêt de la
|
||||
/// machine, pas la moindre écriture.
|
||||
static DANGEROUS: LazyLock<Vec<(Regex, &'static str)>> = LazyLock::new(|| {
|
||||
let root = "efface la racine du disque ou le home";
|
||||
let disk = "écrit directement sur un périphérique disque";
|
||||
let drive = "efface la racine d'un lecteur";
|
||||
[
|
||||
(
|
||||
r#"(?i)\brm\s+(-[a-z]*r[a-z]*f[a-z]*|-[a-z]*f[a-z]*r[a-z]*)\s+("?/"?|~/?|/\*|[a-z]:\\?)\s*$"#,
|
||||
root,
|
||||
),
|
||||
(
|
||||
r#"(?i)\brm\s+(-[a-z]*r[a-z]*f[a-z]*|-[a-z]*f[a-z]*r[a-z]*)\s+("?/"?|~/?|/\*|[a-z]:\\?)\s"#,
|
||||
root,
|
||||
),
|
||||
(r"(?i)\bmkfs(\.[a-z0-9]+)?\b", "formate un système de fichiers"),
|
||||
(r"(?i)\bdd\b[^\n]*\bof=/dev/", disk),
|
||||
(r"(?i)>\s*/dev/sd[a-z]\b", disk),
|
||||
(
|
||||
r"(?i)(^|[;&|]\s*|\bsudo\s+)(shutdown|poweroff|reboot|halt)\b",
|
||||
"arrête ou redémarre la machine",
|
||||
),
|
||||
(r":\(\)\s*\{\s*:\|:", "fork bomb"),
|
||||
(
|
||||
r"(?i)\bch(mod|own)\b[^\n]*-[a-z]*R[a-z]*\s+[^\s]+\s+/\s*$",
|
||||
"change récursivement les droits de la racine",
|
||||
),
|
||||
(r"(?i)\b(rd|rmdir)\s+/s\b[^\n]*\s[a-z]:\\?\s*$", drive),
|
||||
(r"(?i)\bdel\s+(/[fsq]\s+)*[a-z]:\\\*", drive),
|
||||
(r"(?i)\bformat\s+[a-z]:", "formate un lecteur"),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(re, why)| (Regex::new(re).expect("motif de commande dangereuse"), why))
|
||||
.collect()
|
||||
});
|
||||
|
||||
/// `dangerousCommand` : la raison du refus, `None` si la commande passe.
|
||||
pub fn dangerous_command(cmd: &str) -> Option<&'static str> {
|
||||
DANGEROUS
|
||||
.iter()
|
||||
.find(|(re, _)| re.is_match(cmd))
|
||||
.map(|(_, why)| *why)
|
||||
}
|
||||
|
||||
/// `refusedCommandResult` : dire POURQUOI et QUOI FAIRE, sinon le modèle
|
||||
/// réessaie en boucle avec des variantes.
|
||||
pub fn refused_command_result(reason: &str) -> String {
|
||||
format!(
|
||||
"[refusé] Commande bloquée par la politique de sécurité : {reason}. \
|
||||
Ne la réessaie pas, même reformulée. Si l'utilisateur la veut vraiment, dis-lui de la lancer lui-même dans un terminal."
|
||||
)
|
||||
}
|
||||
|
||||
/// `normPath` : absolu, nettoyé, en minuscules sous Windows.
|
||||
fn norm_path(p: &Path) -> String {
|
||||
let abs = if p.is_absolute() {
|
||||
p.to_path_buf()
|
||||
} else {
|
||||
std::env::current_dir().unwrap_or_default().join(p)
|
||||
};
|
||||
let s = clean(&abs).to_string_lossy().into_owned();
|
||||
if cfg!(windows) { s.to_lowercase() } else { s }
|
||||
}
|
||||
|
||||
/// `underDir` : `path` est `dir` ou dessous.
|
||||
fn under_dir(path: &Path, dir: &Path) -> bool {
|
||||
let (a, b) = (norm_path(path), norm_path(dir));
|
||||
a == b || a.starts_with(&format!("{b}{}", std::path::MAIN_SEPARATOR))
|
||||
}
|
||||
|
||||
const TOOL_ONLY_LABEL: &str = "les outils mem_* (mem_search/mem_read/mem_add/mem_edit/mem_delete)";
|
||||
|
||||
/// `guardToolOnlyPath` : refus d'un write/edit dans le dossier mémoire.
|
||||
pub fn guard_tool_only_path(path: &Path, memory_dir: &Path) -> Option<String> {
|
||||
if memory_dir.as_os_str().is_empty() || !under_dir(path, memory_dir) {
|
||||
return None;
|
||||
}
|
||||
Some(format!(
|
||||
"[refusé] pas d'accès direct au dossier {}. Utilise {TOOL_ONLY_LABEL}.",
|
||||
memory_dir.display()
|
||||
))
|
||||
}
|
||||
|
||||
/// `guardToolOnlyCommand` : refus d'une commande qui MENTIONNE le dossier
|
||||
/// mémoire, lecture comprise.
|
||||
pub fn guard_tool_only_command(command: &str, memory_dir: &Path) -> Option<String> {
|
||||
if memory_dir.as_os_str().is_empty()
|
||||
|| !command
|
||||
.to_lowercase()
|
||||
.contains(&norm_path(memory_dir).to_lowercase())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(format!(
|
||||
"[refusé] pas d'accès direct au dossier {} via le shell. Utilise {TOOL_ONLY_LABEL}.",
|
||||
memory_dir.display()
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn nettoyage_comme_filepath_clean() {
|
||||
for (input, want) in [
|
||||
("", "."),
|
||||
("a/./b", "a/b"),
|
||||
("a/../..", ".."),
|
||||
("/../a", "/a"),
|
||||
("a//b/", "a/b"),
|
||||
("../../x/../y", "../../y"),
|
||||
("/", "/"),
|
||||
] {
|
||||
assert_eq!(clean(Path::new(input)), PathBuf::from(want), "{input:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn chemins_de_l_agent() {
|
||||
let cwd = Path::new("/w/conv");
|
||||
assert_eq!(
|
||||
resolve_agent_path(cwd, " a/../b.txt "),
|
||||
PathBuf::from("/w/conv/b.txt")
|
||||
);
|
||||
assert_eq!(resolve_agent_path(cwd, "/etc/x"), PathBuf::from("/etc/x"));
|
||||
assert_eq!(resolve_agent_path(cwd, ""), PathBuf::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commandes_dangereuses() {
|
||||
for c in [
|
||||
"rm -rf /",
|
||||
"sudo rm -fr ~ ",
|
||||
"mkfs.ext4 /dev/sda1",
|
||||
"dd if=x of=/dev/sda",
|
||||
"ls; reboot",
|
||||
":(){ :|:& };:",
|
||||
"format c:",
|
||||
] {
|
||||
assert!(dangerous_command(c).is_some(), "{c}");
|
||||
}
|
||||
for c in [
|
||||
"rm -rf build",
|
||||
"grep reboot log.txt",
|
||||
"dd if=a of=b",
|
||||
"echo format",
|
||||
] {
|
||||
assert!(dangerous_command(c).is_none(), "{c}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bac_a_sable_du_mode_code() {
|
||||
let ws = tempfile::tempdir().unwrap();
|
||||
let w = ws.path();
|
||||
std::fs::create_dir(w.join("src")).unwrap();
|
||||
assert!(code_path_allowed(w, &w.join("src/neuf/a.rs")));
|
||||
assert!(code_path_allowed(w, w));
|
||||
assert!(!code_path_allowed(w, &w.join("../dehors")));
|
||||
assert!(!code_path_allowed(w, Path::new("/etc/passwd")));
|
||||
assert!(!code_path_allowed(Path::new(""), &w.join("a")));
|
||||
#[cfg(unix)]
|
||||
{
|
||||
std::os::unix::fs::symlink("/etc", w.join("lien")).unwrap();
|
||||
assert!(!code_path_allowed(w, &w.join("lien/passwd")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dossier_memoire_reserve() {
|
||||
let mem = std::env::temp_dir().join("loki-mem-test");
|
||||
assert!(guard_tool_only_path(&mem.join("a.md"), &mem).is_some());
|
||||
assert!(guard_tool_only_path(&mem, &mem).is_some());
|
||||
assert!(guard_tool_only_path(&std::env::temp_dir().join("loki-mem-testx"), &mem).is_none());
|
||||
let cmd = format!("cat {}/a.md", mem.display());
|
||||
assert!(guard_tool_only_command(&cmd, &mem).is_some());
|
||||
assert!(guard_tool_only_command("ls", &mem).is_none());
|
||||
assert!(guard_tool_only_command("ls", Path::new("")).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
//! Le shell de l'agent — port de `runShell` (`chat_tools.go`) et de
|
||||
//! `chat_shell_hygiene.go`.
|
||||
//!
|
||||
//! La commande part dans `/bin/bash -c` (`cmd /C` sous Windows), dans le
|
||||
//! dossier de la discussion, délai borné. Ce qu'elle rend au modèle est
|
||||
//! nettoyé : « | tail -N » final retiré (le code de sortie serait celui de
|
||||
//! tail) puis appliqué ici, séquences ANSI retirées, sortie bornée à sa fin.
|
||||
//!
|
||||
//! L'arrêt du tour abandonne le futur : le process est tué (`kill_on_drop`).
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Stdio;
|
||||
use std::sync::{Arc, LazyLock, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use regex::Regex;
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
use super::policy::guard_tool_only_command;
|
||||
|
||||
pub const TOOL_DEFAULT_TIMEOUT: i64 = 30;
|
||||
pub const TOOL_MAX_TIMEOUT: i64 = 300;
|
||||
/// Caractères de stdout/stderr rendus au modèle.
|
||||
pub const TOOL_MAX_OUTPUT: usize = 8000;
|
||||
/// `WaitDelay` : attente bornée des tubes après la fin du process — un
|
||||
/// « ./serveur & » laisse un petit-enfant accroché aux tubes, et le tour ne
|
||||
/// rendrait jamais la main.
|
||||
const WAIT_DELAY: Duration = Duration::from_secs(2);
|
||||
|
||||
/// « | tail », « | tail -20 », « | tail -n 20 », « | tail -n20 » en fin.
|
||||
static TAIL_PIPE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"\s*\|\s*tail(?:\s+-n\s*([0-9]+)|\s+-([0-9]+))?\s*$").expect("motif tail")
|
||||
});
|
||||
|
||||
/// `splitTailPipe` : la commande sans son « | tail -N » final, et N (0 : pas
|
||||
/// de tail ; 10 : tail sans nombre).
|
||||
pub fn split_tail_pipe(command: &str) -> (String, usize) {
|
||||
let Some(c) = TAIL_PIPE.captures(command) else {
|
||||
return (command.to_string(), 0);
|
||||
};
|
||||
let n = [1, 2]
|
||||
.into_iter()
|
||||
.filter_map(|g| c.get(g))
|
||||
.filter_map(|m| m.as_str().parse::<usize>().ok())
|
||||
.find(|v| *v > 0)
|
||||
.unwrap_or(10);
|
||||
let rest = command[..c.get(0).map_or(0, |m| m.start())].trim();
|
||||
if rest.is_empty() {
|
||||
return (command.to_string(), 0);
|
||||
}
|
||||
(rest.to_string(), n)
|
||||
}
|
||||
|
||||
/// Séquences CSI (couleurs, curseur) et OSC (titres de fenêtre).
|
||||
static ANSI: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)").expect("motif ANSI")
|
||||
});
|
||||
|
||||
pub fn strip_ansi(s: &str) -> String {
|
||||
if !s.contains('\x1b') {
|
||||
return s.to_string();
|
||||
}
|
||||
ANSI.replace_all(s, "").into_owned()
|
||||
}
|
||||
|
||||
/// `trailingBackground` : la commande finit par un « & » (pas « && »).
|
||||
pub fn trailing_background(command: &str) -> bool {
|
||||
let c = command.trim();
|
||||
c.ends_with('&') && !c.ends_with("&&")
|
||||
}
|
||||
|
||||
/// `tailLines` : les n dernières lignes.
|
||||
pub fn tail_lines(s: &str, n: usize) -> String {
|
||||
let lines: Vec<&str> = s.trim_end_matches('\n').split('\n').collect();
|
||||
lines[lines.len().saturating_sub(n)..].join("\n")
|
||||
}
|
||||
|
||||
/// `tailOutput` : la FIN d'une sortie (`TOOL_MAX_OUTPUT` caractères), avec ce
|
||||
/// qui a été coupé dit en clair.
|
||||
pub fn tail_output(s: &str) -> String {
|
||||
let n = s.chars().count();
|
||||
if n <= TOOL_MAX_OUTPUT {
|
||||
return s.to_string();
|
||||
}
|
||||
let start = s
|
||||
.char_indices()
|
||||
.nth(n - TOOL_MAX_OUTPUT)
|
||||
.map_or(0, |(i, _)| i);
|
||||
format!(
|
||||
"[sortie tronquée : {n} caractères au total, seuls les {TOOL_MAX_OUTPUT} derniers sont gardés]\n{}",
|
||||
&s[start..]
|
||||
)
|
||||
}
|
||||
|
||||
/// `time.Duration.String()` d'une durée arrondie à 100 ms (« 0s », « 300ms »,
|
||||
/// « 1.2s », « 1m5s », « 1h0m0s »).
|
||||
pub fn go_duration(d: Duration) -> String {
|
||||
let ms = ((d.as_millis() + 50) / 100 * 100) as u64;
|
||||
if ms == 0 {
|
||||
return "0s".into();
|
||||
}
|
||||
if ms < 1000 {
|
||||
return format!("{ms}ms");
|
||||
}
|
||||
let (h, rem) = (ms / 3_600_000, ms % 3_600_000);
|
||||
let (m, rem) = (rem / 60_000, rem % 60_000);
|
||||
let (s, tenth) = (rem / 1000, rem % 1000 / 100);
|
||||
let secs = if tenth > 0 {
|
||||
format!("{s}.{tenth}s")
|
||||
} else {
|
||||
format!("{s}s")
|
||||
};
|
||||
match (h, m) {
|
||||
(0, 0) => secs,
|
||||
(0, m) => format!("{m}m{secs}"),
|
||||
(h, m) => format!("{h}h{m}m{secs}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lit un tube jusqu'au bout dans un tampon partagé (lisible même si la
|
||||
/// lecture est abandonnée en route).
|
||||
fn drain<R: tokio::io::AsyncRead + Unpin + Send + 'static>(
|
||||
mut r: R,
|
||||
) -> (Arc<Mutex<Vec<u8>>>, tokio::task::JoinHandle<()>) {
|
||||
let buf = Arc::new(Mutex::new(Vec::new()));
|
||||
let b = buf.clone();
|
||||
let h = tokio::spawn(async move {
|
||||
let mut chunk = [0u8; 8192];
|
||||
while let Ok(n) = r.read(&mut chunk).await {
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
b.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.extend_from_slice(&chunk[..n]);
|
||||
}
|
||||
});
|
||||
(buf, h)
|
||||
}
|
||||
|
||||
/// La commande du shell de la plateforme. Le jeton de l'API interne n'a rien
|
||||
/// à faire dans le shell de l'agent.
|
||||
fn shell_cmd(command: &str) -> tokio::process::Command {
|
||||
#[cfg(windows)]
|
||||
let mut c = {
|
||||
let mut c = tokio::process::Command::new("cmd");
|
||||
c.arg("/C").arg(command);
|
||||
c.creation_flags(0x0800_0000); // CREATE_NO_WINDOW : pas de flash de console
|
||||
c
|
||||
};
|
||||
#[cfg(not(windows))]
|
||||
let mut c = {
|
||||
let mut c = tokio::process::Command::new("/bin/bash");
|
||||
c.arg("-c").arg(command);
|
||||
c
|
||||
};
|
||||
c.env_remove(crate::auth::INTERNAL_ENV);
|
||||
c
|
||||
}
|
||||
|
||||
/// `runShell` : « exit: N · durée », puis stdout et stderr nettoyés.
|
||||
pub async fn run_shell(cwd: &Path, memory_dir: &Path, command: &str, timeout_sec: i64) -> String {
|
||||
if let Some(msg) = guard_tool_only_command(command, memory_dir) {
|
||||
return msg;
|
||||
}
|
||||
let timeout_sec = match timeout_sec {
|
||||
t if t <= 0 => TOOL_DEFAULT_TIMEOUT,
|
||||
t => t.min(TOOL_MAX_TIMEOUT),
|
||||
};
|
||||
let (command, keep_lines) = split_tail_pipe(command);
|
||||
let start = Instant::now();
|
||||
let mut cmd = shell_cmd(&command);
|
||||
cmd.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
// Dans le dossier de la discussion, recréé au besoin (l'utilisateur ou le
|
||||
// modèle a pu le supprimer) ; à défaut, là où on peut.
|
||||
if !cwd.as_os_str().is_empty() && std::fs::create_dir_all(cwd).is_ok() {
|
||||
cmd.current_dir(cwd);
|
||||
}
|
||||
let mut child = match cmd.spawn() {
|
||||
Ok(c) => c,
|
||||
Err(e) => return format!("[erreur: {e}]"),
|
||||
};
|
||||
let (out_buf, out_h) = drain(child.stdout.take().expect("stdout"));
|
||||
let (err_buf, err_h) = drain(child.stderr.take().expect("stderr"));
|
||||
let deadline = Duration::from_secs(timeout_sec as u64);
|
||||
let (status, timed_out) = match tokio::time::timeout(deadline, child.wait()).await {
|
||||
Ok(st) => (st.ok(), false),
|
||||
Err(_) => {
|
||||
let _ = child.start_kill();
|
||||
(child.wait().await.ok(), true)
|
||||
}
|
||||
};
|
||||
// Tubes encore tenus par un petit-enfant : WAIT_DELAY, puis on lâche.
|
||||
let pipes_closed = tokio::time::timeout(WAIT_DELAY, async {
|
||||
let _ = out_h.await;
|
||||
let _ = err_h.await;
|
||||
})
|
||||
.await
|
||||
.is_ok();
|
||||
let take = |b: &Arc<Mutex<Vec<u8>>>| {
|
||||
String::from_utf8_lossy(&b.lock().unwrap_or_else(|e| e.into_inner())).into_owned()
|
||||
};
|
||||
let clean = |s: &str| tail_output(&strip_ansi(s));
|
||||
let mut out = take(&out_buf);
|
||||
if keep_lines > 0 {
|
||||
out = tail_lines(&out, keep_lines);
|
||||
}
|
||||
let out = clean(&out);
|
||||
let err_out = clean(&take(&err_buf));
|
||||
if timed_out {
|
||||
let mut msg = format!(
|
||||
"[timeout après {timeout_sec}s — commande arrêtée ; pour un serveur ou une tâche longue, utilise bash_bg]"
|
||||
);
|
||||
if !out.is_empty() {
|
||||
msg.push_str(&format!("\n\nstdout (partiel):\n{out}"));
|
||||
}
|
||||
if !err_out.is_empty() {
|
||||
msg.push_str(&format!("\n\nstderr (partiel):\n{err_out}"));
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
let exit = match status {
|
||||
Some(st) => st.code().unwrap_or(-1),
|
||||
None => return "[erreur: attente du process impossible]".into(),
|
||||
};
|
||||
// Go : sortie 0 mais tubes jamais fermés → ErrWaitDelay.
|
||||
if exit == 0 && !pipes_closed {
|
||||
return "[erreur: exec: WaitDelay expired before I/O complete]".into();
|
||||
}
|
||||
let mut parts = vec![format!("exit: {exit} · {}", go_duration(start.elapsed()))];
|
||||
if !out.is_empty() {
|
||||
parts.push(format!("stdout:\n{out}"));
|
||||
}
|
||||
if !err_out.is_empty() {
|
||||
parts.push(format!("stderr:\n{err_out}"));
|
||||
}
|
||||
parts.join("\n\n")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tail_final_retire() {
|
||||
assert_eq!(split_tail_pipe("make | tail -20"), ("make".into(), 20));
|
||||
assert_eq!(split_tail_pipe("make | tail -n 5 "), ("make".into(), 5));
|
||||
assert_eq!(split_tail_pipe("make|tail"), ("make".into(), 10));
|
||||
assert_eq!(split_tail_pipe("| tail -3"), ("| tail -3".into(), 0));
|
||||
assert_eq!(split_tail_pipe("tail -f x"), ("tail -f x".into(), 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ansi_et_arriere_plan() {
|
||||
assert_eq!(
|
||||
strip_ansi("\x1b[31mrouge\x1b[0m \x1b]0;titre\x07ok"),
|
||||
"rouge ok"
|
||||
);
|
||||
assert!(trailing_background("serveur &") && !trailing_background("a && b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn durees_comme_go() {
|
||||
for (ms, want) in [
|
||||
(0, "0s"),
|
||||
(49, "0s"),
|
||||
(50, "100ms"),
|
||||
(340, "300ms"),
|
||||
(1_250, "1.3s"),
|
||||
(12_000, "12s"),
|
||||
(65_400, "1m5.4s"),
|
||||
(3_600_000, "1h0m0s"),
|
||||
] {
|
||||
assert_eq!(go_duration(Duration::from_millis(ms)), want, "{ms}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sortie_bornee() {
|
||||
let s = "x".repeat(TOOL_MAX_OUTPUT + 5);
|
||||
let t = tail_output(&s);
|
||||
assert!(t.starts_with("[sortie tronquée : 8005 caractères au total"));
|
||||
assert_eq!(tail_lines("a\nb\nc\n", 2), "b\nc");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn commande_et_delai() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let mem = d.path().join("memory");
|
||||
let r = run_shell(d.path(), &mem, "echo un; echo deux >&2; exit 3", 5).await;
|
||||
assert!(r.starts_with("exit: 3 · "), "{r}");
|
||||
assert!(r.ends_with("stdout:\nun\n\n\nstderr:\ndeux\n"), "{r:?}");
|
||||
let r = run_shell(d.path(), &mem, "printf 'a\\nb\\nc\\n' | tail -2", 5).await;
|
||||
assert!(r.ends_with("stdout:\nb\nc"), "{r:?}");
|
||||
let r = run_shell(d.path(), &mem, "pwd", 5).await;
|
||||
assert!(r.contains(&d.path().display().to_string()), "{r}");
|
||||
let r = run_shell(d.path(), &mem, "echo avant; sleep 5", 1).await;
|
||||
assert!(r.starts_with("[timeout après 1s"), "{r}");
|
||||
assert!(r.contains("stdout (partiel):\navant"), "{r}");
|
||||
let r = run_shell(d.path(), &mem, &format!("ls {}", mem.display()), 5).await;
|
||||
assert!(r.starts_with("[refusé] pas d'accès direct"), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jeton_interne_absent_du_shell() {
|
||||
let c = shell_cmd("env");
|
||||
assert!(
|
||||
c.as_std()
|
||||
.get_envs()
|
||||
.any(|(k, v)| k == crate::auth::INTERNAL_ENV && v.is_none())
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user