sync: 204 file da Baida98/AI@843961e3 (2026-08-31 07:48 UTC) [deploy-all]

#154
by Baida07 - opened
api/event_store.py CHANGED
@@ -4,7 +4,7 @@ backend/api/event_store.py — Event Store (persistenza, Fase 1 ADR-S26-S30)
4
  Responsabilità: SALVARE tutti gli eventi per replayability, debugging, benchmark.
5
  NON instrada — per pub/sub usa event_bus.py.
6
 
7
- Schema Supabase (tabella `event_store`, creata dalla migration versionata):
8
  id UUID PK default gen_random_uuid()
9
  topic TEXT NOT NULL
10
  payload JSONB NOT NULL default '{}'
@@ -40,24 +40,25 @@ router = APIRouter(
40
 
41
  _TABLE = "event_store"
42
 
43
- # ── Schema probe (la creazione è gestita esclusivamente dalle migration) ────────
44
 
45
  _TABLE_CREATED = False
46
 
47
  async def _ensure_table() -> bool:
48
- """Verifica che la tabella event_store creata dalla migration sia raggiungibile."""
49
  global _TABLE_CREATED
50
  if _TABLE_CREATED:
51
  return True
52
  if not _sb:
53
  return False
54
  try:
55
- # La tabella deve esistere: il client runtime non esegue DDL.
56
  res = _sb.table(_TABLE).select("id").limit(1).execute()
57
  _TABLE_CREATED = True
58
  return True
59
  except Exception as exc:
60
- _logger.warning("[event_store] tabella '%s' non raggiungibile: %s", _TABLE, exc)
 
61
  return False
62
 
63
 
@@ -90,8 +91,9 @@ async def store_event(req: StoreEventRequest) -> StoredEvent:
90
  Chiamato automaticamente dall'event_bus (via hook) o esplicitamente
91
  dai componenti che vogliono garantire persistenza.
92
  """
93
- if not await _ensure_table():
94
- raise HTTPException(503, detail="Event Store non disponibile (schema Supabase assente o non raggiungibile)")
 
95
 
96
  record = {
97
  "topic": req.topic,
@@ -130,8 +132,9 @@ async def replay_events(
130
  Recupera eventi filtrati dall'Event Store. Supporta replay per debugging,
131
  test di regressione e audit trail.
132
  """
133
- if not await _ensure_table():
134
- raise HTTPException(503, detail="Event Store non disponibile (schema Supabase assente o non raggiungibile)")
 
135
 
136
  try:
137
  q = _sb.table(_TABLE).select("*").order("created_at", desc=True).limit(limit)
@@ -157,35 +160,19 @@ async def replay_events(
157
  raise HTTPException(500, detail=f"Event Store query error: {exc}")
158
 
159
 
160
- @router.get("/store/status", summary="Diagnostica Event Store")
161
- async def store_status():
162
- """Verifica connettività dello store e restituisce statistiche."""
163
- if not await _ensure_table():
164
- return {"status": "unavailable", "reason": "schema event_store assente o non raggiungibile"}
165
- try:
166
- res = _sb.table(_TABLE).select("topic", count="exact").execute()
167
- total = res.count if hasattr(res, "count") and res.count else len(res.data or [])
168
- return {
169
- "status": "ok",
170
- "component": "event_store",
171
- "total_events": total,
172
- "table": _TABLE,
173
- }
174
- except Exception as exc:
175
- return {"status": "error", "detail": str(exc)}
176
- @router.get("/store/{event_id:uuid}", summary="Recupera evento singolo")
177
- async def get_event(event_id: uuid.UUID) -> StoredEvent:
178
  """Recupera un evento specifico per ID."""
179
- if not await _ensure_table():
180
- raise HTTPException(503, detail="Event Store non disponibile (schema Supabase assente o non raggiungibile)")
 
181
  try:
182
- event_id_str = str(event_id)
183
- res = _sb.table(_TABLE).select("*").eq("id", event_id_str).limit(1).execute()
184
  if not res.data:
185
  raise HTTPException(404, detail=f"Evento {event_id} non trovato")
186
  row = res.data[0]
187
  return StoredEvent(**{
188
- "id": row.get("id", str(event_id)),
189
  "topic": row.get("topic", ""),
190
  "payload": row.get("payload", {}),
191
  "correlation_id": row.get("correlation_id"),
@@ -199,3 +186,19 @@ async def get_event(event_id: uuid.UUID) -> StoredEvent:
199
  raise HTTPException(500, detail=f"Event Store get error: {exc}")
200
 
201
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  Responsabilità: SALVARE tutti gli eventi per replayability, debugging, benchmark.
5
  NON instrada — per pub/sub usa event_bus.py.
6
 
7
+ Schema Supabase (tabella `event_store`, auto-created se non esiste):
8
  id UUID PK default gen_random_uuid()
9
  topic TEXT NOT NULL
10
  payload JSONB NOT NULL default '{}'
 
40
 
41
  _TABLE = "event_store"
42
 
43
+ # ── Auto-create table (best-effort, richiede service role key) ─────────────────
44
 
45
  _TABLE_CREATED = False
46
 
47
  async def _ensure_table() -> bool:
48
+ """Crea la tabella event_store su Supabase se non esiste. Best-effort."""
49
  global _TABLE_CREATED
50
  if _TABLE_CREATED:
51
  return True
52
  if not _sb:
53
  return False
54
  try:
55
+ # Prova una SELECT se la tabella non esiste, Supabase ritorna un errore
56
  res = _sb.table(_TABLE).select("id").limit(1).execute()
57
  _TABLE_CREATED = True
58
  return True
59
  except Exception as exc:
60
+ _logger.warning("[event_store] tabella '%s' non raggiungibile: %s "
61
+ "crea manualmente con migration Supabase", _TABLE, exc)
62
  return False
63
 
64
 
 
91
  Chiamato automaticamente dall'event_bus (via hook) o esplicitamente
92
  dai componenti che vogliono garantire persistenza.
93
  """
94
+ await _ensure_table()
95
+ if not _sb:
96
+ raise HTTPException(503, detail="Event Store non disponibile (Supabase non configurato)")
97
 
98
  record = {
99
  "topic": req.topic,
 
132
  Recupera eventi filtrati dall'Event Store. Supporta replay per debugging,
133
  test di regressione e audit trail.
134
  """
135
+ await _ensure_table()
136
+ if not _sb:
137
+ raise HTTPException(503, detail="Event Store non disponibile")
138
 
139
  try:
140
  q = _sb.table(_TABLE).select("*").order("created_at", desc=True).limit(limit)
 
160
  raise HTTPException(500, detail=f"Event Store query error: {exc}")
161
 
162
 
163
+ @router.get("/store/{event_id}", summary="Recupera evento singolo")
164
+ async def get_event(event_id: str) -> StoredEvent:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
  """Recupera un evento specifico per ID."""
166
+ await _ensure_table()
167
+ if not _sb:
168
+ raise HTTPException(503, detail="Event Store non disponibile")
169
  try:
170
+ res = _sb.table(_TABLE).select("*").eq("id", event_id).limit(1).execute()
 
171
  if not res.data:
172
  raise HTTPException(404, detail=f"Evento {event_id} non trovato")
173
  row = res.data[0]
174
  return StoredEvent(**{
175
+ "id": row.get("id", event_id),
176
  "topic": row.get("topic", ""),
177
  "payload": row.get("payload", {}),
178
  "correlation_id": row.get("correlation_id"),
 
186
  raise HTTPException(500, detail=f"Event Store get error: {exc}")
187
 
188
 
189
+ @router.get("/store/status", summary="Diagnostica Event Store")
190
+ async def store_status():
191
+ """Verifica connettività dello store e restituisce statistiche."""
192
+ if not _sb:
193
+ return {"status": "unavailable", "reason": "Supabase non configurato"}
194
+ try:
195
+ res = _sb.table(_TABLE).select("topic", count="exact").execute()
196
+ total = res.count if hasattr(res, "count") and res.count else len(res.data or [])
197
+ return {
198
+ "status": "ok",
199
+ "component": "event_store",
200
+ "total_events": total,
201
+ "table": _TABLE,
202
+ }
203
+ except Exception as exc:
204
+ return {"status": "error", "detail": str(exc)}
api/providers.py CHANGED
@@ -250,13 +250,8 @@ async def ai_provider_readiness(role: AuthRole = Depends(require_role(AuthRole.M
250
 
251
 
252
  @router.get('/api/ai/health')
253
- async def ai_provider_health(role: AuthRole = Depends(require_role(AuthRole.OPERATOR))):
254
- """Restituisce diagnostica dettagliata dei provider ai soli operatori autorizzati.
255
-
256
- Il payload contiene profili, modelli, classi di errore e dati di quota upstream;
257
- non deve quindi essere reso disponibile al browser tramite il token interno del
258
- proxy. Il controllo pubblico di disponibilità resta `/api/health`.
259
- """
260
  now = time.monotonic()
261
  if _ai_health_cache["data"] and now - _ai_health_cache["at"] < _AI_HEALTH_TTL:
262
  return _ai_health_cache["data"]
 
250
 
251
 
252
  @router.get('/api/ai/health')
253
+ async def ai_provider_health(role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
254
+ """Testa tutti i provider AI in parallelo — risultati cachati 60s."""
 
 
 
 
 
255
  now = time.monotonic()
256
  if _ai_health_cache["data"] and now - _ai_health_cache["at"] < _AI_HEALTH_TTL:
257
  return _ai_health_cache["data"]
api/public_snapshot.py CHANGED
@@ -55,14 +55,9 @@ from .version import RUNTIME_VERSION
55
 
56
  _logger = logging.getLogger("agente_ai.public_snapshot")
57
 
58
- _SNAPSHOT_ATTEMPTS = 3
59
- _SNAPSHOT_ATTEMPT_TIMEOUT_SECONDS = 4.0
60
- _SNAPSHOT_RETRY_DELAY_SECONDS = 1.0
61
- _SNAPSHOT_TOTAL_BUDGET_SECONDS = (
62
- _SNAPSHOT_ATTEMPTS * _SNAPSHOT_ATTEMPT_TIMEOUT_SECONDS
63
- + (_SNAPSHOT_ATTEMPTS - 1) * _SNAPSHOT_RETRY_DELAY_SECONDS
64
- )
65
  _ACTIVE_STATUSES = {"RUNNING", "IN_PROGRESS", "EXECUTING", "PROCESSING"}
 
 
66
  def _snapshot_row() -> dict[str, Any]:
67
  tasks = list(_agent_tasks.values())
68
  return {
@@ -93,12 +88,9 @@ async def write_public_dashboard_snapshot() -> bool:
93
  client.table("public_dashboard_snapshot").upsert(row, on_conflict="singleton").execute()
94
 
95
  last_error: Exception | None = None
96
- for attempt in range(1, _SNAPSHOT_ATTEMPTS + 1):
97
  try:
98
- await asyncio.wait_for(
99
- asyncio.to_thread(operation),
100
- timeout=_SNAPSHOT_ATTEMPT_TIMEOUT_SECONDS,
101
- )
102
  _logger.info(
103
  "BOOT: public snapshot upserted status=%s sessions=%d queued=%d in_progress=%d version=%s",
104
  row["service_status"], row["active_sessions"], row["queued_tasks"],
@@ -107,8 +99,8 @@ async def write_public_dashboard_snapshot() -> bool:
107
  return True
108
  except Exception as exc:
109
  last_error = exc
110
- if attempt < _SNAPSHOT_ATTEMPTS:
111
- await asyncio.sleep(_SNAPSHOT_RETRY_DELAY_SECONDS)
112
 
113
  error_code = getattr(last_error, "code", None) or getattr(last_error, "status_code", None)
114
  _logger.warning(
 
55
 
56
  _logger = logging.getLogger("agente_ai.public_snapshot")
57
 
 
 
 
 
 
 
 
58
  _ACTIVE_STATUSES = {"RUNNING", "IN_PROGRESS", "EXECUTING", "PROCESSING"}
59
+
60
+
61
  def _snapshot_row() -> dict[str, Any]:
62
  tasks = list(_agent_tasks.values())
63
  return {
 
88
  client.table("public_dashboard_snapshot").upsert(row, on_conflict="singleton").execute()
89
 
90
  last_error: Exception | None = None
91
+ for attempt in range(1, 4):
92
  try:
93
+ await asyncio.to_thread(operation)
 
 
 
94
  _logger.info(
95
  "BOOT: public snapshot upserted status=%s sessions=%d queued=%d in_progress=%d version=%s",
96
  row["service_status"], row["active_sessions"], row["queued_tasks"],
 
99
  return True
100
  except Exception as exc:
101
  last_error = exc
102
+ if attempt < 3:
103
+ await asyncio.sleep(2)
104
 
105
  error_code = getattr(last_error, "code", None) or getattr(last_error, "status_code", None)
106
  _logger.warning(
api/speculative.py CHANGED
@@ -117,8 +117,8 @@ def _prune_cache() -> None:
117
 
118
  # S388: Groq client singleton per speculative — evita new OpenAI() per ogni task.
119
  _spec_groq_client: Any = None
120
- _SPEC_GROQ_MAX_CONCURRENCY = 4
121
- _spec_groq_semaphore = asyncio.Semaphore(_SPEC_GROQ_MAX_CONCURRENCY)
122
  def _get_spec_groq_client() -> Any:
123
  global _spec_groq_client
124
  if _spec_groq_client is not None:
@@ -127,8 +127,8 @@ def _get_spec_groq_client() -> Any:
127
  if not groq_key:
128
  return None
129
  try:
130
- from openai import AsyncOpenAI
131
- _spec_groq_client = AsyncOpenAI(
132
  api_key=groq_key,
133
  base_url="https://api.groq.com/openai/v1",
134
  timeout=3.0,
@@ -137,6 +137,8 @@ def _get_spec_groq_client() -> Any:
137
  except Exception:
138
  _spec_groq_client = None
139
  return _spec_groq_client
 
 
140
  async def _extract_tools_fast(goal: str) -> list[dict]:
141
  """
142
  Usa Groq openai/gpt-oss-20b per estrarre tool calls in ~300ms.
@@ -149,16 +151,16 @@ async def _extract_tools_fast(goal: str) -> list[dict]:
149
  if not client:
150
  return []
151
  prompt = _EXTRACTION_PROMPT.format(message=goal[:500])
152
- async with _spec_groq_semaphore:
153
- resp = await asyncio.wait_for(
154
- client.chat.completions.create(
155
- model="openai/gpt-oss-20b",
156
- messages=[{"role": "user", "content": prompt}],
157
- temperature=0.0,
158
- max_tokens=400, # S587: 256→400 — JSON array da goal[:500] supera 256 tok
159
- ),
160
- timeout=3.0,
161
- )
162
  raw = (resp.choices[0].message.content or "").strip()
163
  # Estrai JSON array anche se ci sono prefissi di testo
164
  start = raw.find("[")
 
117
 
118
  # S388: Groq client singleton per speculative — evita new OpenAI() per ogni task.
119
  _spec_groq_client: Any = None
120
+
121
+
122
  def _get_spec_groq_client() -> Any:
123
  global _spec_groq_client
124
  if _spec_groq_client is not None:
 
127
  if not groq_key:
128
  return None
129
  try:
130
+ from openai import OpenAI
131
+ _spec_groq_client = OpenAI(
132
  api_key=groq_key,
133
  base_url="https://api.groq.com/openai/v1",
134
  timeout=3.0,
 
137
  except Exception:
138
  _spec_groq_client = None
139
  return _spec_groq_client
140
+
141
+
142
  async def _extract_tools_fast(goal: str) -> list[dict]:
143
  """
144
  Usa Groq openai/gpt-oss-20b per estrarre tool calls in ~300ms.
 
151
  if not client:
152
  return []
153
  prompt = _EXTRACTION_PROMPT.format(message=goal[:500])
154
+ resp = await asyncio.wait_for(
155
+ asyncio.to_thread(
156
+ client.chat.completions.create,
157
+ model="openai/gpt-oss-20b",
158
+ messages=[{"role": "user", "content": prompt}],
159
+ temperature=0.0,
160
+ max_tokens=400, # S587: 256→400 — JSON array da goal[:500] supera 256 tok
161
+ ),
162
+ timeout=3.0,
163
+ )
164
  raw = (resp.choices[0].message.content or "").strip()
165
  # Estrai JSON array anche se ci sono prefissi di testo
166
  start = raw.find("[")
main.py CHANGED
@@ -253,7 +253,7 @@ async def _snapshot_heartbeat() -> None:
253
  while True:
254
  await asyncio.sleep(30)
255
  try:
256
- await asyncio.wait_for(refresh_public_dashboard_snapshot(), timeout=15.0)
257
  except asyncio.CancelledError:
258
  raise
259
  except Exception as exc:
@@ -274,7 +274,7 @@ async def startup_event():
274
  # deterministico e non può dichiarare successo prima della migrazione.
275
  try:
276
  from api.public_snapshot import write_public_dashboard_snapshot
277
- snapshot_ok = await asyncio.wait_for(write_public_dashboard_snapshot(), timeout=15.0)
278
  if snapshot_ok:
279
  _logger.info("✅ BOOT: public dashboard snapshot writer completato.")
280
  else:
 
253
  while True:
254
  await asyncio.sleep(30)
255
  try:
256
+ await asyncio.wait_for(refresh_public_dashboard_snapshot(), timeout=10.0)
257
  except asyncio.CancelledError:
258
  raise
259
  except Exception as exc:
 
274
  # deterministico e non può dichiarare successo prima della migrazione.
275
  try:
276
  from api.public_snapshot import write_public_dashboard_snapshot
277
+ snapshot_ok = await asyncio.wait_for(write_public_dashboard_snapshot(), timeout=12.0)
278
  if snapshot_ok:
279
  _logger.info("✅ BOOT: public dashboard snapshot writer completato.")
280
  else:
tests/test_ai_provider_health_access.py DELETED
@@ -1,83 +0,0 @@
1
- """Regressioni di accesso alla diagnostica dettagliata dei provider.
2
-
3
- Esegui con: python3 -m unittest backend.tests.test_ai_provider_health_access -v
4
- """
5
- from __future__ import annotations
6
-
7
- import os
8
- import sys
9
- import time
10
- import unittest
11
- from unittest.mock import patch
12
-
13
- from fastapi import FastAPI
14
- from fastapi.testclient import TestClient
15
-
16
- _BACKEND = os.path.join(os.path.dirname(__file__), "..")
17
- if _BACKEND not in sys.path:
18
- sys.path.insert(0, _BACKEND)
19
-
20
- from api import auth_guard, providers
21
-
22
-
23
- class TestAIProviderHealthAccess(unittest.TestCase):
24
- """Il payload con profili, quote e dettagli upstream è solo per OPERATOR."""
25
-
26
- @classmethod
27
- def setUpClass(cls) -> None:
28
- app = FastAPI()
29
- app.include_router(providers.router)
30
- cls.client = TestClient(app)
31
-
32
- def setUp(self) -> None:
33
- self.env_patch = patch.dict(
34
- os.environ,
35
- {
36
- "INTERNAL_TOKEN": "test-internal-token",
37
- "OPERATOR_TOKEN": "test-operator-token",
38
- },
39
- clear=False,
40
- )
41
- self.env_patch.start()
42
- self.previous_health_cache = dict(providers._ai_health_cache)
43
- auth_guard._rate_store.clear()
44
- auth_guard._rate_store_checks = 0
45
-
46
- def tearDown(self) -> None:
47
- self.env_patch.stop()
48
- providers._ai_health_cache.clear()
49
- providers._ai_health_cache.update(self.previous_health_cache)
50
- auth_guard._rate_store.clear()
51
- auth_guard._rate_store_checks = 0
52
-
53
- def test_anonymous_browser_cannot_request_detailed_provider_diagnostics(self) -> None:
54
- response = self.client.get("/api/ai/health")
55
-
56
- self.assertEqual(response.status_code, 403)
57
- self.assertEqual(response.json()["detail"]["required_role"], "OPERATOR")
58
-
59
- def test_proxy_machine_token_cannot_escalate_browser_to_provider_diagnostics(self) -> None:
60
- response = self.client.get(
61
- "/api/ai/health",
62
- headers={"X-Internal-Token": "test-internal-token"},
63
- )
64
-
65
- self.assertEqual(response.status_code, 403)
66
- self.assertEqual(response.json()["detail"]["your_role"], "MACHINE")
67
- self.assertEqual(response.json()["detail"]["required_role"], "OPERATOR")
68
-
69
- def test_operator_can_read_cached_diagnostics_without_triggering_a_probe(self) -> None:
70
- providers._ai_health_cache["data"] = {"providers": [], "tested_at": 1}
71
- providers._ai_health_cache["at"] = time.monotonic()
72
-
73
- response = self.client.get(
74
- "/api/ai/health",
75
- headers={"X-Operator-Token": "test-operator-token"},
76
- )
77
-
78
- self.assertEqual(response.status_code, 200)
79
- self.assertEqual(response.json(), {"providers": [], "tested_at": 1})
80
-
81
-
82
- if __name__ == "__main__":
83
- unittest.main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_shell_safety_pip.py DELETED
@@ -1,32 +0,0 @@
1
- """Regression tests for the generic shell package-manager boundary."""
2
- from __future__ import annotations
3
- import unittest
4
-
5
- from tools._shell_safety import validate_shell_command
6
-
7
-
8
- class TestPipBlockedFromGenericShell(unittest.TestCase):
9
- def test_blocks_pip_install(self):
10
- self.assertIsNotNone(validate_shell_command("pip install requests"))
11
-
12
- def test_blocks_pip3_install(self):
13
- self.assertIsNotNone(validate_shell_command("pip3 install requests"))
14
-
15
- def test_blocks_python_module_pip(self):
16
- self.assertIsNotNone(validate_shell_command("python -m pip install requests"))
17
-
18
- def test_blocks_python3_module_pip3(self):
19
- self.assertIsNotNone(validate_shell_command("python3 -m pip3 install requests"))
20
-
21
- def test_blocks_python_module_ensurepip(self):
22
- self.assertIsNotNone(validate_shell_command("python -m ensurepip"))
23
-
24
- def test_allows_python_without_package_manager(self):
25
- self.assertIsNone(validate_shell_command("python -c 'print(1)'"))
26
-
27
- def test_allows_pnpm(self):
28
- self.assertIsNone(validate_shell_command("pnpm --version"))
29
-
30
-
31
- if __name__ == "__main__":
32
- unittest.main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tools/_shell_safety.py CHANGED
@@ -7,7 +7,7 @@ Algoritmo:
7
  1. Blocca metacaratteri shell (previene bypass allowlist)
8
  2. shlex.split() — tokenizzazione sicura, rileva quoting anomalo
9
  3. frozenset argv[0] — allowlist letterale, non regex di prefisso
10
- 4. Regole per git (subcmd), python -m e curl/wget (solo https://)
11
  5. create_subprocess_exec / subprocess.run con lista argv — NO shell=True
12
  """
13
  from __future__ import annotations
@@ -17,12 +17,11 @@ from typing import Optional
17
  _METACHAR_RE = re.compile(r'[;&|`<>\n\r]|\$[\(\{]')
18
  _ALLOWED: frozenset = frozenset({
19
  "ls", "cat", "echo", "pwd", "whoami", "date", "uname",
20
- "python3", "python", "node", "npm", "pnpm",
21
  "grep", "find", "head", "tail", "wc", "sort", "uniq", "diff",
22
  "mkdir", "touch", "cp", "mv", "chmod", "git", "curl", "wget",
23
  })
24
  _GIT_OK: frozenset = frozenset({"status", "log", "diff", "show", "branch", "remote"})
25
- _PYTHON_MODULES_BLOCKED: frozenset = frozenset({"pip", "pip3", "ensurepip"})
26
 
27
 
28
  def safe_shell_env() -> dict:
@@ -73,9 +72,6 @@ def validate_shell_command(command: str) -> Optional[str]:
73
  if exe == "git":
74
  if len(argv) < 2 or argv[1].lower() not in _GIT_OK:
75
  return f"git sub-comando non permesso (ammessi: {', '.join(sorted(_GIT_OK))})"
76
- elif exe in ("python", "python3") and len(argv) >= 3 and argv[1] == "-m":
77
- if argv[2].lower() in _PYTHON_MODULES_BLOCKED:
78
- return f"modulo Python non permesso: '{argv[2]}'"
79
  elif exe in ("curl", "wget"):
80
  if not any(a.startswith("https://") for a in argv[1:]):
81
  return f"{exe}: solo URL https://"
 
7
  1. Blocca metacaratteri shell (previene bypass allowlist)
8
  2. shlex.split() — tokenizzazione sicura, rileva quoting anomalo
9
  3. frozenset argv[0] — allowlist letterale, non regex di prefisso
10
+ 4. Regole per git (subcmd) e curl/wget (solo https://)
11
  5. create_subprocess_exec / subprocess.run con lista argv — NO shell=True
12
  """
13
  from __future__ import annotations
 
17
  _METACHAR_RE = re.compile(r'[;&|`<>\n\r]|\$[\(\{]')
18
  _ALLOWED: frozenset = frozenset({
19
  "ls", "cat", "echo", "pwd", "whoami", "date", "uname",
20
+ "python3", "python", "node", "npm", "pip3", "pip", "pnpm",
21
  "grep", "find", "head", "tail", "wc", "sort", "uniq", "diff",
22
  "mkdir", "touch", "cp", "mv", "chmod", "git", "curl", "wget",
23
  })
24
  _GIT_OK: frozenset = frozenset({"status", "log", "diff", "show", "branch", "remote"})
 
25
 
26
 
27
  def safe_shell_env() -> dict:
 
72
  if exe == "git":
73
  if len(argv) < 2 or argv[1].lower() not in _GIT_OK:
74
  return f"git sub-comando non permesso (ammessi: {', '.join(sorted(_GIT_OK))})"
 
 
 
75
  elif exe in ("curl", "wget"):
76
  if not any(a.startswith("https://") for a in argv[1:]):
77
  return f"{exe}: solo URL https://"
tools/registry.py CHANGED
@@ -92,7 +92,7 @@ from tools._shell_safety import (
92
  )
93
  import re as _re_registry
94
  _REGISTRY_SAFE_CMD_RE = _re_registry.compile(
95
- r'^(ls|cat|echo|pwd|whoami|date|uname|python3?|node|npm|pnpm|'
96
  r'grep|find|head|tail|wc|sort|uniq|diff|mkdir|touch|cp|mv|chmod|'
97
  r'git\s+(status|log|diff|show)|curl\s+https?://|wget\s+https?://)(\s|$)',
98
  _re_registry.IGNORECASE,
 
92
  )
93
  import re as _re_registry
94
  _REGISTRY_SAFE_CMD_RE = _re_registry.compile(
95
+ r'^(ls|cat|echo|pwd|whoami|date|uname|python3?|node|npm|pip[3]?|pnpm|'
96
  r'grep|find|head|tail|wc|sort|uniq|diff|mkdir|touch|cp|mv|chmod|'
97
  r'git\s+(status|log|diff|show)|curl\s+https?://|wget\s+https?://)(\s|$)',
98
  _re_registry.IGNORECASE,