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.
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.
These are known, deliberate, and disclosed rather than fixed. Each names the condition under which the trade-off stops being acceptable.
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.
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_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.)
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 (codex → codex-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.
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.
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.
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.
/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.
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).
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.
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.