No cloud. Runs locally. Python · stdlib only Whitelist & graylist security

Give your website
local machine power.

web2local is a tiny daemon that bridges websites to your local environment — safely. Your site can run commands, read files, and talk to hardware that a browser alone can never reach.

How it works

Browsers are sandboxed on purpose — a web page cannot touch your filesystem, run a process, or connect to a serial port without explicit permission. But some tools genuinely need local power: build scripts, hardware dashboards, developer utilities, media processors.

web2local runs a small HTTP server bound to 127.0.0.1 only (never reachable from the internet). Your website calls it with fetch(). The daemon checks who is asking, then acts accordingly.

Your websitemysite.com
──→
web2local daemon127.0.0.1:7878
──→
Local machineshell · files · hardware

The daemon answers only to requests whose Host header is 127.0.0.1 or localhost. This one check kills the most dangerous class of localhost attacks — DNS rebinding — where a malicious site manipulates DNS to make the browser treat evil.com as a local address.

Why Host header, not just CORS? CORS controls which pages can read the response; the Host header check prevents the request from being accepted at all if it arrived via a DNS-rebound domain. They defend against different attacks.

Security model

Every request carries an Origin header — the site that made it. The daemon classifies that origin into one of three tiers:

Whitelist
Origins you fully trust. Commands execute immediately with no prompt. Use for your own sites.
⚠️
Graylist
Origins you want to evaluate. A native dialog shows the exact command. You read it, then allow or deny. Auto-denies after 120 s.
🚫
Blocked
Any origin not on either list. Returns HTTP 403 immediately. No command is shown or executed.

What the graylist dialog shows you

When a graylist site sends a command, a window appears with:

The command is displayed as an argument list — no shell expansion happens. What you see is exactly what will run. Scroll right if it is long; the dialog will not dismiss until you click a button.

What it does not protect against

The graylist is only as good as your reading. Keep these risks in mind:

  • Rubber-stamping — users who click Allow without reading are the weakest link.
  • State accumulation — a site approved for 10 harmless commands may use their combined effect maliciously.
  • Non-browser callersOrigin is only enforced by browsers. A script using curl can forge it. The daemon should only be used from browser-based websites.
  • Broad whitelist scope — whitelisting https://example.com trusts every page on that domain.

Quick start

Get the daemon

A single Python file, no dependencies beyond the standard library. Requires Python 3.7+.

Run the daemon

python3 daemon.py

The daemon binds to 127.0.0.1:7878 and stays running in your terminal. On the first launch it creates a config file at ~/.config/web2local/config.json.

Add your site to a list

You can do this from the demo below, from the config file directly, or via the API:

# Whitelist (trust fully)
curl -s -X POST http://127.0.0.1:7878/config/whitelist \
  -H 'Content-Type: application/json' \
  -d '{"origin":"https://mysite.com"}'

# Graylist (ask before each command)
curl -s -X POST http://127.0.0.1:7878/config/graylist \
  -H 'Content-Type: application/json' \
  -d '{"origin":"https://other-site.com"}'

Include the client library

<script src="client.js"></script>

Copy client.js from this repo into your project.

Call the API from your page

const w2l = new Web2Local();   // default port 7878

if (await w2l.isRunning()) {
  const r = await w2l.run("ls", ["-la", "/tmp"]);
  console.log(r.stdout);
  console.log("exit:", r.exit_code);
} else {
  console.warn("web2local daemon not running");
}

API reference

All endpoints are on http://127.0.0.1:7878.

Method & Path Auth Description
GET /status any origin Returns {"status":"running","version":"1.0.0"}. Use this to detect the daemon.
POST /run whitelist / graylist Execute a command. Body: {"command":"ls","args":["-la"]}. Returns {"stdout":"…","stderr":"…","exit_code":0}.
GET /config any (local only) Return current whitelist and graylist.
POST /config/whitelist any (local only) Add origin to whitelist. Body: {"origin":"https://…"}.
POST /config/graylist any (local only) Add origin to graylist.
POST /config/remove any (local only) Remove origin from all lists.
POST /config/reload any (local only) Reload config from disk without restarting.
GET /log any (local only) Return last 200 audit log lines.
POST /spawn whitelist / graylist Start a long-running process. Returns immediately with {"pid":…, "started_at":"…", "log_path":"…"}. Output is captured to the log file.
POST /stop whitelist / graylist Stop a spawned process by PID. Body: {"pid":1234}. Sends SIGTERM, then SIGKILL after 3 s.
GET /ps any (local only) List spawned processes still alive (verified via /proc start-time, so PID reuse won't fool it).
GET /logs?pid=N any (local only) Return the last 200 lines of a spawned process's captured output.
POST /deploy whitelist / graylist Verify SHA-256 → write script to ~/.config/web2local/agents/ → spawn it. Always shows the approval dialog, even for whitelist origins — the dialog shows filename, full SHA-256, destination path, and the exact command. Body: {"source":"…","sha256":"…","filename":"…","command":"python3","args":["serve",…]}. Returns {"pid":…,"path":"…","already_running"?:true}.
GET /env whitelist / graylist Report which python interpreter a bare python3 resolves to, and why. Returns {"interpreter":…,"source":"config|active-env|path","config_python":"…"}.
POST /env/select whitelist / graylist Select an existing python environment or interpreter under $HOME. Body: {"path":"~/.venv"}. Always shows the approval dialog. On approval it points config["python"] at the resolved interpreter.
POST /setup-env whitelist / graylist Create a python environment the user has none of. Body: {"type":"venv|pixi|conda","name":"myapp","packages":["flask"]} (or a path inside $HOME instead of name). Always shows the approval dialog. Runs as an async job; on success it points config["python"] at the new interpreter. Returns {"status":"running","job_id":"…","interpreter":"…"} (or {"status":"ready"} if it already exists).
GET /setup-env/status?job=ID whitelist / graylist Poll a setup job. Returns {"status":"running|done|failed","interpreter":…,"tail":"…"} with the live setup log.
POST /env/select whitelist / graylist Select an existing environment. Body: {"path":"~/.venv"} — an env prefix (~/.venv, ~/miniconda3/envs/foo, ~/p/.pixi/envs/default) or a python executable (~/.venv/bin/python). Must exist and live inside $HOME. Shows the approval dialog (unless already selected), then sets config["python"]. Returns {"status":"selected","interpreter":"…","already_selected":bool}.

Error responses

403  {"error": "origin not in whitelist or graylist"}
403  {"error": "command denied by user"}
403  {"error": "host must be 127.0.0.1 or localhost"}
400  {"error": "command must be a non-empty string"}
408  {"error": "command timed out (30 s limit)"}

Command rules

Live demo

This page connects to your local daemon in real time.

Checking for daemon at 127.0.0.1:7878…

Run a command

Output will appear here.

Set up a Python environment

No python env yet? Pick a type and the daemon will create one (with your approval) and use it for future scripts.

The setup log will appear here.

Manage lists

✅ Whitelist
Loading…
⚠️ Graylist
Loading…

Audit log (last 20 entries)

Loading…

Process manager

POST /run waits for the command to finish — fine for quick jobs, useless for daemons. POST /spawn starts a process and returns immediately with its PID. The daemon tracks every spawned PID, captures its output to a log file, and lets you stop it from this page.

Spawn a process

Tip: for a shell pipeline use sh with -c "…". Output is captured to ~/.config/web2local/processes/.

Running processes

Loading…
Process output (last 200 lines)

Deployed scripts

Scripts delivered via POST /deploy are stored in ~/.config/web2local/agents/ under a filename prefixed with the first 8 chars of their SHA-256. Each entry records which site deployed it. Delete a script here to revoke it permanently.

Script registry

Loading…