Security
The trust boundaries — origin guards, approval-before-run, credentials and safe URLs.
Paintless runs AI agents against your code and, in production, accepts requests from stakeholders. This page collects the trust boundaries so you can reason about them in one place rather than across the guides.
Dev host: the origin guard
paintless dev opens a WebSocket that can edit your working tree, so an
unguarded socket is a cross-site WebSocket hijacking (CSWSH) target — a malicious
page you visit while the host runs could otherwise push code changes.
- Only localhost / loopback origins may connect.
- Non-browser clients (tests, tooling) send no
Originheader and are allowed. - Add extra origins explicitly with
paintless dev --allow-origin <url>.
--root bounds what the agent can reach: it reads and edits only under that
path. Scope it to the project you are working on.
Prod: approval before execution
The server never runs an agent on submission. The default is approve, then run:
submit → pending (queued) → human approves → runner branches → agent → PR
- Agent-created PRs always go through human review — the runner never merges.
- The runner works on a
paintless/<id>branch and restores the original checkout afterwards, whether it succeeds or fails.mainis never touched. - No-agent routes never execute anything: they deliver the raw request as a ticket at submit time.
Prod: authentication
| Variable | Protects |
|---|---|
PAINTLESS_PROJECT_KEY | Submissions — required as x-paintless-key. |
PAINTLESS_ADMIN_KEY | Required. List / approve / reject and dashboard actions (Authorization: Bearer). Approving a request runs the agent on your repository, so the server refuses to start without it. |
PAINTLESS_ALLOW_OPEN_TRIAGE | Set to 1 to start without an admin key, leaving triage open to anyone who can reach the port. Only for an instance bound to your own machine. |
PAINTLESS_SUBMIT_RATE_LIMIT | Submissions per client IP per minute (default 20; 0 disables). Bodies over ~5MB are rejected with 413. |
PAINTLESS_TRUST_PROXY | Set to 1 only behind a reverse proxy you control — it makes the rate limiter trust x-forwarded-for, which a direct client can forge. |
Credentials for destinations and agents — GITHUB_TOKEN, LINEAR_API_KEY,
ANTHROPIC_API_KEY — stay in the server's environment. Self-hosting is the point:
they never leave your infrastructure. Prefer GitHub App auth (auth) over a PAT
so a personal token is not the credential of record.
Safe URLs
Requests carry URLs (the page, delivery links), and rendering an attacker-chosen
URL into an href is an injection vector. Two rules:
- URL fields validate through
HttpUrlSchema, which rejectsjavascript:,data:and other schemes — plainz.url()accepts them. - Any URL rendered into an
hrefgoes throughsafeHref(), which returns'#'for anything that is nothttp(s).
If you build a dashboard, adapter, or webhook UI that renders a request's URLs, apply the same helpers.
The dev host is unauthenticated — keep it local
paintless dev has no credentials: anything that can open the WebSocket can
submit a request, and a request's text becomes the agent's prompt on your
repository. It therefore binds 127.0.0.1 by default, so reaching it means
already having access to the machine.
Testing from a phone or another machine needs an explicit opt-in, and the host says so on startup:
paintless dev --host 0.0.0.0 --allow-origin http://192.168.1.20:5173Only do that on a network you trust — on a shared or public one, anyone who can reach the port can edit and commit code in the open project.
Agent command templates and Windows batch files
command() spawns without a shell, so request text on the command line is inert
— except for .cmd/.bat agents, which Node can only run through cmd.exe.
There, an argument is re-parsed as shell syntax and a comment carrying & would
execute. The adapter refuses that combination: use stdin instead.
command('aider.cmd --yes', { promptVia: 'stdin' })Agents are bounded by the repo root
An agent adapter must never touch files outside ctx.root — the contract test in
@paintless/adapter-kit states it, but with a real agent the boundary is only as
good as what the agent's own runtime allows.
claudeCode() therefore runs the Claude Agent SDK with settingSources: ['project']: the target repository's CLAUDE.md and its checked-in
.claude/settings.json apply, while the developer's personal ~/.claude
settings do not. That matters because a personal config can carry
permissions.additionalDirectories or blanket allow rules, and inheriting them
would let a request submitted by someone else read directories that have nothing
to do with the project. Widen it deliberately if you need to:
claudeCode({ settingSources: [] }) // full isolation
claudeCode({ settingSources: ['user', 'project'] }) // opt personal settings back inThe same question applies to any agent you wrap with command(): the sandbox
flags belong in the template (codex exec --sandbox workspace-write,
claude -p {prompt} --permission-mode acceptEdits), and the process is spawned
with cwd set to the repo root and no shell.
Widget isolation
The widget mounts in a Shadow DOM with namespaced attributes
(data-paintless-widget, -picker, -panel, -highlight) so it neither
inherits nor leaks host-page styles, and its own UI is never selectable as a
target. If your app enforces a Content Security Policy, allow the widget's inline
styles or serve it from an allowed origin.
Screenshots are user data
A captured screenshot may contain whatever was on screen — customer data
included. It rides inside the ChangeRequest and is stored with it, so treat the
request store, and any destination you forward to, accordingly. If a page shows
sensitive data, consider a route that omits screenshots or a destination inside
your trust boundary.
