Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
57 changes: 31 additions & 26 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -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. |
Expand All @@ -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.

---

Expand All @@ -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.

Expand All @@ -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).

---

Expand All @@ -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 |

---
Expand All @@ -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.
Expand All @@ -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
```
70 changes: 54 additions & 16 deletions apps/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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")),
Expand All @@ -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:
Expand All @@ -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}}
Expand All @@ -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
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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) {
Expand All @@ -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(() => {
Expand Down
19 changes: 15 additions & 4 deletions apps/api/packages/tools/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading