diff --git a/.env.example b/.env.example index f850fdb..a8e0ca3 100644 --- a/.env.example +++ b/.env.example @@ -20,3 +20,9 @@ SLACK_BOT_TOKEN= # API API_PORT=8000 + +# Public demo hardening +LIVE_MODE=false +ENABLE_DEBUG=false +PUBLIC_RUN_TOKEN= +CORS_ORIGINS=https://aegis-agent-api.vercel.app,http://localhost:8000,http://127.0.0.1:8000 diff --git a/SECURITY.md b/SECURITY.md index b7b0027..1a1aa18 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,21 +1,24 @@ # Security Assessment — AEGIS (aegis_vercel) -**Date:** 2026-08-21 +**Date:** 2026-09-06 **Scope:** Auth, XSS, injection, CORS, secrets, tool execution, HITL, evals **Context:** Public deploy is a **portfolio demo** of a LangGraph supervisor + 6 specialists. Live UI: [aegis-agent-api.vercel.app/ui](https://aegis-agent-api.vercel.app/ui). Vercel project `aegis-api`, Root Directory `apps/api`. +Repos stay **public until deliberately made private**. Honest demo threat model — **not** a bank-grade guarantee. + --- ## Executive summary | Area | Risk | Notes | |------|------|--------| -| Authentication | **None (accepted)** | `/invoke`, `/stream`, `/threads/{id}/resume` are public. No API key, JWT, or session. | +| Authentication | **Optional gate** | Demo/sim is public. With `LIVE_MODE=true` and `PUBLIC_RUN_TOKEN`, invoke/stream/resume require `x-run-token`. | | Authorization | **None (accepted)** | HITL “approve” is an unauthenticated POST. Anyone who can hit the URL can resume a thread. | | XSS | **Low (demo UI)** | `/ui` is a server-rendered HTMLResponse. User task text is written into the output pane via `textContent` (escaped). Mermaid SVG is rendered from a fixed template, not from raw user HTML. | | Injection (SQL) | **Low** | Live Vercel path mocks SQL. Write verbs (`INSERT`/`UPDATE`/`DELETE`/`DROP`/`ALTER`) return `WRITE_BLOCKED`. | -| Code execution | **Demo residual** | `code_executor` runs restricted `exec()` with a tiny `__builtins__` allow-list. Not a production sandbox (not E2B / gVisor). | -| CORS | **Demo residual** | `allow_origins=["*"]` with `allow_credentials=True` (spec-invalid combo; browsers ignore credentials on `*`). Dashboard is same-origin so CORS rarely applies. | +| Code execution | **Demo residual** | `code_executor` bans imports/dunders/`open`/`eval` plus tiny builtins. Not a production sandbox (not E2B / gVisor). | +| Rate limits | **Best-effort** | In-memory per-IP on invoke/stream/resume; resets per serverless instance. | +| CORS | **Hardened** | Explicit allowlist from `CORS_ORIGINS` (default: Vercel origin + localhost). No `*` with credentials. | | Secrets in repo | **Low** | `.env` gitignored. `.env.example` has empty placeholders only. | | Payments / PII | **N/A** | No payments, no user accounts, no PII store. | | Eval gate | **Honest mock** | Public CI has no `LANGCHAIN_API_KEY`. `scripts/run_evals.py` writes a **mock** faithfulness report. Do not read CI “≥ 0.82” as a live LangSmith score. | @@ -28,14 +31,14 @@ ## 1. Authentication -**Findings** -- FastAPI app in `apps/api/main.py` has no `Depends`, no bearer header, no cookie session. -- `/health` reports whether LLM keys are *present* (booleans only — not the secret values). -- `/debug` exposes Python version and cwd. Fine for a demo; strip it before any private deploy. - -**Verdict:** Unauthenticated public API. Accepted for the portfolio demo. Not accepted for a company-internal agent that can open PRs or send Slack. +**Controls (2026-09-06)** +- `LIVE_MODE` (default off): live LangGraph/LLM only when true **and** an LLM key is present; otherwise force demo/sim. +- `PUBLIC_RUN_TOKEN`: when set under live mode, require matching `x-run-token` on `/invoke`, `/stream`, resume. +- `ENABLE_DEBUG` (default off): `/debug` returns 404 unless enabled. +- `/health` reports key *presence* booleans + `live_mode` — never secret values. +- In-memory per-IP rate limit on invoke/stream/resume (~20/min). -**If auth is added later:** edge middleware or FastAPI dependency (API key or OIDC), rate-limit `/invoke` and `/stream`, drop `/debug` from production. +**Verdict:** Public demo/sim remains open (accepted). Live path is opt-in and optionally token-gated. Still not company IAM / OIDC. --- @@ -62,7 +65,7 @@ Accepted for the demo. Not a SOC2 control. | `github_toolkit` | action block | `create_pr` / `branch` → `HITL_REQUIRED` | | `slack_toolkit` | action block | `post_message` → `HITL_REQUIRED` | | `send_email_tool` | always | `HITL_REQUIRED` | -| `code_executor` | language + builtins | Non-Python refused; `exec` with `print`/`range`/`len`/`sum` only | +| `code_executor` | language + token ban + builtins | Non-Python refused; bans import/dunder/open/eval; tiny builtins only | Covered by `tests/test_tool_guards.py`. These are string-level guards on mock tools, not a policy engine. @@ -81,12 +84,13 @@ Covered by `tests/test_tool_guards.py`. These are string-level guards on mock to ## 5. CORS -```python -allow_origins=["*"] -allow_credentials=True -``` +Explicit allowlist via `CORS_ORIGINS` (comma-separated). Defaults: + +- `https://aegis-agent-api.vercel.app` +- `http://localhost:3000` / `http://127.0.0.1:3000` +- `http://localhost:8000` / `http://127.0.0.1:8000` -Browsers will not send credentials with a `*` origin. Same-origin `/ui` does not need CORS. Left as-is so a visitor can `fetch` `/stream` from another origin during a take-home clone. Tighten to the production alias if this API is ever put behind a private UI. +Credentials are enabled **only** with that allowlist — never `allow_origins=["*"]` with credentials. `/ui` security headers: `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Permissions-Policy`, CSP (jsDelivr Mermaid + inline scripts required by the single-file UI). --- @@ -107,13 +111,13 @@ Never commit `LANGCHAIN_API_KEY`, LLM keys, or database passwords. |------|------|--------| | `/` | None | Status JSON | | `/health` | None | Graph + key-presence booleans | -| `/debug` | None | Python version / cwd — demo only | +| `/debug` | `ENABLE_DEBUG` | 404 by default | | `/ui` | None | Live dashboard | | `/docs` | None | OpenAPI playground | -| `POST /invoke` | None | Runs the graph or returns mock | -| `POST /stream` | None | SSE; `force_demo=true` is the public demo path | -| `POST /threads/{id}/resume` | None | HITL resume | -| `POST /threads/{id}/resume/stream` | None | Post-HITL SSE simulation (Vercel) | +| `POST /invoke` | rate limit; optional live token | Mock unless `LIVE_MODE` | +| `POST /stream` | rate limit; optional live token | Demo SSE by default | +| `POST /threads/{id}/resume` | rate limit; optional live token | HITL resume | +| `POST /threads/{id}/resume/stream` | rate limit; optional live token | Post-HITL SSE simulation (Vercel) | | `/fleet/*` | None | Stub list of bots | --- @@ -139,12 +143,13 @@ Live LangSmith project: `aegis-production`. Real faithfulness is visible there w ## 10. Residual risk & acceptance **Accepted for portfolio demo** -- No authentication on invoke / stream / HITL resume. -- CORS `*`. -- Restricted `exec` in `code_executor`. +- Public demo/sim without auth (live path opt-in / optional token). +- Best-effort in-memory rate limits (not shared across Vercel isolates). +- Restricted `exec` in `code_executor` (still not a real sandbox). - Mock SQL / GitHub / Slack / FS tools on Vercel. - Mock eval report in public CI. - Leftover unused `apps/web` Next.js stub. +- Public repo until flipped private. **Not accepted if this becomes an internal production copilot** - Unauthenticated `/invoke` that can reach live tools. @@ -161,7 +166,7 @@ Live LangSmith project: `aegis-production`. Real faithfulness is visible there w python -m pip install -r apps/api/requirements.txt python -m pip install pytest ruff mypy PYTHONPATH=. pytest -q --tb=short -ruff check tests packages apps/api/packages apps/api/main.py apps/api/routers scripts +ruff check tests packages apps/api/packages apps/api/main.py apps/api/routers apps/api/security_hardening.py scripts mypy packages/tools packages/evals tests --ignore-missing-imports --follow-imports=skip PYTHONPATH=. python scripts/run_evals.py # mock unless LANGCHAIN_API_KEY is set ``` diff --git a/apps/api/main.py b/apps/api/main.py index 055dcb4..7427583 100644 --- a/apps/api/main.py +++ b/apps/api/main.py @@ -3,9 +3,18 @@ import json import time import asyncio -from fastapi import FastAPI +from fastapi import Depends, FastAPI, Request from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import StreamingResponse, HTMLResponse +from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse + +from security_hardening import ( + SecurityHeadersMiddleware, + cors_origins, + debug_enabled, + live_mode_enabled, + rate_limit_invoke, + require_run_token_if_configured, +) ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) if ROOT not in sys.path: @@ -21,13 +30,15 @@ def _sse(payload: dict) -> str: app = FastAPI(title="AEGIS API", version=VERSION, description="Autonomous Enterprise Graph Intelligence System") +_origins = cors_origins() app.add_middleware( CORSMiddleware, - allow_origins=["*"], + allow_origins=_origins, allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], + allow_methods=["GET", "POST", "OPTIONS"], + allow_headers=["Content-Type", "x-run-token", "Authorization"], ) +app.add_middleware(SecurityHeadersMiddleware) graph = None graph_load_error = None @@ -76,6 +87,7 @@ def health(): "version": VERSION, "graph": bool(graph), "graph_error": graph_load_error, + "live_mode": live_mode_enabled(), "llm_keys": { "google": bool(os.getenv("GOOGLE_API_KEY")), "openai": bool(os.getenv("OPENAI_API_KEY")), @@ -87,18 +99,26 @@ def health(): @app.get("/debug") def debug(): + if not debug_enabled(): + return JSONResponse({"detail": "Not found"}, status_code=404) return { "graph_loaded": bool(graph), "graph_error": graph_load_error, "python": sys.version, "cwd": os.getcwd(), + "live_mode": live_mode_enabled(), } @app.post("/invoke") -async def invoke(req: InvokeRequest): - if not graph: - return {"output": f"[mock] {req.input}", "mock": True, "graph_error": graph_load_error} +async def invoke( + req: InvokeRequest, + request: Request, + _rl: None = Depends(rate_limit_invoke), + _tok: None = Depends(require_run_token_if_configured), +): + if not live_mode_enabled() or req.force_demo or not graph: + return {"output": f"[mock] {req.input}", "mock": True, "graph_error": graph_load_error, "live_mode": False} from langchain_core.messages import HumanMessage config = {"configurable": {"thread_id": req.thread_id}} try: @@ -123,9 +143,15 @@ async def invoke(req: InvokeRequest): @app.post("/threads/{thread_id}/resume") -async def resume_thread(thread_id: str, req: ResumeRequest): - if not graph: - return {"thread_id": thread_id, "resumed": False, "error": "Graph not loaded"} +async def resume_thread( + thread_id: str, + req: ResumeRequest, + request: Request, + _rl: None = Depends(rate_limit_invoke), + _tok: None = Depends(require_run_token_if_configured), +): + if not live_mode_enabled() or not graph: + return {"thread_id": thread_id, "resumed": False, "error": "Graph not loaded or LIVE_MODE off", "mock": True} try: from langgraph.types import Command config = {"configurable": {"thread_id": thread_id}} @@ -145,7 +171,13 @@ async def resume_thread(thread_id: str, req: ResumeRequest): @app.post("/threads/{thread_id}/resume/stream") -async def resume_thread_stream(thread_id: str, req: ResumeRequest): +async def resume_thread_stream( + thread_id: str, + req: ResumeRequest, + request: Request, + _rl: None = Depends(rate_limit_invoke), + _tok: None = Depends(require_run_token_if_configured), +): """SSE streaming resume — emits post-HITL evaluator/communicator flow. On Vercel serverless, in-memory LangGraph checkpoints (MemorySaver) are @@ -375,8 +407,14 @@ async def gen(): @app.post("/stream") -async def stream(req: InvokeRequest): - if not graph or req.force_demo: +async def stream( + req: InvokeRequest, + request: Request, + _rl: None = Depends(rate_limit_invoke), + _tok: None = Depends(require_run_token_if_configured), +): + # Default: demo/sim. Live graph only when LIVE_MODE + keys + graph + not force_demo. + if (not live_mode_enabled()) or (not graph) or req.force_demo: return StreamingResponse(_demo_event_gen(), media_type="text/event-stream") return StreamingResponse( _real_event_gen(req.input, req.thread_id), @@ -958,7 +996,7 @@ async def ui(): // ── Health check ── fetch('/health').then(r => r.json()).then(h => { - graphAvailable = !!h.graph; + graphAvailable = !!h.graph && !!h.live_mode; const badge = $('#mode-badge'); const toggle = $('#demo-toggle'); if (graphAvailable) { @@ -971,7 +1009,7 @@ async def ui(): toggle.disabled = true; $('#toggle-text').textContent = 'Live inference'; $('#status').textContent = 'demo mode'; - out.textContent = 'AEGIS graph not loaded. Running in demo simulation mode.\n\nSet GOOGLE_API_KEY in Vercel and redeploy for live inference.'; + out.textContent = 'AEGIS running in demo simulation mode.\n\nLive inference requires LIVE_MODE=true plus an LLM key on the server.'; isDemoMode = true; } }).catch(() => { diff --git a/apps/api/packages/tools/registry.py b/apps/api/packages/tools/registry.py index 4f0b236..435b9c7 100644 --- a/apps/api/packages/tools/registry.py +++ b/apps/api/packages/tools/registry.py @@ -22,16 +22,27 @@ def code_executor(code: str, language: str = "python") -> str: """Execute code in a sandbox. Read-only safe.""" if language != "python": return "Only python supported in lite mode." - # Very restricted exec for Vercel + if not isinstance(code, str) or len(code) > 1500: + return "SECURITY_BLOCKED: code too long or invalid" + lowered = code.lower() + banned = ( + "import ", "__", "open(", "exec(", "eval(", "compile(", + "globals(", "locals(", "getattr(", "setattr(", "delattr(", + "breakpoint(", "input(", "os.", "sys.", "subprocess", + "builtins", "memoryview", "bytearray", "help(", + ) + if any(b in lowered for b in banned): + return "SECURITY_BLOCKED: disallowed token in code" + # Very restricted exec for Vercel — residual: not a real sandbox (no gVisor/E2B) try: import io, contextlib buf = io.StringIO() - safe_globals = {"__builtins__": {"print": print, "range": range, "len": len, "sum": sum}} + safe_builtins = {"print": print, "range": range, "len": len, "sum": sum, "min": min, "max": max, "abs": abs} with contextlib.redirect_stdout(buf): - exec(code, safe_globals, {}) + exec(code, {"__builtins__": safe_builtins}, {}) # noqa: S102 — intentional demo sandbox return buf.getvalue()[:2000] or "Executed with no output." except Exception as e: - return f"CodeExecutor error: {e}" + return f"CodeExecutor error: {type(e).__name__}" @tool def postgres_sql_toolkit(query: str) -> str: diff --git a/apps/api/security_hardening.py b/apps/api/security_hardening.py new file mode 100644 index 0000000..2f580a8 --- /dev/null +++ b/apps/api/security_hardening.py @@ -0,0 +1,125 @@ +"""Demo-hardening helpers for the public AEGIS FastAPI surface. + +In-memory rate limits reset per serverless instance — residual, documented in SECURITY.md. +""" +from __future__ import annotations + +import os +import time +from collections import defaultdict, deque +from typing import Deque, Dict + +from fastapi import Header, HTTPException, Request +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import Response + +DEFAULT_CORS = [ + "https://aegis-agent-api.vercel.app", + "http://localhost:3000", + "http://127.0.0.1:3000", + "http://localhost:8000", + "http://127.0.0.1:8000", +] + +SECURITY_HEADERS = { + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "Referrer-Policy": "strict-origin-when-cross-origin", + "Permissions-Policy": "camera=(), microphone=(), geolocation=()", + "Content-Security-Policy": ( + "default-src 'self'; " + "script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; " + "style-src 'self' 'unsafe-inline'; " + "img-src 'self' data:; " + "connect-src 'self'; " + "frame-ancestors 'none'; " + "base-uri 'self'" + ), +} + + +def cors_origins() -> list[str]: + raw = (os.getenv("CORS_ORIGINS") or "").strip() + if not raw: + return list(DEFAULT_CORS) + return [o.strip() for o in raw.split(",") if o.strip()] + + +def debug_enabled() -> bool: + return os.getenv("ENABLE_DEBUG", "").strip().lower() in {"1", "true", "yes", "on"} + + +def live_mode_enabled() -> bool: + """Live LLM path only when explicitly enabled AND at least one key is present.""" + flag = os.getenv("LIVE_MODE", "").strip().lower() in {"1", "true", "yes", "on"} + if not flag: + return False + keys = ("GOOGLE_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY") + return any(bool(os.getenv(k)) for k in keys) + + +def public_run_token() -> str | None: + tok = (os.getenv("PUBLIC_RUN_TOKEN") or "").strip() + return tok or None + + +def require_run_token_if_configured( + x_run_token: str | None = Header(default=None, alias="x-run-token"), +) -> None: + """When LIVE_MODE and PUBLIC_RUN_TOKEN are set, require matching x-run-token.""" + if not live_mode_enabled(): + return + expected = public_run_token() + if not expected: + return + if not x_run_token or x_run_token != expected: + raise HTTPException(status_code=401, detail="Missing or invalid x-run-token") + + +class InMemoryRateLimiter: + """Sliding-window per-IP limiter (best-effort on serverless).""" + + def __init__(self, max_requests: int = 30, window_seconds: float = 60.0) -> None: + self.max_requests = max_requests + self.window_seconds = window_seconds + self._hits: Dict[str, Deque[float]] = defaultdict(deque) + + def check(self, key: str) -> None: + now = time.monotonic() + q = self._hits[key] + cutoff = now - self.window_seconds + while q and q[0] < cutoff: + q.popleft() + if len(q) >= self.max_requests: + raise HTTPException(status_code=429, detail="Rate limit exceeded; retry shortly") + q.append(now) + + +# Shared limiter for invoke / stream / resume +invoke_limiter = InMemoryRateLimiter(max_requests=20, window_seconds=60.0) + + +def client_ip(request: Request) -> str: + forwarded = request.headers.get("x-forwarded-for") + if forwarded: + return forwarded.split(",")[0].strip() or "unknown" + if request.client: + return request.client.host or "unknown" + return "unknown" + + +def rate_limit_invoke(request: Request) -> None: + invoke_limiter.check(client_ip(request)) + + +class SecurityHeadersMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + response: Response = await call_next(request) + path = request.url.path or "" + if path == "/ui" or path.startswith("/ui/"): + for k, v in SECURITY_HEADERS.items(): + response.headers.setdefault(k, v) + else: + response.headers.setdefault("X-Content-Type-Options", "nosniff") + response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin") + return response diff --git a/packages/tools/registry.py b/packages/tools/registry.py index 4f0b236..435b9c7 100644 --- a/packages/tools/registry.py +++ b/packages/tools/registry.py @@ -22,16 +22,27 @@ def code_executor(code: str, language: str = "python") -> str: """Execute code in a sandbox. Read-only safe.""" if language != "python": return "Only python supported in lite mode." - # Very restricted exec for Vercel + if not isinstance(code, str) or len(code) > 1500: + return "SECURITY_BLOCKED: code too long or invalid" + lowered = code.lower() + banned = ( + "import ", "__", "open(", "exec(", "eval(", "compile(", + "globals(", "locals(", "getattr(", "setattr(", "delattr(", + "breakpoint(", "input(", "os.", "sys.", "subprocess", + "builtins", "memoryview", "bytearray", "help(", + ) + if any(b in lowered for b in banned): + return "SECURITY_BLOCKED: disallowed token in code" + # Very restricted exec for Vercel — residual: not a real sandbox (no gVisor/E2B) try: import io, contextlib buf = io.StringIO() - safe_globals = {"__builtins__": {"print": print, "range": range, "len": len, "sum": sum}} + safe_builtins = {"print": print, "range": range, "len": len, "sum": sum, "min": min, "max": max, "abs": abs} with contextlib.redirect_stdout(buf): - exec(code, safe_globals, {}) + exec(code, {"__builtins__": safe_builtins}, {}) # noqa: S102 — intentional demo sandbox return buf.getvalue()[:2000] or "Executed with no output." except Exception as e: - return f"CodeExecutor error: {e}" + return f"CodeExecutor error: {type(e).__name__}" @tool def postgres_sql_toolkit(query: str) -> str: diff --git a/tests/test_api.py b/tests/test_api.py index 73e61e0..1ef5488 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -77,3 +77,22 @@ def test_invoke_mock_or_graph_without_auth(): assert r.status_code == 200 body = r.json() assert "thread_id" in body or "output" in body or "error" in body or "interrupted" in body + + +def test_debug_gated_by_default(): + r = client.get("/debug") + assert r.status_code == 404 + + +def test_health_reports_live_mode_flag(): + r = client.get("/health") + assert r.status_code == 200 + assert "live_mode" in r.json() + assert r.json()["live_mode"] is False + + +def test_ui_sets_security_headers(): + r = client.get("/ui") + assert r.status_code == 200 + assert r.headers.get("x-content-type-options") == "nosniff" + assert r.headers.get("x-frame-options") == "DENY" diff --git a/tests/test_tool_guards.py b/tests/test_tool_guards.py index c749b98..1f11063 100644 --- a/tests/test_tool_guards.py +++ b/tests/test_tool_guards.py @@ -50,7 +50,9 @@ def test_code_executor_restricted_builtins(): result = code_executor.invoke({"code": "print(sum(range(5)))", "language": "python"}) assert "10" in result blocked = code_executor.invoke({"code": "open('/etc/passwd')", "language": "python"}) - assert "error" in blocked.lower() or "Error" in blocked + assert "SECURITY_BLOCKED" in blocked + banned_import = code_executor.invoke({"code": "import os", "language": "python"}) + assert "SECURITY_BLOCKED" in banned_import def test_github_pr_is_hitl_gated():