Paintless
Latest
/Reference

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 Origin header 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. main is never touched.
  • No-agent routes never execute anything: they deliver the raw request as a ticket at submit time.

Prod: authentication

VariableProtects
PAINTLESS_PROJECT_KEYSubmissions — required as x-paintless-key.
PAINTLESS_ADMIN_KEYRequired. 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_TRIAGESet 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_LIMITSubmissions per client IP per minute (default 20; 0 disables). Bodies over ~5MB are rejected with 413.
PAINTLESS_TRUST_PROXYSet 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 rejects javascript:, data: and other schemes — plain z.url() accepts them.
  • Any URL rendered into an href goes through safeHref(), which returns '#' for anything that is not http(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:5173

Only 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 in

The 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.

Edit this page

Last updated: