GitView is full read/write, low-friction: the app edits/commits and Claude runs tools without a per-action approval gate in normal use. That is a deliberate, powerful posture — a valid bearer token is effectively arbitrary code execution on the bridge host. The protections below are what make it acceptable for single-user, personal use.
- In scope: a lost/leaked token; a malicious or buggy client request trying to escape the repo; prompt-injection steering the agent; accidental exposure of the bridge to the public internet.
- Out of scope: a compromised bridge host OS; a malicious owner; multi-tenant isolation (this is single-user by design).
The bridge speaks plaintext HTTP and has no TLS of its own. There is no
httpsoption and no certificate setting — a tunnel is the only thing that encrypts anything. Without one, on any network you do not control, the device bearer token is readable in a header on every request, and so is everything else: file contents, diffs, chat, terminal I/O. Because that token grants repo write,git push, Claude sessions and a host PTY shell, capturing one request is equivalent to holding the device until it is revoked. Hashed-at-rest, revocable tokens (ADR-035) do nothing about this — they protect the store, not the wire. Fixing it properly is ADR-039 (self-signed TLS pinned at pairing), which is wanted and backlogged; until it lands, treat "trusted network" as a requirement rather than a recommendation.
The bridge binds 0.0.0.0 by default so a phone on your LAN/tailnet can reach it directly; every
request is still pairing-token gated. Because it is a read/write bridge, keep it behind a firewall
or VPN — the hardened setup is Tailscale: set bind: 127.0.0.1 and front it with Tailscale Serve
(auto-TLS, reachable only from your tailnet, zero public exposure). Never put a read/write bridge on a
public URL. Cloudflare Tunnel (authenticated) is a documented fallback. See SETUP.md.
- Bearer token on every request except
POST /v1/pairandGET /v1/health(the auth hook exempts exactly these — you can't obtain the first token otherwise). - Pairing code → token exchange; the code is short-lived (~10 min), printed to the bridge console, and
rotates after a successful pair. It is never exposed over the network — surfacing it requires host
console/journal access, which is the root of trust for authorizing a new device. Send the bridge
SIGHUP (
kill -HUP <pid>, orsystemctl reload gitview-bridge) to mint + print a fresh code at runtime without a restart; already-issued tokens are unaffected. - Tokens are compared in constant time (
timingSafeEqual), stored0600on the bridge, and in the Android Keystore (EncryptedSharedPreferences) on the device — never in the URL, never in Room, never in logs. - Tokens are per-device and hashed at rest (ADR-035). A token is
<deviceId>.<secret>and the bridge persists onlysha256(secret), so the state file grants nothing if it leaves the host — a backup, a synced dotfile, a badchmod, a copied disk image. Note the bound: because the terminal is already RCE as the run-user, this does not defend against someone who can read the file locally (they could just run commands) — it defends against the file being carried off. A plain SHA-256 is correct here: the secret is 32 random bytes, not a human password, so there is nothing to brute-force and a slow KDF would only tax every request. - Revocation is per-device and immediate.
DELETE /v1/devices/:iddrops the token and closes that device's live sockets with4401, killing its terminals — a WebSocket authenticates only once at connect, so without that a revoked device would keep streaming until it happened to disconnect. Revoking a device cannot be done from that same device (403), so a request can't sever itself. - Audit entries carry the acting device (
device: "dv_…"), so with several devices paired the log attributes each write andterminal.opento one of them rather than an anonymous"app". - Bare tokens issued before ADR-035 are no longer accepted (ADR-037). They carried no identity, could
not be revoked individually, and were stored in plaintext — undoing, for their own devices, the one
property the hashed store exists to provide. Every credential is now
<deviceId>.<secret>with only the hash at rest.⚠️ Those devices are de-authorised and must pair again; the bridge says so loudly at boot rather than starting with fewer devices than the file appears to hold. A device still cannot revoke itself (403) — that stops a client severing the request it is making. - Any paired device may revoke any other. There is no ownership tier — pairing is the only privilege boundary, so a compromised device could revoke its peers (a denial of service, not an escalation: that device already has terminal RCE as the run-user). Treat "paired" as fully trusted, and revoke promptly if a device is lost. Every revoke is audited with the acting device.
- Host administration runs over a unix socket, not the network (ADR-036).
/run/gitview-bridge/control.sock, mode0600, created by systemd'sRuntimeDirectoryowned by the run-user and removed on stop. Filesystem permissions are its only gate — there is no token, and that is deliberate:gitview-bridgectlships in the same package and runs as the same user, so anyone who can open the socket could already edit the token store or signal the process. It adds no trust boundary, only a better channel across the existing one. Anything that can connect can mint a pairing code, which is host access, which was already sufficient to pair a device. It also makes the bridge the single writer of the token store: the CLI no longer editstokens.json, so a write undersudocan no longer leave it unreadable by the bridge — the failure that once wiped every device on a live install. - WebSocket auth is first-frame or subprotocol, never the query string (query strings leak to
logs/proxies). Bad auth closes the socket with
4401.
- Path confinement on every read and write: inputs are rejected if absolute or containing
..; paths arerealpath-resolved and re-checked for containment, so a symlink inside the repo pointing outside it cannot escape. Creates confine the nearest existing ancestor. - Subcommand allowlist: git is only ever invoked with an allowlisted subcommand and an argv array
(no shell). Refs are validated before use;
--separates options from pathspecs. - Binary safety: blobs are read as a
Buffer(encoding: "buffer") before base64 — images/ binaries are never corrupted by a utf-8 round-trip. - Ignored/secret paths aren't served: working-tree browse (
listTree/readBlob) hides.gitand.gitview(the token file + audit log) unconditionally and filters.gitignore-matched paths (viagit check-ignore), sonode_modules, build output,config.yaml, and secrets can't be read through the API. - Explicit caps: an explicit request
bodyLimitAND a per-filewriteSizeCap(don't rely on the framework default). The body limit is raised to at leastwriteSizeCap × 1.4so base64 expansion can't reject a valid near-cap save before the write-size check runs. - No CORS: the only client is the native app (bearer token in a header, not a browser), so no CORS is registered — nothing to reflect or widen.
- Historical refs are read-only: any write with a non–working-tree
refis rejected (409). - Branch checkout + push:
checkoutmutates HEAD (branch/remote names validated — no leading-, no whitespace/glob metacharacters);pushegresses to the network using the host's git credentials (the bridge holds none of its own). Both are pairing-gated and audited like every other write.
The "open a folder as a workspace" feature lets a paired client with a valid token browse the host
filesystem, create directories, and register a folder as a workspace (and, on explicit confirmation,
git init it) — but only within the operator-declared workspaceRoots. This is a deliberate
widening of reach beyond the pre-registered repo set, so it is fenced the same way everything else is:
- Off by default.
workspaceRootsis an empty list unless the operator sets it. While empty, the feature does not exist:/v1/fs/*and/v1/workspaces/*return404, andGET /v1/healthreportsfeatures.workspaces = false. (This on/off boolean is the one browse-related field on the unauthenticated/v1/health, alongside the bridge version/protocol — reconnaissance-only, exposing no paths or data. It is a deliberate, accepted disclosure; keep the bridge fronted by Tailscale Serve / loopback regardless.) - Roots-confined, same containment. Browse (
/v1/fs/list),mkdir, and open are gated by the sameconfine()logic used for every read/write: inputs that are absolute, contain.., or resolve (viarealpath) outside the declared root — including a symlink pointing out — are rejected (path_escape). A bad/unknown root id isnot_found. Nothing outside a declared root is reachable. (The browser does not follow symlinks: a link inside a root is shown as a non-navigable file, so it can neither be traversed nor disclose the type/repo-ness of its out-of-root target.) - Revocation on narrowing/disable. An opened workspace is persisted, but it is only served (and
watched) while the feature is enabled and its path is still inside a currently-declared root.
Emptying or narrowing
workspaceRoots(then restarting) withdraws the full read/write/push surface for any folder that now falls outside — the served set never exceedsworkspaceRoots∪ the config repos. The record stays inworkspaces.json, so re-widening a root restores it. - Never auto-init. The bridge never runs
git initon its own. A non-repo folder returnsneedsInit: true; git is only initialized when the caller passesinitGit: true, which the app sends only after the user confirms the prompt. - Behind the auth gate + audited. Every
/v1/fs/*and/v1/workspaces/*route requires the Bearer token (they are not in the pairing/health exemption).mkdir,git init, and workspace registration are audited like any other write. Opened workspaces persist to.gitview/workspaces.json(mode0600, liketokens.json);config.yamlis never rewritten.
Operator guidance: declare narrow roots — e.g. ~/dev or a single projects directory — and
never the home directory or /. A root is a grant of browse + create + open (and confirm-to-init)
across everything beneath it, to anyone holding a valid token; scope it as tightly as the work allows.
The Terminal view opens a real interactive shell (a PTY) on the bridge host and streams it to the
paired client. Understand what this is: arbitrary command execution as the bridge's run-user, with none
of the agent's protections. It is not fenced by confine(), the sandbox, the permission tiers, the
deny hook, or the egress proxy — those guard the agent; the terminal is a raw shell, so it can read and
write anything the run-user can, anywhere on the host, and reach the network. Treat granting it exactly
like handing out SSH to that account.
Because of that reach it is fenced at the edges instead:
- On by default, one switch to disable.
terminal.enableddefaults totrue. Setterminal.enabled: false(then restart) to turn it off entirely:terminal.openis refused (forbidden) andGET /v1/healthreportsfeatures.terminal = false, so the app hides the view. (Likefeatures.workspaces, this on/off boolean is reconnaissance-only on the unauthenticated/v1/health— it exposes no paths or data. Keep the bridge behind Tailscale Serve / loopback regardless.) - Behind the auth gate. The shell rides the
/v1/liveWebSocket, which requires the first-frame Bearer token like every other live frame — an unpaired client cannot open one. - Starts in the workspace. A terminal opens with its cwd set to the requested repo's directory (or the
run-user's home when none is given). It is not confined there — the user can
cdanywhere the account can — the cwd is a convenience, not a boundary. - Audited. Each
terminal.openis appended to the audit log (actorapp, the repo, the shell). - No orphans. Each connection's shells are tracked and SIGKILLed (process group) when the socket closes or errors, so a closed app never leaves a shell running.
Operator guidance: the terminal is the single most powerful surface the bridge exposes — anyone with a
valid token gets a shell equal to the run-user. Run the bridge as a tightly-scoped unprivileged user
(the same advice as everywhere, but it matters most here), and if you don't want paired devices to have
host shell access, set terminal.enabled: false. The run-user's own confinement (its file permissions,
what it can sudo, its network reach) is the terminal's security boundary — there is no other.
The default chat profile is ask-first (the interactive gate: every edit & command pauses for the
user's explicit OK before it runs). Layered protections, from hard to soft:
- Sandbox (the hard boundary): the local-SDK agent runs inside
@anthropic-ai/sandbox-runtime(bubblewrap/Seatbelt, whole-process). SetfailIfUnavailable: true;denyRead~/.ssh,~/.awsand other secrets; use the default-deny egress allowlist proxy. - PreToolUse deny hook: runs before every permission step; a
denyhere applies even inbypassPermissions. Backstops destructive commands. - Deny rules: scoped
disallowedTools(e.g.Bash(rm -rf /*)) are enforced in every mode including bypass. - Permission profile / tier:
read-only/ask-first(confined-agent— interactivecanUseToolgate: every edit & command prompts) /auto-edit(acceptEdits— edits auto, commands prompt) /auto-run(auto— classifier; edits + safe commands, destructive prompts) /no-prompts(dontAsk) /unrestricted(bypassPermissions). The interactive tiers pause a tool and await the app's decision;read-only/no-prompts/unrestricteddon't prompt. (Wire ids in parens are unchanged.) - Interactive gate (
ask-first/auto-edit/auto-run): the SDKcanUseToolcallback pauses a write/command and awaits the user (ADR-025); theauto-runtier also uses the model classifier for auto-allowed calls. Resists prompt injection and scope-escalation.
The classifier, deny-rules, and egress proxy are defense-in-depth, not a hard isolation boundary —
the sandbox is. bypassPermissions is isolated-use only: explicit opt-in, requires
allowDangerouslySkipPermissions, refuses to run as root, and is re-asserted at launch.
Chat runs on a single provider — the local Claude Agent SDK (the Remote Control provider, whose
transcript was stored on Anthropic servers while connected, was removed — see DECISIONS.md,
ADR-033). With only the local SDK, the transcript stays on your machine (the shared ~/.claude session
store) and the agent runs on the host under the sandbox + permission layers above; there is no longer a
device-sync path that puts the conversation on Anthropic servers. One provider means one trust model.
Every write (REST or MCP) and every agent tool call is appended to an audit log
(.gitview/audit.log) as one JSON line: timestamp, actor (app|claude), repo, action, target, ok.
- Run the bridge as an unprivileged user (never root).
- Register only repos you'd hand an agent.
- Optional dials (documented): approve-each-write, isolated per-session worktree (
--spawn worktree), stricter sandbox egress,confined-agentinstead of full-tool.