security: address all 10 findings from external audit (issue #476)
Implements VT-001 through VT-010 from the 2026-07-10 external security audit (Trivy/Semgrep/ZAP/Gitleaks/pip-audit against a real deployment): - VT-001 (Critical): AST hardening in backtest/runner.py rejects network/subprocess/eval/os.environ/unsafe-open calls reachable from generated SignalEngine method bodies (previously only checked top-level statements). Defense-in-depth: ephemeral per-run HOME, RLIMIT_AS/NOFILE caps, best-effort UID-drop to a new vibe-sandbox user (currently inert under Docker's cap model - see session doc; accepted as documented residual gap, primary AST defense unaffected). - VT-002: multi-stage Dockerfile split so build-essential never reaches the runtime image. - VT-003: SSE auth moved from a long-lived API key in the URL query string to short-lived single-use tickets; access-log redaction for any remaining query secrets. - VT-004: agent/.env split-brain fixed (bind mount + atomic 0600 writes) between docker-compose and Web UI settings writes. - VT-005: /correlation now requires auth + per-IP rate limit; generic exceptions no longer leak raw text to clients. - VT-006: CSP-Report-Only + security headers middleware; Google Fonts replaced with self-hosted @fontsource packages. - VT-007: docker-compose hardening (cap_drop ALL + minimal cap_add, no-new-privileges, read_only rootfs, resource limits). - VT-008: /health split into /live (liveness) and /ready (real provider-config readiness check); /health kept as a compat alias. - VT-009: base images pinned by digest, GitHub Actions pinned by commit SHA, dependabot.yml added, hash-verified requirements-lock.txt now actually wired into the Docker build via --require-hashes. - VT-010: universe-panel pickle cache sidecar upgraded from an unkeyed SHA-256 (forgeable by anyone with cache-dir write access) to a keyed HMAC. Implemented via a 5-parcel Workflow (file-disjoint, independent adversarial verification per parcel, then a holistic final audit). The final audit caught a real regression (CI env-var-gate violation) and confirmed a dependency-pinning gap, both fixed and re-verified here, including a latent transitive-dependency bug the fix surfaced (smartmoneyconcepts<0.0.27 pulls an unbuildable zigzag package) - see docs/sessions/2026-07-13_session_vt476_security_hardening.md (local, gitignored) for full evidence trail. Full suite: 5191 passed, 6 pre-existing/environmental failures (unchanged from main). Docker build + hardened compose boot verified end-to-end.
This commit is contained in:
1 parent
2f4ba89733
commit
4e3c91c77a
34 files changed
+6748
-113
No files matched your search
@@ -0,0 +1,22 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
# Root Python project (pyproject.toml).
|
||||||
|
- package-ecosystem: "pip"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
open-pull-requests-limit: 10
|
||||||
|
|
||||||
|
# Frontend npm dependencies.
|
||||||
|
- package-ecosystem: "npm"
|
||||||
|
directory: "/frontend"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
open-pull-requests-limit: 10
|
||||||
|
|
||||||
|
# GitHub Actions workflow pins.
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
open-pull-requests-limit: 10
|
||||||
@@ -12,9 +12,9 @@ jobs:
|
|||||||
timeout-minutes: 10
|
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
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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
@@ -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"]
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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()."""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""Auth helper routes — short-lived SSE tickets for browser EventSource auth.
|
||||||
|
|
||||||
|
Mounted by ``agent/api_server.py`` via ``register_auth_routes(app, ...)``.
|
||||||
|
|
||||||
|
A browser ``EventSource`` cannot send an ``Authorization`` header, so instead of
|
||||||
|
putting the long-lived API key in the SSE URL (where it leaks into browser
|
||||||
|
history, proxy/access logs, and Referer headers) the frontend exchanges the
|
||||||
|
header-authenticated key for a one-shot ticket here, then opens the stream with
|
||||||
|
``?ticket=``. The ticket store + validation live in ``src.api.security``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Awaitable, Callable
|
||||||
|
|
||||||
|
from fastapi import Depends, FastAPI
|
||||||
|
|
||||||
|
AuthDep = Callable[..., Awaitable[Any] | Any]
|
||||||
|
|
||||||
|
|
||||||
|
def register_auth_routes(
|
||||||
|
app: FastAPI,
|
||||||
|
require_auth: AuthDep | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Mount the auth helper routes onto ``app``.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
app: The host FastAPI app.
|
||||||
|
require_auth: Header-only auth dependency guarding ticket minting. When
|
||||||
|
omitted it is resolved from the host ``api_server`` module via
|
||||||
|
``sys.modules`` (matches the other ``register_*_routes`` helpers).
|
||||||
|
"""
|
||||||
|
if require_auth is None:
|
||||||
|
import sys as _sys
|
||||||
|
|
||||||
|
host = _sys.modules.get("api_server") or _sys.modules.get("agent.api_server")
|
||||||
|
if host is None: # pragma: no cover — only triggers on weird import setups
|
||||||
|
raise RuntimeError(
|
||||||
|
"register_auth_routes: api_server module not in sys.modules; "
|
||||||
|
"pass require_auth explicitly"
|
||||||
|
)
|
||||||
|
require_auth = host.require_auth
|
||||||
|
|
||||||
|
from src.api.security import _mint_sse_ticket
|
||||||
|
|
||||||
|
@app.post("/auth/sse-ticket", dependencies=[Depends(require_auth)])
|
||||||
|
async def mint_sse_ticket() -> dict[str, str]:
|
||||||
|
"""Mint a single-use, ~60s ticket for a browser EventSource connection.
|
||||||
|
|
||||||
|
Gated by the header-only ``require_auth`` dependency, so minting still
|
||||||
|
requires the real API key in an ``Authorization`` header — never in a
|
||||||
|
URL. The returned ticket replaces the long-lived key in the SSE query
|
||||||
|
string and is invalidated on first use.
|
||||||
|
"""
|
||||||
|
return {"ticket": _mint_sse_ticket()}
|
||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
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
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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]")
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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)
|
||||||
@@ -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}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
"""VT-003 / VT-006 regression tests: SSE tickets, security headers, log redaction."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
import api_server
|
||||||
|
from src.api import security
|
||||||
|
|
||||||
|
|
||||||
|
def _remote_client() -> TestClient:
|
||||||
|
"""Return a TestClient that simulates a non-loopback caller."""
|
||||||
|
return TestClient(api_server.app, client=("203.0.113.10", 50000))
|
||||||
|
|
||||||
|
|
||||||
|
def _local_client() -> TestClient:
|
||||||
|
"""Return a TestClient that simulates a loopback caller."""
|
||||||
|
return TestClient(api_server.app, client=("127.0.0.1", 50000))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clean_state(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Start each test from dev-mode auth with an empty ticket store."""
|
||||||
|
monkeypatch.delenv("API_AUTH_KEY", raising=False)
|
||||||
|
monkeypatch.delenv("VIBE_TRADING_TRUST_DOCKER_LOOPBACK", raising=False)
|
||||||
|
monkeypatch.setattr(api_server, "_API_KEY", "")
|
||||||
|
with security._sse_tickets_lock:
|
||||||
|
security._sse_tickets.clear()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SSE ticket store (unit)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_minted_ticket_is_unguessable_and_urlsafe() -> None:
|
||||||
|
ticket = security._mint_sse_ticket()
|
||||||
|
assert isinstance(ticket, str)
|
||||||
|
assert len(ticket) >= 32
|
||||||
|
# token_urlsafe alphabet only
|
||||||
|
assert all(c.isalnum() or c in "-_" for c in ticket)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ticket_is_single_use() -> None:
|
||||||
|
ticket = security._mint_sse_ticket()
|
||||||
|
assert security._consume_sse_ticket(ticket) is True
|
||||||
|
# Second use is rejected — the ticket was invalidated on first use.
|
||||||
|
assert security._consume_sse_ticket(ticket) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_ticket_is_rejected() -> None:
|
||||||
|
assert security._consume_sse_ticket("never-minted") is False
|
||||||
|
assert security._consume_sse_ticket("") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_expired_ticket_is_rejected() -> None:
|
||||||
|
import time
|
||||||
|
|
||||||
|
ticket = security._mint_sse_ticket()
|
||||||
|
# Force the stored expiry into the past without waiting the full TTL.
|
||||||
|
with security._sse_tickets_lock:
|
||||||
|
security._sse_tickets[ticket] = time.monotonic() - 1.0
|
||||||
|
assert security._consume_sse_ticket(ticket) is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /auth/sse-ticket endpoint
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_sse_ticket_endpoint_mints_in_loopback_dev_mode() -> None:
|
||||||
|
response = _local_client().post("/auth/sse-ticket")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert isinstance(response.json().get("ticket"), str)
|
||||||
|
assert response.json()["ticket"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_sse_ticket_endpoint_requires_bearer_when_key_configured(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setenv("API_AUTH_KEY", "secret")
|
||||||
|
monkeypatch.setattr(api_server, "_API_KEY", "secret")
|
||||||
|
|
||||||
|
# Remote caller without the header is rejected — minting requires the key
|
||||||
|
# in an Authorization header (never a URL).
|
||||||
|
assert _remote_client().post("/auth/sse-ticket").status_code == 401
|
||||||
|
|
||||||
|
ok = _remote_client().post("/auth/sse-ticket", headers={"Authorization": "Bearer secret"})
|
||||||
|
assert ok.status_code == 200
|
||||||
|
assert ok.json()["ticket"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# require_event_stream_auth (integration via /sessions/{id}/events)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_stream_accepts_ticket_once_then_rejects_reuse(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setenv("API_AUTH_KEY", "secret")
|
||||||
|
monkeypatch.setattr(api_server, "_API_KEY", "secret")
|
||||||
|
|
||||||
|
ticket = api_server._mint_sse_ticket()
|
||||||
|
client = _remote_client()
|
||||||
|
|
||||||
|
first = client.get(f"/sessions/missing/events?ticket={ticket}")
|
||||||
|
# Auth passed: the 404/501 comes from the missing session / disabled runtime.
|
||||||
|
assert first.status_code in {404, 501}
|
||||||
|
|
||||||
|
# The same ticket cannot be replayed.
|
||||||
|
second = client.get(f"/sessions/missing/events?ticket={ticket}")
|
||||||
|
assert second.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_stream_still_accepts_bearer_header_for_non_browser_callers(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setenv("API_AUTH_KEY", "secret")
|
||||||
|
monkeypatch.setattr(api_server, "_API_KEY", "secret")
|
||||||
|
|
||||||
|
response = _remote_client().get(
|
||||||
|
"/sessions/missing/events",
|
||||||
|
headers={"Authorization": "Bearer secret"},
|
||||||
|
)
|
||||||
|
assert response.status_code in {404, 501}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# VT-006: security headers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_security_headers_present_on_response() -> None:
|
||||||
|
response = _local_client().get("/health")
|
||||||
|
|
||||||
|
assert response.headers.get("X-Content-Type-Options") == "nosniff"
|
||||||
|
assert response.headers.get("X-Frame-Options") == "DENY"
|
||||||
|
assert response.headers.get("Referrer-Policy") == "strict-origin-when-cross-origin"
|
||||||
|
assert "geolocation=()" in response.headers.get("Permissions-Policy", "")
|
||||||
|
|
||||||
|
csp = response.headers.get("Content-Security-Policy-Report-Only", "")
|
||||||
|
assert "default-src 'self'" in csp
|
||||||
|
assert "frame-ancestors 'none'" in csp
|
||||||
|
# Ships Report-Only first, never as an enforcing policy.
|
||||||
|
assert response.headers.get("Content-Security-Policy") is None
|
||||||
|
|
||||||
|
# No HSTS from the app — that belongs at the TLS-terminating proxy.
|
||||||
|
assert response.headers.get("Strict-Transport-Security") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_security_headers_present_on_error_response(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Headers must wrap error responses from inner middleware too."""
|
||||||
|
monkeypatch.setenv("API_AUTH_KEY", "secret")
|
||||||
|
monkeypatch.setattr(api_server, "_API_KEY", "secret")
|
||||||
|
|
||||||
|
response = _local_client().get("/runs", headers={"Host": "attacker.example:8899"})
|
||||||
|
|
||||||
|
assert response.status_code == 403 # rebound-host guard short-circuits
|
||||||
|
assert response.headers.get("X-Content-Type-Options") == "nosniff"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# VT-003: access-log secret redaction
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_redact_query_secrets_masks_ticket_and_api_key_values() -> None:
|
||||||
|
line = 'GET /sessions/x/events?ticket=abc123&foo=bar HTTP/1.1'
|
||||||
|
red = security._redact_query_secrets(line)
|
||||||
|
assert "abc123" not in red
|
||||||
|
assert "ticket=***REDACTED***" in red
|
||||||
|
assert "foo=bar" in red
|
||||||
|
|
||||||
|
line2 = "/alpha/bench/1/stream?api_key=SUPERSECRET"
|
||||||
|
red2 = security._redact_query_secrets(line2)
|
||||||
|
assert "SUPERSECRET" not in red2
|
||||||
|
assert "api_key=***REDACTED***" in red2
|
||||||
|
|
||||||
|
|
||||||
|
def test_access_log_filter_redacts_record_args() -> None:
|
||||||
|
filt = security._AccessLogRedactionFilter()
|
||||||
|
record = logging.LogRecord(
|
||||||
|
name="uvicorn.access",
|
||||||
|
level=logging.INFO,
|
||||||
|
pathname=__file__,
|
||||||
|
lineno=1,
|
||||||
|
msg='%s - "%s %s HTTP/%s" %d',
|
||||||
|
args=("127.0.0.1:5000", "GET", "/sessions/x/events?ticket=LEAKME", "1.1", 404),
|
||||||
|
exc_info=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert filt.filter(record) is True
|
||||||
|
formatted = record.getMessage()
|
||||||
|
assert "LEAKME" not in formatted
|
||||||
|
assert "ticket=***REDACTED***" in formatted
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_access_log_redaction_filter_is_idempotent() -> None:
|
||||||
|
access = logging.getLogger("uvicorn.access")
|
||||||
|
before = [f for f in access.filters if isinstance(f, security._AccessLogRedactionFilter)]
|
||||||
|
for f in list(before):
|
||||||
|
access.removeFilter(f)
|
||||||
|
|
||||||
|
security.install_access_log_redaction_filter()
|
||||||
|
security.install_access_log_redaction_filter()
|
||||||
|
|
||||||
|
installed = [f for f in access.filters if isinstance(f, security._AccessLogRedactionFilter)]
|
||||||
|
assert len(installed) == 1
|
||||||
|
|
||||||
|
# Restore prior state (leave exactly what was there before this test).
|
||||||
|
for f in installed:
|
||||||
|
access.removeFilter(f)
|
||||||
|
for f in before:
|
||||||
|
access.addFilter(f)
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
"""Route-level contracts for the system endpoints.
|
||||||
|
|
||||||
|
Exercises the REST surface mounted by ``register_system_routes``:
|
||||||
|
``/live`` + ``/ready`` (VT-008 liveness/readiness split) and the hardened
|
||||||
|
``/correlation`` endpoint (VT-005: auth + per-IP rate limit + masked errors).
|
||||||
|
|
||||||
|
Each test drives the app through ``TestClient``. The default ``TestClient``
|
||||||
|
client host (``testclient``) and explicit ``127.0.0.1`` are treated as loopback
|
||||||
|
callers, so ``require_auth`` passes without a configured API key; a non-loopback
|
||||||
|
client with a configured key is used to prove the auth gate rejects.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
import api_server
|
||||||
|
from src.api import system_routes
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def local_client(monkeypatch: pytest.MonkeyPatch) -> TestClient:
|
||||||
|
"""Loopback client with no API key configured (dev-mode: auth passes)."""
|
||||||
|
monkeypatch.delenv("API_AUTH_KEY", raising=False)
|
||||||
|
monkeypatch.setattr(api_server, "_API_KEY", "")
|
||||||
|
return TestClient(api_server.app, client=("127.0.0.1", 50000))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_correlation_limiter() -> None:
|
||||||
|
"""Clear the module-level rate limiter so tests never leak hits into each other."""
|
||||||
|
system_routes._correlation_rate_limiter.reset()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# VT-008 — /live, /health alias, /ready
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_returns_healthy(local_client: TestClient):
|
||||||
|
resp = local_client.get("/live")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["status"] == "healthy"
|
||||||
|
assert body["service"] == "Vibe-Trading API"
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_is_backward_compatible_alias(local_client: TestClient):
|
||||||
|
resp = local_client.get("/health")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["status"] == "healthy"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ready_returns_200_when_provider_ready(
|
||||||
|
local_client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||||
|
):
|
||||||
|
monkeypatch.setattr(system_routes, "_provider_readiness", lambda: (True, "ready"))
|
||||||
|
resp = local_client.get("/ready")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["status"] == "ready"
|
||||||
|
assert body["service"] == "Vibe-Trading API"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ready_returns_503_when_provider_not_ready(
|
||||||
|
local_client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||||
|
):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
system_routes,
|
||||||
|
"_provider_readiness",
|
||||||
|
lambda: (False, "LLM provider not configured"),
|
||||||
|
)
|
||||||
|
resp = local_client.get("/ready")
|
||||||
|
assert resp.status_code == 503
|
||||||
|
assert resp.json()["detail"] == "LLM provider not configured"
|
||||||
|
|
||||||
|
|
||||||
|
def test_readiness_helper_real_missing_credential(monkeypatch: pytest.MonkeyPatch):
|
||||||
|
"""The un-mocked helper flags a configured provider with no credential."""
|
||||||
|
import src.providers.llm as llm
|
||||||
|
from src.config.accessor import reset_env_config
|
||||||
|
|
||||||
|
# Neutralize the .env reload inside _sync_provider_env so the test fully
|
||||||
|
# controls the environment (otherwise a machine-local OPENAI_API_KEY leaks in).
|
||||||
|
monkeypatch.setattr(llm, "_ensure_dotenv", lambda: None)
|
||||||
|
monkeypatch.setenv("LANGCHAIN_PROVIDER", "openai")
|
||||||
|
monkeypatch.setenv("LANGCHAIN_MODEL_NAME", "gpt-4o-mini")
|
||||||
|
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||||
|
reset_env_config()
|
||||||
|
|
||||||
|
ready, reason = system_routes._provider_readiness()
|
||||||
|
assert ready is False
|
||||||
|
assert "credential" in reason.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_readiness_helper_real_ready(monkeypatch: pytest.MonkeyPatch):
|
||||||
|
"""The un-mocked helper reports ready when provider+model+key are present."""
|
||||||
|
from src.config.accessor import reset_env_config
|
||||||
|
|
||||||
|
monkeypatch.setenv("LANGCHAIN_PROVIDER", "openai")
|
||||||
|
monkeypatch.setenv("LANGCHAIN_MODEL_NAME", "gpt-4o-mini")
|
||||||
|
monkeypatch.setenv("OPENAI_API_KEY", "sk-test-not-a-real-key")
|
||||||
|
reset_env_config()
|
||||||
|
|
||||||
|
ready, reason = system_routes._provider_readiness()
|
||||||
|
assert ready is True
|
||||||
|
assert reason == "ready"
|
||||||
|
|
||||||
|
|
||||||
|
def test_readiness_helper_missing_provider(monkeypatch: pytest.MonkeyPatch):
|
||||||
|
from src.config.accessor import reset_env_config
|
||||||
|
|
||||||
|
monkeypatch.setenv("LANGCHAIN_PROVIDER", "")
|
||||||
|
monkeypatch.setenv("LANGCHAIN_MODEL_NAME", "")
|
||||||
|
reset_env_config()
|
||||||
|
|
||||||
|
ready, reason = system_routes._provider_readiness()
|
||||||
|
assert ready is False
|
||||||
|
assert "provider" in reason.lower()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# VT-005 — /correlation auth + rate limit + error masking
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_correlation_requires_auth_for_remote_client(monkeypatch: pytest.MonkeyPatch):
|
||||||
|
"""A non-loopback caller with a configured key but no token is rejected."""
|
||||||
|
monkeypatch.setattr(api_server, "_API_KEY", "server-secret")
|
||||||
|
remote = TestClient(api_server.app, client=("203.0.113.9", 51000))
|
||||||
|
resp = remote.get("/correlation", params={"codes": "AAPL,SPY"})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_correlation_allows_local_and_masks_generic_error(
|
||||||
|
local_client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||||
|
):
|
||||||
|
"""A generic backend failure must not leak its exception text to the client."""
|
||||||
|
import backtest.correlation as corr
|
||||||
|
|
||||||
|
def _boom(**_kwargs):
|
||||||
|
raise RuntimeError("sensitive internal detail: db=prod host=10.0.0.5")
|
||||||
|
|
||||||
|
monkeypatch.setattr(corr, "compute_correlation_matrix", _boom)
|
||||||
|
resp = local_client.get("/correlation", params={"codes": "AAPL,SPY"})
|
||||||
|
assert resp.status_code == 500
|
||||||
|
detail = resp.json()["detail"]
|
||||||
|
assert detail == "Correlation computation failed"
|
||||||
|
assert "sensitive internal detail" not in detail
|
||||||
|
|
||||||
|
|
||||||
|
def test_correlation_value_error_still_surfaces_message(
|
||||||
|
local_client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||||
|
):
|
||||||
|
"""Hand-raised validation errors remain user-facing (unchanged behavior)."""
|
||||||
|
import backtest.correlation as corr
|
||||||
|
|
||||||
|
def _bad(**_kwargs):
|
||||||
|
raise ValueError("Not enough overlapping history")
|
||||||
|
|
||||||
|
monkeypatch.setattr(corr, "compute_correlation_matrix", _bad)
|
||||||
|
resp = local_client.get("/correlation", params={"codes": "AAPL,SPY"})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert resp.json()["detail"] == "Not enough overlapping history"
|
||||||
|
|
||||||
|
|
||||||
|
def test_correlation_rate_limit_returns_429(
|
||||||
|
local_client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||||
|
):
|
||||||
|
"""Exceeding the per-client window yields 429 with a generic message."""
|
||||||
|
import backtest.correlation as corr
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
corr,
|
||||||
|
"compute_correlation_matrix",
|
||||||
|
lambda **_kwargs: {"labels": ["AAPL", "SPY"], "matrix": [[1.0, 0.5], [0.5, 1.0]]},
|
||||||
|
)
|
||||||
|
# Tighten the limiter to make the boundary cheap and deterministic.
|
||||||
|
monkeypatch.setattr(
|
||||||
|
system_routes,
|
||||||
|
"_correlation_rate_limiter",
|
||||||
|
system_routes._SlidingWindowRateLimiter(max_requests=2, window_seconds=60.0),
|
||||||
|
)
|
||||||
|
|
||||||
|
ok1 = local_client.get("/correlation", params={"codes": "AAPL,SPY"})
|
||||||
|
ok2 = local_client.get("/correlation", params={"codes": "AAPL,SPY"})
|
||||||
|
blocked = local_client.get("/correlation", params={"codes": "AAPL,SPY"})
|
||||||
|
|
||||||
|
assert ok1.status_code == 200
|
||||||
|
assert ok2.status_code == 200
|
||||||
|
assert blocked.status_code == 429
|
||||||
|
assert blocked.json()["detail"] == "Rate limit exceeded, try again later"
|
||||||
|
|
||||||
|
|
||||||
|
def test_correlation_rate_limiter_is_per_client(monkeypatch: pytest.MonkeyPatch):
|
||||||
|
"""Distinct client IPs get independent budgets."""
|
||||||
|
limiter = system_routes._SlidingWindowRateLimiter(max_requests=1, window_seconds=60.0)
|
||||||
|
assert limiter.allow("1.1.1.1") is True
|
||||||
|
assert limiter.allow("1.1.1.1") is False
|
||||||
|
assert limiter.allow("2.2.2.2") is True
|
||||||
+31
-1
@@ -26,10 +26,40 @@ services:
|
|||||||
- vibe-home:/home/vibe/.vibe-trading
|
- vibe-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
@@ -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">
|
||||||
|
|||||||
Generated
+20
@@ -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",
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)}`;
|
||||||
}
|
}
|
||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
Reference in new issue
Block a user