Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,12 @@ PERPLEXITY_API_KEY=pplx-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# PERPLEXITY_HTTP_AUTH_TOKEN=
# PERPLEXITY_HTTP_HOST=127.0.0.1
# PERPLEXITY_HTTP_PORT=8080

# --- Optional interactive TUI (`perplexity-agent tui`; needs the `tui` extra) ---
# User-Agent the page fetcher sends when you `/open` a URL.
# PERPLEXITY_FETCH_USER_AGENT=PerplexityAgent-TUI/0.1 (+https://codeberg.org/CryptoJones/PerplexityAgent)
# Allow fetching private/loopback/link-local addresses. Leave false (SSRF guard).
# PERPLEXITY_FETCH_ALLOW_PRIVATE=false
# Where the TUI keeps its local sqlite store (history, tabs, spaces). Defaults to
# an XDG data dir (e.g. ~/.local/share/perplexity-agent/store.db).
# PERPLEXITY_STORE_PATH=
10 changes: 10 additions & 0 deletions .github/codeql/codeql-config.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
name: "PerplexityAgent CodeQL config"

# SAST targets the shipped package only. Test files contain deliberate edge-case
# patterns (substring URL assertions, mixed import styles) that are benign in test
# context but generate noise; analyzing src/ keeps findings actionable.
paths:
- src

paths-ignore:
- tests
18 changes: 18 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
version: 2
updates:
# Python dependencies (pyproject.toml / uv.lock).
- package-ecosystem: "uv"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
groups:
python-deps:
patterns: ["*"]

# Pin the GitHub Actions used in the workflows above.
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
87 changes: 76 additions & 11 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,90 @@ on:
push:
pull_request:

# Least privilege: jobs only need to read the repo. CodeQL (separate workflow)
# requests the extra scopes it needs.
permissions:
contents: read

# Cancel superseded runs on the same ref to save CI minutes.
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
lint:
name: Lint (ruff)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
- run: uv python install 3.12
- run: uv sync --extra dev --extra tui --python 3.12
- name: Ruff lint
run: uv run ruff check --output-format=github .

typecheck:
name: Type check (mypy strict)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
- run: uv python install 3.12
- run: uv sync --extra dev --extra tui --python 3.12
- name: mypy
run: uv run mypy src

test:
name: Test (py${{ matrix.python-version }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
- name: Set up Python ${{ matrix.python-version }}
run: uv python install ${{ matrix.python-version }}
- name: Sync dependencies
run: uv sync --extra dev --python ${{ matrix.python-version }}
- name: Lint
run: uv run ruff check .
- name: Type check
run: uv run mypy src
- name: Test
run: uv run pytest -q
- name: Audit dependencies
with:
enable-cache: true
- run: uv python install ${{ matrix.python-version }}
# --extra tui is required: the TUI tests import textual/selectolax.
- run: uv sync --extra dev --extra tui --python ${{ matrix.python-version }}
- name: Pytest with coverage gate
run: >
uv run pytest -q
--cov=perplexity_agent
--cov-report=term-missing
--cov-report=xml
- name: Upload coverage report
if: matrix.python-version == '3.12'
uses: actions/upload-artifact@v4
with:
name: coverage-xml
path: coverage.xml
if-no-files-found: error

security:
name: Security (audit + secrets)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
- run: uv python install 3.12
- run: uv sync --extra dev --extra tui --python 3.12
- name: Dependency vulnerability audit (pip-audit)
run: uv run pip-audit
- name: Secret scan (gitleaks)
run: |
docker run --rm -v "${{ github.workspace }}:/repo" \
ghcr.io/gitleaks/gitleaks:latest \
detect --source /repo --no-git --redact --verbose
37 changes: 37 additions & 0 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: CodeQL

# Semantic static analysis (SAST) for the Python source. Runs on pushes/PRs to
# main and weekly, so new code paths and newly-disclosed query packs are caught.
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
# Mondays at 06:00 UTC.
- cron: "0 6 * * 1"
# Manual trigger (validated a clean run on the feature branch before merge).
workflow_dispatch:

permissions:
contents: read

jobs:
analyze:
name: Analyze (python)
runs-on: ubuntu-latest
permissions:
security-events: write
contents: read
steps:
- uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: python
queries: security-and-quality
config-file: ./.github/codeql/codeql-config.yml
- name: Perform CodeQL analysis
uses: github/codeql-action/analyze@v3
with:
category: "/language:python"
29 changes: 29 additions & 0 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# gitleaks configuration for PerplexityAgent.
#
# The repo contains NO real secrets: the Perplexity API key is loaded at runtime
# from the environment as a pydantic SecretStr and never committed. The only
# `pplx-...` strings in the tree are obvious placeholders (`.env.example`, README)
# and deterministic test fixtures. This config keeps the default ruleset and
# allowlists those placeholders so the scan stays signal, not noise.

title = "PerplexityAgent gitleaks config"

[extend]
useDefault = true

[allowlist]
description = "Placeholder and test-only Perplexity keys; not real credentials."
# Match against the whole source line so allowlisting works regardless of which
# substring a rule captures as the "secret".
regexTarget = "line"
regexes = [
'''pplx-x{4,}''', # .env.example / README placeholders (pplx-xxxx…)
'''pplx-testkey[0-9]+''', # deterministic unit-test fixture key
'''pplx-dummy''', # README / smoke-test key
'''pplx-\.{3}''', # README "pplx-..." snippet
'''pplx-[a-f0-9]{6,}''', # hex fixtures in test_security.py (abcdef…, deadbeef…)
]
paths = [
'''\.env\.example$''',
'''\.gitleaks\.toml$''',
]
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **Comet-style interactive TUI** (`perplexity-agent tui`, optional `tui` extra).
A Textual app that maps Perplexity Comet's browser features onto the terminal,
backed by the existing Search / Sonar / deep-research client: an assistant chat
sidebar, answer-first `/search`, page `/open` + `/summary` + `/ask` + `/translate`,
"chat with your tabs" cross-tab synthesis, AI `/group`ing, `/research`, local
Spaces/history (SQLite), and background `/task` monitors. Out of scope by physics:
voice and real web actions (clicking/booking/buying).
- An **SSRF-hardened page fetcher** (`fetch.py`) backing `/open`: scheme allowlist,
private/loopback/link-local IP rejection re-checked on every redirect hop,
size/time caps, and indirect-prompt-injection flagging. Reachable only from the
TUI, never from an MCP tool — the tool surface is unchanged. See `SECURITY.md`.

### Security

- The HTTP transport's bearer-token check is now constant-time
Expand Down
41 changes: 38 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,16 +235,51 @@ client at `http://127.0.0.1:8080/mcp` and send the bearer token:
Authorization: Bearer <PERPLEXITY_HTTP_AUTH_TOKEN>
```

## Comet-style TUI (`perplexity-agent tui`)

An optional interactive terminal app that brings the spirit of Perplexity's
[Comet](https://www.perplexity.ai/comet/) browser to the terminal, backed by the
same Search / Sonar / deep-research client. A terminal can't render web pages, drive
a real browser (clicking, booking, buying), or do voice — those are out of scope by
physics. Everything else maps onto terminal-feasible equivalents:

| Comet feature | In the TUI |
| --- | --- |
| Assistant sidebar | A persistent chat pane (Sonar) that answers with your open "tabs" as context |
| Answer-first search | `/search` — ranked results **plus** a grounded, cited answer |
| Open / summarize a page | `/open <url>` — SSRF-guarded fetch → readable text → one-click summary |
| Ask about / translate a page | `/ask <q>`, `/translate <lang>` on the current page |
| Chat with your tabs / synthesis | `/summary` across all open tabs; bare chat is tab-aware |
| AI tab grouping | `/group` clusters open tabs into named groups |
| Deep research | `/research <q>` runs the full validated, cited pipeline |
| Memory & Spaces | Local SQLite store; `/space [name]` switches workspaces |
| Background / scheduled tasks | `/task search\|fetch <seconds> <target>` monitors and alerts on change; `/untask <id>` stops it |
| Agentic task planning | Research-only planning (decompose a goal); **no real web actions** |

Install the extra and launch it (needs `PERPLEXITY_API_KEY`, same as the server):

```bash
uv sync --extra tui
uv run perplexity-agent tui
```

The page fetcher (`/open`) is the only egress path other than the Perplexity API and
is reachable **only from the TUI**, never via the MCP tools. It is SSRF-hardened
(scheme allowlist, private/loopback/link-local IPs rejected on every redirect hop,
size/time caps) and flags fetched text for indirect prompt injection before it
reaches Sonar. See [`SECURITY.md`](SECURITY.md). The MCP tool surface is unchanged.

## Configuration

All optional knobs are environment variables (see [`.env.example`](.env.example)):
timeouts, response-size cap, retry count, rate limits, and an optional JSON
audit-log path.
timeouts, response-size cap, retry count, rate limits, an optional JSON audit-log
path, and (for the TUI) the fetch User-Agent, `PERPLEXITY_FETCH_ALLOW_PRIVATE`, and
`PERPLEXITY_STORE_PATH`.

## Development

```bash
uv sync --extra dev
uv sync --extra dev --extra tui # add --extra tui to exercise the TUI tests
uv run pytest # unit tests (no live API needed; httpx is mocked)
uv run ruff check . # lint
uv run mypy src # type check (strict)
Expand Down
31 changes: 30 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ operating-system account the server runs under.
| NSA recommendation | How PerplexityAgent implements it |
| --- | --- |
| **Choose supported MCP projects** | Built on the official `mcp` Python SDK (FastMCP). Dependencies are pinned and fully locked in `uv.lock`. |
| **Design for boundaries / least privilege** | Default **stdio** transport runs locally with no network exposure. No shell execution, no filesystem writes (except an optional, explicitly-configured audit-log path). Egress is only to `api.perplexity.ai`. The API key lives only in the client layer and is **never** returned by a tool. |
| **Design for boundaries / least privilege** | Default **stdio** transport runs locally with no network exposure. No shell execution, no filesystem writes (except an optional, explicitly-configured audit-log path). Egress is only to `api.perplexity.ai`. The API key lives only in the client layer and is **never** returned by a tool. The MCP server's only egress is `api.perplexity.ai`; the optional TUI adds an SSRF-guarded page fetcher (see below) that is never reachable from a tool. |
| **Validate parameters** | Every tool input is validated against a strict `pydantic` model (`schemas.py`) with bounded string lengths (≤ 4 KB), numeric ranges (`max_results` 1–20, `num_subquestions` 1–8), and a `model` enum. Unknown fields are rejected (`extra="forbid"`), preventing parameter smuggling. |
| **Constrain & sandbox tool execution** | Per-request timeouts, a hard response-size cap, and capped retries with jittered backoff (`client.py`). Run the process under seccomp/AppArmor/SELinux or in a container for OS-level isolation (see below). |
| **Sign & verify messages (transport)** | stdio mode is local-trusted. The optional HTTP transport **refuses to start without a bearer token**, binds to localhost by default, and rejects any request lacking the exact `Authorization: Bearer <token>` header. Terminate TLS at a reverse proxy in front of it. |
Expand All @@ -32,6 +32,35 @@ operating-system account the server runs under.
| **DoS / fatigue resistance** | A token-bucket rate limiter (`rate_per_minute` / `rate_burst`), bounded sub-question counts, input-size caps, and request timeouts. |
| **Access control / token security** | `PERPLEXITY_API_KEY` is loaded server-side from the environment only; the server fails fast if it is absent. No token passthrough. |

## Interactive TUI page fetcher (added egress surface)

The optional `perplexity-agent tui` (the `tui` extra) adds a **page fetcher**
(`fetch.py`) so commands like `/open <url>` can pull a page into context — the only
egress path other than `api.perplexity.ai`. It is reachable **only from the
interactive TUI**, never from an MCP tool, so the agent-facing threat model and tool
surface are unchanged. Because the fetched URL is attacker-influenceable, the
fetcher applies SSRF + DoS controls mirroring the API client:

- **Scheme allowlist** — only `http` / `https`; `file://`, `gopher://`, `data:` etc.
are rejected.
- **Private-address rejection** — the host is resolved and the fetch is refused if
**any** resolved IP is private, loopback, link-local (incl. `169.254.169.254`
cloud metadata), multicast, reserved, or unspecified. This is re-checked on **every
redirect hop** (redirects are followed manually, not by httpx). Default-deny;
override only with `PERPLEXITY_FETCH_ALLOW_PRIVATE=true`.
- **Size / time caps** — reuses `PERPLEXITY_TIMEOUT` and `PERPLEXITY_MAX_RESPONSE_BYTES`.
- **Untrusted-output handling** — extracted page text is run through the same
indirect-prompt-injection scan as `deep_research`; matches are surfaced to the user
before the text is sent to Sonar.
- **Audit** — fetches share the TUI's `TokenBucket` rate limiter and audit logger.

**Residual risk:** a DNS-rebinding TOCTOU window exists between resolution and
connect (we validate the resolved IPs, then connect by hostname for correct TLS).
With `fetch_allow_private=False` the blast radius is "publicly routable internet
only". Operators who fetch untrusted URLs should still run the TUI behind a filtering
egress proxy. The TUI's local SQLite store (history/tabs/Spaces) holds only the
user's own artifacts — no secrets.

## Secret handling

- The API key is stored as a `pydantic` `SecretStr`, kept out of `repr`/logs.
Expand Down
27 changes: 27 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,18 @@ dependencies = [
dev = [
"pytest>=9.0.0",
"pytest-asyncio>=1.4.0",
"pytest-cov>=6.0.0",
"respx>=0.23.1",
"pip-audit>=2.10.0",
"mypy>=1.9",
"ruff>=0.9.0",
]
# Interactive terminal UI (`perplexity-agent tui`). Optional so the core MCP
# server install stays lean; httpx is already a core dependency.
tui = [
"textual>=0.80,<2.0.0",
"selectolax>=0.3,<0.4",
]

[project.scripts]
perplexity-agent = "perplexity_agent.__main__:main"
Expand All @@ -45,6 +52,21 @@ packages = ["src/perplexity_agent"]
asyncio_mode = "auto"
testpaths = ["tests"]

[tool.coverage.run]
branch = true
source = ["perplexity_agent"]

[tool.coverage.report]
# CI gates on this. Entrypoint glue (__main__ HTTP wiring) is exercised manually,
# not in unit tests, so the bar is set below 100% deliberately.
fail_under = 85
show_missing = true
exclude_lines = [
"pragma: no cover",
"if __name__ == .__main__.:",
"raise NotImplementedError",
]

[tool.mypy]
python_version = "3.11"
strict = true
Expand All @@ -57,6 +79,11 @@ strict = true
module = "perplexity_agent.server"
disallow_any_generics = false

# selectolax (optional `tui` extra) ships no type stubs.
[[tool.mypy.overrides]]
module = "selectolax.*"
ignore_missing_imports = true

[tool.ruff]
line-length = 100
target-version = "py311"
Expand Down
Loading
Loading