Skip to content

Commit 09e0bac

Browse files
authored
fix(security): stop leaking stack traces at API boundaries + least-privilege workflow permissions (#2236)
Any browser client of the Agent UI memory dashboard or the EMR dashboard could see raw exception text — internal file paths, exception classes, and error internals — whenever a scan, inference, or model load failed. Responses now return a generic message with a short correlation id, and the full exception (with traceback) is logged server-side, so debugging stays possible without exposing internals. `test_eval_rag.yml` also drops the default token grants in favor of an explicit `contents: read`. Closes 8 of the 9 open medium-severity CodeQL alerts in these two rule classes; the 9th is dismissed as a verified false positive. | Alert | Rule | Location | Resolution | |---|---|---|---| | #249 | py/stack-trace-exposure | `src/gaia/ui/routers/memory.py` stream-discovery SSE | Generic message + correlation id; full detail logged server-side | | #250 | py/stack-trace-exposure | `src/gaia/ui/routers/memory.py` stream-inference SSE | Same | | #346 | py/stack-trace-exposure | `src/gaia/ui/routers/memory.py` settings `system_context_error` | Same | | #347#349 | py/stack-trace-exposure | EMR dashboard `/api/init` (3 sinks) | Removed `str(e)` from the returned `steps` payload; logged instead | | #323, #324 | actions/missing-workflow-permissions | `.github/workflows/test_eval_rag.yml` | Top-level `permissions: contents: read` (both jobs only checkout + run the eval) | | #327 | py/stack-trace-exposure | EMR dashboard `/api/chat` | **Dismissed (false positive)** — response already routed through `_sanitize_response_text`, which strips tracebacks, `File` lines, exception class names, and paths; CodeQL can't model the regex barrier | ## Test plan - [x] `python util/lint.py --all` passes - [x] `tests/unit/test_memory_router.py` — 768 passed (1 pre-existing, unrelated failure also fails on clean main: expects faiss missing, but it's installed locally) - [x] EMR package tests — 67 passed - [x] Workflow YAML parses; both jobs verified read-only (checkout + eval run, no writes to repo/issues/checks) - [ ] CodeQL on this PR reports no new alerts and closes the fixed ones after merge
1 parent 3df5a3d commit 09e0bac

3 files changed

Lines changed: 68 additions & 12 deletions

File tree

.github/workflows/test_eval_rag.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ name: Eval RAG Quality
1212
on:
1313
workflow_dispatch:
1414

15+
# Both jobs only check out code and run the eval — no writes to the repo,
16+
# issues, or checks are needed.
17+
permissions:
18+
contents: read
19+
1520
concurrency:
1621
group: lemonade-eval
1722
cancel-in-progress: false

hub/agents/python/emr/gaia_agent_emr/dashboard/server.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1513,8 +1513,13 @@ async def run_init() -> Dict[str, Any]:
15131513
)
15141514
steps[-1]["status"] = "complete"
15151515
except Exception as e:
1516+
# Full detail stays server-side; steps are returned to the
1517+
# client (CodeQL py/stack-trace-exposure).
1518+
logger.error(
1519+
f"Failed to load {model_type} model {model_name}: {e}"
1520+
)
15161521
steps[-1]["status"] = "warning"
1517-
steps[-1]["error"] = str(e)[:50]
1522+
steps[-1]["error"] = "Model load failed — check server logs."
15181523

15191524
step_num += 1
15201525

src/gaia/ui/routers/memory.py

Lines changed: 57 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import logging
88
import os
99
import threading
10+
import uuid
1011
from datetime import datetime
1112
from typing import Any, Dict, List, Optional
1213

@@ -26,6 +27,17 @@
2627
router = APIRouter(tags=["memory"])
2728

2829

30+
def _log_server_error(context: str, exc: Exception) -> str:
31+
"""Log full exception detail server-side and return a short correlation id.
32+
33+
Responses carry only a generic message plus the id so exception
34+
internals never reach the client (CodeQL py/stack-trace-exposure).
35+
"""
36+
correlation_id = uuid.uuid4().hex[:8]
37+
logger.error("[memory router] %s (id=%s)", context, correlation_id, exc_info=exc)
38+
return correlation_id
39+
40+
2941
def _require_ui_header(request: Request) -> None:
3042
"""Require ``X-Gaia-UI: 1`` header as a lightweight CSRF guard."""
3143
if request.headers.get("x-gaia-ui") != "1":
@@ -1216,14 +1228,28 @@ def _generate():
12161228
else:
12171229
yield _sse({"type": "log", "message": " Nothing found"})
12181230
except Exception as e:
1231+
cid = _log_server_error(
1232+
f"discovery scanner '{source_key}' failed", e
1233+
)
12191234
yield _sse(
1220-
{"type": "error", "source": source_key, "message": str(e)}
1235+
{
1236+
"type": "error",
1237+
"source": source_key,
1238+
"message": f"Scanner failed — see server logs (id={cid}).",
1239+
}
12211240
)
12221241

12231242
yield _sse({"type": "done", "total": total})
12241243

12251244
except Exception as exc:
1226-
yield _sse({"type": "error", "source": "discovery", "message": str(exc)})
1245+
cid = _log_server_error("discovery stream failed", exc)
1246+
yield _sse(
1247+
{
1248+
"type": "error",
1249+
"source": "discovery",
1250+
"message": f"Discovery failed — see server logs (id={cid}).",
1251+
}
1252+
)
12271253
yield _sse({"type": "done", "total": 0})
12281254

12291255
return StreamingResponse(
@@ -1274,10 +1300,14 @@ def _generate():
12741300
}
12751301
)
12761302
except Exception as e:
1303+
cid = _log_server_error("browser history scan failed", e)
12771304
yield _sse(
12781305
{
12791306
"type": "log",
1280-
"message": f" Browser history unavailable: {e}",
1307+
"message": (
1308+
" Browser history unavailable — "
1309+
f"see server logs (id={cid})"
1310+
),
12811311
}
12821312
)
12831313

@@ -1413,10 +1443,14 @@ def _generate():
14131443
):
14141444
raw_response = "".join(raw_response)
14151445
except Exception as e:
1446+
cid = _log_server_error("inference LLM call failed", e)
14161447
yield _sse(
14171448
{
14181449
"type": "error",
1419-
"message": f"LLM call failed: {e}. Is Lemonade Server running?",
1450+
"message": (
1451+
"LLM call failed. Is Lemonade Server running? "
1452+
f"See server logs (id={cid})."
1453+
),
14201454
}
14211455
)
14221456
yield _sse({"type": "done", "total": 0})
@@ -1440,8 +1474,15 @@ def _generate():
14401474
and i["content"].strip()
14411475
]
14421476
except Exception as e:
1477+
cid = _log_server_error("failed to parse LLM inference response", e)
14431478
yield _sse(
1444-
{"type": "error", "message": f"Failed to parse LLM response: {e}"}
1479+
{
1480+
"type": "error",
1481+
"message": (
1482+
"Failed to parse LLM response — "
1483+
f"see server logs (id={cid})."
1484+
),
1485+
}
14451486
)
14461487
yield _sse({"type": "done", "total": 0})
14471488
return
@@ -1460,8 +1501,13 @@ def _generate():
14601501
yield _sse({"type": "done", "total": len(insights)})
14611502

14621503
except Exception as exc:
1463-
logger.error("[memory router] stream-inference failed: %s", exc)
1464-
yield _sse({"type": "error", "message": str(exc)})
1504+
cid = _log_server_error("stream-inference failed", exc)
1505+
yield _sse(
1506+
{
1507+
"type": "error",
1508+
"message": f"Inference failed — see server logs (id={cid}).",
1509+
}
1510+
)
14651511
yield _sse({"type": "done", "total": 0})
14661512

14671513
return StreamingResponse(
@@ -1637,10 +1683,10 @@ def update_memory_settings(
16371683
result["system_context_refresh"] = refresh_result
16381684
return result
16391685
except Exception as exc:
1640-
logger.warning(
1641-
"[memory router] system discovery after consent failed: %s", exc
1642-
)
1686+
cid = _log_server_error("system discovery after consent failed", exc)
16431687
result = _get_memory_settings_dict(db)
1644-
result["system_context_error"] = str(exc)
1688+
result["system_context_error"] = (
1689+
f"System discovery failed — see server logs (id={cid})."
1690+
)
16451691
return result
16461692
return _get_memory_settings_dict(db)

0 commit comments

Comments
 (0)