security: address all 10 findings from external audit (issue #476)
Implements VT-001 through VT-010 from the 2026-07-10 external security audit (Trivy/Semgrep/ZAP/Gitleaks/pip-audit against a real deployment): - VT-001 (Critical): AST hardening in backtest/runner.py rejects network/subprocess/eval/os.environ/unsafe-open calls reachable from generated SignalEngine method bodies (previously only checked top-level statements). Defense-in-depth: ephemeral per-run HOME, RLIMIT_AS/NOFILE caps, best-effort UID-drop to a new vibe-sandbox user (currently inert under Docker's cap model - see session doc; accepted as documented residual gap, primary AST defense unaffected). - VT-002: multi-stage Dockerfile split so build-essential never reaches the runtime image. - VT-003: SSE auth moved from a long-lived API key in the URL query string to short-lived single-use tickets; access-log redaction for any remaining query secrets. - VT-004: agent/.env split-brain fixed (bind mount + atomic 0600 writes) between docker-compose and Web UI settings writes. - VT-005: /correlation now requires auth + per-IP rate limit; generic exceptions no longer leak raw text to clients. - VT-006: CSP-Report-Only + security headers middleware; Google Fonts replaced with self-hosted @fontsource packages. - VT-007: docker-compose hardening (cap_drop ALL + minimal cap_add, no-new-privileges, read_only rootfs, resource limits). - VT-008: /health split into /live (liveness) and /ready (real provider-config readiness check); /health kept as a compat alias. - VT-009: base images pinned by digest, GitHub Actions pinned by commit SHA, dependabot.yml added, hash-verified requirements-lock.txt now actually wired into the Docker build via --require-hashes. - VT-010: universe-panel pickle cache sidecar upgraded from an unkeyed SHA-256 (forgeable by anyone with cache-dir write access) to a keyed HMAC. Implemented via a 5-parcel Workflow (file-disjoint, independent adversarial verification per parcel, then a holistic final audit). The final audit caught a real regression (CI env-var-gate violation) and confirmed a dependency-pinning gap, both fixed and re-verified here, including a latent transitive-dependency bug the fix surfaced (smartmoneyconcepts<0.0.27 pulls an unbuildable zigzag package) - see docs/sessions/2026-07-13_session_vt476_security_hardening.md (local, gitignored) for full evidence trail. Full suite: 5191 passed, 6 pre-existing/environmental failures (unchanged from main). Docker build + hardened compose boot verified end-to-end.
This commit is contained in:
1 parent
2f4ba89733
commit
4e3c91c77a
34 files changed
+6748
-113
No files matched your search
@@ -0,0 +1,22 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Root Python project (pyproject.toml).
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 10
|
||||
|
||||
# Frontend npm dependencies.
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/frontend"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 10
|
||||
|
||||
# GitHub Actions workflow pins.
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 10
|
||||
@@ -12,9 +12,9 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
|
||||
- uses: actions/setup-python@v6
|
||||
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
cache: pip
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
# the suite never silently switches state on env presence.
|
||||
run: pytest --ignore=agent/tests/e2e_backtest --ignore=agent/tests/test_e2e_harness_v2.py --cov=agent --cov-report=term-missing --cov-report=xml --tb=short -q
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: npm
|
||||
|
||||
@@ -21,10 +21,10 @@ jobs:
|
||||
deployments: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
|
||||
- name: Deploy wiki
|
||||
uses: cloudflare/wrangler-action@v3
|
||||
uses: cloudflare/wrangler-action@9acf94ace14e7dc412b076f2c5c20b8ce93c79cd # v3
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
|
||||
@@ -18,13 +18,13 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- uses: actions/setup-python@v6
|
||||
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
|
||||
+59
-23
@@ -1,7 +1,8 @@
|
||||
# ============================================================================
|
||||
# Stage 1: Build frontend
|
||||
# ============================================================================
|
||||
FROM node:20-slim AS frontend-build
|
||||
FROM node:20-slim@sha256:2cf067cfed83d5ea958367df9f966191a942351a2df77d6f0193e162b5febfc0 AS frontend-build
|
||||
# node:20-slim digest resolved 2026-07-13
|
||||
|
||||
WORKDIR /app/frontend
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
@@ -10,9 +11,42 @@ COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
# ============================================================================
|
||||
# Stage 2: Python runtime
|
||||
# Stage 2: Python builder — compiles wheels + builds a self-contained venv.
|
||||
# build-essential lives ONLY here; it never reaches the runtime image.
|
||||
# ============================================================================
|
||||
FROM python:3.11-slim AS runtime
|
||||
FROM python:3.11-slim@sha256:e031123e3d85762b141ad1cbc56452ba69c6e722ebf2f042cc0dc86c47c0d8b3 AS builder
|
||||
# python:3.11-slim digest resolved 2026-07-13
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Isolated venv we can copy wholesale into the runtime stage.
|
||||
ENV VIRTUAL_ENV=/opt/venv
|
||||
RUN python -m venv "$VIRTUAL_ENV"
|
||||
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Python deps first for layer caching. Installed from the hash-pinned lock
|
||||
# (agent/requirements.txt is the human-edited source; regenerate the lock
|
||||
# with the command documented at the top of requirements-lock.txt whenever
|
||||
# agent/requirements.txt changes).
|
||||
COPY agent/requirements.txt agent/requirements.txt
|
||||
COPY requirements-lock.txt requirements-lock.txt
|
||||
RUN pip install --no-cache-dir --require-hashes -r requirements-lock.txt
|
||||
|
||||
# Copy project + install the CLI entrypoint (editable — the runtime stage
|
||||
# re-creates the same /app/agent source tree the .pth file points at).
|
||||
COPY pyproject.toml LICENSE README.md ./
|
||||
COPY agent/ agent/
|
||||
RUN pip install --no-cache-dir -e .
|
||||
|
||||
# ============================================================================
|
||||
# Stage 3: Runtime — carries the prebuilt venv only, no compilers/dev headers.
|
||||
# ============================================================================
|
||||
FROM python:3.11-slim@sha256:e031123e3d85762b141ad1cbc56452ba69c6e722ebf2f042cc0dc86c47c0d8b3 AS runtime
|
||||
# python:3.11-slim digest resolved 2026-07-13
|
||||
|
||||
LABEL org.opencontainers.image.title="Vibe-Trading" \
|
||||
org.opencontainers.image.description="Natural-language finance research AI agent with backtesting" \
|
||||
@@ -22,14 +56,15 @@ LABEL org.opencontainers.image.title="Vibe-Trading" \
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# System deps
|
||||
# build-essential — compile any wheels without prebuilt manylinux artifacts.
|
||||
# The rest are weasyprint's runtime native libs (Pango/HarfBuzz/Fontconfig/
|
||||
# Cairo/gdk-pixbuf) per its official Debian install list; without them the
|
||||
# lazy `from weasyprint import HTML` in reporter.py fails and PDF rendering
|
||||
# silently downgrades to HTML-only. fonts-dejavu-core gives non-blank PDFs.
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
# Runtime-only native libs. NO build-essential here — these are weasyprint's
|
||||
# shared libraries (Pango/HarfBuzz/Fontconfig/Cairo/gdk-pixbuf) per its official
|
||||
# Debian install list; without them the lazy `from weasyprint import HTML` in
|
||||
# reporter.py fails and PDF rendering silently downgrades to HTML-only.
|
||||
# fonts-dejavu-core gives non-blank PDFs.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
libpango-1.0-0 \
|
||||
libpangoft2-1.0-0 \
|
||||
libharfbuzz0b \
|
||||
@@ -39,23 +74,24 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
fonts-dejavu-core \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Python deps (install before copying code for layer caching)
|
||||
COPY agent/requirements.txt agent/requirements.txt
|
||||
RUN pip install --no-cache-dir -r agent/requirements.txt
|
||||
# Bring in the prebuilt venv from the builder stage.
|
||||
ENV VIRTUAL_ENV=/opt/venv
|
||||
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
|
||||
COPY --from=builder /opt/venv /opt/venv
|
||||
|
||||
# Copy project
|
||||
# Re-materialize the source tree the editable install references, plus the
|
||||
# built frontend static assets.
|
||||
COPY pyproject.toml LICENSE README.md ./
|
||||
COPY agent/ agent/
|
||||
|
||||
# Copy built frontend
|
||||
COPY --from=frontend-build /app/frontend/dist frontend/dist
|
||||
|
||||
# Install CLI entrypoint
|
||||
RUN pip install --no-cache-dir -e .
|
||||
|
||||
# Runtime should not run as root. Keep writable app data directories owned by
|
||||
# the service user so named Docker volumes inherit usable permissions.
|
||||
# Runtime should not run as root. `vibe` owns the writable app-data dirs so
|
||||
# named volumes inherit usable permissions. `vibe-sandbox` is an unprivileged
|
||||
# system account (no home, no shell) that runner.py drops into via
|
||||
# subprocess.run(user="vibe-sandbox") to execute LLM-generated code with the
|
||||
# least privilege — created here by fixed contract, not otherwise used.
|
||||
RUN useradd --create-home --shell /usr/sbin/nologin vibe \
|
||||
&& useradd --system --no-create-home --shell /usr/sbin/nologin --uid 10001 vibe-sandbox \
|
||||
&& mkdir -p agent/runs agent/sessions agent/uploads agent/.swarm/runs /home/vibe/.vibe-trading \
|
||||
&& chown -R vibe:vibe /app /home/vibe/.vibe-trading
|
||||
USER vibe
|
||||
@@ -63,9 +99,9 @@ USER vibe
|
||||
# Default port
|
||||
EXPOSE 8899
|
||||
|
||||
# Health check
|
||||
# Health check — hits /live (liveness probe; /health remains a legacy alias).
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8899/health')" || exit 1
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8899/live')" || exit 1
|
||||
|
||||
# Run API server (serves frontend/dist as static files)
|
||||
CMD ["vibe-trading", "serve", "--host", "0.0.0.0", "--port", "8899"]
|
||||
@@ -39,8 +39,10 @@ from src.api.security import ( # noqa: F401, E402
|
||||
_DEFAULT_LOOPBACK_HOSTS,
|
||||
_EXTRA_LOOPBACK_HOSTS,
|
||||
_SAFE_BROWSER_METHODS,
|
||||
_apply_security_headers,
|
||||
_auth_credential_from_header_or_query,
|
||||
_configured_api_key,
|
||||
_consume_sse_ticket,
|
||||
_default_gateway_ips,
|
||||
_env_shell_tools_enabled,
|
||||
_host_without_port,
|
||||
@@ -48,9 +50,11 @@ from src.api.security import ( # noqa: F401, E402
|
||||
_is_local_client,
|
||||
_is_loopback_bind_host,
|
||||
_is_loopback_origin,
|
||||
_mint_sse_ticket,
|
||||
_origin_matches_request_host,
|
||||
_parse_cors_origins,
|
||||
_parse_extra_loopback_hosts,
|
||||
_redact_query_secrets,
|
||||
_reject_cross_site_browser_request,
|
||||
_reject_untrusted_loopback_host,
|
||||
_require_shutdown_authorization,
|
||||
@@ -58,6 +62,7 @@ from src.api.security import ( # noqa: F401, E402
|
||||
_shell_tools_enabled_for_request,
|
||||
_trusted_docker_loopback_ip,
|
||||
_validate_api_auth,
|
||||
install_access_log_redaction_filter,
|
||||
require_auth,
|
||||
require_event_stream_auth,
|
||||
require_local_or_auth,
|
||||
@@ -132,6 +137,7 @@ app.add_middleware(
|
||||
# programmatically instead.
|
||||
app.middleware("http")(_reject_untrusted_loopback_host)
|
||||
app.middleware("http")(_spa_html_deep_link_fallback)
|
||||
app.middleware("http")(_apply_security_headers)
|
||||
|
||||
# ============================================================================
|
||||
# Lifecycle hooks
|
||||
@@ -268,6 +274,10 @@ from src.api.live_routes import ( # noqa: F401, E402
|
||||
from src.api.alpha_routes import register_alpha_routes # noqa: E402
|
||||
register_alpha_routes(app)
|
||||
|
||||
# --- Auth helpers (SSE tickets) ---
|
||||
from src.api.auth_routes import register_auth_routes # noqa: E402
|
||||
register_auth_routes(app)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Scheduled Research Routes - defined in src/api/scheduled_routes.py
|
||||
@@ -357,6 +367,11 @@ def serve_main(argv: list[str] | None = None) -> int:
|
||||
print(f" http://127.0.0.1:{args.port}")
|
||||
print("=" * 50)
|
||||
|
||||
# Redact api_key=/ticket= values from Uvicorn's access log (it logs the full
|
||||
# request line including the query string). Installed before run() so the
|
||||
# filter is attached when Uvicorn configures its loggers.
|
||||
install_access_log_redaction_filter()
|
||||
|
||||
try:
|
||||
uvicorn.run(app, host=args.host, port=args.port, log_level="info")
|
||||
finally:
|
||||
|
||||
@@ -242,6 +242,180 @@ def _validate_class_body(node: ast.ClassDef) -> None:
|
||||
)
|
||||
|
||||
|
||||
# --- Runtime-reachable operation scrubber (VT-001 defense-in-depth) ---
|
||||
#
|
||||
# The structural checks above only reject *import-time* execution. The real
|
||||
# exposure is that once ``SignalEngine`` is instantiated and ``.generate()`` is
|
||||
# called, arbitrary code inside its method bodies runs with no runtime sandbox.
|
||||
# This scrubber walks the code that actually executes during a backtest — every
|
||||
# ``SignalEngine`` method plus any module-level helper transitively called from
|
||||
# one — and rejects network / process-spawn / dynamic-exec / filesystem-write
|
||||
# operations there.
|
||||
#
|
||||
# It is deliberately scoped to *reachable* code, not the whole file: the bundled
|
||||
# skill examples (agent/src/skills/*/example_signal_engine.py) legitimately carry
|
||||
# ``import requests`` + ``requests.get`` inside standalone ``_fetch_okx`` helpers
|
||||
# and ``if __name__ == "__main__"`` demo blocks that the runner never executes
|
||||
# (it does ``import module; SignalEngine().generate(data_map)``). Blocking those
|
||||
# imports file-wide would reject strategies generated from ~12 shipped skills, so
|
||||
# we block the dangerous *use* along the executed path instead of a harmless
|
||||
# unused top-level import. Transitive reach via ``getattr``/indirection is a
|
||||
# documented residual — see the VT-001 note; this is not a kernel-level guarantee.
|
||||
_FORBIDDEN_IMPORT_MODULES = frozenset(
|
||||
{
|
||||
"socket",
|
||||
"socketserver",
|
||||
"subprocess",
|
||||
"urllib",
|
||||
"urllib2",
|
||||
"urllib3",
|
||||
"http",
|
||||
"requests",
|
||||
"httpx",
|
||||
"aiohttp",
|
||||
"ftplib",
|
||||
"smtplib",
|
||||
"telnetlib",
|
||||
"multiprocessing",
|
||||
"ctypes",
|
||||
}
|
||||
)
|
||||
# ``os`` itself is allowed (os.path etc.), but these attributes shell out, spawn,
|
||||
# or read the process environment — none has a place in a signal engine.
|
||||
_FORBIDDEN_OS_ATTRS = frozenset(
|
||||
{
|
||||
"system",
|
||||
"popen",
|
||||
"popen2",
|
||||
"popen3",
|
||||
"popen4",
|
||||
"fork",
|
||||
"forkpty",
|
||||
"putenv",
|
||||
"unsetenv",
|
||||
"getenv",
|
||||
"environ",
|
||||
"environb",
|
||||
"startfile",
|
||||
}
|
||||
)
|
||||
_FORBIDDEN_BUILTINS = frozenset(
|
||||
{"eval", "exec", "compile", "__import__", "globals", "locals", "vars", "breakpoint"}
|
||||
)
|
||||
_OPEN_WRITE_MODE_CHARS = frozenset("wax+")
|
||||
_SCRUB_MSG = "is not allowed inside generated strategy code"
|
||||
|
||||
|
||||
def _is_forbidden_os_attr(attr: str) -> bool:
|
||||
"""Return whether ``os.<attr>`` shells out, spawns, execs, or reads env."""
|
||||
return attr in _FORBIDDEN_OS_ATTRS or attr.startswith(("spawn", "exec"))
|
||||
|
||||
|
||||
def _attribute_root_name(node: ast.Attribute) -> str | None:
|
||||
"""Return the leftmost ``Name`` id of an attribute chain (``a.b.c`` -> ``a``)."""
|
||||
current: ast.AST = node
|
||||
while isinstance(current, ast.Attribute):
|
||||
current = current.value
|
||||
return current.id if isinstance(current, ast.Name) else None
|
||||
|
||||
|
||||
def _reject_forbidden_open(node: ast.Call) -> None:
|
||||
"""Reject ``open()`` used to write files or read a non-relative-literal path."""
|
||||
func = node.func
|
||||
is_builtin_open = isinstance(func, ast.Name) and func.id == "open"
|
||||
is_io_os_open = (
|
||||
isinstance(func, ast.Attribute)
|
||||
and func.attr == "open"
|
||||
and isinstance(func.value, ast.Name)
|
||||
and func.value.id in {"io", "os"}
|
||||
)
|
||||
if not (is_builtin_open or is_io_os_open):
|
||||
return
|
||||
|
||||
mode_node: ast.AST | None = node.args[1] if len(node.args) >= 2 else None
|
||||
for kw in node.keywords:
|
||||
if kw.arg == "mode":
|
||||
mode_node = kw.value
|
||||
if mode_node is not None:
|
||||
if not (isinstance(mode_node, ast.Constant) and isinstance(mode_node.value, str)):
|
||||
raise ValueError(f"open() with a non-literal mode {_SCRUB_MSG}")
|
||||
if any(ch in _OPEN_WRITE_MODE_CHARS for ch in mode_node.value):
|
||||
raise ValueError(f"Writing files via open(mode={mode_node.value!r}) {_SCRUB_MSG}")
|
||||
|
||||
path_node = node.args[0] if node.args else None
|
||||
if not (isinstance(path_node, ast.Constant) and isinstance(path_node.value, str)):
|
||||
raise ValueError(f"open() with a non-literal path {_SCRUB_MSG}")
|
||||
path = path_node.value
|
||||
if path.startswith(("/", "~", "\\")) or ".." in path or (len(path) > 1 and path[1] == ":"):
|
||||
raise ValueError(f"open() with a non-relative path {path!r} {_SCRUB_MSG}")
|
||||
|
||||
|
||||
def _reject_forbidden_node(node: ast.AST) -> None:
|
||||
"""Raise ``ValueError`` if a single AST node performs a forbidden operation."""
|
||||
if isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
if alias.name.split(".")[0] in _FORBIDDEN_IMPORT_MODULES:
|
||||
raise ValueError(f"Import of {alias.name!r} {_SCRUB_MSG}")
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
root = (node.module or "").split(".")[0]
|
||||
if root in _FORBIDDEN_IMPORT_MODULES:
|
||||
raise ValueError(f"Import from {node.module!r} {_SCRUB_MSG}")
|
||||
if root == "os":
|
||||
for alias in node.names:
|
||||
if _is_forbidden_os_attr(alias.name):
|
||||
raise ValueError(f"Import of os.{alias.name} {_SCRUB_MSG}")
|
||||
elif isinstance(node, ast.Attribute):
|
||||
root = _attribute_root_name(node)
|
||||
if root in _FORBIDDEN_IMPORT_MODULES:
|
||||
raise ValueError(f"Use of {root}.{node.attr} {_SCRUB_MSG}")
|
||||
if root == "os" and _is_forbidden_os_attr(node.attr):
|
||||
raise ValueError(f"Use of os.{node.attr} {_SCRUB_MSG}")
|
||||
elif isinstance(node, ast.Name):
|
||||
if node.id in _FORBIDDEN_BUILTINS:
|
||||
raise ValueError(f"Use of {node.id!r} {_SCRUB_MSG}")
|
||||
elif isinstance(node, ast.Call):
|
||||
_reject_forbidden_open(node)
|
||||
|
||||
|
||||
def _scan_runtime_reachable(tree: ast.Module) -> None:
|
||||
"""Reject forbidden ops in ``SignalEngine`` methods + their transitive callees.
|
||||
|
||||
Entry points are every method defined directly on the ``SignalEngine`` class.
|
||||
From there, any bare-name call that resolves to a module-level function is
|
||||
followed and scanned too, so a payload hidden in a helper that ``generate()``
|
||||
calls is still caught. Module-level functions never reached from a
|
||||
``SignalEngine`` method (standalone data-fetch helpers, ``__main__`` demos)
|
||||
are intentionally left unscanned.
|
||||
"""
|
||||
engine_cls = next(
|
||||
(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == "SignalEngine"),
|
||||
None,
|
||||
)
|
||||
if engine_cls is None:
|
||||
return
|
||||
|
||||
module_funcs = {
|
||||
n.name: n
|
||||
for n in tree.body
|
||||
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||
}
|
||||
worklist: list[ast.AST] = [
|
||||
m for m in engine_cls.body if isinstance(m, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||
]
|
||||
visited: set[int] = set()
|
||||
while worklist:
|
||||
fn = worklist.pop()
|
||||
if id(fn) in visited:
|
||||
continue
|
||||
visited.add(id(fn))
|
||||
for node in ast.walk(fn):
|
||||
_reject_forbidden_node(node)
|
||||
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
|
||||
target = module_funcs.get(node.func.id)
|
||||
if target is not None:
|
||||
worklist.append(target)
|
||||
|
||||
|
||||
def _validate_signal_engine_source(file_path: Path) -> None:
|
||||
"""Reject import-time executable statements before loading signal_engine.py."""
|
||||
try:
|
||||
@@ -271,6 +445,10 @@ def _validate_signal_engine_source(file_path: Path) -> None:
|
||||
f"Executable top-level statement {type(node).__name__} is not allowed"
|
||||
)
|
||||
|
||||
# Deep pass: the structural loop above only guards import-time execution;
|
||||
# this walks the code that runs on SignalEngine().generate() (VT-001).
|
||||
_scan_runtime_reachable(tree)
|
||||
|
||||
|
||||
def _validate_signal_engine_class(engine_cls) -> None:
|
||||
"""Pre-flight check: SignalEngine can be instantiated with no args and has generate()."""
|
||||
|
||||
@@ -31,7 +31,7 @@ scikit-learn>=1.3.0
|
||||
joblib>=1.3.0
|
||||
|
||||
# Signal Skills
|
||||
smartmoneyconcepts>=0.0.1
|
||||
smartmoneyconcepts>=0.0.27 # floor bumped 2026-07-13: 0.0.10 pulls in zigzag, an abandoned transitive dep with no arm64/py3.11 wheel (sdist Cython build fails); 0.0.27 (current latest) confirmed dependency-free of it
|
||||
# optional: pip install "vibe-trading-ai[harmonic]" for the pyharmonics-backed harmonic pattern detector
|
||||
|
||||
# Data Providers
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Auth helper routes — short-lived SSE tickets for browser EventSource auth.
|
||||
|
||||
Mounted by ``agent/api_server.py`` via ``register_auth_routes(app, ...)``.
|
||||
|
||||
A browser ``EventSource`` cannot send an ``Authorization`` header, so instead of
|
||||
putting the long-lived API key in the SSE URL (where it leaks into browser
|
||||
history, proxy/access logs, and Referer headers) the frontend exchanges the
|
||||
header-authenticated key for a one-shot ticket here, then opens the stream with
|
||||
``?ticket=``. The ticket store + validation live in ``src.api.security``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Awaitable, Callable
|
||||
|
||||
from fastapi import Depends, FastAPI
|
||||
|
||||
AuthDep = Callable[..., Awaitable[Any] | Any]
|
||||
|
||||
|
||||
def register_auth_routes(
|
||||
app: FastAPI,
|
||||
require_auth: AuthDep | None = None,
|
||||
) -> None:
|
||||
"""Mount the auth helper routes onto ``app``.
|
||||
|
||||
Args:
|
||||
app: The host FastAPI app.
|
||||
require_auth: Header-only auth dependency guarding ticket minting. When
|
||||
omitted it is resolved from the host ``api_server`` module via
|
||||
``sys.modules`` (matches the other ``register_*_routes`` helpers).
|
||||
"""
|
||||
if require_auth is None:
|
||||
import sys as _sys
|
||||
|
||||
host = _sys.modules.get("api_server") or _sys.modules.get("agent.api_server")
|
||||
if host is None: # pragma: no cover — only triggers on weird import setups
|
||||
raise RuntimeError(
|
||||
"register_auth_routes: api_server module not in sys.modules; "
|
||||
"pass require_auth explicitly"
|
||||
)
|
||||
require_auth = host.require_auth
|
||||
|
||||
from src.api.security import _mint_sse_ticket
|
||||
|
||||
@app.post("/auth/sse-ticket", dependencies=[Depends(require_auth)])
|
||||
async def mint_sse_ticket() -> dict[str, str]:
|
||||
"""Mint a single-use, ~60s ticket for a browser EventSource connection.
|
||||
|
||||
Gated by the header-only ``require_auth`` dependency, so minting still
|
||||
requires the real API key in an ``Authorization`` header — never in a
|
||||
URL. The returned ticket replaces the long-lived key in the SSE query
|
||||
string and is invalidated on first use.
|
||||
"""
|
||||
return {"ticket": _mint_sse_ticket()}
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Dict
|
||||
|
||||
@@ -64,11 +66,55 @@ async def _spa_html_deep_link_fallback(request: Request, call_next):
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def _atomic_write_secret(path: Path, content: str) -> None:
|
||||
"""Write *content* to *path* atomically with 0600 permissions.
|
||||
|
||||
The file holds provider API keys, so a crash mid-write must never leave a
|
||||
half-written or world-readable secret. The primary path writes to a sibling
|
||||
temp file (created 0600 by ``mkstemp``) and ``os.replace``s it onto the
|
||||
target — an atomic swap on the same filesystem.
|
||||
|
||||
Fallback: when the parent directory is read-only (the ``.env`` is
|
||||
bind-mounted into a container whose rootfs is ``read_only: true``, so no
|
||||
sibling temp file can be created) the swap is impossible. There we write in
|
||||
place, still enforcing 0600; atomicity is sacrificed for that one edge but
|
||||
the secret still persists and stays owner-only.
|
||||
"""
|
||||
data = content.encode("utf-8")
|
||||
try:
|
||||
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=".env.", suffix=".tmp")
|
||||
except OSError:
|
||||
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
try:
|
||||
os.write(fd, data)
|
||||
finally:
|
||||
os.close(fd)
|
||||
try:
|
||||
os.chmod(path, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
return
|
||||
try:
|
||||
os.write(fd, data)
|
||||
os.fchmod(fd, 0o600)
|
||||
finally:
|
||||
os.close(fd)
|
||||
try:
|
||||
os.replace(tmp, path)
|
||||
except BaseException:
|
||||
# Never leave a stray temp file holding the secret behind.
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def _ensure_agent_env_file() -> Path:
|
||||
"""Ensure the project-local agent/.env exists."""
|
||||
env_path = _host_attr("ENV_PATH", ENV_PATH)
|
||||
if not env_path.exists():
|
||||
env_path.write_text("# Created by Vibe-Trading Web UI settings.\n", encoding="utf-8")
|
||||
_atomic_write_secret(env_path, "# Created by Vibe-Trading Web UI settings.\n")
|
||||
return env_path
|
||||
|
||||
|
||||
@@ -143,7 +189,7 @@ def _write_env_values(path: Path, updates: Dict[str, str]) -> None:
|
||||
lines.append("# Updated from Web UI")
|
||||
for key in missing:
|
||||
lines.append(f"{key}={_format_env_value(updates[key])}")
|
||||
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
_atomic_write_secret(path, "\n".join(lines) + "\n")
|
||||
|
||||
|
||||
def _is_configured_secret(value: str, placeholders: set[str]) -> bool:
|
||||
|
||||
+169
-3
@@ -4,6 +4,11 @@ from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import ipaddress
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
@@ -119,6 +124,139 @@ async def _reject_untrusted_loopback_host(request: Request, call_next):
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Security-headers middleware
|
||||
# ============================================================================
|
||||
|
||||
# Deny powerful browser features the app never uses.
|
||||
_PERMISSIONS_POLICY = (
|
||||
"geolocation=(), camera=(), microphone=(), payment=(), usb=(), "
|
||||
"magnetometer=(), gyroscope=(), accelerometer=()"
|
||||
)
|
||||
|
||||
# Report-Only first: a later switch to enforcing mode can be validated against
|
||||
# real traffic without risking a broken app. Scoped to what the built SPA needs:
|
||||
# same-origin scripts/styles/fonts/img plus same-origin fetch & EventSource.
|
||||
# Inline styles are allowed because ECharts and React ``style={}`` props set
|
||||
# them; fonts are self-hosted (@fontsource) so no external font host is listed.
|
||||
_CSP_REPORT_ONLY = (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self'; "
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"img-src 'self' data:; "
|
||||
"font-src 'self' data:; "
|
||||
"connect-src 'self'; "
|
||||
"frame-ancestors 'none'; "
|
||||
"base-uri 'self'; "
|
||||
"form-action 'self'"
|
||||
)
|
||||
|
||||
|
||||
async def _apply_security_headers(request: Request, call_next):
|
||||
"""Attach baseline security response headers to every response.
|
||||
|
||||
HSTS is deliberately NOT set here: the app is commonly deployed behind a
|
||||
TLS-terminating reverse proxy and cannot guarantee it is always served over
|
||||
HTTPS. Set ``Strict-Transport-Security`` at the proxy/ingress layer that
|
||||
terminates TLS instead.
|
||||
"""
|
||||
response = await call_next(request)
|
||||
headers = response.headers
|
||||
headers.setdefault("Content-Security-Policy-Report-Only", _CSP_REPORT_ONLY)
|
||||
headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||
headers.setdefault("X-Frame-Options", "DENY")
|
||||
headers.setdefault("Permissions-Policy", _PERMISSIONS_POLICY)
|
||||
headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
return response
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Access-log secret redaction
|
||||
# ============================================================================
|
||||
|
||||
# Redact the VALUES of sensitive query params (keeping the name) from access
|
||||
# logs. Uvicorn's default access logger writes the full request line including
|
||||
# the query string, so without this an ``api_key=`` / ``ticket=`` value would
|
||||
# land in logs verbatim.
|
||||
_QUERY_SECRET_RE = re.compile(r"((?:api_key|ticket)=)[^&\s\"']+", re.IGNORECASE)
|
||||
|
||||
|
||||
def _redact_query_secrets(text: str) -> str:
|
||||
"""Replace ``api_key=``/``ticket=`` values with a fixed placeholder."""
|
||||
return _QUERY_SECRET_RE.sub(r"\1***REDACTED***", text)
|
||||
|
||||
|
||||
class _AccessLogRedactionFilter(logging.Filter):
|
||||
"""Strip secret query-param values from log records before they are emitted."""
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
if isinstance(record.args, tuple):
|
||||
record.args = tuple(
|
||||
_redact_query_secrets(a) if isinstance(a, str) else a
|
||||
for a in record.args
|
||||
)
|
||||
if isinstance(record.msg, str):
|
||||
record.msg = _redact_query_secrets(record.msg)
|
||||
return True
|
||||
|
||||
|
||||
def install_access_log_redaction_filter() -> None:
|
||||
"""Attach the redaction filter to Uvicorn's access/error loggers.
|
||||
|
||||
Idempotent: skips a logger that already carries the filter so repeated
|
||||
server starts in one process don't stack duplicate filters.
|
||||
"""
|
||||
for name in ("uvicorn.access", "uvicorn.error", "uvicorn"):
|
||||
target = logging.getLogger(name)
|
||||
if any(isinstance(f, _AccessLogRedactionFilter) for f in target.filters):
|
||||
continue
|
||||
target.addFilter(_AccessLogRedactionFilter())
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# SSE tickets (short-lived, single-use browser EventSource credentials)
|
||||
# ============================================================================
|
||||
|
||||
# EventSource cannot send an Authorization header, so a browser exchanges the
|
||||
# header-authenticated API key for a one-shot ticket via POST /auth/sse-ticket
|
||||
# and passes it as ?ticket=. This keeps the long-lived key out of URLs and logs.
|
||||
_SSE_TICKET_TTL_SECONDS = 60.0
|
||||
_sse_tickets: dict[str, float] = {} # ticket -> monotonic expiry timestamp
|
||||
_sse_tickets_lock = threading.Lock()
|
||||
|
||||
|
||||
def _sweep_expired_sse_tickets_locked(now: float) -> None:
|
||||
"""Drop expired tickets. Caller must hold ``_sse_tickets_lock``."""
|
||||
expired = [t for t, exp in _sse_tickets.items() if exp <= now]
|
||||
for t in expired:
|
||||
_sse_tickets.pop(t, None)
|
||||
|
||||
|
||||
def _mint_sse_ticket() -> str:
|
||||
"""Mint a single-use, ~60s ticket for one browser EventSource connection."""
|
||||
ticket = secrets.token_urlsafe(32)
|
||||
now = time.monotonic()
|
||||
with _sse_tickets_lock:
|
||||
_sweep_expired_sse_tickets_locked(now)
|
||||
_sse_tickets[ticket] = now + _SSE_TICKET_TTL_SECONDS
|
||||
return ticket
|
||||
|
||||
|
||||
def _consume_sse_ticket(ticket: str) -> bool:
|
||||
"""Validate and invalidate a ticket; True iff it was valid and unexpired.
|
||||
|
||||
Single-use: the ticket is removed on the first lookup whether or not it had
|
||||
expired, so a captured ticket can never be replayed.
|
||||
"""
|
||||
if not ticket:
|
||||
return False
|
||||
now = time.monotonic()
|
||||
with _sse_tickets_lock:
|
||||
_sweep_expired_sse_tickets_locked(now)
|
||||
expiry = _sse_tickets.pop(ticket, None)
|
||||
return expiry is not None and expiry > now
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# API Key Authentication
|
||||
# ============================================================================
|
||||
@@ -330,11 +468,39 @@ async def require_auth(
|
||||
|
||||
async def require_event_stream_auth(
|
||||
request: Request,
|
||||
api_key: Optional[str] = Query(None),
|
||||
ticket: Optional[str] = Query(None),
|
||||
cred: Optional[HTTPAuthorizationCredentials] = Security(_security),
|
||||
) -> None:
|
||||
"""Validate auth for browser EventSource streams."""
|
||||
_validate_api_auth(request=request, cred=cred, query_api_key=api_key, allow_query=True)
|
||||
"""Validate auth for browser EventSource streams.
|
||||
|
||||
EventSource cannot send an ``Authorization`` header, so a browser first
|
||||
mints a short-lived, single-use ticket via ``POST /auth/sse-ticket`` (which
|
||||
is itself header-authenticated) and passes it as ``?ticket=``. Non-browser
|
||||
callers keep using the bearer header unchanged. The long-lived API key is
|
||||
never accepted in the query string — that would leak it into browser
|
||||
history, proxy/access logs, and Referer headers.
|
||||
"""
|
||||
if request.method.upper() not in _SAFE_BROWSER_METHODS:
|
||||
_reject_cross_site_browser_request(request)
|
||||
|
||||
if _is_local_client(request):
|
||||
return
|
||||
|
||||
api_key = _configured_api_key()
|
||||
if not api_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="API_AUTH_KEY is required for non-local API access",
|
||||
)
|
||||
|
||||
token = cred.credentials if (cred and cred.credentials) else ""
|
||||
if token and hmac.compare_digest(token, api_key):
|
||||
return
|
||||
|
||||
if ticket and _consume_sse_ticket(ticket):
|
||||
return
|
||||
|
||||
raise HTTPException(status_code=401, detail="Invalid or missing API key")
|
||||
|
||||
|
||||
async def require_local_or_auth(
|
||||
|
||||
@@ -5,16 +5,30 @@ Mounted by ``agent/api_server.py`` via ``register_system_routes(app, ...)``.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict, deque
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from typing import Deque, Dict, Optional, Tuple
|
||||
|
||||
from fastapi import BackgroundTasks, FastAPI, HTTPException, Query, Request, Security, status
|
||||
from fastapi import (
|
||||
BackgroundTasks,
|
||||
Depends,
|
||||
FastAPI,
|
||||
HTTPException,
|
||||
Query,
|
||||
Request,
|
||||
Security,
|
||||
status,
|
||||
)
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pydantic models (defined locally -- NO shared modules, per maintainer rule)
|
||||
@@ -38,6 +52,111 @@ def _terminate_current_process() -> None:
|
||||
os.kill(os.getpid(), signal.SIGTERM)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# In-process per-client rate limiter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _SlidingWindowRateLimiter:
|
||||
"""Thread-safe fixed-capacity sliding-window limiter keyed by client.
|
||||
|
||||
Deliberately in-process and dependency-free: the API has no shared cache
|
||||
or Redis, and a single endpoint doing bounded computation does not warrant
|
||||
a third-party limiter. A monotonic clock is used so wall-clock jumps never
|
||||
widen or collapse the window.
|
||||
"""
|
||||
|
||||
def __init__(self, max_requests: int, window_seconds: float) -> None:
|
||||
self._max = max_requests
|
||||
self._window = window_seconds
|
||||
self._hits: Dict[str, Deque[float]] = defaultdict(deque)
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def allow(self, key: str) -> bool:
|
||||
"""Record a hit for ``key`` and report whether it stays within budget."""
|
||||
now = time.monotonic()
|
||||
cutoff = now - self._window
|
||||
with self._lock:
|
||||
bucket = self._hits[key]
|
||||
while bucket and bucket[0] < cutoff:
|
||||
bucket.popleft()
|
||||
if len(bucket) >= self._max:
|
||||
return False
|
||||
bucket.append(now)
|
||||
return True
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Clear all recorded hits (test/maintenance helper)."""
|
||||
with self._lock:
|
||||
self._hits.clear()
|
||||
|
||||
|
||||
# 30 requests / minute / client IP: /correlation runs real cross-asset math on
|
||||
# each hit, so a moderate ceiling is enough to blunt abuse without hurting UX.
|
||||
_correlation_rate_limiter = _SlidingWindowRateLimiter(max_requests=30, window_seconds=60.0)
|
||||
|
||||
|
||||
def _client_key(request: Request) -> str:
|
||||
"""Return a stable per-client bucket key (client IP, or a fixed fallback)."""
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Readiness check (network-free)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _provider_readiness() -> Tuple[bool, str]:
|
||||
"""Report whether the configured LLM provider looks usable, cheaply.
|
||||
|
||||
Mirrors the config-validation portion of
|
||||
``src.preflight._check_llm_provider`` (provider + model present, a
|
||||
credential derivable) but deliberately omits the outbound base-URL ping:
|
||||
a readiness probe is hit frequently and must never block on the network or
|
||||
incur LLM cost.
|
||||
|
||||
Returns:
|
||||
``(ready, reason)`` where ``reason`` is a non-sensitive explanation
|
||||
suitable for returning to a probe when not ready.
|
||||
"""
|
||||
try:
|
||||
from src.config.accessor import get_env_config
|
||||
from src.providers.capabilities import provider_env_names
|
||||
from src.providers.llm import _sync_provider_env
|
||||
except Exception as exc: # noqa: BLE001 — degrade to not-ready, never crash the probe
|
||||
logger.warning("readiness: provider config import failed: %r", exc)
|
||||
return False, "provider configuration unavailable"
|
||||
|
||||
cfg = get_env_config()
|
||||
provider = cfg.llm.langchain_provider.strip()
|
||||
model = cfg.llm.langchain_model_name.strip()
|
||||
if not provider:
|
||||
return False, "LLM provider not configured"
|
||||
if not model:
|
||||
return False, "LLM model not configured"
|
||||
|
||||
# OAuth-based providers carry no API key; a local login token stands in.
|
||||
if provider.lower() in {"openai-codex", "openai_codex"}:
|
||||
try:
|
||||
from src.providers.openai_codex import get_openai_codex_login_status
|
||||
|
||||
if not get_openai_codex_login_status():
|
||||
return False, "provider OAuth login not found"
|
||||
except Exception as exc: # noqa: BLE001 — treat unknown OAuth state as not-ready
|
||||
logger.warning("readiness: OAuth status check failed: %r", exc)
|
||||
return False, "provider OAuth status unavailable"
|
||||
return True, "ready"
|
||||
|
||||
_sync_provider_env()
|
||||
key_env, _base_env = provider_env_names(provider, model)
|
||||
# key_env is None for keyless local providers (e.g. Ollama).
|
||||
if key_env is not None:
|
||||
has_key = bool(os.getenv(key_env, "") or os.getenv("OPENAI_API_KEY", "")) # noqa: env-gate — readiness credential probe
|
||||
if not has_key:
|
||||
return False, "LLM provider credential not configured"
|
||||
return True, "ready"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registration
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -65,6 +184,7 @@ def register_system_routes(
|
||||
|
||||
_security = host._security
|
||||
_require_shutdown_authorization = host._require_shutdown_authorization
|
||||
require_auth = host.require_auth
|
||||
_app_version = app_version if app_version is not None else host.APP_VERSION
|
||||
|
||||
def _get_terminate_process():
|
||||
@@ -78,17 +198,48 @@ def register_system_routes(
|
||||
|
||||
# --- Routes ---
|
||||
|
||||
@app.get("/health", response_model=HealthResponse)
|
||||
async def health_check():
|
||||
"""Liveness probe."""
|
||||
def _health_payload() -> HealthResponse:
|
||||
return HealthResponse(
|
||||
status="healthy",
|
||||
service="Vibe-Trading API",
|
||||
timestamp=datetime.now(timezone.utc).isoformat()
|
||||
timestamp=datetime.now(timezone.utc).isoformat(),
|
||||
)
|
||||
|
||||
@app.get("/correlation")
|
||||
@app.get("/live", response_model=HealthResponse)
|
||||
async def liveness_probe():
|
||||
"""Liveness: the process is up and can serve HTTP.
|
||||
|
||||
Intentionally unconditional — a liveness probe only answers "should the
|
||||
container be restarted?", and the answer is no as long as this handler
|
||||
runs at all. Deeper checks belong on ``/ready``.
|
||||
"""
|
||||
return _health_payload()
|
||||
|
||||
@app.get("/health", response_model=HealthResponse)
|
||||
async def health_check():
|
||||
"""Backward-compatible alias for ``/live`` (legacy monitors)."""
|
||||
return _health_payload()
|
||||
|
||||
@app.get("/ready")
|
||||
async def readiness_probe():
|
||||
"""Readiness: the configured LLM provider looks usable.
|
||||
|
||||
Returns 200 when ready and 503 (with a non-sensitive reason) when the
|
||||
provider/model/credential is not configured, so orchestrators can hold
|
||||
traffic until the agent can actually serve requests.
|
||||
"""
|
||||
ready, reason = _provider_readiness()
|
||||
if not ready:
|
||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=reason)
|
||||
return {
|
||||
"status": "ready",
|
||||
"service": "Vibe-Trading API",
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
@app.get("/correlation", dependencies=[Depends(require_auth)])
|
||||
async def get_correlation_matrix(
|
||||
request: Request,
|
||||
codes: str = Query(..., description="Comma-separated asset codes, e.g. BTC-USDT,ETH-USDT,SPY"),
|
||||
days: int = Query(90, description="Lookback window in days", ge=7, le=365),
|
||||
method: str = Query("pearson", description="Correlation method: pearson or spearman"),
|
||||
@@ -100,6 +251,12 @@ def register_system_routes(
|
||||
"""
|
||||
from backtest.correlation import compute_correlation_matrix
|
||||
|
||||
if not _correlation_rate_limiter.allow(_client_key(request)):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="Rate limit exceeded, try again later",
|
||||
)
|
||||
|
||||
code_list = [c.strip() for c in codes.split(",") if c.strip()]
|
||||
if len(code_list) < 2:
|
||||
raise HTTPException(status_code=400, detail="At least 2 asset codes required")
|
||||
@@ -113,8 +270,9 @@ def register_system_routes(
|
||||
return result
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Correlation computation failed: {exc}")
|
||||
except Exception:
|
||||
logger.exception("Correlation computation failed for codes=%s", code_list)
|
||||
raise HTTPException(status_code=500, detail="Correlation computation failed")
|
||||
|
||||
@app.post("/system/shutdown")
|
||||
async def shutdown_local_api(
|
||||
|
||||
+198
-6
@@ -2,18 +2,148 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
try: # POSIX-only; absent on Windows
|
||||
import resource
|
||||
except ImportError: # pragma: no cover - Windows
|
||||
resource = None # type: ignore[assignment]
|
||||
|
||||
try: # POSIX-only; absent on Windows
|
||||
import pwd
|
||||
except ImportError: # pragma: no cover - Windows
|
||||
pwd = None # type: ignore[assignment]
|
||||
|
||||
|
||||
console = Console(stderr=True)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# --- Sandbox subprocess hardening (VT-001 defense-in-depth) ---------------
|
||||
# These layers only bite inside the hardened Docker deployment; everywhere else
|
||||
# (bare pip install, local dev, CI, non-Linux) they detect the missing
|
||||
# precondition and degrade to the previous behaviour with a WARNING. The
|
||||
# always-on control for VT-001 is the AST scrubber in backtest/runner.py, not
|
||||
# any of this.
|
||||
_SANDBOX_USER = "vibe-sandbox"
|
||||
# The only paths under ~/.vibe-trading re-exposed into the ephemeral sandbox
|
||||
# HOME. Data loaders that run in the same subprocess resolve these via
|
||||
# Path.home(); everything else in the real home (.env, sessions.db, memory/,
|
||||
# live/ mandate+audit ledger, shadow_*, dotfiles) stays unreadable to generated
|
||||
# strategy code — that broad read access was the VT-001 exposure.
|
||||
_SANDBOX_HOME_REEXPOSE = ("cache", "data-bridge", "qveris.json")
|
||||
# RLIMIT_AS caps *virtual* address space (mmap included). numpy/BLAS reserve
|
||||
# multi-GB virtual regions that are never resident, so a 2 GB cap spuriously
|
||||
# OOMs legitimate backtests; 4 GB keeps a DoS ceiling without false failures.
|
||||
# Operator-tunable for large minute-level runs.
|
||||
_DEFAULT_RLIMIT_AS_MB = 4096
|
||||
_SANDBOX_RLIMIT_AS_MB_ENV = "VIBE_TRADING_SANDBOX_RLIMIT_AS_MB"
|
||||
_SANDBOX_RLIMIT_NOFILE = 512
|
||||
|
||||
|
||||
def _resolve_sandbox_credentials() -> tuple[str, str] | None:
|
||||
"""Return ``(user, group)`` for the privilege-dropped subprocess, else None.
|
||||
|
||||
None (run without a UID drop) is returned whenever the ``vibe-sandbox``
|
||||
account is absent or the platform has no ``pwd`` module — i.e. every
|
||||
environment except the hardened Docker image that pre-creates the account and
|
||||
is granted CAP_SETUID/CAP_SETGID. A user that exists but cannot actually be
|
||||
dropped to (no capability) is handled at the ``subprocess.run`` call site.
|
||||
"""
|
||||
if pwd is None:
|
||||
return None
|
||||
try:
|
||||
pwd.getpwnam(_SANDBOX_USER)
|
||||
except (KeyError, OSError):
|
||||
return None
|
||||
return (_SANDBOX_USER, _SANDBOX_USER)
|
||||
|
||||
|
||||
def _rlimit_as_bytes() -> int:
|
||||
"""Return the configured RLIMIT_AS ceiling in bytes."""
|
||||
raw = os.environ.get(_SANDBOX_RLIMIT_AS_MB_ENV, "") # noqa: env-gate — sandbox rlimit tuning, not app config
|
||||
try:
|
||||
mb = int(raw) if raw.strip() else _DEFAULT_RLIMIT_AS_MB
|
||||
except ValueError:
|
||||
mb = _DEFAULT_RLIMIT_AS_MB
|
||||
if mb <= 0:
|
||||
mb = _DEFAULT_RLIMIT_AS_MB
|
||||
return mb * 1024 * 1024
|
||||
|
||||
|
||||
def _make_rlimit_preexec() -> Callable[[], None] | None:
|
||||
"""Build a POSIX ``preexec_fn`` that caps subprocess address space + fds.
|
||||
|
||||
Returns None on Windows (no ``resource`` module). The closure runs in the
|
||||
forked child after any UID drop; lowering rlimits is always permitted for an
|
||||
unprivileged process, and each limit is applied best-effort so a hardened
|
||||
parent limit already below the target is never raised.
|
||||
"""
|
||||
if resource is None:
|
||||
return None
|
||||
|
||||
as_bytes = _rlimit_as_bytes()
|
||||
|
||||
def _apply_limits() -> None: # pragma: no cover - runs in forked child
|
||||
for res, target in (
|
||||
(resource.RLIMIT_AS, as_bytes),
|
||||
(resource.RLIMIT_NOFILE, _SANDBOX_RLIMIT_NOFILE),
|
||||
):
|
||||
try:
|
||||
_soft, hard = resource.getrlimit(res)
|
||||
new_hard = target if hard == resource.RLIM_INFINITY else min(target, hard)
|
||||
resource.setrlimit(res, (min(target, new_hard), new_hard))
|
||||
except (ValueError, OSError):
|
||||
pass
|
||||
|
||||
return _apply_limits
|
||||
|
||||
|
||||
def _prepare_sandbox_home(real_home: Path | None) -> Path:
|
||||
"""Create an ephemeral HOME and symlink in only the loader-owned paths.
|
||||
|
||||
The generated strategy runs in the same subprocess as the data loaders, so
|
||||
the ephemeral home re-exposes the narrow set of ``~/.vibe-trading`` paths the
|
||||
loaders need (opt-in cache, local data-bridge config, qveris config) and
|
||||
nothing else. Symlinks are used so the opt-in loader cache still persists
|
||||
across runs; ``shutil.rmtree`` later removes the links, never their targets.
|
||||
"""
|
||||
sandbox = Path(tempfile.mkdtemp(prefix="vibe-sandbox-home-"))
|
||||
if real_home is not None:
|
||||
src_root = real_home / ".vibe-trading"
|
||||
if src_root.is_dir():
|
||||
dst_root = sandbox / ".vibe-trading"
|
||||
dst_root.mkdir(parents=True, exist_ok=True)
|
||||
for rel in _SANDBOX_HOME_REEXPOSE:
|
||||
src = src_root / rel
|
||||
if not src.exists():
|
||||
continue
|
||||
try:
|
||||
(dst_root / rel).symlink_to(src, target_is_directory=src.is_dir())
|
||||
except OSError:
|
||||
# Best-effort: a loader that can't find its config just falls
|
||||
# back to a live fetch / disabled cache — never a hard break.
|
||||
pass
|
||||
try:
|
||||
os.chmod(dst_root, 0o755)
|
||||
except OSError:
|
||||
pass
|
||||
# mkdtemp is 0700; widen so a dropped UID can still traverse the home.
|
||||
try:
|
||||
os.chmod(sandbox, 0o755)
|
||||
except OSError:
|
||||
pass
|
||||
return sandbox
|
||||
|
||||
|
||||
_PROXY_ENV_KEYS = frozenset(
|
||||
@@ -296,12 +426,36 @@ class Runner:
|
||||
existing = env.get("PYTHONPATH", "")
|
||||
env["PYTHONPATH"] = str(pythonpath_extra) + (os.pathsep + existing if existing else "")
|
||||
|
||||
# Preserve system proxy settings; data sources (OKX/yfinance) need network access
|
||||
# NOTE: do NOT override HOME/USERPROFILE — data libraries (yfinance, akshare)
|
||||
# cache downloads under ~/; overriding HOME causes full re-download every run.
|
||||
# Preserve system proxy settings; data sources (OKX/yfinance) need network access.
|
||||
# HOME/USERPROFILE are overridden per-execution in ``execute()`` (ephemeral
|
||||
# sandbox home, VT-001); the inherited value from the allowlist is only a
|
||||
# fallback if that temp-dir creation fails.
|
||||
|
||||
return env
|
||||
|
||||
def _run_sandboxed(self, cmd: list[str], run_kwargs: dict[str, Any]) -> "subprocess.CompletedProcess[str]":
|
||||
"""Run the subprocess, dropping to ``vibe-sandbox`` when the host allows it.
|
||||
|
||||
When the UID drop is unavailable — no such user (the common case outside
|
||||
the hardened container) or the caller lacks CAP_SETUID — a clear WARNING
|
||||
is logged and the process runs without the drop. The AST static defense in
|
||||
backtest/runner.py stays active regardless, so this fallback is safe.
|
||||
"""
|
||||
creds = _resolve_sandbox_credentials()
|
||||
if creds is not None:
|
||||
user, group = creds
|
||||
try:
|
||||
return subprocess.run(cmd, user=user, group=group, **run_kwargs)
|
||||
except (PermissionError, LookupError, OSError) as exc:
|
||||
logger.warning(
|
||||
"Sandbox UID drop to %r failed (%s); running the generated-code "
|
||||
"subprocess WITHOUT the privilege-drop layer. The AST static "
|
||||
"defense (backtest/runner.py) remains active.",
|
||||
user,
|
||||
exc,
|
||||
)
|
||||
return subprocess.run(cmd, **run_kwargs)
|
||||
|
||||
def execute(
|
||||
self,
|
||||
entry_script: Path,
|
||||
@@ -340,8 +494,37 @@ class Runner:
|
||||
if cli_args:
|
||||
cmd.extend(cli_args)
|
||||
|
||||
process = subprocess.run(
|
||||
cmd,
|
||||
# VT-001 defense-in-depth: give the generated-code subprocess an ephemeral
|
||||
# HOME (so it can't read the persistent ~/.vibe-trading secrets/state),
|
||||
# drop to an unprivileged UID where the hardened container supports it,
|
||||
# and cap its address space / fd count on top of the wall-clock timeout.
|
||||
real_home = None
|
||||
home_value = env.get("HOME") or env.get("USERPROFILE")
|
||||
if home_value:
|
||||
real_home = Path(home_value)
|
||||
elif pwd is not None:
|
||||
try:
|
||||
real_home = Path.home()
|
||||
except (RuntimeError, OSError):
|
||||
real_home = None
|
||||
|
||||
sandbox_home: Path | None = None
|
||||
try:
|
||||
sandbox_home = _prepare_sandbox_home(real_home)
|
||||
except OSError as exc:
|
||||
logger.warning(
|
||||
"Could not create ephemeral sandbox HOME (%s); subprocess inherits HOME.",
|
||||
exc,
|
||||
)
|
||||
if sandbox_home is not None:
|
||||
env["HOME"] = str(sandbox_home)
|
||||
env["USERPROFILE"] = str(sandbox_home)
|
||||
# Keep well-behaved (platformdirs) library caches persistent so the
|
||||
# ephemeral HOME does not force a full re-download every run.
|
||||
if real_home is not None:
|
||||
env["XDG_CACHE_HOME"] = str(real_home / ".cache")
|
||||
|
||||
run_kwargs: dict[str, Any] = dict(
|
||||
cwd=str(effective_cwd),
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
@@ -352,6 +535,15 @@ class Runner:
|
||||
encoding="utf-8",
|
||||
errors="ignore",
|
||||
)
|
||||
preexec = _make_rlimit_preexec()
|
||||
if preexec is not None:
|
||||
run_kwargs["preexec_fn"] = preexec
|
||||
|
||||
try:
|
||||
process = self._run_sandboxed(cmd, run_kwargs)
|
||||
finally:
|
||||
if sandbox_home is not None:
|
||||
shutil.rmtree(sandbox_home, ignore_errors=True)
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
console.print(f"[blue]Runner: subprocess finished in {elapsed:.2f}s[/blue]")
|
||||
|
||||
@@ -14,22 +14,26 @@ Output contract — JSON envelope:
|
||||
"top": [{"id": ..., "ic_mean": ..., "ir": ..., ...}, ...]}
|
||||
|
||||
Cache integrity note: the universe panel cache lives in ``~/.vibe-trading/cache/``
|
||||
as pickle blobs. Each pickle is paired with a ``<name>.sha256`` sidecar; on
|
||||
load we recompute the digest and refuse the cache on mismatch. This guards
|
||||
against accidental corruption (truncated writes, partial syncs) — it is NOT a
|
||||
defence against an attacker with local write access (they can rewrite both
|
||||
files). Cache files are user-local; if shared across machines they can be
|
||||
tampered with and the sha256 sidecar is only an integrity check, not authenticity.
|
||||
as pickle blobs. Each pickle is paired with a ``<name>.sha256`` sidecar holding a
|
||||
**keyed HMAC-SHA256 tag** (not a bare digest) computed over the blob. On load we
|
||||
recompute the tag with the same key and refuse the cache on mismatch before the
|
||||
``pickle.loads`` call. The key is ``API_AUTH_KEY`` when configured, else a
|
||||
machine-local random 32-byte secret persisted at ``cache/.hmac_key`` (mode 0600).
|
||||
Because the tag is keyed, a local attacker who rewrites the pickle cannot forge a
|
||||
matching sidecar without the secret — so this is an authenticity guard against
|
||||
local-write RCE via ``pickle.loads``, not merely a corruption check.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
@@ -152,8 +156,55 @@ def _sha256_path(cache_path: Path) -> Path:
|
||||
return cache_path.with_suffix(cache_path.suffix + ".sha256")
|
||||
|
||||
|
||||
def _cache_hmac_key(cache_dir: Path) -> bytes:
|
||||
"""Return the secret backing the cache sidecar HMAC.
|
||||
|
||||
Priority: ``API_AUTH_KEY`` (UTF-8) when configured, else a persisted
|
||||
machine-local 32-byte random key. The fallback is required because an empty
|
||||
HMAC key would let any local attacker forge a matching sidecar for a
|
||||
malicious pickle — defeating the whole point of the tag.
|
||||
"""
|
||||
configured = get_env_config().api.api_auth_key.strip()
|
||||
if configured:
|
||||
return configured.encode("utf-8")
|
||||
|
||||
key_path = cache_dir / ".hmac_key"
|
||||
try:
|
||||
return bytes.fromhex(key_path.read_text(encoding="utf-8").strip())
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
key = secrets.token_bytes(32)
|
||||
try:
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
# O_EXCL + mode 0600 from creation: a concurrent writer can't race us
|
||||
# into a world-readable key, and we never widen perms after the fact.
|
||||
fd = os.open(str(key_path), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
try:
|
||||
os.write(fd, key.hex().encode("utf-8"))
|
||||
finally:
|
||||
os.close(fd)
|
||||
except FileExistsError:
|
||||
try:
|
||||
return bytes.fromhex(key_path.read_text(encoding="utf-8").strip())
|
||||
except (OSError, ValueError):
|
||||
return key
|
||||
except OSError as exc:
|
||||
logger.warning("hmac key persist failed (%s); using ephemeral key", exc)
|
||||
return key
|
||||
|
||||
|
||||
def _cache_mac(key: bytes, blob: bytes) -> str:
|
||||
"""Keyed HMAC-SHA256 authentication tag (hex) for a cache blob."""
|
||||
return hmac.new(key, blob, hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def _read_pickle_cache(cache_path: Path) -> dict[str, pd.DataFrame] | None:
|
||||
"""Load a pickle cache, validating its sha256 sidecar. None on any failure."""
|
||||
"""Load a pickle cache, authenticating its keyed HMAC sidecar. None on failure.
|
||||
|
||||
The HMAC is verified before ``pickle.loads`` so a locally-tampered blob whose
|
||||
sidecar was forged without the secret is rejected, never deserialized.
|
||||
"""
|
||||
import pickle
|
||||
|
||||
sidecar = _sha256_path(cache_path)
|
||||
@@ -174,7 +225,7 @@ def _read_pickle_cache(cache_path: Path) -> dict[str, pd.DataFrame] | None:
|
||||
except OSError as exc:
|
||||
logger.warning("cache sidecar read failed (%s); refetching", exc)
|
||||
return None
|
||||
actual = hashlib.sha256(blob).hexdigest()
|
||||
actual = _cache_mac(_cache_hmac_key(cache_path.parent), blob)
|
||||
if not _hashes_equal(expected, actual):
|
||||
logger.warning(
|
||||
"cache integrity mismatch for %s (expected %s..., got %s...); refetching",
|
||||
@@ -183,7 +234,7 @@ def _read_pickle_cache(cache_path: Path) -> dict[str, pd.DataFrame] | None:
|
||||
return None
|
||||
|
||||
try:
|
||||
cached = pickle.loads(blob) # noqa: S301 — local cache, integrity-checked above
|
||||
cached = pickle.loads(blob) # noqa: S301 — local cache, HMAC-authenticated above
|
||||
except Exception as exc: # noqa: BLE001 — degrade to fresh fetch
|
||||
logger.warning("cache unpickle failed (%s); refetching", exc)
|
||||
return None
|
||||
@@ -196,7 +247,7 @@ def _read_pickle_cache(cache_path: Path) -> dict[str, pd.DataFrame] | None:
|
||||
def _write_pickle_cache(
|
||||
cache_dir: Path, cache_path: Path, panel: dict[str, Any]
|
||||
) -> None:
|
||||
"""Pickle ``panel`` + write its sha256 sidecar. Failures are non-fatal."""
|
||||
"""Pickle ``panel`` + write its keyed HMAC sidecar. Failures are non-fatal."""
|
||||
import pickle
|
||||
|
||||
try:
|
||||
@@ -204,16 +255,14 @@ def _write_pickle_cache(
|
||||
blob = pickle.dumps(panel, protocol=pickle.HIGHEST_PROTOCOL)
|
||||
cache_path.write_bytes(blob)
|
||||
_sha256_path(cache_path).write_text(
|
||||
hashlib.sha256(blob).hexdigest(), encoding="utf-8"
|
||||
_cache_mac(_cache_hmac_key(cache_dir), blob), encoding="utf-8"
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — cache miss is non-fatal
|
||||
logger.warning("cache write failed: %s", exc)
|
||||
|
||||
|
||||
def _hashes_equal(a: str, b: str) -> bool:
|
||||
"""Constant-time comparison of two hex digests."""
|
||||
import hmac
|
||||
|
||||
"""Constant-time comparison of two hex authentication tags."""
|
||||
return hmac.compare_digest(a.strip().lower(), b.strip().lower())
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Tests for the HMAC-authenticated universe-panel pickle cache (VT-010).
|
||||
|
||||
The sidecar next to each ``<name>.pkl`` is a *keyed* HMAC-SHA256 tag, not a bare
|
||||
digest. These tests pin the security property that matters: a local attacker who
|
||||
can rewrite the pickle but does not hold the secret cannot forge a matching
|
||||
sidecar, so the tampered blob is rejected before ``pickle.loads``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import pickle
|
||||
import stat
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from src.tools import alpha_bench_tool as abt
|
||||
|
||||
|
||||
def _panel() -> dict[str, pd.DataFrame]:
|
||||
close = pd.DataFrame(
|
||||
{"AAA": [10.0, 11.0, 12.0], "BBB": [20.0, 19.0, 21.0]},
|
||||
index=pd.date_range("2020-01-01", periods=3, freq="B"),
|
||||
)
|
||||
return {"close": close, "open": close, "volume": close}
|
||||
|
||||
|
||||
class _FakeCfg:
|
||||
def __init__(self, api_auth_key: str) -> None:
|
||||
self.api = type("_Api", (), {"api_auth_key": api_auth_key})()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def no_api_key(monkeypatch):
|
||||
"""Force the key-file fallback path (no API_AUTH_KEY configured)."""
|
||||
monkeypatch.setattr(abt, "get_env_config", lambda: _FakeCfg(""))
|
||||
|
||||
|
||||
def test_cache_roundtrips_when_untampered(tmp_path, no_api_key):
|
||||
cache_path = tmp_path / "csi300_2020-01-01_2020-12-31.pkl"
|
||||
panel = _panel()
|
||||
|
||||
abt._write_pickle_cache(tmp_path, cache_path, panel)
|
||||
loaded = abt._read_pickle_cache(cache_path)
|
||||
|
||||
assert loaded is not None
|
||||
pd.testing.assert_frame_equal(loaded["close"], panel["close"])
|
||||
|
||||
|
||||
def test_tampered_pickle_with_unkeyed_sha256_is_rejected(tmp_path, no_api_key):
|
||||
cache_path = tmp_path / "sp500_2020-01-01_2020-12-31.pkl"
|
||||
abt._write_pickle_cache(tmp_path, cache_path, _panel())
|
||||
|
||||
# Attacker who lacks the HMAC secret: rewrites the pickle with a malicious
|
||||
# payload and recomputes only a *bare* sha256 sidecar (the pre-VT-010 tag).
|
||||
evil = pickle.dumps({"close": pd.DataFrame({"X": [9.0]}), "pwned": True})
|
||||
cache_path.write_bytes(evil)
|
||||
abt._sha256_path(cache_path).write_text(
|
||||
hashlib.sha256(evil).hexdigest(), encoding="utf-8"
|
||||
)
|
||||
|
||||
assert abt._read_pickle_cache(cache_path) is None
|
||||
|
||||
|
||||
def test_fallback_key_is_stable_and_0600(tmp_path, no_api_key):
|
||||
key1 = abt._cache_hmac_key(tmp_path)
|
||||
key2 = abt._cache_hmac_key(tmp_path)
|
||||
|
||||
assert key1 == key2 # reused, not freshly random per call
|
||||
assert len(key1) == 32
|
||||
|
||||
key_file = tmp_path / ".hmac_key"
|
||||
assert key_file.is_file()
|
||||
assert stat.S_IMODE(key_file.stat().st_mode) == 0o600
|
||||
|
||||
|
||||
def test_configured_api_key_takes_priority(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(abt, "get_env_config", lambda: _FakeCfg("super-secret-key"))
|
||||
|
||||
key = abt._cache_hmac_key(tmp_path)
|
||||
|
||||
assert key == b"super-secret-key"
|
||||
# No key file is written when an explicit secret is configured.
|
||||
assert not (tmp_path / ".hmac_key").exists()
|
||||
@@ -83,3 +83,142 @@ def test_signal_engine_allows_minimal_valid_strategy(tmp_path) -> None:
|
||||
module = _load_module_from_file(signal_file, _module_name())
|
||||
|
||||
assert module.SignalEngine().generate() == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# VT-001: forbidden operations hidden INSIDE method bodies.
|
||||
#
|
||||
# Every fixture below is structurally valid (valid class + method defs, only
|
||||
# import-time-safe top-level statements) and therefore passed the pre-VT-001
|
||||
# validator, which never walked into function bodies. They must now be rejected
|
||||
# because the danger lives on the code path that runs on SignalEngine().generate().
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
# Each entry: (id, body_lines) — spliced into SignalEngine.generate().
|
||||
_FORBIDDEN_IN_METHOD_BODY = [
|
||||
("import_socket", [" import socket", " return socket.gethostname()"]),
|
||||
(
|
||||
"subprocess_call",
|
||||
[" import subprocess", " return subprocess.run(['id'])"],
|
||||
),
|
||||
("os_system", [" import os", " return os.system('id')"]),
|
||||
("os_environ_read", [" import os", " return os.environ['SECRET']"]),
|
||||
("os_getenv", [" import os", " return os.getenv('SECRET')"]),
|
||||
("eval_call", [" return eval('1+1')"]),
|
||||
("exec_call", [" exec('x = 1')", " return []"]),
|
||||
("dunder_import", [" return __import__('os').getcwd()"]),
|
||||
("requests_get", [" import requests", " return requests.get('http://x')"]),
|
||||
("urllib_urlopen", [" import urllib.request as u", " return u.urlopen('http://x')"]),
|
||||
("open_write", [" open('evil.txt', 'w').write('x')", " return []"]),
|
||||
("open_abs_read", [" return open('/etc/passwd').read()"]),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"case_id,body",
|
||||
_FORBIDDEN_IN_METHOD_BODY,
|
||||
ids=[c[0] for c in _FORBIDDEN_IN_METHOD_BODY],
|
||||
)
|
||||
def test_signal_engine_rejects_forbidden_op_in_method_body(tmp_path, case_id, body) -> None:
|
||||
signal_file = tmp_path / "signal_engine.py"
|
||||
lines = [
|
||||
'"""Generated signal engine."""',
|
||||
"class SignalEngine:",
|
||||
" def generate(self, *args, **kwargs):",
|
||||
*body,
|
||||
]
|
||||
signal_file.write_text("\n".join(lines), encoding="utf-8")
|
||||
|
||||
with pytest.raises(ValueError, match="not allowed inside generated strategy code"):
|
||||
_load_module_from_file(signal_file, _module_name())
|
||||
|
||||
|
||||
def test_signal_engine_rejects_forbidden_op_in_transitively_called_helper(tmp_path) -> None:
|
||||
# Payload hidden in a module-level helper that generate() calls — the
|
||||
# reachability walk must follow the call and reject it.
|
||||
signal_file = tmp_path / "signal_engine.py"
|
||||
signal_file.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
'"""Generated signal engine."""',
|
||||
"def _exfiltrate():",
|
||||
" import socket",
|
||||
" return socket.socket()",
|
||||
"class SignalEngine:",
|
||||
" def generate(self, *args, **kwargs):",
|
||||
" return _exfiltrate()",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="not allowed inside generated strategy code"):
|
||||
_load_module_from_file(signal_file, _module_name())
|
||||
|
||||
|
||||
def test_signal_engine_allows_realistic_pandas_strategy(tmp_path) -> None:
|
||||
# A representative generated strategy: numpy/pandas math, a for-loop, an if,
|
||||
# a module-level pure helper called from generate(), and a private method.
|
||||
signal_file = tmp_path / "signal_engine.py"
|
||||
signal_file.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
'"""Momentum strategy."""',
|
||||
"from typing import Dict",
|
||||
"import numpy as np",
|
||||
"import pandas as pd",
|
||||
"",
|
||||
"def _zscore(s: pd.Series) -> pd.Series:",
|
||||
" return (s - s.rolling(20).mean()) / s.rolling(20).std()",
|
||||
"",
|
||||
"class SignalEngine:",
|
||||
" def __init__(self, lookback: int = 20):",
|
||||
" self.lookback = lookback",
|
||||
" def generate(self, data_map: Dict[str, pd.DataFrame]):",
|
||||
" out = {}",
|
||||
" for code, df in data_map.items():",
|
||||
" z = _zscore(df['close'])",
|
||||
" sig = pd.Series(0.0, index=df.index)",
|
||||
" if len(df) > self.lookback:",
|
||||
" sig = np.sign(z).fillna(0.0)",
|
||||
" out[code] = self._clip(sig)",
|
||||
" return out",
|
||||
" def _clip(self, s):",
|
||||
" return s.clip(-1, 1)",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
module = _load_module_from_file(signal_file, _module_name())
|
||||
assert hasattr(module, "SignalEngine")
|
||||
|
||||
|
||||
def test_signal_engine_allows_unreachable_network_helper(tmp_path) -> None:
|
||||
# Mirrors the bundled skill examples: a top-level ``import requests`` plus a
|
||||
# standalone ``_fetch_okx`` data-fetch helper that generate() never calls.
|
||||
# Because it is unreachable from any SignalEngine method it must NOT trip the
|
||||
# scrubber — blocking it would reject strategies generated from ~12 skills.
|
||||
signal_file = tmp_path / "signal_engine.py"
|
||||
signal_file.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
'"""Strategy with an unused standalone fetch helper."""',
|
||||
"from typing import Dict",
|
||||
"import pandas as pd",
|
||||
"import requests",
|
||||
"",
|
||||
"def _fetch_okx(inst_id):",
|
||||
" resp = requests.get('https://www.okx.com/api/v5/market/candles')",
|
||||
" return resp.json()",
|
||||
"",
|
||||
"class SignalEngine:",
|
||||
" def generate(self, data_map: Dict[str, pd.DataFrame]):",
|
||||
" return {c: df['close'] * 0.0 for c, df in data_map.items()}",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
module = _load_module_from_file(signal_file, _module_name())
|
||||
assert hasattr(module, "SignalEngine")
|
||||
@@ -5,7 +5,15 @@ from __future__ import annotations
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from src.core.runner import Runner
|
||||
import pytest
|
||||
|
||||
from src.core import runner as runner_mod
|
||||
from src.core.runner import (
|
||||
Runner,
|
||||
_make_rlimit_preexec,
|
||||
_prepare_sandbox_home,
|
||||
_resolve_sandbox_credentials,
|
||||
)
|
||||
|
||||
|
||||
def test_backtest_runtime_env_keeps_market_data_configuration(
|
||||
@@ -89,3 +97,157 @@ def test_backtest_runtime_env_prepends_runtime_pythonpath(
|
||||
env = Runner(timeout=1)._build_runtime_env(tmp_path, pythonpath_extra=pythonpath_extra)
|
||||
|
||||
assert env["PYTHONPATH"] == f"{pythonpath_extra}{os.pathsep}existing-path"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# VT-001 runtime defense-in-depth: ephemeral HOME, UID-drop fallback, rlimits.
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_sandbox_credentials_absent_in_this_environment() -> None:
|
||||
# No vibe-sandbox account here, so the UID-drop pre-check returns None and
|
||||
# execute() must run WITHOUT a user= kwarg (the graceful fallback path).
|
||||
assert _resolve_sandbox_credentials() is None
|
||||
|
||||
|
||||
def test_prepare_sandbox_home_reexposes_only_loader_paths(tmp_path: Path) -> None:
|
||||
real_home = tmp_path / "home"
|
||||
vt = real_home / ".vibe-trading"
|
||||
(vt / "cache").mkdir(parents=True)
|
||||
(vt / "memory").mkdir(parents=True)
|
||||
(vt / ".env").write_text("SECRET=1", encoding="utf-8")
|
||||
(vt / "qveris.json").write_text("{}", encoding="utf-8")
|
||||
|
||||
sandbox = _prepare_sandbox_home(real_home)
|
||||
try:
|
||||
dst_vt = sandbox / ".vibe-trading"
|
||||
# Loader-owned paths re-exposed (symlinked)...
|
||||
assert (dst_vt / "cache").exists()
|
||||
assert (dst_vt / "qveris.json").exists()
|
||||
# ...persistent secrets/state are NOT.
|
||||
assert not (dst_vt / "memory").exists()
|
||||
assert not (dst_vt / ".env").exists()
|
||||
assert sandbox != real_home
|
||||
finally:
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(sandbox, ignore_errors=True)
|
||||
# Cleanup removes the ephemeral home; symlink targets (real cache) survive.
|
||||
assert not sandbox.exists()
|
||||
assert (vt / "cache").exists()
|
||||
|
||||
|
||||
def test_make_rlimit_preexec_returns_callable_on_posix() -> None:
|
||||
# Structural check only — the returned closure mutates *this* process's
|
||||
# rlimits if called directly, so it must never be invoked in-process here;
|
||||
# test_execute_* below already exercises it end-to-end via a real fork.
|
||||
preexec = _make_rlimit_preexec()
|
||||
assert preexec is None or callable(preexec)
|
||||
|
||||
|
||||
def test_rlimit_as_bytes_respects_env_override(monkeypatch) -> None:
|
||||
monkeypatch.setenv("VIBE_TRADING_SANDBOX_RLIMIT_AS_MB", "256")
|
||||
assert runner_mod._rlimit_as_bytes() == 256 * 1024 * 1024
|
||||
|
||||
|
||||
def test_rlimit_as_bytes_falls_back_on_invalid_env(monkeypatch) -> None:
|
||||
monkeypatch.setenv("VIBE_TRADING_SANDBOX_RLIMIT_AS_MB", "not-a-number")
|
||||
assert runner_mod._rlimit_as_bytes() == runner_mod._DEFAULT_RLIMIT_AS_MB * 1024 * 1024
|
||||
|
||||
|
||||
def _probe_entry(tmp_path: Path, body: str) -> Path:
|
||||
entry = tmp_path / "probe.py"
|
||||
entry.write_text(body, encoding="utf-8")
|
||||
return entry
|
||||
|
||||
|
||||
def test_execute_uses_ephemeral_home_and_cleans_up(tmp_path: Path) -> None:
|
||||
run_dir = tmp_path / "run"
|
||||
run_dir.mkdir()
|
||||
entry = _probe_entry(
|
||||
tmp_path,
|
||||
"import os, sys\nsys.stdout.write(os.environ.get('HOME', '') + '\\n')\n",
|
||||
)
|
||||
|
||||
result = Runner(timeout=60).execute(entry, run_dir, cwd=tmp_path)
|
||||
|
||||
assert result.success, result.stderr
|
||||
home_line = result.stdout.strip()
|
||||
# The subprocess saw an ephemeral HOME, not the real one...
|
||||
assert "vibe-sandbox-home-" in home_line
|
||||
# ...and it was cleaned up after the process exited.
|
||||
assert not Path(home_line).exists()
|
||||
|
||||
|
||||
def test_execute_falls_back_without_uid_drop_and_succeeds(tmp_path: Path) -> None:
|
||||
# With no vibe-sandbox user, execute() must NOT pass user=/group= and must
|
||||
# complete normally — this is the path that fires in CI / dev / non-Docker.
|
||||
run_dir = tmp_path / "run"
|
||||
run_dir.mkdir()
|
||||
entry = _probe_entry(tmp_path, "print('ran-without-uid-drop')\n")
|
||||
|
||||
result = Runner(timeout=60).execute(entry, run_dir, cwd=tmp_path)
|
||||
|
||||
assert result.success, result.stderr
|
||||
assert "ran-without-uid-drop" in result.stdout
|
||||
|
||||
|
||||
def test_execute_retries_without_uid_drop_when_drop_fails(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
# Simulate a host where vibe-sandbox exists but the drop is not permitted:
|
||||
# execute() must catch the failure, warn, and re-run without user=/group=.
|
||||
monkeypatch.setattr(
|
||||
runner_mod,
|
||||
"_resolve_sandbox_credentials",
|
||||
lambda: ("vibe-sandbox", "vibe-sandbox"),
|
||||
)
|
||||
real_run = runner_mod.subprocess.run
|
||||
attempts: list[bool] = []
|
||||
|
||||
def _fake_run(cmd, **kwargs):
|
||||
used_user = "user" in kwargs
|
||||
attempts.append(used_user)
|
||||
if used_user:
|
||||
raise PermissionError("Operation not permitted")
|
||||
return real_run(cmd, **kwargs)
|
||||
|
||||
monkeypatch.setattr(runner_mod.subprocess, "run", _fake_run)
|
||||
|
||||
run_dir = tmp_path / "run"
|
||||
run_dir.mkdir()
|
||||
entry = _probe_entry(tmp_path, "print('after-fallback')\n")
|
||||
|
||||
result = Runner(timeout=60).execute(entry, run_dir, cwd=tmp_path)
|
||||
|
||||
# The interpreter-readiness probe also calls subprocess.run (never with a
|
||||
# UID drop); what matters is the execute() call itself: drop attempted, then
|
||||
# retried without it.
|
||||
assert attempts[-2:] == [True, False]
|
||||
assert attempts.count(True) == 1
|
||||
assert result.success, result.stderr
|
||||
assert "after-fallback" in result.stdout
|
||||
|
||||
|
||||
@pytest.mark.skipif(runner_mod.resource is None, reason="POSIX resource module required")
|
||||
def test_execute_applies_address_space_rlimit(monkeypatch, tmp_path: Path) -> None:
|
||||
# Prove the preexec_fn actually ran in the child by reading back its
|
||||
# RLIMIT_NOFILE (RLIMIT_AS is a no-op on macOS but NOFILE is portable).
|
||||
import resource as _resource
|
||||
|
||||
_soft, hard = _resource.getrlimit(_resource.RLIMIT_NOFILE)
|
||||
expected = 512 if hard == _resource.RLIM_INFINITY else min(512, hard)
|
||||
|
||||
monkeypatch.setenv("VIBE_TRADING_SANDBOX_RLIMIT_AS_MB", "4096")
|
||||
run_dir = tmp_path / "run"
|
||||
run_dir.mkdir()
|
||||
entry = _probe_entry(
|
||||
tmp_path,
|
||||
"import resource\n"
|
||||
"print(resource.getrlimit(resource.RLIMIT_NOFILE)[0])\n",
|
||||
)
|
||||
|
||||
result = Runner(timeout=60).execute(entry, run_dir, cwd=tmp_path)
|
||||
|
||||
assert result.success, result.stderr
|
||||
assert result.stdout.strip().splitlines()[-1] == str(expected)
|
||||
@@ -351,14 +351,30 @@ def test_configured_api_key_required_for_session_event_stream(
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_session_event_stream_accepts_query_token_for_browser_eventsource(
|
||||
def test_session_event_stream_rejects_long_lived_api_key_query(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""VT-003: the long-lived key is no longer accepted in the SSE query string."""
|
||||
monkeypatch.setenv("API_AUTH_KEY", "secret")
|
||||
monkeypatch.setattr(api_server, "_API_KEY", "secret")
|
||||
|
||||
response = _remote_client().get("/sessions/missing/events?api_key=secret")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_session_event_stream_accepts_single_use_ticket_for_browser_eventsource(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""VT-003: a header-minted single-use ticket authenticates the EventSource."""
|
||||
monkeypatch.setenv("API_AUTH_KEY", "secret")
|
||||
monkeypatch.setattr(api_server, "_API_KEY", "secret")
|
||||
|
||||
ticket = api_server._mint_sse_ticket()
|
||||
response = _remote_client().get(f"/sessions/missing/events?ticket={ticket}")
|
||||
|
||||
# Auth passed (the 404/501 comes from the missing session / disabled runtime,
|
||||
# not from the auth layer).
|
||||
assert response.status_code in {404, 501}
|
||||
|
||||
|
||||
|
||||
@@ -265,3 +265,52 @@ def test_settings_writes_reject_remote_dev_mode_clients(
|
||||
|
||||
assert response.status_code == 403
|
||||
assert not env_path.exists()
|
||||
|
||||
|
||||
def test_update_settings_writes_env_file_with_0600_mode(
|
||||
client: TestClient, tmp_path: Path,
|
||||
) -> None:
|
||||
"""A Web-UI settings write must leave agent/.env owner-read/write only."""
|
||||
response = client.put(
|
||||
"/settings/data-sources",
|
||||
json={"tushare_token": "ts-secret-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
mode = (tmp_path / ".env").stat().st_mode & 0o777
|
||||
assert mode == 0o600
|
||||
|
||||
|
||||
def test_atomic_write_secret_is_crash_safe(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A crash during the replace must not corrupt or truncate the secret file,
|
||||
nor leave a stray temp file holding the secret behind."""
|
||||
from src.api import helpers
|
||||
|
||||
target = tmp_path / ".env"
|
||||
target.write_text("OLD=1\n", encoding="utf-8")
|
||||
|
||||
def _boom(*_args: object, **_kwargs: object) -> None:
|
||||
raise OSError("simulated crash before commit")
|
||||
|
||||
monkeypatch.setattr(helpers.os, "replace", _boom)
|
||||
|
||||
with pytest.raises(OSError):
|
||||
helpers._atomic_write_secret(target, "NEW=2\n")
|
||||
|
||||
# Original content is intact — the swap never happened.
|
||||
assert target.read_text(encoding="utf-8") == "OLD=1\n"
|
||||
# No half-written temp secret left in the directory.
|
||||
assert list(tmp_path.glob(".env.*")) == []
|
||||
|
||||
|
||||
def test_atomic_write_secret_creates_0600_file(tmp_path: Path) -> None:
|
||||
"""Fresh secret files are created owner-only via the atomic path."""
|
||||
from src.api import helpers
|
||||
|
||||
target = tmp_path / ".env"
|
||||
helpers._atomic_write_secret(target, "KEY=value\n")
|
||||
|
||||
assert target.read_text(encoding="utf-8") == "KEY=value\n"
|
||||
assert (target.stat().st_mode & 0o777) == 0o600
|
||||
@@ -0,0 +1,221 @@
|
||||
"""VT-003 / VT-006 regression tests: SSE tickets, security headers, log redaction."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import api_server
|
||||
from src.api import security
|
||||
|
||||
|
||||
def _remote_client() -> TestClient:
|
||||
"""Return a TestClient that simulates a non-loopback caller."""
|
||||
return TestClient(api_server.app, client=("203.0.113.10", 50000))
|
||||
|
||||
|
||||
def _local_client() -> TestClient:
|
||||
"""Return a TestClient that simulates a loopback caller."""
|
||||
return TestClient(api_server.app, client=("127.0.0.1", 50000))
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_state(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Start each test from dev-mode auth with an empty ticket store."""
|
||||
monkeypatch.delenv("API_AUTH_KEY", raising=False)
|
||||
monkeypatch.delenv("VIBE_TRADING_TRUST_DOCKER_LOOPBACK", raising=False)
|
||||
monkeypatch.setattr(api_server, "_API_KEY", "")
|
||||
with security._sse_tickets_lock:
|
||||
security._sse_tickets.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SSE ticket store (unit)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_minted_ticket_is_unguessable_and_urlsafe() -> None:
|
||||
ticket = security._mint_sse_ticket()
|
||||
assert isinstance(ticket, str)
|
||||
assert len(ticket) >= 32
|
||||
# token_urlsafe alphabet only
|
||||
assert all(c.isalnum() or c in "-_" for c in ticket)
|
||||
|
||||
|
||||
def test_ticket_is_single_use() -> None:
|
||||
ticket = security._mint_sse_ticket()
|
||||
assert security._consume_sse_ticket(ticket) is True
|
||||
# Second use is rejected — the ticket was invalidated on first use.
|
||||
assert security._consume_sse_ticket(ticket) is False
|
||||
|
||||
|
||||
def test_unknown_ticket_is_rejected() -> None:
|
||||
assert security._consume_sse_ticket("never-minted") is False
|
||||
assert security._consume_sse_ticket("") is False
|
||||
|
||||
|
||||
def test_expired_ticket_is_rejected() -> None:
|
||||
import time
|
||||
|
||||
ticket = security._mint_sse_ticket()
|
||||
# Force the stored expiry into the past without waiting the full TTL.
|
||||
with security._sse_tickets_lock:
|
||||
security._sse_tickets[ticket] = time.monotonic() - 1.0
|
||||
assert security._consume_sse_ticket(ticket) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /auth/sse-ticket endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_sse_ticket_endpoint_mints_in_loopback_dev_mode() -> None:
|
||||
response = _local_client().post("/auth/sse-ticket")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert isinstance(response.json().get("ticket"), str)
|
||||
assert response.json()["ticket"]
|
||||
|
||||
|
||||
def test_sse_ticket_endpoint_requires_bearer_when_key_configured(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("API_AUTH_KEY", "secret")
|
||||
monkeypatch.setattr(api_server, "_API_KEY", "secret")
|
||||
|
||||
# Remote caller without the header is rejected — minting requires the key
|
||||
# in an Authorization header (never a URL).
|
||||
assert _remote_client().post("/auth/sse-ticket").status_code == 401
|
||||
|
||||
ok = _remote_client().post("/auth/sse-ticket", headers={"Authorization": "Bearer secret"})
|
||||
assert ok.status_code == 200
|
||||
assert ok.json()["ticket"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# require_event_stream_auth (integration via /sessions/{id}/events)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_event_stream_accepts_ticket_once_then_rejects_reuse(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("API_AUTH_KEY", "secret")
|
||||
monkeypatch.setattr(api_server, "_API_KEY", "secret")
|
||||
|
||||
ticket = api_server._mint_sse_ticket()
|
||||
client = _remote_client()
|
||||
|
||||
first = client.get(f"/sessions/missing/events?ticket={ticket}")
|
||||
# Auth passed: the 404/501 comes from the missing session / disabled runtime.
|
||||
assert first.status_code in {404, 501}
|
||||
|
||||
# The same ticket cannot be replayed.
|
||||
second = client.get(f"/sessions/missing/events?ticket={ticket}")
|
||||
assert second.status_code == 401
|
||||
|
||||
|
||||
def test_event_stream_still_accepts_bearer_header_for_non_browser_callers(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("API_AUTH_KEY", "secret")
|
||||
monkeypatch.setattr(api_server, "_API_KEY", "secret")
|
||||
|
||||
response = _remote_client().get(
|
||||
"/sessions/missing/events",
|
||||
headers={"Authorization": "Bearer secret"},
|
||||
)
|
||||
assert response.status_code in {404, 501}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# VT-006: security headers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_security_headers_present_on_response() -> None:
|
||||
response = _local_client().get("/health")
|
||||
|
||||
assert response.headers.get("X-Content-Type-Options") == "nosniff"
|
||||
assert response.headers.get("X-Frame-Options") == "DENY"
|
||||
assert response.headers.get("Referrer-Policy") == "strict-origin-when-cross-origin"
|
||||
assert "geolocation=()" in response.headers.get("Permissions-Policy", "")
|
||||
|
||||
csp = response.headers.get("Content-Security-Policy-Report-Only", "")
|
||||
assert "default-src 'self'" in csp
|
||||
assert "frame-ancestors 'none'" in csp
|
||||
# Ships Report-Only first, never as an enforcing policy.
|
||||
assert response.headers.get("Content-Security-Policy") is None
|
||||
|
||||
# No HSTS from the app — that belongs at the TLS-terminating proxy.
|
||||
assert response.headers.get("Strict-Transport-Security") is None
|
||||
|
||||
|
||||
def test_security_headers_present_on_error_response(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Headers must wrap error responses from inner middleware too."""
|
||||
monkeypatch.setenv("API_AUTH_KEY", "secret")
|
||||
monkeypatch.setattr(api_server, "_API_KEY", "secret")
|
||||
|
||||
response = _local_client().get("/runs", headers={"Host": "attacker.example:8899"})
|
||||
|
||||
assert response.status_code == 403 # rebound-host guard short-circuits
|
||||
assert response.headers.get("X-Content-Type-Options") == "nosniff"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# VT-003: access-log secret redaction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_redact_query_secrets_masks_ticket_and_api_key_values() -> None:
|
||||
line = 'GET /sessions/x/events?ticket=abc123&foo=bar HTTP/1.1'
|
||||
red = security._redact_query_secrets(line)
|
||||
assert "abc123" not in red
|
||||
assert "ticket=***REDACTED***" in red
|
||||
assert "foo=bar" in red
|
||||
|
||||
line2 = "/alpha/bench/1/stream?api_key=SUPERSECRET"
|
||||
red2 = security._redact_query_secrets(line2)
|
||||
assert "SUPERSECRET" not in red2
|
||||
assert "api_key=***REDACTED***" in red2
|
||||
|
||||
|
||||
def test_access_log_filter_redacts_record_args() -> None:
|
||||
filt = security._AccessLogRedactionFilter()
|
||||
record = logging.LogRecord(
|
||||
name="uvicorn.access",
|
||||
level=logging.INFO,
|
||||
pathname=__file__,
|
||||
lineno=1,
|
||||
msg='%s - "%s %s HTTP/%s" %d',
|
||||
args=("127.0.0.1:5000", "GET", "/sessions/x/events?ticket=LEAKME", "1.1", 404),
|
||||
exc_info=None,
|
||||
)
|
||||
|
||||
assert filt.filter(record) is True
|
||||
formatted = record.getMessage()
|
||||
assert "LEAKME" not in formatted
|
||||
assert "ticket=***REDACTED***" in formatted
|
||||
|
||||
|
||||
def test_install_access_log_redaction_filter_is_idempotent() -> None:
|
||||
access = logging.getLogger("uvicorn.access")
|
||||
before = [f for f in access.filters if isinstance(f, security._AccessLogRedactionFilter)]
|
||||
for f in list(before):
|
||||
access.removeFilter(f)
|
||||
|
||||
security.install_access_log_redaction_filter()
|
||||
security.install_access_log_redaction_filter()
|
||||
|
||||
installed = [f for f in access.filters if isinstance(f, security._AccessLogRedactionFilter)]
|
||||
assert len(installed) == 1
|
||||
|
||||
# Restore prior state (leave exactly what was there before this test).
|
||||
for f in installed:
|
||||
access.removeFilter(f)
|
||||
for f in before:
|
||||
access.addFilter(f)
|
||||
@@ -0,0 +1,206 @@
|
||||
"""Route-level contracts for the system endpoints.
|
||||
|
||||
Exercises the REST surface mounted by ``register_system_routes``:
|
||||
``/live`` + ``/ready`` (VT-008 liveness/readiness split) and the hardened
|
||||
``/correlation`` endpoint (VT-005: auth + per-IP rate limit + masked errors).
|
||||
|
||||
Each test drives the app through ``TestClient``. The default ``TestClient``
|
||||
client host (``testclient``) and explicit ``127.0.0.1`` are treated as loopback
|
||||
callers, so ``require_auth`` passes without a configured API key; a non-loopback
|
||||
client with a configured key is used to prove the auth gate rejects.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import api_server
|
||||
from src.api import system_routes
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def local_client(monkeypatch: pytest.MonkeyPatch) -> TestClient:
|
||||
"""Loopback client with no API key configured (dev-mode: auth passes)."""
|
||||
monkeypatch.delenv("API_AUTH_KEY", raising=False)
|
||||
monkeypatch.setattr(api_server, "_API_KEY", "")
|
||||
return TestClient(api_server.app, client=("127.0.0.1", 50000))
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_correlation_limiter() -> None:
|
||||
"""Clear the module-level rate limiter so tests never leak hits into each other."""
|
||||
system_routes._correlation_rate_limiter.reset()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# VT-008 — /live, /health alias, /ready
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_live_returns_healthy(local_client: TestClient):
|
||||
resp = local_client.get("/live")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["status"] == "healthy"
|
||||
assert body["service"] == "Vibe-Trading API"
|
||||
|
||||
|
||||
def test_health_is_backward_compatible_alias(local_client: TestClient):
|
||||
resp = local_client.get("/health")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "healthy"
|
||||
|
||||
|
||||
def test_ready_returns_200_when_provider_ready(
|
||||
local_client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
monkeypatch.setattr(system_routes, "_provider_readiness", lambda: (True, "ready"))
|
||||
resp = local_client.get("/ready")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["status"] == "ready"
|
||||
assert body["service"] == "Vibe-Trading API"
|
||||
|
||||
|
||||
def test_ready_returns_503_when_provider_not_ready(
|
||||
local_client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
monkeypatch.setattr(
|
||||
system_routes,
|
||||
"_provider_readiness",
|
||||
lambda: (False, "LLM provider not configured"),
|
||||
)
|
||||
resp = local_client.get("/ready")
|
||||
assert resp.status_code == 503
|
||||
assert resp.json()["detail"] == "LLM provider not configured"
|
||||
|
||||
|
||||
def test_readiness_helper_real_missing_credential(monkeypatch: pytest.MonkeyPatch):
|
||||
"""The un-mocked helper flags a configured provider with no credential."""
|
||||
import src.providers.llm as llm
|
||||
from src.config.accessor import reset_env_config
|
||||
|
||||
# Neutralize the .env reload inside _sync_provider_env so the test fully
|
||||
# controls the environment (otherwise a machine-local OPENAI_API_KEY leaks in).
|
||||
monkeypatch.setattr(llm, "_ensure_dotenv", lambda: None)
|
||||
monkeypatch.setenv("LANGCHAIN_PROVIDER", "openai")
|
||||
monkeypatch.setenv("LANGCHAIN_MODEL_NAME", "gpt-4o-mini")
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
reset_env_config()
|
||||
|
||||
ready, reason = system_routes._provider_readiness()
|
||||
assert ready is False
|
||||
assert "credential" in reason.lower()
|
||||
|
||||
|
||||
def test_readiness_helper_real_ready(monkeypatch: pytest.MonkeyPatch):
|
||||
"""The un-mocked helper reports ready when provider+model+key are present."""
|
||||
from src.config.accessor import reset_env_config
|
||||
|
||||
monkeypatch.setenv("LANGCHAIN_PROVIDER", "openai")
|
||||
monkeypatch.setenv("LANGCHAIN_MODEL_NAME", "gpt-4o-mini")
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "sk-test-not-a-real-key")
|
||||
reset_env_config()
|
||||
|
||||
ready, reason = system_routes._provider_readiness()
|
||||
assert ready is True
|
||||
assert reason == "ready"
|
||||
|
||||
|
||||
def test_readiness_helper_missing_provider(monkeypatch: pytest.MonkeyPatch):
|
||||
from src.config.accessor import reset_env_config
|
||||
|
||||
monkeypatch.setenv("LANGCHAIN_PROVIDER", "")
|
||||
monkeypatch.setenv("LANGCHAIN_MODEL_NAME", "")
|
||||
reset_env_config()
|
||||
|
||||
ready, reason = system_routes._provider_readiness()
|
||||
assert ready is False
|
||||
assert "provider" in reason.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# VT-005 — /correlation auth + rate limit + error masking
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_correlation_requires_auth_for_remote_client(monkeypatch: pytest.MonkeyPatch):
|
||||
"""A non-loopback caller with a configured key but no token is rejected."""
|
||||
monkeypatch.setattr(api_server, "_API_KEY", "server-secret")
|
||||
remote = TestClient(api_server.app, client=("203.0.113.9", 51000))
|
||||
resp = remote.get("/correlation", params={"codes": "AAPL,SPY"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_correlation_allows_local_and_masks_generic_error(
|
||||
local_client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
"""A generic backend failure must not leak its exception text to the client."""
|
||||
import backtest.correlation as corr
|
||||
|
||||
def _boom(**_kwargs):
|
||||
raise RuntimeError("sensitive internal detail: db=prod host=10.0.0.5")
|
||||
|
||||
monkeypatch.setattr(corr, "compute_correlation_matrix", _boom)
|
||||
resp = local_client.get("/correlation", params={"codes": "AAPL,SPY"})
|
||||
assert resp.status_code == 500
|
||||
detail = resp.json()["detail"]
|
||||
assert detail == "Correlation computation failed"
|
||||
assert "sensitive internal detail" not in detail
|
||||
|
||||
|
||||
def test_correlation_value_error_still_surfaces_message(
|
||||
local_client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
"""Hand-raised validation errors remain user-facing (unchanged behavior)."""
|
||||
import backtest.correlation as corr
|
||||
|
||||
def _bad(**_kwargs):
|
||||
raise ValueError("Not enough overlapping history")
|
||||
|
||||
monkeypatch.setattr(corr, "compute_correlation_matrix", _bad)
|
||||
resp = local_client.get("/correlation", params={"codes": "AAPL,SPY"})
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["detail"] == "Not enough overlapping history"
|
||||
|
||||
|
||||
def test_correlation_rate_limit_returns_429(
|
||||
local_client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
"""Exceeding the per-client window yields 429 with a generic message."""
|
||||
import backtest.correlation as corr
|
||||
|
||||
monkeypatch.setattr(
|
||||
corr,
|
||||
"compute_correlation_matrix",
|
||||
lambda **_kwargs: {"labels": ["AAPL", "SPY"], "matrix": [[1.0, 0.5], [0.5, 1.0]]},
|
||||
)
|
||||
# Tighten the limiter to make the boundary cheap and deterministic.
|
||||
monkeypatch.setattr(
|
||||
system_routes,
|
||||
"_correlation_rate_limiter",
|
||||
system_routes._SlidingWindowRateLimiter(max_requests=2, window_seconds=60.0),
|
||||
)
|
||||
|
||||
ok1 = local_client.get("/correlation", params={"codes": "AAPL,SPY"})
|
||||
ok2 = local_client.get("/correlation", params={"codes": "AAPL,SPY"})
|
||||
blocked = local_client.get("/correlation", params={"codes": "AAPL,SPY"})
|
||||
|
||||
assert ok1.status_code == 200
|
||||
assert ok2.status_code == 200
|
||||
assert blocked.status_code == 429
|
||||
assert blocked.json()["detail"] == "Rate limit exceeded, try again later"
|
||||
|
||||
|
||||
def test_correlation_rate_limiter_is_per_client(monkeypatch: pytest.MonkeyPatch):
|
||||
"""Distinct client IPs get independent budgets."""
|
||||
limiter = system_routes._SlidingWindowRateLimiter(max_requests=1, window_seconds=60.0)
|
||||
assert limiter.allow("1.1.1.1") is True
|
||||
assert limiter.allow("1.1.1.1") is False
|
||||
assert limiter.allow("2.2.2.2") is True
|
||||
+31
-1
@@ -26,10 +26,40 @@ services:
|
||||
- vibe-home:/home/vibe/.vibe-trading
|
||||
- vibe-swarm-runs:/app/agent/.swarm/runs
|
||||
- vibe-uploads:/app/agent/uploads
|
||||
# Persist Web-UI-driven agent/.env edits (_write_env_values in
|
||||
# src/api/helpers.py writes to /app/agent/.env) across container recreation.
|
||||
# env_file above still seeds the initial boot env from the same file.
|
||||
- ./agent/.env:/app/agent/.env
|
||||
# --- Runtime hardening (VT-007) ---
|
||||
cap_drop:
|
||||
- ALL
|
||||
# SETUID/SETGID are kept on purpose: runner.py drops privilege to the
|
||||
# unprivileged vibe-sandbox user for LLM-generated-code subprocesses, which
|
||||
# needs setuid/setgid. They are granted from container start (not gained via
|
||||
# escalation), so no-new-privileges below does not neutralize them.
|
||||
cap_add:
|
||||
- SETUID
|
||||
- SETGID
|
||||
security_opt:
|
||||
- "no-new-privileges:true"
|
||||
read_only: true
|
||||
# Writable scratch on the read-only rootfs. Named volumes above already
|
||||
# cover agent/runs, agent/sessions, agent/uploads, agent/.swarm/runs and
|
||||
# /home/vibe/.vibe-trading; these tmpfs mounts cover /tmp and the cache dirs
|
||||
# matplotlib/fontconfig write to during PDF rendering.
|
||||
tmpfs:
|
||||
- /tmp
|
||||
- /home/vibe/.cache
|
||||
- /home/vibe/.config
|
||||
# Bound a single runaway backtest. Generous self-hosted defaults; override
|
||||
# in a compose override file for constrained hosts.
|
||||
mem_limit: 4g
|
||||
cpus: 2
|
||||
pids_limit: 512
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
image: node:20
|
||||
image: node:20@sha256:8f693eaa7e0a8e71560c9a82b55fd54c2ae920a2ba5d2cde28bac7d1c01c9ba5 # node:20 digest resolved 2026-07-13
|
||||
working_dir: /app
|
||||
ports:
|
||||
- "127.0.0.1:5899:5899"
|
||||
|
||||
+2
-3
@@ -5,9 +5,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vibe-Trading — vibe trading with your professional financial agent team</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<!-- Fonts are self-hosted via @fontsource (imported in src/main.tsx) so no
|
||||
external font CDN is contacted — see VT-006 / the CSP font-src policy. -->
|
||||
<meta name="description" content="Vibe-Trading — vibe trading with your professional financial agent team. Natural-language finance research, backtesting, swarm workflows, document reading, and multi-market analysis." />
|
||||
</head>
|
||||
<body class="min-h-screen bg-background text-foreground antialiased">
|
||||
|
||||
Generated
+20
@@ -8,6 +8,8 @@
|
||||
"name": "vibe-trading-frontend",
|
||||
"version": "0.1.11",
|
||||
"dependencies": {
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
"clsx": "^2.1.0",
|
||||
"echarts": "^6.1.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
@@ -1001,6 +1003,24 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/inter": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz",
|
||||
"integrity": "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/jetbrains-mono": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz",
|
||||
"integrity": "sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
"clsx": "^2.1.0",
|
||||
"echarts": "^6.1.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
|
||||
@@ -310,3 +310,49 @@ describe("useSSE — Last-Event-ID resume", () => {
|
||||
expect(newUrl).toContain("Last-Event-ID=resume-42");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSSE — SSE ticket auth (VT-003)", () => {
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("stays synchronous and mints no ticket in dev mode (no stored key)", () => {
|
||||
const fetchSpy = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
|
||||
const { result } = renderHook(() => useSSE());
|
||||
act(() => result.current.connect("http://test/events", {}));
|
||||
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
expect(MockEventSource.latest.url).toBe("http://test/events");
|
||||
});
|
||||
|
||||
it("mints a single-use ticket and opens the stream with ?ticket= when a key is stored", async () => {
|
||||
localStorage.setItem("vibe_trading_api_auth_key", "remote-key");
|
||||
const fetchSpy = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ ticket: "SSE-TICKET" }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
|
||||
const { result } = renderHook(() => useSSE());
|
||||
await act(async () => {
|
||||
result.current.connect("http://test/events", {});
|
||||
// Flush the ticket-fetch promise chain before the EventSource is opened.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
"/auth/sse-ticket",
|
||||
expect.objectContaining({ method: "POST" }),
|
||||
);
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
expect(MockEventSource.latest.url).toContain("ticket=SSE-TICKET");
|
||||
// The long-lived key must never appear in the stream URL.
|
||||
expect(MockEventSource.latest.url).not.toContain("remote-key");
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import { useCallback, useRef } from "react";
|
||||
import { getApiAuthKey, withAuthTicket } from "@/lib/apiAuth";
|
||||
|
||||
type EventHandler = (data: Record<string, unknown>) => void;
|
||||
type Handlers = Record<string, EventHandler>;
|
||||
@@ -66,10 +67,9 @@ export function useSSE(config?: SSEConfig) {
|
||||
return baseUrl;
|
||||
}, []);
|
||||
|
||||
const doConnect = useCallback(() => {
|
||||
const attach = useCallback((url: string) => {
|
||||
if (closedRef.current) return;
|
||||
|
||||
const url = buildUrl(urlRef.current);
|
||||
const source = new EventSource(url);
|
||||
sourceRef.current = source;
|
||||
|
||||
@@ -117,7 +117,27 @@ export function useSSE(config?: SSEConfig) {
|
||||
sourceRef.current = null;
|
||||
scheduleReconnect();
|
||||
};
|
||||
}, [buildUrl, trackEventId, setStatus]);
|
||||
}, [trackEventId, setStatus]);
|
||||
|
||||
const doConnect = useCallback(() => {
|
||||
if (closedRef.current) return;
|
||||
|
||||
const baseUrl = buildUrl(urlRef.current);
|
||||
|
||||
// When an API key is stored we must first mint a single-use SSE ticket —
|
||||
// EventSource can't send an Authorization header. In loopback dev mode (no
|
||||
// key) the backend bypasses auth, so we connect synchronously and preserve
|
||||
// the original zero-round-trip behavior (and the synchronous test path).
|
||||
if (!getApiAuthKey()) {
|
||||
attach(baseUrl);
|
||||
return;
|
||||
}
|
||||
withAuthTicket(baseUrl)
|
||||
.then(attach)
|
||||
.catch(() => {
|
||||
if (!closedRef.current) scheduleReconnect();
|
||||
});
|
||||
}, [buildUrl, attach]);
|
||||
|
||||
const scheduleReconnect = useCallback(() => {
|
||||
if (closedRef.current) return;
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { getApiAuthKey, setApiAuthKey, authHeaders, authQuerySuffix, withAuthQuery } from "../apiAuth";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getApiAuthKey, setApiAuthKey, authHeaders, withAuthTicket } from "../apiAuth";
|
||||
|
||||
describe("apiAuth", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("getApiAuthKey", () => {
|
||||
it("returns empty string when nothing stored", () => {
|
||||
expect(getApiAuthKey()).toBe("");
|
||||
@@ -42,27 +47,84 @@ describe("apiAuth", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("authQuerySuffix", () => {
|
||||
it("returns empty string when no key", () => {
|
||||
expect(authQuerySuffix()).toBe("");
|
||||
describe("withAuthTicket", () => {
|
||||
it("returns url unchanged and makes no request when no key (dev/loopback)", async () => {
|
||||
const fetchSpy = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
await expect(withAuthTicket("http://api/stream")).resolves.toBe("http://api/stream");
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
it("returns encoded query param when key exists", () => {
|
||||
setApiAuthKey("key with spaces");
|
||||
expect(authQuerySuffix()).toBe("api_key=key%20with%20spaces");
|
||||
});
|
||||
});
|
||||
|
||||
describe("withAuthQuery", () => {
|
||||
it("returns url unchanged when no key", () => {
|
||||
expect(withAuthQuery("https://api.com/data")).toBe("https://api.com/data");
|
||||
it("mints a ticket via header-authed POST and appends ?ticket=", async () => {
|
||||
setApiAuthKey("token-xyz");
|
||||
const fetchSpy = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ ticket: "TICKET-123" }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
|
||||
const url = await withAuthTicket("http://api/stream");
|
||||
|
||||
expect(url).toBe("http://api/stream?ticket=TICKET-123");
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
const [path, init] = fetchSpy.mock.calls[0];
|
||||
expect(path).toBe("/auth/sse-ticket");
|
||||
expect(init.method).toBe("POST");
|
||||
expect(init.headers).toEqual({ Authorization: "Bearer token-xyz" });
|
||||
});
|
||||
it("appends with ? when url has no query string", () => {
|
||||
setApiAuthKey("abc");
|
||||
expect(withAuthQuery("https://api.com/data")).toBe("https://api.com/data?api_key=abc");
|
||||
|
||||
it("never puts the raw API key in the returned URL", async () => {
|
||||
setApiAuthKey("super-secret-key");
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ ticket: "one-shot" }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
const url = await withAuthTicket("http://api/stream");
|
||||
expect(url).not.toContain("super-secret-key");
|
||||
expect(url).not.toContain("api_key=");
|
||||
expect(url).toContain("ticket=one-shot");
|
||||
});
|
||||
it("appends with & when url already has query string", () => {
|
||||
setApiAuthKey("abc");
|
||||
expect(withAuthQuery("https://api.com/data?foo=bar")).toBe("https://api.com/data?foo=bar&api_key=abc");
|
||||
|
||||
it("joins with & when the url already has a query string", async () => {
|
||||
setApiAuthKey("k");
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ ticket: "t1" }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
const url = await withAuthTicket("http://api/stream?replay=active");
|
||||
expect(url).toBe("http://api/stream?replay=active&ticket=t1");
|
||||
});
|
||||
|
||||
it("throws when the ticket endpoint returns a non-OK status", async () => {
|
||||
setApiAuthKey("k");
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("", { status: 401 })));
|
||||
await expect(withAuthTicket("http://api/stream")).rejects.toThrow(/HTTP 401/);
|
||||
});
|
||||
|
||||
it("throws when the response is missing a ticket", async () => {
|
||||
setApiAuthKey("k");
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({}), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
await expect(withAuthTicket("http://api/stream")).rejects.toThrow(/missing ticket/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { authHeaders, withAuthQuery } from "@/lib/apiAuth";
|
||||
import { authHeaders, withAuthTicket } from "@/lib/apiAuth";
|
||||
|
||||
const BASE = "";
|
||||
|
||||
@@ -122,8 +122,11 @@ export const api = {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
// Returns the bare stream URL (no auth in the query string). The SSE ticket
|
||||
// is minted per connect/reconnect inside useSSE (tickets are single-use, so
|
||||
// baking one into a cached URL would break reconnection).
|
||||
sseUrl: (sid: string, options?: { replay?: "active" }) => {
|
||||
let url = withAuthQuery(`${BASE}/sessions/${sid}/events`);
|
||||
let url = `${BASE}/sessions/${sid}/events`;
|
||||
if (options?.replay) url = appendQueryParam(url, "replay", options.replay);
|
||||
return url;
|
||||
},
|
||||
@@ -137,7 +140,7 @@ export const api = {
|
||||
}),
|
||||
listSwarmRuns: () => request<SwarmRunSummary[]>("/swarm/runs"),
|
||||
getSwarmRun: (id: string) => request<Record<string, unknown>>(`/swarm/runs/${id}`),
|
||||
swarmSseUrl: (id: string) => withAuthQuery(`${BASE}/swarm/runs/${id}/events`),
|
||||
swarmSseUrl: (id: string) => withAuthTicket(`${BASE}/swarm/runs/${id}/events`),
|
||||
cancelSwarmRun: (id: string) =>
|
||||
request<{ status: string }>(`/swarm/runs/${id}/cancel`, { method: "POST" }),
|
||||
retrySwarmRun: (id: string) =>
|
||||
@@ -181,14 +184,14 @@ export const api = {
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
alphaBenchStreamUrl: (jobId: string) =>
|
||||
withAuthQuery(`${BASE}/alpha/bench/${encodeURIComponent(jobId)}/stream`),
|
||||
withAuthTicket(`${BASE}/alpha/bench/${encodeURIComponent(jobId)}/stream`),
|
||||
createAlphaCompare: (body: AlphaCompareRequest) =>
|
||||
request<{ status: string; job_id: string }>("/alpha/compare", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
alphaCompareStreamUrl: (jobId: string) =>
|
||||
withAuthQuery(`${BASE}/alpha/compare/${encodeURIComponent(jobId)}/stream`),
|
||||
withAuthTicket(`${BASE}/alpha/compare/${encodeURIComponent(jobId)}/stream`),
|
||||
|
||||
// Connector runtime channel — privileged surface actions (NOT agent tools).
|
||||
// commit is the ONLY action that writes a mandate; halt trips the kill switch.
|
||||
|
||||
@@ -18,13 +18,34 @@ export function authHeaders(): Record<string, string> {
|
||||
return key ? { Authorization: `Bearer ${key}` } : {};
|
||||
}
|
||||
|
||||
export function authQuerySuffix(): string {
|
||||
/**
|
||||
* Append a short-lived, single-use SSE ticket to an EventSource URL.
|
||||
*
|
||||
* A browser `EventSource` cannot set an `Authorization` header, so we exchange
|
||||
* the stored API key (sent in a header on this POST) for a one-shot ticket via
|
||||
* `POST /auth/sse-ticket`, then open the stream with `?ticket=`. This keeps the
|
||||
* long-lived key out of URLs, browser history, and proxy/access logs.
|
||||
*
|
||||
* When no key is stored the backend is in loopback dev mode (auth bypassed), so
|
||||
* the URL is returned unchanged and no ticket round-trip is made. Tickets are
|
||||
* single-use: every connect/reconnect must mint a fresh one, so callers invoke
|
||||
* this per connection attempt rather than caching the result.
|
||||
*/
|
||||
export async function withAuthTicket(url: string): Promise<string> {
|
||||
const key = getApiAuthKey();
|
||||
return key ? `api_key=${encodeURIComponent(key)}` : "";
|
||||
}
|
||||
|
||||
export function withAuthQuery(url: string): string {
|
||||
const suffix = authQuerySuffix();
|
||||
if (!suffix) return url;
|
||||
return `${url}${url.includes("?") ? "&" : "?"}${suffix}`;
|
||||
if (!key) return url;
|
||||
const res = await fetch("/auth/sse-ticket", {
|
||||
method: "POST",
|
||||
headers: authHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to obtain SSE ticket (HTTP ${res.status})`);
|
||||
}
|
||||
const data: unknown = await res.json();
|
||||
const ticket = (data as { ticket?: unknown } | null)?.ticket;
|
||||
if (typeof ticket !== "string" || !ticket) {
|
||||
throw new Error("SSE ticket response missing ticket");
|
||||
}
|
||||
const sep = url.includes("?") ? "&" : "?";
|
||||
return `${url}${sep}ticket=${encodeURIComponent(ticket)}`;
|
||||
}
|
||||
@@ -5,6 +5,16 @@ import { RouterProvider } from "react-router-dom";
|
||||
import { Toaster } from "sonner";
|
||||
import { ErrorBoundary } from "./components/common/ErrorBoundary";
|
||||
import { router } from "./router";
|
||||
// Self-hosted fonts (VT-006): vendor the woff2 files locally instead of the
|
||||
// Google Fonts CDN. Weights match tailwind.config.ts (Inter 400/500/600/700,
|
||||
// JetBrains Mono 400/500/700).
|
||||
import "@fontsource/inter/400.css";
|
||||
import "@fontsource/inter/500.css";
|
||||
import "@fontsource/inter/600.css";
|
||||
import "@fontsource/inter/700.css";
|
||||
import "@fontsource/jetbrains-mono/400.css";
|
||||
import "@fontsource/jetbrains-mono/500.css";
|
||||
import "@fontsource/jetbrains-mono/700.css";
|
||||
import "highlight.js/styles/github-dark-dimmed.min.css";
|
||||
import "./index.css";
|
||||
|
||||
|
||||
@@ -698,7 +698,7 @@ function BenchView() {
|
||||
top: safeTop,
|
||||
});
|
||||
setJobId(res.job_id);
|
||||
attachStream(res.job_id);
|
||||
await attachStream(res.job_id);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : "Failed to start bench";
|
||||
// BTC-USDT is single-asset — surface inline rather than as a toast,
|
||||
@@ -715,9 +715,11 @@ function BenchView() {
|
||||
}
|
||||
};
|
||||
|
||||
const attachStream = (newJobId: string) => {
|
||||
const attachStream = async (newJobId: string) => {
|
||||
setStatus("streaming");
|
||||
const url = api.alphaBenchStreamUrl(newJobId);
|
||||
// Mint a single-use SSE ticket, then open the stream with ?ticket= (the
|
||||
// long-lived API key never goes in the URL).
|
||||
const url = await api.alphaBenchStreamUrl(newJobId);
|
||||
const source = new EventSource(url);
|
||||
sourceRef.current = source;
|
||||
|
||||
@@ -1157,9 +1159,11 @@ function CompareView() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const attachStream = (newJobId: string) => {
|
||||
const attachStream = async (newJobId: string) => {
|
||||
setStatus("streaming");
|
||||
const source = new EventSource(api.alphaCompareStreamUrl(newJobId));
|
||||
// Mint a single-use SSE ticket, then open the stream with ?ticket= (the
|
||||
// long-lived API key never goes in the URL).
|
||||
const source = new EventSource(await api.alphaCompareStreamUrl(newJobId));
|
||||
sourceRef.current = source;
|
||||
|
||||
source.addEventListener("progress", (e) => {
|
||||
@@ -1225,7 +1229,7 @@ function CompareView() {
|
||||
sort,
|
||||
});
|
||||
setJobId(res.job_id);
|
||||
attachStream(res.job_id);
|
||||
await attachStream(res.job_id);
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
err instanceof Error ? err.message : "Failed to start comparison";
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@ dependencies = [
|
||||
"duckdb>=1.2.0",
|
||||
"scikit-learn>=1.3.0",
|
||||
"joblib>=1.3.0",
|
||||
"smartmoneyconcepts>=0.0.1",
|
||||
"smartmoneyconcepts>=0.0.27",
|
||||
"tushare>=1.2.89",
|
||||
"requests>=2.31.0",
|
||||
"yfinance>=0.2.30",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user