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:
Haozhe Wu committed 2026-07-13 01:40:53 +08:00
1 parent 2f4ba89733
commit 4e3c91c77a
34 files changed
+6748 -113

No files matched your search

+22
View File
@@ -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
+3 -3
View File
@@ -12,9 +12,9 @@ jobs:
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: actions/setup-python@v6 - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with: with:
python-version: "3.11" python-version: "3.11"
cache: pip cache: pip
@@ -41,7 +41,7 @@ jobs:
# the suite never silently switches state on env presence. # 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 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: with:
node-version: "20" node-version: "20"
cache: npm cache: npm
+2 -2
View File
@@ -21,10 +21,10 @@ jobs:
deployments: write deployments: write
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- name: Deploy wiki - name: Deploy wiki
uses: cloudflare/wrangler-action@v3 uses: cloudflare/wrangler-action@9acf94ace14e7dc412b076f2c5c20b8ce93c79cd # v3
with: with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+3 -3
View File
@@ -18,13 +18,13 @@ jobs:
timeout-minutes: 5 timeout-minutes: 5
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: actions/setup-node@v6 - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with: with:
node-version: "20" node-version: "20"
- uses: actions/setup-python@v6 - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with: with:
python-version: "3.11" python-version: "3.11"
+59 -23
View File
@@ -1,7 +1,8 @@
# ============================================================================ # ============================================================================
# Stage 1: Build frontend # 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 WORKDIR /app/frontend
COPY frontend/package.json frontend/package-lock.json ./ COPY frontend/package.json frontend/package-lock.json ./
@@ -10,9 +11,42 @@ COPY frontend/ ./
RUN npm run build 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" \ LABEL org.opencontainers.image.title="Vibe-Trading" \
org.opencontainers.image.description="Natural-language finance research AI agent with backtesting" \ 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 WORKDIR /app
# System deps ENV PYTHONDONTWRITEBYTECODE=1 \
# build-essential — compile any wheels without prebuilt manylinux artifacts. PYTHONUNBUFFERED=1
# The rest are weasyprint's runtime native libs (Pango/HarfBuzz/Fontconfig/
# Cairo/gdk-pixbuf) per its official Debian install list; without them the # Runtime-only native libs. NO build-essential here — these are weasyprint's
# lazy `from weasyprint import HTML` in reporter.py fails and PDF rendering # shared libraries (Pango/HarfBuzz/Fontconfig/Cairo/gdk-pixbuf) per its official
# silently downgrades to HTML-only. fonts-dejavu-core gives non-blank PDFs. # 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 \ RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libpango-1.0-0 \ libpango-1.0-0 \
libpangoft2-1.0-0 \ libpangoft2-1.0-0 \
libharfbuzz0b \ libharfbuzz0b \
@@ -39,23 +74,24 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
fonts-dejavu-core \ fonts-dejavu-core \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Python deps (install before copying code for layer caching) # Bring in the prebuilt venv from the builder stage.
COPY agent/requirements.txt agent/requirements.txt ENV VIRTUAL_ENV=/opt/venv
RUN pip install --no-cache-dir -r agent/requirements.txt 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 pyproject.toml LICENSE README.md ./
COPY agent/ agent/ COPY agent/ agent/
# Copy built frontend
COPY --from=frontend-build /app/frontend/dist frontend/dist COPY --from=frontend-build /app/frontend/dist frontend/dist
# Install CLI entrypoint # Runtime should not run as root. `vibe` owns the writable app-data dirs so
RUN pip install --no-cache-dir -e . # named volumes inherit usable permissions. `vibe-sandbox` is an unprivileged
# system account (no home, no shell) that runner.py drops into via
# Runtime should not run as root. Keep writable app data directories owned by # subprocess.run(user="vibe-sandbox") to execute LLM-generated code with the
# the service user so named Docker volumes inherit usable permissions. # least privilege — created here by fixed contract, not otherwise used.
RUN useradd --create-home --shell /usr/sbin/nologin vibe \ 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 \ && mkdir -p agent/runs agent/sessions agent/uploads agent/.swarm/runs /home/vibe/.vibe-trading \
&& chown -R vibe:vibe /app /home/vibe/.vibe-trading && chown -R vibe:vibe /app /home/vibe/.vibe-trading
USER vibe USER vibe
@@ -63,9 +99,9 @@ USER vibe
# Default port # Default port
EXPOSE 8899 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 \ 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) # Run API server (serves frontend/dist as static files)
CMD ["vibe-trading", "serve", "--host", "0.0.0.0", "--port", "8899"] CMD ["vibe-trading", "serve", "--host", "0.0.0.0", "--port", "8899"]
+15
View File
@@ -39,8 +39,10 @@ from src.api.security import ( # noqa: F401, E402
_DEFAULT_LOOPBACK_HOSTS, _DEFAULT_LOOPBACK_HOSTS,
_EXTRA_LOOPBACK_HOSTS, _EXTRA_LOOPBACK_HOSTS,
_SAFE_BROWSER_METHODS, _SAFE_BROWSER_METHODS,
_apply_security_headers,
_auth_credential_from_header_or_query, _auth_credential_from_header_or_query,
_configured_api_key, _configured_api_key,
_consume_sse_ticket,
_default_gateway_ips, _default_gateway_ips,
_env_shell_tools_enabled, _env_shell_tools_enabled,
_host_without_port, _host_without_port,
@@ -48,9 +50,11 @@ from src.api.security import ( # noqa: F401, E402
_is_local_client, _is_local_client,
_is_loopback_bind_host, _is_loopback_bind_host,
_is_loopback_origin, _is_loopback_origin,
_mint_sse_ticket,
_origin_matches_request_host, _origin_matches_request_host,
_parse_cors_origins, _parse_cors_origins,
_parse_extra_loopback_hosts, _parse_extra_loopback_hosts,
_redact_query_secrets,
_reject_cross_site_browser_request, _reject_cross_site_browser_request,
_reject_untrusted_loopback_host, _reject_untrusted_loopback_host,
_require_shutdown_authorization, _require_shutdown_authorization,
@@ -58,6 +62,7 @@ from src.api.security import ( # noqa: F401, E402
_shell_tools_enabled_for_request, _shell_tools_enabled_for_request,
_trusted_docker_loopback_ip, _trusted_docker_loopback_ip,
_validate_api_auth, _validate_api_auth,
install_access_log_redaction_filter,
require_auth, require_auth,
require_event_stream_auth, require_event_stream_auth,
require_local_or_auth, require_local_or_auth,
@@ -132,6 +137,7 @@ app.add_middleware(
# programmatically instead. # programmatically instead.
app.middleware("http")(_reject_untrusted_loopback_host) app.middleware("http")(_reject_untrusted_loopback_host)
app.middleware("http")(_spa_html_deep_link_fallback) app.middleware("http")(_spa_html_deep_link_fallback)
app.middleware("http")(_apply_security_headers)
# ============================================================================ # ============================================================================
# Lifecycle hooks # 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 from src.api.alpha_routes import register_alpha_routes # noqa: E402
register_alpha_routes(app) 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 # 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(f" http://127.0.0.1:{args.port}")
print("=" * 50) 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: try:
uvicorn.run(app, host=args.host, port=args.port, log_level="info") uvicorn.run(app, host=args.host, port=args.port, log_level="info")
finally: finally:
+178
View File
@@ -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: def _validate_signal_engine_source(file_path: Path) -> None:
"""Reject import-time executable statements before loading signal_engine.py.""" """Reject import-time executable statements before loading signal_engine.py."""
try: 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" 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: def _validate_signal_engine_class(engine_cls) -> None:
"""Pre-flight check: SignalEngine can be instantiated with no args and has generate().""" """Pre-flight check: SignalEngine can be instantiated with no args and has generate()."""
+1 -1
View File
@@ -31,7 +31,7 @@ scikit-learn>=1.3.0
joblib>=1.3.0 joblib>=1.3.0
# Signal Skills # 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 # optional: pip install "vibe-trading-ai[harmonic]" for the pyharmonics-backed harmonic pattern detector
# Data Providers # Data Providers
+55
View File
@@ -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()}
+48 -2
View File
@@ -2,7 +2,9 @@
from __future__ import annotations from __future__ import annotations
import os
import re import re
import tempfile
from pathlib import Path from pathlib import Path
from typing import Dict 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: def _ensure_agent_env_file() -> Path:
"""Ensure the project-local agent/.env exists.""" """Ensure the project-local agent/.env exists."""
env_path = _host_attr("ENV_PATH", ENV_PATH) env_path = _host_attr("ENV_PATH", ENV_PATH)
if not env_path.exists(): 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 return env_path
@@ -143,7 +189,7 @@ def _write_env_values(path: Path, updates: Dict[str, str]) -> None:
lines.append("# Updated from Web UI") lines.append("# Updated from Web UI")
for key in missing: for key in missing:
lines.append(f"{key}={_format_env_value(updates[key])}") 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: def _is_configured_secret(value: str, placeholders: set[str]) -> bool:
+169 -3
View File
@@ -4,6 +4,11 @@ from __future__ import annotations
import hmac import hmac
import ipaddress import ipaddress
import logging
import re
import secrets
import threading
import time
import urllib.parse import urllib.parse
from pathlib import Path from pathlib import Path
from typing import List, Optional from typing import List, Optional
@@ -119,6 +124,139 @@ async def _reject_untrusted_loopback_host(request: Request, call_next):
return await call_next(request) 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 # API Key Authentication
# ============================================================================ # ============================================================================
@@ -330,11 +468,39 @@ async def require_auth(
async def require_event_stream_auth( async def require_event_stream_auth(
request: Request, request: Request,
api_key: Optional[str] = Query(None), ticket: Optional[str] = Query(None),
cred: Optional[HTTPAuthorizationCredentials] = Security(_security), cred: Optional[HTTPAuthorizationCredentials] = Security(_security),
) -> None: ) -> None:
"""Validate auth for browser EventSource streams.""" """Validate auth for browser EventSource streams.
_validate_api_auth(request=request, cred=cred, query_api_key=api_key, allow_query=True)
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( async def require_local_or_auth(
+167 -9
View File
@@ -5,16 +5,30 @@ Mounted by ``agent/api_server.py`` via ``register_system_routes(app, ...)``.
from __future__ import annotations from __future__ import annotations
import logging
import os import os
import signal import signal
import threading
import time import time
from collections import defaultdict, deque
from datetime import datetime, timezone 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 fastapi.security import HTTPAuthorizationCredentials
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
logger = logging.getLogger(__name__)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Pydantic models (defined locally -- NO shared modules, per maintainer rule) # 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) 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 # Registration
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -65,6 +184,7 @@ def register_system_routes(
_security = host._security _security = host._security
_require_shutdown_authorization = host._require_shutdown_authorization _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 _app_version = app_version if app_version is not None else host.APP_VERSION
def _get_terminate_process(): def _get_terminate_process():
@@ -78,17 +198,48 @@ def register_system_routes(
# --- Routes --- # --- Routes ---
@app.get("/health", response_model=HealthResponse) def _health_payload() -> HealthResponse:
async def health_check():
"""Liveness probe."""
return HealthResponse( return HealthResponse(
status="healthy", status="healthy",
service="Vibe-Trading API", 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( async def get_correlation_matrix(
request: Request,
codes: str = Query(..., description="Comma-separated asset codes, e.g. BTC-USDT,ETH-USDT,SPY"), 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), days: int = Query(90, description="Lookback window in days", ge=7, le=365),
method: str = Query("pearson", description="Correlation method: pearson or spearman"), 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 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()] code_list = [c.strip() for c in codes.split(",") if c.strip()]
if len(code_list) < 2: if len(code_list) < 2:
raise HTTPException(status_code=400, detail="At least 2 asset codes required") raise HTTPException(status_code=400, detail="At least 2 asset codes required")
@@ -113,8 +270,9 @@ def register_system_routes(
return result return result
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) raise HTTPException(status_code=400, detail=str(exc))
except Exception as exc: except Exception:
raise HTTPException(status_code=500, detail=f"Correlation computation failed: {exc}") logger.exception("Correlation computation failed for codes=%s", code_list)
raise HTTPException(status_code=500, detail="Correlation computation failed")
@app.post("/system/shutdown") @app.post("/system/shutdown")
async def shutdown_local_api( async def shutdown_local_api(
+198 -6
View File
@@ -2,18 +2,148 @@
from __future__ import annotations from __future__ import annotations
import logging
import os import os
import shutil
import subprocess import subprocess
import sys import sys
import tempfile
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Optional from typing import Any, Callable, Dict, Optional
from rich.console import Console 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) 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( _PROXY_ENV_KEYS = frozenset(
@@ -296,12 +426,36 @@ class Runner:
existing = env.get("PYTHONPATH", "") existing = env.get("PYTHONPATH", "")
env["PYTHONPATH"] = str(pythonpath_extra) + (os.pathsep + existing if existing else "") env["PYTHONPATH"] = str(pythonpath_extra) + (os.pathsep + existing if existing else "")
# Preserve system proxy settings; data sources (OKX/yfinance) need network access # Preserve system proxy settings; data sources (OKX/yfinance) need network access.
# NOTE: do NOT override HOME/USERPROFILE — data libraries (yfinance, akshare) # HOME/USERPROFILE are overridden per-execution in ``execute()`` (ephemeral
# cache downloads under ~/; overriding HOME causes full re-download every run. # sandbox home, VT-001); the inherited value from the allowlist is only a
# fallback if that temp-dir creation fails.
return env 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( def execute(
self, self,
entry_script: Path, entry_script: Path,
@@ -340,8 +494,37 @@ class Runner:
if cli_args: if cli_args:
cmd.extend(cli_args) cmd.extend(cli_args)
process = subprocess.run( # VT-001 defense-in-depth: give the generated-code subprocess an ephemeral
cmd, # 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), cwd=str(effective_cwd),
stdin=subprocess.DEVNULL, stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
@@ -352,6 +535,15 @@ class Runner:
encoding="utf-8", encoding="utf-8",
errors="ignore", 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 elapsed = time.time() - start_time
console.print(f"[blue]Runner: subprocess finished in {elapsed:.2f}s[/blue]") console.print(f"[blue]Runner: subprocess finished in {elapsed:.2f}s[/blue]")
+63 -14
View File
@@ -14,22 +14,26 @@ Output contract — JSON envelope:
"top": [{"id": ..., "ic_mean": ..., "ir": ..., ...}, ...]} "top": [{"id": ..., "ic_mean": ..., "ir": ..., ...}, ...]}
Cache integrity note: the universe panel cache lives in ``~/.vibe-trading/cache/`` 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 as pickle blobs. Each pickle is paired with a ``<name>.sha256`` sidecar holding a
load we recompute the digest and refuse the cache on mismatch. This guards **keyed HMAC-SHA256 tag** (not a bare digest) computed over the blob. On load we
against accidental corruption (truncated writes, partial syncs) — it is NOT a recompute the tag with the same key and refuse the cache on mismatch before the
defence against an attacker with local write access (they can rewrite both ``pickle.loads`` call. The key is ``API_AUTH_KEY`` when configured, else a
files). Cache files are user-local; if shared across machines they can be machine-local random 32-byte secret persisted at ``cache/.hmac_key`` (mode 0600).
tampered with and the sha256 sidecar is only an integrity check, not authenticity. 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 from __future__ import annotations
import hashlib import hashlib
import hmac
import html import html
import json import json
import logging import logging
import os import os
import re import re
import secrets
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@@ -152,8 +156,55 @@ def _sha256_path(cache_path: Path) -> Path:
return cache_path.with_suffix(cache_path.suffix + ".sha256") 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: 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 import pickle
sidecar = _sha256_path(cache_path) 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: except OSError as exc:
logger.warning("cache sidecar read failed (%s); refetching", exc) logger.warning("cache sidecar read failed (%s); refetching", exc)
return None return None
actual = hashlib.sha256(blob).hexdigest() actual = _cache_mac(_cache_hmac_key(cache_path.parent), blob)
if not _hashes_equal(expected, actual): if not _hashes_equal(expected, actual):
logger.warning( logger.warning(
"cache integrity mismatch for %s (expected %s..., got %s...); refetching", "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 return None
try: 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 except Exception as exc: # noqa: BLE001 — degrade to fresh fetch
logger.warning("cache unpickle failed (%s); refetching", exc) logger.warning("cache unpickle failed (%s); refetching", exc)
return None return None
@@ -196,7 +247,7 @@ def _read_pickle_cache(cache_path: Path) -> dict[str, pd.DataFrame] | None:
def _write_pickle_cache( def _write_pickle_cache(
cache_dir: Path, cache_path: Path, panel: dict[str, Any] cache_dir: Path, cache_path: Path, panel: dict[str, Any]
) -> None: ) -> None:
"""Pickle ``panel`` + write its sha256 sidecar. Failures are non-fatal.""" """Pickle ``panel`` + write its keyed HMAC sidecar. Failures are non-fatal."""
import pickle import pickle
try: try:
@@ -204,16 +255,14 @@ def _write_pickle_cache(
blob = pickle.dumps(panel, protocol=pickle.HIGHEST_PROTOCOL) blob = pickle.dumps(panel, protocol=pickle.HIGHEST_PROTOCOL)
cache_path.write_bytes(blob) cache_path.write_bytes(blob)
_sha256_path(cache_path).write_text( _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 except Exception as exc: # noqa: BLE001 — cache miss is non-fatal
logger.warning("cache write failed: %s", exc) logger.warning("cache write failed: %s", exc)
def _hashes_equal(a: str, b: str) -> bool: def _hashes_equal(a: str, b: str) -> bool:
"""Constant-time comparison of two hex digests.""" """Constant-time comparison of two hex authentication tags."""
import hmac
return hmac.compare_digest(a.strip().lower(), b.strip().lower()) return hmac.compare_digest(a.strip().lower(), b.strip().lower())
+85
View File
@@ -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()) module = _load_module_from_file(signal_file, _module_name())
assert module.SignalEngine().generate() == [] 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")
+163 -1
View File
@@ -5,7 +5,15 @@ from __future__ import annotations
import os import os
from pathlib import Path 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( 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) env = Runner(timeout=1)._build_runtime_env(tmp_path, pythonpath_extra=pythonpath_extra)
assert env["PYTHONPATH"] == f"{pythonpath_extra}{os.pathsep}existing-path" 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)
+17 -1
View File
@@ -351,14 +351,30 @@ def test_configured_api_key_required_for_session_event_stream(
assert response.status_code == 401 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, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
"""VT-003: the long-lived key is no longer accepted in the SSE query string."""
monkeypatch.setenv("API_AUTH_KEY", "secret") monkeypatch.setenv("API_AUTH_KEY", "secret")
monkeypatch.setattr(api_server, "_API_KEY", "secret") monkeypatch.setattr(api_server, "_API_KEY", "secret")
response = _remote_client().get("/sessions/missing/events?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} assert response.status_code in {404, 501}
+49
View File
@@ -265,3 +265,52 @@ def test_settings_writes_reject_remote_dev_mode_clients(
assert response.status_code == 403 assert response.status_code == 403
assert not env_path.exists() 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
+221
View File
@@ -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)
+206
View File
@@ -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
View File
@@ -26,10 +26,40 @@ services:
- vibe-home:/home/vibe/.vibe-trading - vibe-home:/home/vibe/.vibe-trading
- vibe-swarm-runs:/app/agent/.swarm/runs - vibe-swarm-runs:/app/agent/.swarm/runs
- vibe-uploads:/app/agent/uploads - 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 restart: unless-stopped
frontend: frontend:
image: node:20 image: node:20@sha256:8f693eaa7e0a8e71560c9a82b55fd54c2ae920a2ba5d2cde28bac7d1c01c9ba5 # node:20 digest resolved 2026-07-13
working_dir: /app working_dir: /app
ports: ports:
- "127.0.0.1:5899:5899" - "127.0.0.1:5899:5899"
+2 -3
View File
@@ -5,9 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vibe-Trading — vibe trading with your professional financial agent team</title> <title>Vibe-Trading — vibe trading with your professional financial agent team</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com"> <!-- Fonts are self-hosted via @fontsource (imported in src/main.tsx) so no
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> external font CDN is contacted — see VT-006 / the CSP font-src policy. -->
<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">
<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." /> <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> </head>
<body class="min-h-screen bg-background text-foreground antialiased"> <body class="min-h-screen bg-background text-foreground antialiased">
+20
View File
@@ -8,6 +8,8 @@
"name": "vibe-trading-frontend", "name": "vibe-trading-frontend",
"version": "0.1.11", "version": "0.1.11",
"dependencies": { "dependencies": {
"@fontsource/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8",
"clsx": "^2.1.0", "clsx": "^2.1.0",
"echarts": "^6.1.0", "echarts": "^6.1.0",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
@@ -1001,6 +1003,24 @@
"node": ">=18" "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": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+2
View File
@@ -12,6 +12,8 @@
"test:coverage": "vitest run --coverage" "test:coverage": "vitest run --coverage"
}, },
"dependencies": { "dependencies": {
"@fontsource/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8",
"clsx": "^2.1.0", "clsx": "^2.1.0",
"echarts": "^6.1.0", "echarts": "^6.1.0",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
@@ -310,3 +310,49 @@ describe("useSSE — Last-Event-ID resume", () => {
expect(newUrl).toContain("Last-Event-ID=resume-42"); 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");
});
});
+23 -3
View File
@@ -3,6 +3,7 @@
*/ */
import { useCallback, useRef } from "react"; import { useCallback, useRef } from "react";
import { getApiAuthKey, withAuthTicket } from "@/lib/apiAuth";
type EventHandler = (data: Record<string, unknown>) => void; type EventHandler = (data: Record<string, unknown>) => void;
type Handlers = Record<string, EventHandler>; type Handlers = Record<string, EventHandler>;
@@ -66,10 +67,9 @@ export function useSSE(config?: SSEConfig) {
return baseUrl; return baseUrl;
}, []); }, []);
const doConnect = useCallback(() => { const attach = useCallback((url: string) => {
if (closedRef.current) return; if (closedRef.current) return;
const url = buildUrl(urlRef.current);
const source = new EventSource(url); const source = new EventSource(url);
sourceRef.current = source; sourceRef.current = source;
@@ -117,7 +117,27 @@ export function useSSE(config?: SSEConfig) {
sourceRef.current = null; sourceRef.current = null;
scheduleReconnect(); 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(() => { const scheduleReconnect = useCallback(() => {
if (closedRef.current) return; if (closedRef.current) return;
+80 -18
View File
@@ -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", () => { describe("apiAuth", () => {
beforeEach(() => { beforeEach(() => {
localStorage.clear(); localStorage.clear();
}); });
afterEach(() => {
vi.unstubAllGlobals();
});
describe("getApiAuthKey", () => { describe("getApiAuthKey", () => {
it("returns empty string when nothing stored", () => { it("returns empty string when nothing stored", () => {
expect(getApiAuthKey()).toBe(""); expect(getApiAuthKey()).toBe("");
@@ -42,27 +47,84 @@ describe("apiAuth", () => {
}); });
}); });
describe("authQuerySuffix", () => { describe("withAuthTicket", () => {
it("returns empty string when no key", () => { it("returns url unchanged and makes no request when no key (dev/loopback)", async () => {
expect(authQuerySuffix()).toBe(""); 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("mints a ticket via header-authed POST and appends ?ticket=", async () => {
it("returns url unchanged when no key", () => { setApiAuthKey("token-xyz");
expect(withAuthQuery("https://api.com/data")).toBe("https://api.com/data"); 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"); it("never puts the raw API key in the returned URL", async () => {
expect(withAuthQuery("https://api.com/data")).toBe("https://api.com/data?api_key=abc"); 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"); it("joins with & when the url already has a query string", async () => {
expect(withAuthQuery("https://api.com/data?foo=bar")).toBe("https://api.com/data?foo=bar&api_key=abc"); 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/);
}); });
}); });
}); });
+8 -5
View File
@@ -1,4 +1,4 @@
import { authHeaders, withAuthQuery } from "@/lib/apiAuth"; import { authHeaders, withAuthTicket } from "@/lib/apiAuth";
const BASE = ""; const BASE = "";
@@ -122,8 +122,11 @@ export const api = {
method: "PATCH", method: "PATCH",
body: JSON.stringify(body), 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" }) => { 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); if (options?.replay) url = appendQueryParam(url, "replay", options.replay);
return url; return url;
}, },
@@ -137,7 +140,7 @@ export const api = {
}), }),
listSwarmRuns: () => request<SwarmRunSummary[]>("/swarm/runs"), listSwarmRuns: () => request<SwarmRunSummary[]>("/swarm/runs"),
getSwarmRun: (id: string) => request<Record<string, unknown>>(`/swarm/runs/${id}`), 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) => cancelSwarmRun: (id: string) =>
request<{ status: string }>(`/swarm/runs/${id}/cancel`, { method: "POST" }), request<{ status: string }>(`/swarm/runs/${id}/cancel`, { method: "POST" }),
retrySwarmRun: (id: string) => retrySwarmRun: (id: string) =>
@@ -181,14 +184,14 @@ export const api = {
body: JSON.stringify(body), body: JSON.stringify(body),
}), }),
alphaBenchStreamUrl: (jobId: string) => alphaBenchStreamUrl: (jobId: string) =>
withAuthQuery(`${BASE}/alpha/bench/${encodeURIComponent(jobId)}/stream`), withAuthTicket(`${BASE}/alpha/bench/${encodeURIComponent(jobId)}/stream`),
createAlphaCompare: (body: AlphaCompareRequest) => createAlphaCompare: (body: AlphaCompareRequest) =>
request<{ status: string; job_id: string }>("/alpha/compare", { request<{ status: string; job_id: string }>("/alpha/compare", {
method: "POST", method: "POST",
body: JSON.stringify(body), body: JSON.stringify(body),
}), }),
alphaCompareStreamUrl: (jobId: string) => 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). // Connector runtime channel — privileged surface actions (NOT agent tools).
// commit is the ONLY action that writes a mandate; halt trips the kill switch. // commit is the ONLY action that writes a mandate; halt trips the kill switch.
+29 -8
View File
@@ -18,13 +18,34 @@ export function authHeaders(): Record<string, string> {
return key ? { Authorization: `Bearer ${key}` } : {}; 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(); const key = getApiAuthKey();
return key ? `api_key=${encodeURIComponent(key)}` : ""; if (!key) return url;
} const res = await fetch("/auth/sse-ticket", {
method: "POST",
export function withAuthQuery(url: string): string { headers: authHeaders(),
const suffix = authQuerySuffix(); });
if (!suffix) return url; if (!res.ok) {
return `${url}${url.includes("?") ? "&" : "?"}${suffix}`; 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)}`;
} }
+10
View File
@@ -5,6 +5,16 @@ import { RouterProvider } from "react-router-dom";
import { Toaster } from "sonner"; import { Toaster } from "sonner";
import { ErrorBoundary } from "./components/common/ErrorBoundary"; import { ErrorBoundary } from "./components/common/ErrorBoundary";
import { router } from "./router"; 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 "highlight.js/styles/github-dark-dimmed.min.css";
import "./index.css"; import "./index.css";
+10 -6
View File
@@ -698,7 +698,7 @@ function BenchView() {
top: safeTop, top: safeTop,
}); });
setJobId(res.job_id); setJobId(res.job_id);
attachStream(res.job_id); await attachStream(res.job_id);
} catch (err: unknown) { } catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Failed to start bench"; const msg = err instanceof Error ? err.message : "Failed to start bench";
// BTC-USDT is single-asset — surface inline rather than as a toast, // 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"); 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); const source = new EventSource(url);
sourceRef.current = source; sourceRef.current = source;
@@ -1157,9 +1159,11 @@ function CompareView() {
}; };
}, []); }, []);
const attachStream = (newJobId: string) => { const attachStream = async (newJobId: string) => {
setStatus("streaming"); 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; sourceRef.current = source;
source.addEventListener("progress", (e) => { source.addEventListener("progress", (e) => {
@@ -1225,7 +1229,7 @@ function CompareView() {
sort, sort,
}); });
setJobId(res.job_id); setJobId(res.job_id);
attachStream(res.job_id); await attachStream(res.job_id);
} catch (err: unknown) { } catch (err: unknown) {
const msg = const msg =
err instanceof Error ? err.message : "Failed to start comparison"; err instanceof Error ? err.message : "Failed to start comparison";
+1 -1
View File
@@ -40,7 +40,7 @@ dependencies = [
"duckdb>=1.2.0", "duckdb>=1.2.0",
"scikit-learn>=1.3.0", "scikit-learn>=1.3.0",
"joblib>=1.3.0", "joblib>=1.3.0",
"smartmoneyconcepts>=0.0.1", "smartmoneyconcepts>=0.0.27",
"tushare>=1.2.89", "tushare>=1.2.89",
"requests>=2.31.0", "requests>=2.31.0",
"yfinance>=0.2.30", "yfinance>=0.2.30",
File diff suppressed because it is too large. Load diff