Skip to content

Security

AgentVision renders untrusted, attacker-controlled HTML / URLs / images and ships a network service, so it treats its inputs as hostile and is hardened by default. This is the overview; the full policy + reporting is in SECURITY.md.

What's protected

  • SSRF — one policy (netguard) blocks private / loopback / link-local / reserved / multicast / CGNAT (100.64/10) / cloud-metadata; IPv4-mapped IPv6 is normalized; unresolvable hosts fail closed. Checked at resolve time and re-checked at fetch time for every request, redirect, and WebSocket. Non-http(s) schemes are default-denied; file:// only for an explicit top-level navigation.
  • DNS rebinding — closed by a vetting egress proxy. Chromium routes all egress through a local proxy that resolves each host once, vets the IP, and connects to that exact IP (HTTP, HTTPS/WSS via CONNECT, WS). Chromium never resolves a host itself, so there's no second lookup to rebind; Host / TLS SNI are preserved. The proxy caps concurrent connections and times out idle ones.
  • Renderer isolation — Chromium OS sandbox on by default; downloads disabled, popups closed; viewport / device_scale / full-page capture clamped (OOM bound).
  • Untrusted bytes — byte + pixel caps before any image decode (decompression-bomb guard); PDF byte/size/timeout bounds; OCR timeout.
  • Office documents (LibreOffice).docx/.pptx/.xlsx/… are converted to PDF via LibreOffice headless, which is hardened: argv form (no shell), the input passed as an absolute path so a --leading filename can't become a flag, a byte cap, an isolated throwaway user profile per conversion (and --convert-to does not execute document macros), and a hard timeout with process-group kill. Gated off by default on the REST service (allow_office_render=False) — LibreOffice is a large attack surface on untrusted input.
  • Motion media (ffmpeg) — local video / animated-GIF decode is hardened like Office conversion: a byte cap before decode, ffmpeg in argv form (no shell) with the input as an absolute path, a file-only protocol whitelist and a demuxer allowlist so a file named .mp4 that is really an HLS playlist / ffconcat script can't make ffmpeg dereference a URL (SSRF) or another local file (LFI), a hard timeout with process-group kill, and frame byte/pixel caps. Gated off by default on the REST service (allow_motion_render=False).
  • HTTP service — loopback is zero-config; a non-loopback bind refuses to start without a token; token auth is constant-time; request bodies are capped (incl. chunked); renders are bounded by a semaphore; errors don't leak internals; local-file sources are refused.
  • Path confinement — every local-file read is normalized and confined with a resolve-then-commonpath barrier, so .. traversal, symlink escapes, and poisoned artifact ids can't reach a file outside the allowed area. The REST service refuses bare-path / file:// reads outright; set file_root to additionally pin all reads beneath one directory (see below).
  • Secrets — no default/hardcoded secret; API keys/token are value-scrubbed from logs.

Confidential inputs (no on-disk cache)

For a confidential artifact, run in ephemeral mode so renders and session state never land in the persistent cache — a 0700 temp dir is used and wiped at the end of the run (even on error):

agentvision check confidential.pptx --no-cache --backend local   # nothing leaves the box, nothing cached

Equivalent to AGENTVISION_EPHEMERAL=true, or ephemeral_cache() in the Python API. Note ephemeral mode only controls disk persistence — a cloud vision backend still sends the render to its provider, so pair --no-cache with --backend local (or check) for fully on-box handling. The offline PowerPoint inspector (agentvision check deck.pptx) is key-free and no-egress, so it's safe for confidential decks. See Configuration → Confidential inputs.

Path confinement for file sources (file_root)

By default the CLI/library trust the local user and read any path you point them at. When a service reads paths chosen by an untrusted caller, set file_root to confine every local read beneath one directory — anything that resolves outside it (via .., symlink, or absolute path) is refused:

export AGENTVISION_FILE_ROOT=/srv/agentvision/inbox
export AGENTVISION_API_TOKEN=$(openssl rand -hex 32)
agentvision serve --host 0.0.0.0 --port 8000

The REST service additionally refuses bare-path and file:// sources by default, so this is a second layer for deployments that intentionally serve files from a directory.

Authenticated rendering

--storage-state PATH renders an app while logged in (see Configuration). The security model:

  • Credentials by reference, never inline. The CLI accepts only a path to a Playwright storage_state JSON (or the AGENTVISION_STORAGE_STATE env var) — never a password or token on the command line (which would land in shell history and ps).
  • Read-only consumption. AgentVision loads the session into memory and never re-serializes it, so no fresh credential file is written as a side effect. Tracing/HAR are never enabled, so session cookies can't leak into a trace artifact.
  • Secrets are redaction-registered. Every cookie / localStorage value in the state file is registered with the log scrubber on load, so it can never appear in a log line; only non-sensitive counts (N cookies, M origins) are logged.
  • Forced ephemeral. Supplying a session forces --no-cache, so authenticated captures (which contain real user data) are never written to the shared on-disk cache.
  • Fail-closed on expiry. If the session is expired/invalid and the page lands on a login wall, AgentVision errors (AuthExpiredError) instead of silently grading the login page.

The state file is a live credential — anyone with it can impersonate the account. Keep it out of version control (echo 'state.json' >> .gitignore; verify with git check-ignore -v state.json). An authenticated screenshot also contains real user data; treat the output the same way (the analyze/check path keeps it in the ephemeral temp dir and wipes it).

Origin-scoped header / Basic auth

For token- or header-gated URL sources, --auth-header-env NAME and --http-credentials-env NAME supply auth by env-var name (never inline), forcing ephemeral mode and registering the value with the log scrubber. The key property is origin scoping:

  • The Authorization header is injected at the route layer, only on requests whose origin matches the target page's origin — so a Bearer token can never be attached to a third-party subresource (a CDN, analytics, fonts). This is why Phase 1 deliberately did not ship Playwright's extra_http_headers, which attaches a header to every request the context makes, to every origin — a cross-origin token leak. A regression test proves the header reaches the same-origin requests and is absent from a cross-origin fetch.
  • HTTP Basic is scoped to the target origin via Playwright's http_credentials.origin.

Residual limits (honest). Scoping is by request origin, evaluated per request (a redirect to another origin gets no token). It covers HTTP requests the route sees; a header isn't injected on a WebSocket handshake. The header value comes from your own environment, not an untrusted caller — like storage_state and --interactions, header/Basic auth is not settable by a remote REST/MCP caller.

Pre-capture interactions

--interactions drives the page (click/hover/fill/…) before grading, so it can act inside a live authenticated app. Guardrails:

  • Closed vocabulary, no code execution. Steps are a fixed enum mapped one-to-one to Playwright calls; there is no eval/raw-JS step, so a steps file can't run arbitrary code in the page.
  • Read-only by default. While steps run, non-GET HTTP requests (POST/PUT/PATCH/DELETE) are aborted, so a click can't submit/delete/send. Opt in per run with --allow-mutations when a step legitimately needs a write.
  • Fail-closed. A missing selector or timeout aborts the render rather than grading the wrong state; a runaway is bounded by max_interactions, a per-step timeout, and the overall render timeout.
  • Secrets by reference. Use fill_env (types the value of a named env var, redaction- registered) rather than fill for a password, so no secret is written into the steps JSON.
  • Local-only surface. Interactions and storage_state are not settable by a remote REST/MCP caller — both resolve from server-side config only.

Residual limits (stated honestly). The read-only guard covers HTTP non-GET requests; it does not block a write sent over a WebSocket, nor a side-effecting GET (an app that mutates on a GET is its own bug). Login-wall detection is a heuristic (login-ish URL or a visible password field) and is best-effort. Treat --allow-mutations against a production app the way you'd treat any write — it is not a sandbox.

Motion media (video / GIF decode)

Grading a local video or animated GIF (see Configuration → motion media) runs an ffmpeg decoder over untrusted bytes, so it is bounded the same way LibreOffice conversion is:

  • Gated by allow_motion_renderoff on the REST service, so a remote caller can't reach the decoder. Enabled for trusted local CLI/library use.
  • Byte cap (max_document_bytes) before any decode; a hard timeout (motion_decode_timeout_s) with process-group kill so a malformed file can't park the process; extracted frames pass the same byte + pixel caps as any image (decompression-bomb guard), and a GIF frame-count cap bounds iteration.
  • Content, not extension, decides the demuxer. ffmpeg picks its demuxer from the file's bytes, so a .mp4 extension proves nothing. Two layers close the classic playlist/script abuse: a -protocol_whitelist file so the decoder can only ever open local files (never a network protocol → no SSRF), and a demuxer allowlist (mp4/mov/webm/matroska/avi/gif) that refuses playlist- or script-style inputs (hls, concat, …) which can reference other files/URLs. Regression tests feed an HLS playlist and an ffconcat/LFI file disguised as .mp4 and assert both are refused before any frame is extracted.

Residual limits (honest). This is decode hardening around a large third-party binary, not a sandbox; keep the recommended network-layer egress restriction below (which also backstops ffmpeg), and prefer running motion grading against files you trust. As with storage_state and --interactions, motion decode is not exposed to a remote REST/MCP caller.

export AGENTVISION_API_TOKEN=$(openssl rand -hex 32)   # required for any non-loopback bind
  • Restrict the renderer's egress at the network layer (deny outbound to 169.254.0.0/16, RFC-1918, CGNAT) — defense in depth around the app controls. This also backstops LibreOffice, which can attempt to fetch remote templates/images that the conversion step can't fully block.
  • Containerize so the Chromium sandbox is available without --no-sandbox.
  • Keep block_private_networks on (default); only use --allow-local against trusted targets.

Reporting

Report privately to amit.patole@gmail.com — please don't open a public issue for an unpatched vulnerability.