Tool Risk & Supply Chain
Every tool is classified by risk level at the governance layer:
f62d7985
View source ↗
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 ownplugin_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.