Skip to content

Security: wrotek/painapple-code

Security

SECURITY.md

Security Policy

Deployment security model — read this first

painapple-code is a single-user server that runs Claude Code (and other CLI agents) on your behalf. Anyone who can authenticate to the server gains the full shell and filesystem authority of the server process — everything the OS user (or container) it runs as can read, write, and execute (/api/exec, the terminal, and any agent tool). Treat the password like an SSH key.

Because of that:

  • The server binds to loopback (127.0.0.1) by default. Keep it there.
  • For remote access, put it behind a VPN, SSH tunnel, or an authenticating reverse proxy — do not expose it directly to the internet or a shared LAN.
  • It is not hardened for untrusted multi-user hosting. Do not run one instance shared between people who should not have each other's shell access. Run separate instances as separate OS users instead.
  • Prefer a dedicated unprivileged user, minimal bind mounts, and no Docker socket when containerized.

Credential model

There is one secret — the password generated on first start. Every other credential is HMAC-derived from it, domain-separated, and revocable on its own. No request path accepts the password; it is used by the login form and nothing else.

Credential Presented on Derived from Revoke by If it leaks
painapple_auth cookie HTTP + WebSocket password + cookie_epoch bump cookie_epoch ("Log out everywhere") full access until revoked; cannot open the login form
api_token Authorization: Bearer, ?tkn= password + bearer_epoch bump bearer_epoch ("Revoke API token") full access until revoked; cannot open the login form
?dl= token one exact URL password, separate key expires (5 min) that one file, for 5 minutes
password login form only delete the config and restart everything, including the ability to mint the above

Both epochs live in the same 0600 config file and take effect immediately via POST /api/auth/revoke (Settings → System → Account & Credentials), or on next start if edited by hand. Revoking either class leaves the other working — that separation is the point: losing a device should not mean re-provisioning every script.

The server logs the first time each (auth path, client IP, user agent) combination authenticates, so an unfamiliar client is visible in the log. No credential material is ever logged.

Known limitations and accepted risks

These are known, deliberate, and disclosed rather than fixed. Each names the condition under which the trade-off stops being acceptable.

Credentials are stateless — no per-device revocation

There is no server-side session store. Credentials are derived, not minted, so the server keeps no record of who is logged in, cannot expire a session, and cannot revoke one device while leaving another signed in. POST /api/logout clears the local cookie only; the value stays valid until an epoch is bumped. The two epochs are class-wide levers, not per-device ones.

Accepted because a single-user server grants total authority the moment anyone authenticates (see the model above): there is no post-auth blast radius for session management to contain, so what it would buy is convenience — keeping your other devices signed in while cutting off a lost one — at the cost of persistent auth state. The epochs cover the case that actually stings: revoking a lost device's access without breaking automation.

This stops being acceptable if the server ever serves more than one person, or is reachable from an untrusted network. Both make "one credential = total authority" a real finding rather than a documented property.

Auto-journal records your work by default

Every turn is recorded to a local shadow-git repository and a local DuckDB — prompts, file diffs, and AI-generated summaries. This is on by default, per project, and can be turned off globally or per project (Settings → Auto-journal, or the status-bar pill).

Accepted by design: it is a core feature of the tool, matching how editor local-history features behave. Everything stays on your machine — except the optional summaries, which are generated by the same agent provider the session already uses. Turn off Rich Commits to keep the journal local-only.

Subprocess tracking is incomplete

subprocess_registry records the background summary forks and is read by the terminal API, but agent sessions, /api/exec, and the render helpers spawn processes without registering them. A process orphaned by a crash may therefore not be visible or reapable through the registry.

Impact is a resource leak on a machine the user already controls. Revisit if the server ever runs processes on behalf of someone other than its own OS user. (This entry is about tracking only — for what a child process exposes while it runs, see the next item.)

Prompts never reach ps (the codex exec provider was removed)

Historical: the codex exec provider (codex exec --json, off by default) accepted its prompt only as a command-line argument — no stdin protocol — so on a session bound to it, each turn's prompt and the background summary fork's prompt were visible in ps and in /proc/<pid>/cmdline (world-readable on Linux) for as long as the turn ran. The server could not route around it; the constraint was the CLI's argument interface.

Resolved by removing the provider rather than accepting the risk: every registered provider passes prompts over stdio (claude-sdk via the SDK driver's stdin protocol, codex-app-server via JSON-RPC over stdio). Sessions persisted with the removed provider names resolve to their same-family successor (codexcodex-app-server, which reads the same $CODEX_HOME thread store; claude → the wire-identical claude-sdk).

The Capabilities.prompt_in_argv seam survives: a drop-in third-party provider that takes prompts in argv must declare it and gets the Settings → Providers warning automatically (test_no_registered_provider_puts_prompts_in_argv keeps the shipped set clean). Note that commands you run yourself — the terminal, !bang commands, /api/exec — appear in ps like any shell command; only prompts are guaranteed off the command line.

Windows support is smoke-tested only

CI on Windows installs the package, imports it, runs the CLI gate, and boots the server to check /health. The test suite — including every auth, CSRF, and path-traversal test — does not run on Windows, and there is no ARM64 leg. Platform-specific regressions in those areas would not be caught by CI.

Risk-averse Windows users should run under WSL2, where the Linux test coverage applies.

Session data is retained until you remove it

Deleting a session is not offered in the UI, and closing a tab only removes the tab — the session's on-disk store (messages.jsonl, raw.jsonl, tool outputs, uploads), its rows in the DuckDB journal, and its shadow-git commits all remain. The one endpoint that erases anything, DELETE /api/session/{id}, removes only the on-disk store and is called by no UI. So nothing in the app claims to erase your history, and nothing does so behind your back — but equally, there is no one-click "forget this session."

This is deliberate: the journal is on by default (see Auto-journal above), and its value is a durable, searchable record. Everything stays local, under your OS user, on your disk.

This stops being acceptable if the box becomes multi-user, or you add a "delete session" UI — at which point that UI must stop the live agent first and state plainly what it removes versus retains (the on-disk store is easy; DuckDB rows and shadow-git commits are separate operations with a wider blast radius). To clear history today, remove the session directory and, if you want it gone from search too, its journal rows by hand.

Subprocesses inherit the server's environment

The agent CLIs (claude, codex), !bang//api/exec shell commands, git, and the renderers are spawned with the server process's full environment. A secret exported into the shell that launched the server is therefore visible to those children — and the agent can print its own environment (env) into a turn, which then reaches the model provider and the local session store.

Accepted because those children are things you already trust with your machine: the agents you are here to run, and commands you typed yourself. The environment they inherit is the one your own shell already had.

This stops being acceptable if you put a credential in the server's environment that the agents themselves must not see (a deploy key for a different system, say). There is no per-child environment allowlist yet; until there is, keep such secrets out of the launching shell's environment and pass them only to the specific tool that needs them.

The browser proxy caps responses after decompression

/api/browser/proxy enforces its 25 MiB size limit on the decompressed body, after httpx has inflated the response into memory. A response advertising a small size but decompressing to gigabytes (a "zip bomb") is refused, but only once it has been inflated — so peak memory is bounded by the upstream's compression ratio, not by the cap.

Accepted because you are the only one who drives the proxy: it fetches the URL you put in the browser widget, on a single-user bridge. The connection is already SSRF-guarded (DNS-pinned, private hosts refused, redirects re-validated), so it cannot be aimed at an internal target.

This stops being acceptable if anyone but you can reach the proxy — a shared bridge, or a chat automation that fetches attacker-chosen URLs. The fix is to stream the response into a bounded sink and abort past the cap mid-transfer, rather than buffering then measuring.

Supported versions

Security fixes land on the latest released version only; there are no long-term support branches.

Version Supported
Latest release (:latest / newest PyPI)
Older releases ❌ — upgrade to the latest

PyPI and Docker Hub are published by separate pipelines, so a fix can briefly be available on one channel before the other. A fix counts as released only once it is on both. When it matters, verify the version you are actually running: painapple --version on a pip install, or the org.opencontainers.image.version label on the image (printed by painapple pull, or via docker inspect).

Reporting a vulnerability

Please report privately — do not open a public issue for security bugs.

  • Preferred: GitHub private vulnerability reporting — the Report a vulnerability button under the repository's Security tab (https://github.com/wrotek/painapple-code/security/advisories/new).
  • This opens a private advisory thread visible only to the maintainer and you.

Please include: affected version/commit, deployment mode (host / Docker / Dev Container Feature), reproduction steps or a proof of concept, and the impact you observed.

Scope

In scope: the server, its HTTP/WebSocket API, the web client, the packaging/release artifacts (PyPI wheel/sdist, Docker image, Dev Container Feature).

Out of scope: vulnerabilities in Claude Code itself or other third-party CLI agents (report those to their vendors); issues that require an attacker to already hold the server's password or OS-user shell (that is the documented authority model above, not a privilege boundary); and native/Tauri desktop builds, which are development-only and not a supported release surface.

There aren't any published security advisories