# gsearch-mcp-cli Design Spec

**Date:** 2026-03-21
**Status:** Approved (Rev 2 — post-review)
**Author:** Jacob BD + AI

---

## Goal

Build a Python CLI and MCP server that provides Google Search (regular + AI Mode) and web page fetching/parsing for AI agents. No-account, pseudonymous sessions — no Google login, but persistent BotGuard cookies (NID, AEC) create a pseudonymous browser identity.

## Architecture Decision Records

### ADR-1: CDP Transport — Async websockets

Use the `websockets` library (async) instead of `websocket-client` (sync). FastMCP is async; blocking the event loop with sync CDP calls prevents parallel MCP tool invocations. All CDP calls are `async def` throughout.

### ADR-2: Data Models — Pydantic BaseModel

All data models use Pydantic `BaseModel`. Gives us validation, JSON serialization (`model_dump_json()`), and automatic MCP schema generation. No dataclasses.

### ADR-3: CDP Connection Model — Shared browser, per-tab WebSocket

One Chrome process per session. Browser-level WebSocket for tab lifecycle (`Target.createTarget` / `Target.closeTarget`). Separate per-tab WebSocket connections for page commands. Context manager pattern guarantees tab cleanup.

### ADR-4: Chrome Lifecycle — Health check + auto-relaunch

Before each operation, `_ensure_browser()` pings CDP `/json/version`. If Chrome is unresponsive, auto-relaunch with the same profile. MCP server keeps Chrome alive for its lifetime. CLI launches per invocation.

### ADR-5: Timeout Budget

| Operation | Timeout | Notes |
|-----------|---------|-------|
| Chrome launch + CDP ready | 15s | Poll /json/version every 500ms |
| Page navigation | 30s | Wait for `Page.loadEventFired` |
| JS evaluation | 10s | Single `Runtime.evaluate` |
| Search result extraction | 15s | Navigate + wait + extract combined |
| AI Mode response | 30s | Streaming AI generation takes longer |
| Page fetch (browser) | 30s | JS-heavy sites need time |
| Page fetch (httpx) | 15s | Static pages |
| Warmup sequence | 30s | All 3 pages combined |

CLI flag: `--timeout <seconds>` overrides the per-operation default.
MCP param: `timeout_seconds` on all tools.

## Naming

| Artifact | Name |
|----------|------|
| PyPI package | `gsearch-mcp-cli` |
| CLI command | `gsearch` |
| MCP server binary | `gsearch-mcp` |
| Config directory | `~/.gsearch-mcp-cli/` |
| Chrome profile | `~/.gsearch-mcp-cli/chrome-profile/` |

## Architecture

```
┌─────────────────────────────────────────────────┐
│               User / AI Agent                   │
│          (CLI or MCP tool call)                  │
└───────────┬──────────────────┬──────────────────┘
            │                  │
      ┌─────▼──────┐    ┌─────▼──────┐
      │  gsearch    │    │  gsearch   │
      │   (CLI)     │    │   -mcp     │
      │  Click      │    │  FastMCP   │
      └─────┬───────┘    └─────┬──────┘
            │                  │
      ┌─────▼──────────────────▼──────┐
      │        shared.py              │  ← single backend
      │  search() / ai() / fetch()   │
      └──────────────┬────────────────┘
                     │
      ┌──────────────▼────────────────┐
      │       core/browser.py         │  ← browser lifecycle
      │  launch / reuse / shutdown    │
      │  multi-browser detection      │
      │  port management              │
      └──────────────┬────────────────┘
                     │
      ┌──────────────▼────────────────┐
      │       core/cdp.py             │  ← CDP over WebSocket
      │  connect / navigate / eval    │
      │  tab management (parallel)    │
      │  UA override                  │
      └──────────────┬────────────────┘
                     │
      ┌──────────────▼────────────────┐
      │       core/extractor.py       │  ← result extraction
      │  parse search results         │
      │  parse AI Mode response       │
      │  extract page content → MD    │
      └──────────────────────────────-┘
```

**Key principle:** `shared.py` is the single backend for both CLI and MCP — same pattern as `pwm` (Perplexity) and `gemcli` (Gemini).

## Tech Stack

| Component | Choice | Rationale |
|-----------|--------|-----------|
| Language | Python ≥3.10 | Matches pwm and gemcli |
| Build system | Hatchling + uv | Matches gemcli |
| CLI framework | Click + rich-click | Matches both MCPs |
| MCP framework | FastMCP | Matches both MCPs |
| CDP transport | websockets (async) | Async-native; won't block FastMCP event loop (ADR-1) |
| HTTP client | httpx | For page fetching fallback |
| HTML → MD | trafilatura + html-to-markdown | Content extraction + MD conversion |
| Output formatting | rich | Pretty CLI output |
| Data models | pydantic | Structured results |
| Serialization | orjson | Fast JSON |

## Browser Automation

### Why CDP

Google Search requires JavaScript execution. All HTTP-only approaches (curl_cffi, httpx, etc.) receive a BotGuard JS challenge page with zero results. CDP with a warmed-up persistent profile is the only approach that works without authentication.

Validated in testing:
- Regular search: headless works
- AI Mode: headless works (with UA override)
- Parallel searches: multiple tabs in one Chrome instance works
- No auth required: "Sign in" visible, anonymous session
- Fresh profile warmup: visit google.com + about.google + preferences → search works

### Browser Detection

Scan for Chromium-based browsers (pattern from NotebookLM MCP):

**macOS** (`/Applications` and `~/Applications`):
- Google Chrome
- Brave Browser
- Microsoft Edge
- Chromium
- Vivaldi
- Opera
- Opera GX

**Linux**: `shutil.which` for `google-chrome`, `google-chrome-stable`, `chromium`, `chromium-browser`, `brave-browser`, `microsoft-edge-stable`

**Windows**: Fixed paths under `Program Files`, `Program Files (x86)`, `%LOCALAPPDATA%`

Configurable via `gsearch config set browser <name>` or `GSEARCH_BROWSER` env var. Default: `auto` (first found).

### Chrome Launch Flags

```
<browser-binary>
  --remote-debugging-port=0              # OS assigns free port; read from stderr (eliminates TOCTOU race)
  --user-data-dir=<~/.gsearch-mcp-cli/chrome-profile/>
  --headless=new
  --no-first-run
  --no-default-browser-check
  --disable-extensions
  --disable-sync
  --disable-background-networking
  --remote-allow-origins=http://localhost # restrict CDP access to localhost only
  --window-size=1280,900
  --disable-blink-features=AutomationControlled
  --disable-gpu                          # stability in headless/Docker
  --disable-dev-shm-usage                # critical in Docker (uses /tmp instead of /dev/shm)
  --disable-features=TranslateUI         # prevent translate popups
```

**Port discovery:** Launch with `--remote-debugging-port=0`. Chrome prints `DevTools listening on ws://127.0.0.1:<PORT>/...` to stderr. Parse the actual port from this line. This eliminates the TOCTOU race condition from socket.bind() + launch.

### UA Override

Chrome's headless mode includes `HeadlessChrome` in the User-Agent string, which Google checks to gate AI Mode. On every CDP session:

```python
ua = evaluate("navigator.userAgent")
set_user_agent_override(ua.replace("HeadlessChrome", "Chrome"))
```

### Port Management

- **No pre-selection**: Use `--remote-debugging-port=0` (OS assigns free port)
- **Discovery**: Parse actual port from Chrome's stderr line: `DevTools listening on ws://127.0.0.1:<PORT>/...`
- **Port map file**: `~/.gsearch-mcp-cli/chrome-port-map.json` records `{port: {pid, profile, started_at}}`
- **Stale PID pruning**: On startup, verify each PID is alive AND is actually Chrome AND port is listening. Remove stale entries.

### Browser Lifecycle

**Per-session model:** One Chrome instance launched per CLI/MCP session. Multiple searches use multiple tabs within that instance.

- **CLI**: Launch Chrome on first search command, terminate on exit (atexit handler).
- **MCP**: Launch Chrome lazily on first tool call, keep alive for server lifetime, terminate on server shutdown.
- **Health check**: `_ensure_browser()` called before each operation — pings `http://localhost:<port>/json/version`. If unresponsive, auto-relaunch Chrome with the same profile (ADR-4).
- **Crash recovery**: On CDP connection failure, kill stale Chrome process (if PID still exists), clean port map, relaunch.
- **Parallel searches**: Create new tab per search via `Target.createTarget`, close via `Target.closeTarget` when done.
- **Tab context manager**: All tab operations wrapped in `async with cdp.tab(url) as tab:` which guarantees `Target.closeTarget` in `__aexit__`, even on exception. Prevents tab/memory leaks.
- **Max concurrent tabs**: Default 5, configurable. Prevents runaway resource usage from parallel agent calls.

### Profile Warmup

On first use (or when profile doesn't exist), `gsearch setup` runs automatically:

1. Launch headless Chrome with fresh profile
2. Navigate to `https://www.google.com/?hl=en` — wait for `Page.loadEventFired`
3. Verify cookies set via `Network.getCookies` (expect NID, AEC at minimum)
4. Navigate to `https://about.google/` — wait for `Page.loadEventFired`
5. Navigate to `https://www.google.com/preferences?hl=en` — wait for `Page.loadEventFired`
6. Final cookie verification — if NID + AEC not present, fail with actionable error
7. Profile now has BotGuard trust cookies; subsequent searches work immediately

Uses CDP load events instead of fixed delays — works reliably on slow connections. Total timeout: 30s for the full sequence.

## CLI Commands

### gsearch search

Primary search command. Returns combined response: AI Overview + organic results + sources.

```bash
gsearch search "what is kubernetes"
gsearch search "python async tutorial" --max-results 10
gsearch search "latest AI news" --json
gsearch search "kubernetes vs docker" --no-ai    # skip AI Overview
```

**Output (default):**
```
AI Overview
───────────
Kubernetes (K8s) is an open-source system for automating deployment,
scaling, and management of containerized applications...

Sources: kubernetes.io, cloud.google.com, redhat.com (+4)

Search Results
──────────────
1. Overview | Kubernetes
   https://kubernetes.io/docs/concepts/overview/
   Kubernetes is a portable, extensible, open source platform for...

2. What is Kubernetes? | Google Cloud
   https://cloud.google.com/learn/what-is-kubernetes
   With the widespread adoption of containers among organizations...

[... more results ...]

People Also Ask
───────────────
• What is Kubernetes and why is it used?
• What is Docker vs Kubernetes?
```

**Output (--json):**
```json
{
  "query": "what is kubernetes",
  "ai_overview": {
    "text": "Kubernetes (K8s) is an open-source...",
    "sources": ["kubernetes.io", "cloud.google.com"]
  },
  "results": [
    {
      "position": 1,
      "title": "Overview | Kubernetes",
      "url": "https://kubernetes.io/docs/concepts/overview/",
      "snippet": "Kubernetes is a portable, extensible..."
    }
  ],
  "people_also_ask": [...],
  "related_searches": [...]
}
```

| Flag | Description | Default |
|------|-------------|---------|
| `--max-results, -n` | Max organic results | 10 |
| `--json` | JSON output | false |
| `--no-ai` | Skip AI Overview extraction | false |
| `--lang` | Search language | en |
| `--region` | Search region (gl param) | auto |

### gsearch ai

Google AI Mode search. Returns synthesized AI response with cited sources.

```bash
gsearch ai "explain kubernetes architecture"
gsearch ai "compare React vs Vue for 2026" --json
```

**Output:** Structured AI synthesis with sections, bullet points, and inline source citations — same format as g.ai.

| Flag | Description | Default |
|------|-------------|---------|
| `--json` | JSON output | false |
| `--lang` | Language | en |

### gsearch fetch

Fetch a URL and return clean Markdown content.

```bash
gsearch fetch "https://kubernetes.io/docs/concepts/overview/"
gsearch fetch "https://kubernetes.io/docs/" --raw    # raw text, no MD
gsearch fetch "https://example.com" --json           # structured JSON
```

Uses trafilatura for boilerplate removal + html-to-markdown for conversion. Can use either the CDP browser (for JS-rendered pages) or httpx (for static pages, faster).

| Flag | Description | Default |
|------|-------------|---------|
| `--raw` | Plain text instead of Markdown | false |
| `--json` | JSON with metadata (title, author, date, content) | false |
| `--browser` | Force browser rendering (for JS-heavy sites) | auto |

### gsearch setup

Initialize or refresh the Chrome profile.

```bash
gsearch setup              # warmup profile
gsearch setup --reset      # delete and re-create profile
gsearch setup --check      # verify profile is working
```

### gsearch config

Configuration management.

```bash
gsearch config show
gsearch config set browser brave
gsearch config set lang fr
gsearch config set region CA
```

Config file: `~/.gsearch-mcp-cli/config.json`

| Key | Description | Default |
|-----|-------------|---------|
| `browser` | Preferred browser (auto/chrome/brave/edge/etc) | auto |
| `lang` | Default search language | en |
| `region` | Default search region | (none) |
| `max_results` | Default max results | 10 |
| `json_output` | Always output JSON | false |
| `rate_limit` | Min seconds between searches | 2.0 |
| `max_tabs` | Max concurrent browser tabs | 5 |
| `timeout` | Default timeout (seconds) | 30 |

### gsearch doctor

Diagnostics.

```bash
gsearch doctor             # check browser, profile, connectivity
gsearch doctor --verbose
```

Checks: browser found, CDP connectable, profile exists, Google reachable, search works.

### gsearch skill

Skill installer for AI tools.

```bash
gsearch skill list
gsearch skill install cursor
gsearch skill install claude-code
gsearch skill update
```

Installs SKILL.md + references/ into platform-specific paths (same pattern as gemcli/pwm).

### Default Command

`search` is the default subcommand. Bare `gsearch "query"` is equivalent to `gsearch search "query"`.

```bash
gsearch "what is kubernetes"              # same as gsearch search "what is kubernetes"
gsearch "query" --json                    # same as gsearch search "query" --json
```

### Global Flags

```bash
gsearch --version          # print version
gsearch --docs             # print AI-optimized documentation (for LLM agents)
gsearch --help             # help
gsearch --verbose          # debug logging
gsearch --timeout 20       # override per-operation timeout (seconds)
```

Note: `--docs` (not `--ai`) to avoid collision with the `gsearch ai` subcommand.

## MCP Tools

### gsearch_search

Combined Google Search: AI Overview + organic results.

```python
gsearch_search(
    query: str,
    max_results: int = 10,
    include_ai: bool = True,
    language: str = "en",
    region: str | None = None,
    timeout_seconds: int = 30,
) -> SearchResult
```

### gsearch_ai

Google AI Mode search with synthesized response.

```python
gsearch_ai(
    query: str,
    language: str = "en",
    timeout_seconds: int = 30,
) -> AIResult
```

### gsearch_fetch

Fetch and parse a URL to clean Markdown.

```python
gsearch_fetch(
    url: str,             # URL to fetch (https:// only)
    format: str = "md",   # "md" | "text" | "raw_html"
    use_browser: str = "auto",  # "auto" | "always" | "never"
    timeout_seconds: int = 30,
) -> FetchResult
```

**Security (SSRF prevention):**
- Whitelist `http://` and `https://` schemes only. Reject `file://`, `ftp://`, `data:`, etc.
- Block private/reserved IP ranges: `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `127.0.0.0/8`, `169.254.169.254` (cloud metadata).
- Resolve DNS before connecting; re-check resolved IP against blocklist.
- `use_browser` defaults to `"auto"` for both CLI and MCP (aligned behavior).

## Data Models (Pydantic BaseModel — ADR-2)

```python
class SearchResult(BaseModel):
    query: str
    ai_overview: AIOverview | None = None
    results: list[OrganicResult]
    people_also_ask: list[str] = []
    related_searches: list[str] = []

class AIOverview(BaseModel):
    text: str
    sources: list[Source] = []

class OrganicResult(BaseModel):
    position: int
    title: str
    url: str
    snippet: str
    source: str  # display domain

class AIResult(BaseModel):
    query: str
    synthesis: str
    sections: list[Section] = []
    sources: list[Source] = []

class Section(BaseModel):
    heading: str
    content: str
    items: list[str] = []

class Source(BaseModel):
    title: str
    url: str
    snippet: str = ""
    domain: str

class FetchResult(BaseModel):
    url: str
    title: str
    content: str
    word_count: int
    metadata: dict = {}
```

## Directory Structure

```
gsearch-mcp-cli/
├── pyproject.toml
├── README.md
├── CHANGELOG.md
├── src/gsearch_mcp_cli/
│   ├── __init__.py
│   ├── cli/
│   │   ├── __init__.py
│   │   ├── main.py          # CLI entry point (Click group)
│   │   ├── ai_doc.py        # --docs flag content
│   │   ├── search_cmd.py    # gsearch search
│   │   ├── ai_cmd.py        # gsearch ai
│   │   ├── fetch_cmd.py     # gsearch fetch
│   │   ├── setup_cmd.py     # gsearch setup
│   │   ├── config_cmd.py    # gsearch config
│   │   ├── doctor_cmd.py    # gsearch doctor
│   │   └── skill_cmd.py     # gsearch skill
│   ├── mcp/
│   │   ├── __init__.py
│   │   ├── server.py        # MCP server (FastMCP tools)
│   │   └── __main__.py
│   ├── core/
│   │   ├── __init__.py
│   │   ├── browser.py       # browser detection, launch, lifecycle, health check
│   │   ├── cdp.py           # async CDP connection, commands, tab context manager
│   │   ├── extractor.py     # parse search results, AI mode, DOM
│   │   ├── fetcher.py       # page fetch + MD conversion + SSRF validation
│   │   ├── warmup.py        # profile warmup with event-based waits
│   │   └── rate_limiter.py  # token bucket rate limiter
│   ├── shared.py            # single backend: search(), ai(), fetch()
│   ├── config.py            # config management
│   ├── models.py            # pydantic data models
│   ├── constants.py         # URLs, ports, browser paths
│   ├── exceptions.py        # error hierarchy
│   └── data/
│       ├── SKILL.md
│       ├── AGENTS_SECTION.md
│       └── references/
│           ├── commands.md
│           └── mcp-tools.md
├── skills/gsearch-mcp/
│   ├── SKILL.md
│   └── references/
├── tests/
│   ├── test_browser.py
│   ├── test_cdp.py
│   ├── test_extractor.py
│   ├── test_fetcher.py
│   └── test_search.py
└── desktop-extension/
    ├── manifest.json
    └── run_server.py
```

## Entry Points (pyproject.toml)

```toml
[project.scripts]
gsearch = "gsearch_mcp_cli.cli.main:main"
gsearch-mcp = "gsearch_mcp_cli.mcp:run_server"
```

## Result Extraction Strategy

### Regular Search (`/search?q=...`)

Extract via `Runtime.evaluate` JavaScript execution:

1. **AI Overview**: Query `[data-attrid]` and heading elements containing "AI Overview". Extract text + inline source links.
2. **Organic results**: Query all `h3` elements, traverse to parent `<a>` for URL, sibling elements for snippet text.
3. **People Also Ask**: Query accordion-style buttons with question text.
4. **Related searches**: Query link elements in the "People also search for" section.
5. **Source metadata**: Extract domain from URLs, fetch cite elements for display names.

### AI Mode (`/search?q=...&udm=50`)

1. **AI synthesis**: Extract the main text content from the AI response area.
2. **Sections**: Parse `h3` headings within the response for structured sections.
3. **Bullet points**: Extract `li` elements within each section.
4. **Source citations**: Parse inline source badges and the source list at the bottom.
5. **Follow-up support**: The AI Mode page has a textbox for follow-ups — future enhancement.

### Page Fetching

1. **Static pages** (default): httpx GET → trafilatura extract → html-to-markdown convert.
2. **JS-rendered pages** (`--browser`): CDP navigate → wait for load → extract `document.body.innerHTML` → trafilatura → html-to-markdown.

## Error Handling

```python
class GSearchError(Exception): ...
class BrowserNotFoundError(GSearchError): ...
class CDPConnectionError(GSearchError): ...
class SearchBlockedError(GSearchError): ...
class ProfileWarmupError(GSearchError): ...
class FetchError(GSearchError): ...
class SSRFBlockedError(FetchError): ...
class TimeoutError(GSearchError): ...
```

- **Browser not found**: Clear message listing supported browsers + install instructions.
- **CDP connection failed**: Auto-relaunch Chrome (ADR-4). If still failing, suggest `gsearch doctor`.
- **Search blocked** ("unusual traffic"): Auto-trigger profile re-warmup, retry once. If still blocked, suggest VPN or waiting.
- **Profile warmup failed**: Clear error with manual steps.
- **Fetch failed**: Fall back from browser to httpx, or vice versa.
- **SSRF blocked**: Clear message explaining the URL was rejected for security reasons.
- **Timeout**: Operation-specific message with the timeout value and suggestion to increase via `--timeout`.

### Error Sanitization

- **CLI**: Full error details in `--verbose` mode. User-friendly messages by default.
- **MCP**: Sanitized errors only — no file paths, PIDs, or port numbers. Returns `{"error": "<user-friendly message>"}`. Full details logged server-side only.

## Privacy Model

**No-account, pseudonymous** — honest about what this means:

- **No Google account**: No login. "Sign in" button visible in all responses.
- **Pseudonymous session**: The persistent NID cookie creates a pseudonymous browser identity that Google can track across searches within the same profile. This is the tradeoff for bypassing BotGuard without authentication.
- **No search history linked to account**: Searches are not tied to any Google account or email.
- **Cookie rotation**: `gsearch setup --reset` wipes the profile and creates a new pseudonymous identity. Future enhancement: optional automatic cookie rotation on a configurable interval.
- **Ephemeral mode** (future): `gsearch search --ephemeral "query"` would create a throwaway profile per search, at the cost of ~8s warmup overhead. For users who want maximum privacy.
- **Clean profile**: `gsearch setup --reset` wipes all cookies, cache, and profile data.
- **Headless**: No visible browser window. No dock icon. Fully transparent.
- **No personal data leakage**: Profile contains only Google infrastructure cookies (NID, AEC, OGPC, CONSENT), never auth cookies or personal identifiers.

## Rate Limiting

Built-in rate limiter to prevent triggering Google's "unusual traffic" block:

- **Default**: 1 search per 2 seconds (configurable via `gsearch config set rate_limit 1.0`)
- **Implementation**: Token bucket in `shared.py`, shared across CLI invocations via the single Chrome instance
- **MCP**: Rate limiter is per-server-instance (shared across concurrent tool calls)
- **Burst**: Allow burst of 3 searches, then throttle to configured rate
- **Override**: `--no-rate-limit` flag for power users who accept the risk

## Future Enhancements

- **AI Mode follow-ups**: Conversational search (type in the follow-up textbox).
- **Image search**: `gsearch images "query"` using Google Images.
- **Google Scholar**: `gsearch scholar "query"` (already works via HTTP).
- **SearXNG backend**: Optional config for users with a self-hosted instance.
- **Multi-engine fallback**: DuckDuckGo/Brave/Bing via curl_cffi as backup when Google blocks.
- **Result caching**: Short-lived cache to avoid duplicate searches.
- **Proxy support**: SOCKS5/HTTP proxy for all requests.
- **Ephemeral mode**: `--ephemeral` flag for single-use throwaway profiles.
- **Cookie rotation**: Automatic periodic profile reset for enhanced privacy.
- **Search pagination**: `--start` / `--page` flags mapping to Google's `start=` param.
- **Fetch content length limit**: `--max-length` with truncation marker.

## Review Changelog

### Rev 2 (2026-03-21) — Post-review fixes

Addressed findings from 4-reviewer spec review (Security, Architect, Browser, DevEx):

**P0 fixes:**
- SSRF prevention in fetch (scheme whitelist, private IP block, DNS re-check)
- CDP `--remote-allow-origins=http://localhost` (was `*`)
- TOCTOU port race eliminated (`--remote-debugging-port=0` + stderr parsing)
- Async CDP transport: `websockets` library instead of sync `websocket-client` (ADR-1)
- Browser crash recovery with `_ensure_browser()` health check (ADR-4)
- Tab leak prevention via async context manager + max concurrent tab limit

**P1 fixes:**
- Privacy model renamed to "no-account / pseudonymous" with honest NID disclosure
- Warmup uses CDP `Page.loadEventFired` + cookie verification (no fixed delays)
- Pydantic BaseModel for all data models (ADR-2)
- Timeout budget defined for all operations (ADR-5) + CLI/MCP timeout params
- Rate limiting: 1 search / 2s default with token bucket
- `--ai` renamed to `--docs` to avoid collision with `gsearch ai` subcommand
- `search` is now the default subcommand (`gsearch "query"` works)
- Fetch `use_browser` aligned to `"auto"` for both CLI and MCP
- Added `--disable-gpu`, `--disable-dev-shm-usage`, `--disable-features=TranslateUI` Chrome flags
- MCP errors sanitized (no paths/PIDs/ports leaked to agents)
