Phase 7 : outils web_search et run_shell avec validation

Backend :
- tools.py : web_search (DuckDuckGo HTML sans clé, ou SearxNG via SEARX_URL) et
  run_shell (confiné au workspace, sortie bornée, timeout)
- agent.py : run_shell sensible -> émet tool_confirm et ne s'exécute pas tant que
  l'utilisateur n'a pas validé (confirm_shell)
- agent_config.py : web_search/run_shell désactivés par défaut, flag confirm_shell
- routes/shell.py : POST /api/shell/run exécute une commande validée
- routes/chat.py : relaie l'événement tool_confirm, passe confirm_shell

Frontend :
- streamChat : événement tool_confirm
- store : pendingShell + approveShell/rejectShell (réinjecte le résultat à l'agent)
- ChatPanel : carte de validation de commande (Approuver / Refuser)
- SettingsView : toggles web_search/run_shell, marqueur sensible, switch confirm_shell
- ToolCard : glyphes web_search/run_shell, statut 'à valider', aperçu d'argument

Tests : run_shell confiné, gate de confirmation (commande dangereuse non exécutée),
route shell, parser DuckDuckGo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVay7z3y7q2gEe54ByAE6N
This commit is contained in:
MichaelandClaude Opus 4.8 committed 2026-06-29 21:09:27 +00:00
1 parent 2db6beea5c
commit de2befe31e
12 files changed
+398 -26

No files matched your search

+139
View File
@@ -9,7 +9,12 @@ toute tentative de sortie (../, chemin absolu hors workspace) est rejetée.
"""
from __future__ import annotations
import html
import os
import re
import subprocess
import httpx
from .config import settings
@@ -71,11 +76,114 @@ def list_dir(path: str = ".") -> dict:
}
def web_search(query: str, max_results: int = 5) -> dict:
"""Recherche web (DuckDuckGo HTML, sans clé d'API).
Optionnellement, si SEARX_URL est défini, interroge une instance SearxNG.
Renvoie une liste de résultats {title, url, snippet}.
"""
query = (query or "").strip()
if not query:
raise ToolError("requête de recherche vide")
searx = os.environ.get("SEARX_URL")
try:
if searx:
results = _search_searx(searx, query, max_results)
else:
results = _search_duckduckgo(query, max_results)
except httpx.HTTPError as exc:
raise ToolError(f"recherche web indisponible : {exc}") from exc
summary = f"{len(results)} résultat(s)" if results else "aucun résultat"
return {"ok": True, "results": results, "summary": summary}
def _search_searx(base: str, query: str, n: int) -> list[dict]:
with httpx.Client(timeout=10.0) as client:
r = client.get(
base.rstrip("/") + "/search",
params={"q": query, "format": "json"},
)
r.raise_for_status()
data = r.json().get("results", [])[:n]
return [
{"title": d.get("title", ""), "url": d.get("url", ""),
"snippet": d.get("content", "")}
for d in data
]
_DDG_RESULT = re.compile(
r'<a[^>]*class="result__a"[^>]*href="([^"]+)"[^>]*>(.*?)</a>'
r'.*?class="result__snippet"[^>]*>(.*?)</a>',
re.DOTALL,
)
_TAGS = re.compile(r"<[^>]+>")
def _clean(text: str) -> str:
return html.unescape(_TAGS.sub("", text)).strip()
def _search_duckduckgo(query: str, n: int) -> list[dict]:
with httpx.Client(timeout=10.0, follow_redirects=True) as client:
r = client.post(
"https://html.duckduckgo.com/html/",
data={"q": query},
headers={"User-Agent": "Mozilla/5.0 (Loki agent)"},
)
r.raise_for_status()
results = []
for url, title, snippet in _DDG_RESULT.findall(r.text)[:n]:
results.append({
"title": _clean(title),
"url": html.unescape(url),
"snippet": _clean(snippet),
})
return results
def run_shell(command: str, timeout: int = 60) -> dict:
"""Exécute une commande shell dans le workspace (outil sensible).
L'exécution effective n'a lieu qu'après validation utilisateur (gérée par
la boucle agentique / la route /api/shell). Confinée au workspace.
"""
command = (command or "").strip()
if not command:
raise ToolError("commande vide")
try:
proc = subprocess.run(
command,
shell=True,
cwd=_workspace_root(),
capture_output=True,
text=True,
timeout=timeout,
)
except subprocess.TimeoutExpired as exc:
raise ToolError(f"délai dépassé ({timeout}s)") from exc
out = (proc.stdout or "") + (proc.stderr or "")
out = out[:4000] # borne la taille renvoyée au modèle
status = "ok" if proc.returncode == 0 else "error"
return {
"ok": proc.returncode == 0,
"exit_code": proc.returncode,
"output": out,
"summary": f"code {proc.returncode}",
"_status": status,
}
# ── Registre & définitions exposées au modèle ────────────────────────────
TOOL_IMPL = {
"read_file": read_file,
"write_file": write_file,
"list_dir": list_dir,
"web_search": web_search,
"run_shell": run_shell,
}
TOOL_DEFINITIONS = [
@@ -121,6 +229,37 @@ TOOL_DEFINITIONS = [
},
},
},
{
"type": "function",
"function": {
"name": "web_search",
"description": "Rechercher sur le web et renvoyer les meilleurs résultats.",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Termes de recherche"}
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "run_shell",
"description": (
"Exécuter une commande shell dans le workspace. Outil sensible :"
" l'utilisateur doit valider la commande avant exécution."
),
"parameters": {
"type": "object",
"properties": {
"command": {"type": "string", "description": "Commande à exécuter"}
},
"required": ["command"],
},
},
},
]