Tool Risk & Supply Chain

Every tool is classified by risk level at the governance layer:

Synced from Prax at f62d7985 View source ↗

← Security

Tool risk classification

Every tool is classified by risk level at the governance layer:

Risk Behavior Examples
HIGH Blocked on first call; requires user confirmation self_improve_deploy, browser_click, plugin_write, plugin_import, schedule_create
MEDIUM Executes immediately; logged to audit trail note_create, browser_navigate, arxiv_search, course_publish
LOW Executes immediately; logged note_list, todo_add, workspace_read, get_current_datetime

Risk levels are declared at the tool definition site via @risk_tool(risk=RiskLevel.HIGH) and enforced centrally in tool_registry.get_registered_tools().

Supply chain hardening

In March 2026, the TeamPCP supply chain campaign compromised Trivy, Checkmarx KICS, and LiteLLM across GitHub Actions, Docker Hub, PyPI, and npm — all by stealing CI/CD credentials and publishing poisoned versions under legitimate project names. The attack exploited mutable version tags (GitHub Action tags force-pushed to malicious commits, Docker Hub tags pointing to backdoored images) and compromised PyPI publishing tokens.

Prax applies the following mitigations against this class of attack:

Layer Mitigation Status
GitHub Actions All actions pinned to full commit SHAs, not mutable version tags. A tag like @v4 can be force-pushed; a SHA cannot. Done
Docker base images Dockerfile and sandbox/Dockerfile pin base images to @sha256: digests. Tag hijacking on Docker Hub or GHCR has no effect. Done
Python dependencies uv.lock contains SHA-256 hashes for every wheel and sdist. uv sync --frozen in Docker builds rejects any package whose hash doesn’t match. A poisoned PyPI upload (the LiteLLM vector) fails verification. Done
PyPI quarantine window exclude-newer = "7 days" in pyproject.toml prevents uv from resolving any package version published less than 7 days ago. Most PyPI compromises (LiteLLM, TeamPCP) are detected within 24–72 hours; a 7-day rolling buffer ensures poisoned releases are flagged and yanked before they can enter the dependency tree. Done
CI/CD secrets CI jobs have no publishing credentials, API keys, or deploy tokens. There is nothing to steal from a compromised workflow. Done
No pull_request_target CI uses pull_request (safe — runs on the PR’s merge commit with read-only access), not pull_request_target (the Trivy entry point — runs in the base repo context with write access and secrets). Done

Updating pinned digests: Each Dockerfile contains a comment with the docker inspect command to refresh the digest. For GitHub Actions, look up the SHA at https://api.github.com/repos/{owner}/{repo}/git/ref/tags/{tag}.

Upgrading dependencies: Since exclude-newer = "7 days" is a rolling window, simply run uv lock --upgrade at any time to pull the latest packages that have cleared the 7-day quarantine. Review the uv.lock diff for unexpected new packages or version jumps, then commit both files together.

Remaining risk: plugin code execution

Imported plugins execute in isolated subprocesses with a stripped environment (no API keys, no secrets). The OS process boundary prevents credential theft and memory-space attacks. However:

  • Side-channel attacks (timing, cache) are theoretically possible but impractical over JSON-RPC pipes.
  • Capability abuse — a malicious plugin could use caps.http_get() to exfiltrate data from its scoped directory to an external server, or to probe internal services (SSRF). The HTTP rate limit (50 requests/invocation), filesystem scoping (plugins can only read/write their own plugin_data/{plugin}/ directory), the SSRF egress guard (see below), and the HIGH-risk confirmation gate mitigate but do not eliminate this.
  • Subprocess escape — if a kernel vulnerability allows escaping process isolation, the subprocess has access to the host filesystem (though not to env vars). Docker container isolation (future enhancement) would add a second boundary.

Current controls: subprocess isolation + static analysis + capabilities gateway + per-tier policy + call budgets + HIGH risk classification + user confirmation gate + audit hooks + import blockers + runtime auto-rollback + blocking security scan + SSRF egress guard.

SSRF egress guard

Outbound HTTP from the plugin gateway (caps.http_get/http_post) and the URL reader (url_reader.fetch_markdown) is validated by prax/utils/ssrf.py: an http/https scheme allowlist, plus rejection of any host that is or resolves to a non-public address. Redirects are followed manually so every hop is re-validated. Controlled by SSRF_PROTECTION_ENABLED (default on) with an SSRF_ALLOWED_HOSTS allowlist for self-hosted dev services. (The sandbox /v1/shell is deliberately arbitrary execution behind the root-equivalent daemon bearer, and is out of scope.)

Blocked address classes — a URL is refused if its host is, or DNS-resolves to, any of these. The check uses Python’s ipaddress module (is_private/is_loopback/is_link_local/ is_reserved/is_multicast/is_unspecified), so it is not a hand-maintained list, but concretely that covers:

Class Ranges Why it’s blocked
RFC 1918 private 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 internal LAN / VPC hosts
Loopback 127.0.0.0/8, ::1 services on the host itself
Link-local 169.254.0.0/16, fe80::/10 includes the cloud metadata endpoint 169.254.169.254 (credential theft on AWS/GCP/Azure)
Unique-local IPv6 fc00::/7 the IPv6 equivalent of RFC 1918
Unspecified / reserved / multicast 0.0.0.0, ::, reserved + multicast blocks non-routable / abuse-prone

IPv4-mapped IPv6 addresses (::ffff:10.0.0.1) are unwrapped and re-checked so they can’t smuggle a private IPv4 past the guard. Hostnames that fail to resolve are allowed through (a non-resolving host is not an internal-resource risk, and the real request simply fails) — see prax/utils/ssrf.py for the rationale and the residual DNS-rebinding caveat.

Importing external plugins

plugin_import and plugin_import_activate are HIGH-risk (confirmation-gated). When the import security scan finds warnings, the plugin is flagged requires_acknowledgement and the loader refuses to activate it until the warnings are acknowledged — a hard, load-time gate, not a prompt the model can skip. plugin_import_activate (itself HIGH-gated) records that acknowledgement.