---
id: security-scanner-plugins
title: Security Scanner Plugin System
sidebar_label: Security Scanners
description: Docker-based scanner plugins for detecting tool poisoning, prompt injection, CVEs, and supply-chain risks in upstream MCP servers
keywords: [security, scanner, sarif, quarantine, mcp, tool-poisoning, supply-chain]
---

# Security Scanner Plugin System

MCPProxy integrates external security scanners as Docker-based plugins. Scanners analyze quarantined servers before approval, detecting tool poisoning attacks, prompt injection, malware, secrets leakage, and supply chain risks.

> **CLI reference**: for the full breakdown of every `mcpproxy security` subcommand, flags, and examples see [Security Commands](/cli/security-commands).

## Overview

Every scanner is a plugin — there is no built-in scanner. MCPProxy exposes a universal plugin interface that scanner authors implement by shipping a Docker image that reads from `/scan/source` and writes SARIF to `/scan/report/results.sarif`. Users browse a bundled scanner registry, enable scanners with one command, configure API keys if needed, and start scanning.

### Key features

- **Plugin-only architecture** — every scanner is a Docker-based plugin.
- **Universal scanner interface** — source filesystem input, SARIF output.
- **Parallel scanning** — multiple scanners run concurrently; per-scanner failures are isolated.
- **Source resolver** — detects the right thing to scan for npx/uvx/pipx/bunx package-runner commands, falls back to working dir or tool definitions for HTTP/SSE servers.
- **Risk scoring** — composite 0–100 risk score from aggregated findings.
- **Integrity verification** — image digest checks on server restart.
- **Multi-UI** — REST API, CLI, Web UI all powered by the same backend, consistent status vocabulary.
- **Failed-scanner visibility** — both CLI and Web UI surface per-scanner failures so silent crashes don't look like "clean".

### What changed recently (2026-04)

- `security approve` now actually unquarantines the server and indexes its tools (was a no-op stub).
- Source resolver tries the package cache **first** for package-runner commands, so filesystem-server-style data-dir args are no longer mistaken for source code.
- The scanner configure path no longer touches the OS keyring by default on macOS. Env values are stored in the scanner record in BBolt. See [Security Commands → configure](/cli/security-commands#security-configure) for details and the opt-in flag.
- `security report` surfaces the count of failed scanners and their names.
- `security scan --dry-run` prints a source-resolution plan without starting any containers.
- `security scan` blocking mode has a real wait loop (no more hangs) with a hard timeout.
- `scanner status` vocabulary unified between table and JSON: `available` / `pulling` / `installed` / `configured` / `error`.

## Quick start

### 1. List available scanners

```bash
mcpproxy security scanners
```

```
ID                   NAME                     VENDOR                  STATUS       INPUTS
-------------------------------------------------------------------------------------------------------
cisco-mcp-scanner    Cisco MCP Scanner        Cisco AI Defense        available    source
mcp-ai-scanner       MCP AI Scanner           MCPProxy                available    source
mcp-scan             Snyk Agent Scan          Snyk (Invariant Labs)   available    source
nova-proximity       Nova Proximity           MCPProxy                available    source
ramparts             Ramparts MCP Scanner     Javelin                 available    source
semgrep-mcp          Semgrep MCP Rules        Semgrep                 available    source
tpa-descriptions     Tool Description Analy... MCPProxy                installed    source
trivy-mcp            Trivy Vulnerability...   Aqua Security           available    source, container_image
```

> `tpa-descriptions` is a built-in, **Docker-less** scanner and is `installed` (always on) out of the box — there is no image to pull. It analyzes a connected server's tool descriptions/schemas in-process, so it runs even for **remote `http`/`sse` servers** that have no source files or Docker container.

### 2. Enable scanners

```bash
mcpproxy security enable nova-proximity
mcpproxy security enable trivy-mcp
mcpproxy security enable mcp-ai-scanner
```

(`install` is a hidden alias for `enable`, kept for backward compatibility.)

> **Docker-based scanners belong to the opt-in deep-scan layer (Spec 077).**
> Enabling a Docker scanner pulls its image but does **not** by itself make it
> run: the whole deep-scan layer is gated behind `security.deep_scan.enabled`
> (default `false`). While that switch is off, `security enable <docker-scanner>`
> prints a reminder — `scanner enabled, but it will not run until
> security.deep_scan.enabled=true` — and only the built-in, Docker-less
> `tpa-descriptions` baseline scanner actually runs. Set
> `security.deep_scan.enabled: true` to turn the layer on. See
> [Configuration](#configuration) below.

### 3. Configure API keys (if the scanner needs them)

Only a subset of scanners requires an API key. `mcp-scan` (Snyk Agent Scan) needs a free token from Snyk; `mcp-ai-scanner` can use an optional Anthropic API key or Claude Code OAuth token for richer analysis but works in pattern-only mode without one.

```bash
mcpproxy security configure mcp-scan --env SNYK_TOKEN=snyk_xxx
```

Values are stored directly in the scanner record in BBolt. The scanner container receives them via Docker `--env` flags at scan time.

### 4. Scan a quarantined server

```bash
mcpproxy security scan github-server
```

The CLI runs all enabled scanners in parallel, prints live progress, and shows a summary.

### 5. Review the findings

```bash
mcpproxy security report github-server
mcpproxy security status github-server     # per-scanner detail including stderr
mcpproxy security report github-server -o sarif > github-server.sarif
```

### 6. Approve or reject

```bash
# Approve (unquarantines and indexes tools)
mcpproxy security approve github-server

# Or reject (deletes scan artifacts, keeps server quarantined)
mcpproxy security reject github-server
```

## Scanner registry

MCPProxy ships with a bundled registry of 8 scanners. The bundled list lives in [`internal/security/scanner/registry_bundled.go`](https://github.com/smart-mcp-proxy/mcpproxy-go/blob/main/internal/security/scanner/registry_bundled.go).

| Scanner | Vendor | Inputs | Required env | Notes |
|---------|--------|--------|--------------|-------|
| `cisco-mcp-scanner` | Cisco AI Defense | source | — | YARA rules + readiness analysis. Needs `tools.json` in the source dir. |
| `mcp-ai-scanner` | MCPProxy | source | — (optional `ANTHROPIC_API_KEY` / `CLAUDE_CODE_OAUTH_TOKEN`) | Agent-based AI analysis with a pattern-only fallback. Lives in a [separate repo](https://github.com/smart-mcp-proxy/mcp-scanner). |
| `mcp-scan` | Snyk (Invariant Labs) | source | `SNYK_TOKEN` | Tool poisoning, prompt injection, tool shadowing, toxic flows, secrets, rug pulls. |
| `nova-proximity` | MCPProxy (NOVA-inspired rules) | source | — | Keyword-based, fully offline. Very fast. |
| `ramparts` | Javelin | source | — | Rust-based YARA scanner. Runs fully offline: v0.8.x scans a live MCP endpoint, so MCPProxy replays the captured tool definitions to it over stdio (the upstream is never re-executed). *(`amd64`-only image; runs under emulation on arm64 — see [Scanner Images](/features/scanner-images).)* |
| `semgrep-mcp` | Semgrep | source | — | Static analysis with MCP-specific rules. Uses the upstream `returntocorp/semgrep:latest` image. |
| `tpa-descriptions` | MCPProxy | source | — | **Built-in, Docker-less, always on.** In-process analysis of tool descriptions/schemas via the deterministic offline [detect engine (Spec 076/077)](/features/tool-scanner): seven checks across two tiers — **hard** (hidden-Unicode smuggling, cross-server shadowing, decode-to-shell payloads, curated injection/exfiltration phrases) auto-quarantine and block approval; **soft** (prompt-injection directives, capability-mismatch, embedded secrets) raise a review item. Each finding carries a `confidence` score and the contributing check `signals`. Since Spec 077 the detect engine is the **sole in-process detector** — the duplicated legacy TPA keyword rules were removed and their approval-blocking posture folded into the hard-tier `phrase.injection` check. Fully offline (no network/filesystem/Docker), deterministic, and runs for any connected server — including remote `http`/`sse` servers with no source or Docker. See [Tool Scanner](/features/tool-scanner) for the full rule reference and the CI eval gate. |
| `trivy-mcp` | Aqua Security | source, container_image | — | Filesystem + CVE scan. Uses the upstream `ghcr.io/aquasecurity/trivy:latest` image. |

See [Scanner Images](/features/scanner-images) for the image sources and why vendor images are preferred over custom wrappers.

### Custom / out-of-tree scanners

Custom scanners can be added by pushing a Docker image that implements the plugin contract (see the [Plugin Interface](#plugin-interface) section below) and registering it via the REST API:

```bash
curl -X POST http://localhost:8080/api/v1/security/scanners/my-custom-scanner/enable \
  -H "X-API-Key: $API_KEY"
```

Remote scanner registries (not just the bundled one) are planned but currently opt-in via the `security.scanner_registry_url` config field.

## CLI commands

The complete CLI reference is in [docs/cli/security-commands.md](/cli/security-commands). At a glance:

```bash
# Scanner lifecycle
mcpproxy security scanners                           # list with status
mcpproxy security enable <scanner-id>                # pull image
mcpproxy security disable <scanner-id>               # remove image
mcpproxy security configure <id> --env KEY=VALUE     # set env vars (repeatable)

# Scan operations
mcpproxy security scan <server>                      # blocking, with live progress
mcpproxy security scan <server> --async              # return job id
mcpproxy security scan <server> --dry-run            # print plan, no containers
mcpproxy security scan --all                         # batch scan, progress table
mcpproxy security scan <server> --scanners a,b,c     # subset of scanners
mcpproxy security rescan <server>                    # alias for scan
mcpproxy security status <server>                    # per-scanner detail
mcpproxy security report <server> [-o json|yaml|sarif]
mcpproxy security cancel-all                         # cancel batch in progress

# Approval workflow
mcpproxy security approve <server> [--force]         # unquarantine + index
mcpproxy security reject <server>                    # delete artifacts, keep quarantined
mcpproxy security integrity <server>                 # verify against baseline

# Dashboard
mcpproxy security overview
```

All subcommands support `-o json`, `-o yaml`, and `--json` (shorthand) for scripting.

## REST API

### Scanner management

| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET`    | `/api/v1/security/scanners` | List all scanners from the registry |
| `GET`    | `/api/v1/security/scanners/{id}/status` | Per-scanner detail |
| `POST`   | `/api/v1/security/scanners/{id}/enable` | Enable (pull image) |
| `POST`   | `/api/v1/security/scanners/{id}/disable` | Disable (remove image) |
| `PUT`    | `/api/v1/security/scanners/{id}/config` | Set env vars (JSON body `{"env": {"KEY": "VALUE"}}`) |
| `POST`   | `/api/v1/security/scanners/install` | Legacy install endpoint — prefer the per-scanner enable |
| `DELETE` | `/api/v1/security/scanners/{id}` | Legacy remove endpoint — prefer disable |

### Scan operations

| Method | Endpoint | Description |
|--------|----------|-------------|
| `POST` | `/api/v1/servers/{name}/scan` | Start a scan (body: `{"scanners": [...], "dry_run": false}`) |
| `GET`  | `/api/v1/servers/{name}/scan/status` | Current/latest scan job with per-scanner statuses |
| `GET`  | `/api/v1/servers/{name}/scan/report` | Aggregated report (add `?include_sarif=true` for raw SARIF) |
| `GET`  | `/api/v1/servers/{name}/scan/files` | Source-resolution preview (source_method, path, file list) |
| `POST` | `/api/v1/servers/{name}/scan/cancel` | Cancel the current scan |
| `GET`  | `/api/v1/security/scans` | Scan history across all servers |
| `GET`  | `/api/v1/security/scans/{jobId}/report` | Fetch a specific scan's aggregated report |
| `POST` | `/api/v1/security/scan-all` | Kick off a batch scan of all servers |
| `GET`  | `/api/v1/security/queue` | Batch scan progress |
| `POST` | `/api/v1/security/cancel-all` | Cancel the current batch |

### Approval workflow

| Method | Endpoint | Description |
|--------|----------|-------------|
| `POST` | `/api/v1/servers/{name}/security/approve` | Approve: saves integrity baseline + unquarantines + indexes tools. Body `{"force": true}` bypasses the critical-findings guard. |
| `POST` | `/api/v1/servers/{name}/security/reject` | Reject: deletes scan artifacts, keeps server quarantined. |
| `GET`  | `/api/v1/servers/{name}/integrity` | Integrity check against the approved baseline. |

### Dashboard

| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/v1/security/overview` | Totals: scanners installed, total scans, findings by severity, Docker availability, last scan time |

### SSE events

Emitted on the `/events` SSE stream:

| Event | Payload fields |
|-------|----------------|
| `security.scan_started` | `server_name`, `scanners[]`, `job_id` |
| `security.scan_progress` | `server_name`, `scanner_id`, `status`, `progress` |
| `security.scan_completed` | `server_name`, `findings_summary` |
| `security.scan_failed` | `server_name`, `scanner_id`, `error` |
| `security.integrity_alert` | `server_name`, `alert_type`, `action` |

## Plugin interface

Every scanner plugin is a Docker image with the following contract:

| Mount / path | Direction | Description |
|--------------|-----------|-------------|
| `/scan/source` | read-only | The resolved source directory for the server under scan |
| `/scan/report` | read-write | The scanner must emit `results.sarif` here |
| `/scan/source/tools.json` | read-only | The server's exported MCP tool schemas (`[{ "name": ..., "description": ..., "inputSchema": {...} }, ...]`). Written by mcpproxy before starting the scanner container. |

Scanners communicate results via SARIF 2.1.0. Exit code `0` indicates "scan completed (with or without findings)". Non-zero exit codes are surfaced as `scanner failed` in the aggregated report.

### Source resolution order

```
1. Docker extraction (for Docker-isolated servers)
2. Package cache (npx/uvx/pipx/bunx) — PREFERRED for package runners
3. WorkingDir (from server config)
4. Arg-scan fallback — accepts only directories containing source markers
5. Published package fetch (npx/uvx) — download & unpack real source, no execution
6. Tool definitions only — last resort (HTTP / SSE / unresolvable)
```

For `uvx` servers the package cache lookup (step 2) covers persistent `uv tool install`
locations, git checkouts, **and** the ephemeral `~/.cache/uv/archive-v0` wheel
cache that a plain `uvx <pkg>` populates — so a locally-run Python MCP server
resolves to its real source from the local cache (no network) before step 5's
published fetch is reached, and works in air-gapped deployments.

The resolved method and path are recorded on the scan job and visible via both the text and JSON report. The `source_method` field reports how source was obtained: `docker_extract`, `npx_cache`, `uvx_cache`, `working_dir`, `npm_pack`, `pip_download`, `url`, or `tool_definitions_only`. See [Security Commands → scan](/cli/security-commands#security-scan) for more.

#### Published package fetch (npx / uvx)

Package-runner servers (`npx`, `uvx`, plus `pnpm dlx` / `yarn dlx`, `pipx run`, and `bunx`) are the **primary** quarantine/scan target, but a server quarantined on add has never run locally — so the local package cache (step 2) misses and, before this fallback existed, the scan degraded to **tool definitions only** (no real source-level analysis). Step 5 closes that gap by downloading the *published* package source so the AI and supply-chain scanners run against real code. The target package is parsed from the launch command — subcommand runners (`pipx run X`, `pnpm dlx X`), package-naming flags (`npx --package X`, `uvx --from X`), and extra-dependency flags (`uvx --with <dep> X`, which names `X`, not the dep) are all handled. When the spec carries an exact version pin (`pkg@1.2.3`, `pkg==1.2.3`), the local-cache lookups (steps 2–4) prefer the cache entry matching that version rather than the newest one.

**The source is fetched but never executed.** A scanner must not run the untrusted code it is scanning. The fetch only ever downloads and unpacks archives:

- **Registry name required.** Only a server whose configured spec is a **bare registry name** (PEP 503 for PyPI, npm package name — optionally version-pinned) is fetched. Local-path, `file:`, URL, and VCS (`git+…`, `[email protected]:…`) specs are **rejected** and fall back to tool definitions only. This is mandatory: for a non-registry spec, `pip download` / `uv pip download` still invokes the package's `setup.py` / PEP 517 build backend to resolve metadata **even with `--only-binary=:all:`** — executing untrusted code on the (static) scan path. `--only-binary=:all:` alone protects only bare registry names. Validation covers the **whole spec including the `@`-tail**: a PEP 508 / npm direct reference such as `pkg@./local`, `pkg@/abs/path`, or `pkg@git+https://…` is rejected (the `name@<ref>` tail must be a bare version specifier, not a path/URL/VCS); for PyPI, any `@` is treated as a direct reference and refused.
- **npm (`npx`)** — `npm pack <pkg>@<version> --ignore-scripts` downloads the published tarball without running any lifecycle scripts (`install`/`postinstall`), then it is extracted (`source_method=npm_pack`).
- **PyPI (`uvx`)** — `uv pip download <pkg>==<version> --no-deps --only-binary=:all:` (falling back to `pip download`) fetches **only a prebuilt wheel**, which is unpacked without building or running `setup.py` (`source_method=pip_download`). `--only-binary=:all:` is mandatory: downloading an sdist would invoke the package's PEP 517 build backend (`setup.py egg_info`) to resolve metadata, executing the untrusted code. A package that ships **no wheel** therefore fails the fetch and falls back to tool definitions only — sdists are never built or extracted.

Extraction is hardened against path traversal (zip-slip), symlink escape, and decompression bombs (bounded file count and total size). The whole fetch (download + extract) is bounded by a timeout, so a hung or throttled registry cannot stall the scan. If the toolchain is missing, the host is offline, or the fetch fails or times out, resolution falls through to **tool definitions only** with no regression.

**This fallback runs only under the opt-in deep-scan layer (Spec 077).** Published-package fetch — like every Docker-based scanner — is part of the heavy deep-scan layer, so it runs only when `security.deep_scan.enabled: true`. With deep scan off (the default), package-runner servers without local source scan **tool definitions only**, and no network fetch is attempted. When deep scan is on, the fetch is enabled by default and can be turned off (for air-gapped deployments that must forbid the scanner's network egress) with `security.deep_scan.fetch_package_source: false`. The deprecated top-level `security.scanner_fetch_package_source` key still parses and is migrated into `security.deep_scan.fetch_package_source` on load.

## SARIF normalization

Scanners produce results in SARIF 2.1.0 format. MCPProxy normalizes findings to a consistent severity vocabulary:

| SARIF level | MCPProxy severity |
|-------------|-------------------|
| `error`     | `high` |
| `warning`   | `medium` |
| `note`      | `low` |
| `none`      | `info` |

`critical` severity is reserved for findings explicitly marked critical in the scanner's SARIF `properties.severity` field or via a rule-level override. Critical findings block `security approve` unless `--force` is supplied.

MCPProxy also augments each finding with a user-facing `threat_type` (`tool_poisoning` / `prompt_injection` / `rug_pull` / `supply_chain` / `malicious_code` / `uncategorized`) and `threat_level` (`dangerous` / `warning` / `info`) so the Web UI can group findings in a way that's meaningful to MCP server trust decisions.

## Configuration

```json
{
  "security": {
    "scan_timeout_default": "60s",
    "integrity_check_interval": "1h",
    "integrity_check_on_restart": false,
    "scanner_registry_url": "",
    "runtime_read_only": false,
    "runtime_tmpfs_size": "100M",
    "deep_scan": {
      "enabled": false,
      "fetch_package_source": true,
      "disable_no_new_privileges": false,
      "scanners": []
    }
  }
}
```

| Setting | Default | Description |
|---------|---------|-------------|
| `scan_timeout_default` | `60s` | Per-scanner timeout. The blocking CLI `security scan` computes a hard ceiling as `scan_timeout_default × scanner_count + 30s`, clamped between 15 and 30 minutes. |
| `integrity_check_interval` | `1h` | Periodic integrity check interval (when running as a daemon with integrity checks enabled). |
| `integrity_check_on_restart` | `false` | Re-verify integrity baseline every time an approved server is restarted. |
| `scanner_registry_url` | `""` | Remote scanner registry URL (opt-in). When empty, only the bundled registry is used. |
| `runtime_read_only` | `false` | Run approved server containers with `--read-only` and a tmpfs overlay. (P2 feature, requires Docker isolation.) |
| `runtime_tmpfs_size` | `100M` | Tmpfs size for read-only runtime containers. |

### The `security.deep_scan` block (Spec 077)

The deterministic, offline `tpa-descriptions` baseline scanner always runs and is the sole source of the approval verdict. **Every heavier scan capability — the Docker-based scanner plugins and published-package-source extraction — lives behind the opt-in `security.deep_scan` block.** The whole layer is off by default; a deep-scan failure is surfaced as an informational note and **never** blocks approval or degrades the baseline verdict (FR-007/FR-008).

| Setting | Default | Description |
|---------|---------|-------------|
| `deep_scan.enabled` | `false` | Master opt-in for the heavy layer. When `false`, no Docker scanner runs and no source extraction is attempted — only the in-process baseline scanner executes. |
| `deep_scan.fetch_package_source` | `true` (when deep scan is on) | Whether the scanner fetches (never executes) the published source of `npx`/`uvx` package-runner servers when no local source is available. Set `false` for air-gapped deployments. Only consulted when `deep_scan.enabled` is `true`. |
| `deep_scan.disable_no_new_privileges` | `false` | Omits `--security-opt no-new-privileges` from scanner container runs — the snap-docker/AppArmor escape hatch for hosts where the default flag makes every scanner fail with EPERM. |
| `deep_scan.scanners` | `[]` | Optional allow-list restricting which deep scanners may run (by scanner id). Empty ⇒ all enabled deep scanners are eligible. |

**Deprecated-key migration.** Configs that still carry the old top-level `security.scanner_fetch_package_source` or `security.scanner_disable_no_new_privileges` keys continue to parse and are folded into `deep_scan.fetch_package_source` / `deep_scan.disable_no_new_privileges` on load (then cleared, so the config serializes only `deep_scan.*`). The old `security.auto_scan_quarantined` key was removed; a config still carrying it loads without error and the key is ignored.

The block is hot-reloaded: toggling `deep_scan.enabled` in `mcp_config.json` takes effect on the next scan without a restart.

### Environment variables

| Variable | Effect |
|----------|--------|
| `MCPPROXY_KEYRING_WRITE` | Set to `1`, `true`, or `yes` to opt in to writing secrets to the OS keyring on macOS. Default (empty) routes writes to the in-config fallback. Linux and Windows default to enabled. |

## Data storage

Scanner data is stored in the BBolt database (`~/.mcpproxy/config.db`) in these buckets:

| Bucket | Content |
|--------|---------|
| `security_scanners` | Installed scanner configurations (including `configured_env`) |
| `security_scan_jobs` | Scan job records and per-scanner statuses |
| `security_reports` | Aggregated reports, per-scanner SARIF payloads, normalized findings |
| `integrity_baselines` | Per-server approval records (image digest, scan report IDs, approved-by) |

## Web UI

The Security page at `/security` in the Web UI mirrors the CLI and provides:

- **Dashboard stats** — scanners installed, total scans, findings by severity, Docker availability, Last scan timestamp.
- **Scanner table** — every scanner in the registry with its current status, vendor link, and configure button.
- **Scan All Servers** — batch scan trigger with live progress.
- **Scan report viewer** at `/security/scans/{jobId}` — risk-score badge, finding detail cards with rule/location/scanner, per-scanner execution logs including stderr from failed scanners, and scan context (source method + path + file count).
- **Approve Server / Force Approve / Reject** — scanner-gated approval dialog that requires a completed scan (or explicit force) before calling the approval endpoint.

## Known limitations

- **Ramparts is `amd64`-only and runs emulated on arm64** — the GLIBC build break is fixed (builder pinned to bookworm, MCP-2395/#665) and the v0.8.x URL-based invocation is wired via a static stdio replay shim (MCP-2422), but the image is published for `linux/amd64` only because the arm64 Rust build exhausts the CI runner budget. On arm64 hosts (e.g. Apple Silicon) it runs under emulation — functional but slower. See [Scanner Images](/features/scanner-images) for the design.
- **Cisco scanner is static-analysis only — coverage caveat.** The bundled `cisco-mcp-scanner` runs `static --tools tools.json` (YARA + readiness rules over the exported tool definitions). It **never connects to or probes the live server endpoint and makes no network request**, so an `is_safe`/`SAFE` result reflects the analyzed tool definitions, not the server's live runtime behavior — do not read a clean Cisco result for a remote/URL server as proof the live endpoint was exercised. mcpproxy prepends this caveat to every Cisco execution log. Relatedly, the upstream tool hardcodes a placeholder `server_url` header (`https://mcp.deepwiki.com/mcp`) in its stdout; this is cosmetic and does not affect findings, and since #383 mcpproxy strips that line from the user-visible execution log and replaces it with an annotation explaining no network request was made.
- **Pass 2 (supply-chain audit)** currently requires Docker isolation to be enabled, otherwise it fails source resolution. The UI doesn't yet surface this precondition.

## Related reading

- [Tool Scanner (Spec 076/077)](/features/tool-scanner) — the built-in offline detect engine behind `tpa-descriptions`: the seven checks, two-tier model, and CI eval gate
- [Security Commands](/cli/security-commands) — exhaustive CLI reference
- [Scanner Images](/features/scanner-images) — where each Docker image comes from
- [Security Quarantine](/features/security-quarantine) — the underlying quarantine mechanism that scanners gate
- [Tool Quarantine (Spec 032)](/features/tool-quarantine) — per-tool hash-based approval, a complementary layer
