sidebranch 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,83 @@
1
+ # Changelog
2
+
3
+ Notable changes to sidebranch. This project follows
4
+ [semantic versioning](https://semver.org/spec/v2.0.0.html).
5
+
6
+ ## [Unreleased]
7
+
8
+ ### Added
9
+
10
+ - **Browser extension** (`extension/`) — a second delivery channel for the
11
+ widget: install once and every project you run `sidebranch start` in gets
12
+ the pill, with no `<script>` tag in your app. MV3, loadable unpacked, not
13
+ part of the npm package. Permissions are `storage` (a port override) plus
14
+ `http://localhost/*` and `http://127.0.0.1/*`. It has no service worker, so
15
+ all daemon traffic goes through the content script and every invariant in
16
+ `SECURITY.md` still holds. Unlike the tag, it can't be blocked by a page's
17
+ CSP — and can't reach a page served from `http://[::1]`.
18
+ - **Extension toolbar popup** — undoes "Hide for this session", which is per
19
+ tab and previously needed a new tab to clear.
20
+ - **View ports** — each pane gets a local pass-through proxy that deletes
21
+ `X-Frame-Options` and CSP `frame-ancestors`, so the compare view works with
22
+ apps that refuse framing. Removals are named in a `Sidebranch-Removed-Headers`
23
+ response header, bodies stream through byte-identical, and the proxy carries
24
+ the daemon's loopback/Host gate (upgrades included). Set `"frameProxy": false`
25
+ to disable. Direct pane ports are untouched.
26
+ - **Frame-refusal detection** — the daemon probes for framing headers once per
27
+ server start, and the shell names the header and the fix instead of rendering
28
+ a blank rectangle.
29
+ - **Info flyout** in the widget toolbar (ⓘ): start and stop commands with this
30
+ daemon's port substituted, click-to-copy, and which delivery channel you're
31
+ looking at.
32
+ - `sidebranch stop` — shuts down the daemon and its pane dev servers from any
33
+ terminal. Backed by a per-repo `daemon.json` record, which also lets `start`
34
+ refuse to launch over an existing daemon, lets `clean` refuse to delete
35
+ worktrees out from under a running dev server, and lets `doctor` report
36
+ daemon status. Stale records are detected and cleared; no command signals a
37
+ process it hasn't confirmed is a sidebranch daemon.
38
+ - `GET /handshake` — unauthenticated credential bootstrap returning
39
+ `{token, port, widget, version, apiVersion}` for the extension, behind the
40
+ same loopback/Host/Origin gate as every other route. Discloses nothing that
41
+ `GET /widget.js` did not.
42
+ - `fontSource` option in `widget-core.js`, so a caller can pass the font as
43
+ binary instead of a URL. The extension uses it because a `FontFace` built
44
+ from a URL is subject to the page's `font-src` CSP.
45
+ - `src/assets/geist-pixel.LICENSE.txt` — the bundled font is SIL OFL 1.1, not
46
+ MIT, and previously shipped without its license text.
47
+ - A landing page under `site/`, deployed to GitHub Pages. Not part of the npm
48
+ package and adds no dependencies.
49
+ - CI: the suite runs on Node 20/22/24 across Linux and macOS and asserts the
50
+ dependency count is still zero.
51
+ - `prepublishOnly` runs the suite, so a red build can't reach the registry.
52
+ - Releases publish from a tagged workflow with npm provenance.
53
+
54
+ ### Changed
55
+
56
+ - The bundled UI font is now `geist-pixel.woff2` (24 KB) instead of
57
+ `geist-pixel.ttf` (3.7 MB). All 481 glyphs are retained and nothing renders
58
+ differently; the package drops from 3.8 MB unpacked to ~150 KB.
59
+ - `src/assets/widget.js` split into `widget-core.js` (the UI) and `boot-tag.js`
60
+ (the tag channel's credential delivery). No user-visible change —
61
+ `GET /widget.js` serves the same assembled script. This is what lets the
62
+ extension ship the identical widget without shipping a secret.
63
+ - `SECURITY.md` names a concrete reporting route.
64
+
65
+ ### Fixed
66
+
67
+ - A worktree deleted from disk but still registered in git no longer wedges its
68
+ pane with a misleading `spawn git ENOENT`. `ensurePane` prunes the stale
69
+ registration and recreates the worktree; `doctor` reports the state. Also
70
+ fixes worktree path matching for deleted directories on macOS tmp paths
71
+ (the `/var` → `/private/var` symlink).
72
+ - Piping CLI output (`sidebranch stop | head -1`) no longer crashes with EPIPE
73
+ when the pipe closes early.
74
+ - The extension no longer injects the widget into the daemon's own `/shell`
75
+ page, which matched the content script like any other localhost dev server.
76
+ - CI runs on Node 20 again. `node --test "test/*.test.js"` relies on glob
77
+ expansion Node only gained in v21, so both Node 20 jobs had been failing
78
+ without running a test. The glob is now unquoted and expanded by the shell.
79
+
80
+ ## [0.1.0]
81
+
82
+ Initial release: loopback-only PR review sidecar with worktree panes, an
83
+ in-page widget, and the side-by-side / blend / layer compare shell.
package/LICENSE ADDED
@@ -0,0 +1,28 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 sidebranch contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ ---
24
+
25
+ The bundled font `src/assets/geist-pixel.woff2` is NOT covered by the MIT
26
+ license above. It is a modified build of Geist Pixel, licensed under the SIL
27
+ Open Font License, Version 1.1. See `src/assets/geist-pixel.LICENSE.txt` for
28
+ the full text, the copyright notices, and a record of the modifications.
package/README.md ADDED
@@ -0,0 +1,368 @@
1
+ # sidebranch
2
+
3
+ Review pull requests from inside your running app. sidebranch is a local
4
+ sidecar daemon plus a small in-page widget: pick a branch from a floating
5
+ control and it appears in a dedicated **review worktree** on its own dev
6
+ server. Your working tree, uncommitted changes and all, is never touched.
7
+ Open a PR branch next to `main` in a synced side-by-side view, or stack them
8
+ with a blend/layer diff to spot visual regressions.
9
+
10
+ - **Framework-agnostic.** Next on 3000, Vite on 5173, Django on 8080 — if it
11
+ starts from a command and answers HTTP on a port, it works.
12
+ - **Nothing leaves your machine.** The daemon binds loopback only, every
13
+ request is authenticated, and the widget is inert outside a localhost page.
14
+ Zero runtime dependencies — Node builtins only.
15
+ - **Unobtrusive.** One small pill in the corner, rendered only when the daemon
16
+ is running. `×` hides it for the session, Esc closes the panel, and
17
+ `"widget": false` turns it off entirely.
18
+
19
+ ## How it works
20
+
21
+ ```
22
+ your app (:5173, your working tree — never touched)
23
+ └── <script src="http://localhost:49400/widget.js"> ← the widget
24
+ sidebranch daemon (127.0.0.1:49400)
25
+ ├── pane A → worktree ~/.sidebranch/…/panes/a → dev server :4410
26
+ ├── pane B → worktree ~/.sidebranch/…/panes/b → dev server :4411
27
+ └── /shell → compare view (side-by-side / blend / layer)
28
+ ```
29
+
30
+ Panes are persistent review environments built on `git worktree`. Switching a
31
+ pane to another branch is a checkout inside that pane. Dependencies are
32
+ re-installed only when a lockfile actually changed (by content hash), and the
33
+ dev server restarts only in that case; otherwise its file watcher picks up the
34
+ checkout like any other change. Panes are never edited by hand, so they are
35
+ always clean — nothing to stash, nothing to lose.
36
+
37
+ ## Quick start
38
+
39
+ ```sh
40
+ cd your-repo
41
+ npx sidebranch init # writes .sidebranch.json — edit for your stack
42
+ npx sidebranch start # daemon on http://127.0.0.1:49400
43
+ ```
44
+
45
+ Add the widget to your app, **dev builds only**:
46
+
47
+ ```html
48
+ <script src="http://localhost:49400/widget.js" defer></script>
49
+ ```
50
+
51
+ Gate it however your stack gates dev-only code, e.g.:
52
+
53
+ ```jsx
54
+ // Next.js (app/layout.tsx)
55
+ {process.env.NODE_ENV === "development" && (
56
+ <script src="http://localhost:49400/widget.js" defer />
57
+ )}
58
+ ```
59
+
60
+ ```html
61
+ <!-- Vite (index.html) -->
62
+ <script>
63
+ if (import.meta.env.DEV) {
64
+ const s = document.createElement("script");
65
+ s.src = "http://localhost:49400/widget.js"; s.defer = true;
66
+ document.head.appendChild(s);
67
+ }
68
+ </script>
69
+ ```
70
+
71
+ If the tag does ship to production, it's harmless: the widget refuses to run
72
+ on non-localhost pages, and a visitor's own daemon — if they run one — rejects
73
+ requests from non-loopback origins. See [SECURITY.md](SECURITY.md).
74
+
75
+ Then: click the pill → pick a branch → it builds and serves in pane A.
76
+ **Open A** views it; **Compare A/B** opens the split view.
77
+
78
+ ### Or skip the tag: the browser extension
79
+
80
+ The widget also ships as a Chrome/Edge extension, which puts the same pill on
81
+ every project you run `sidebranch start` in without touching any app's HTML.
82
+ It lives in [`extension/`](extension/) and loads unpacked:
83
+
84
+ ```
85
+ chrome://extensions → Developer mode → Load unpacked → select extension/
86
+ ```
87
+
88
+ It requests two origins — `http://localhost/*` and `http://127.0.0.1/*` — and
89
+ one permission, `storage`, for a port override on its options page. It talks
90
+ to nothing but your own daemon, and renders nothing if none is running.
91
+
92
+ Two differences from the tag:
93
+
94
+ - **A strict `Content-Security-Policy` can block the script tag.** It can't
95
+ block the extension, whose content script isn't subject to the page's CSP.
96
+ (The widget's font is bundled and loaded as binary so a strict `font-src`
97
+ can't downgrade it either.)
98
+ - **A page served from `http://[::1]:5173` gets no widget from the
99
+ extension** — Chrome match patterns can't express an IPv6 literal. Use the
100
+ tag, or `http://localhost`, which reaches the same server.
101
+
102
+ Everything else is identical, including the compare view. See
103
+ [`extension/README.md`](extension/README.md) for details.
104
+
105
+ Used **Hide for this session** and want the widget back? The flag is per tab,
106
+ so a reload won't clear it — click the sidebranch toolbar icon and choose
107
+ **Show the widget here**. (With the script tag, open a new tab.)
108
+
109
+ ## Configuration (`.sidebranch.json`)
110
+
111
+ ```jsonc
112
+ {
113
+ "dev": "npm run dev", // start command, run inside each pane
114
+ // {port} is substituted; PORT env is always set
115
+ "install": "pnpm install", // run only when lockfiles change; "" disables
116
+ "ready": { "path": "/", "statuses": null }, // readiness probe; null = any HTTP answer
117
+ "panes": 2, // review environments kept warm (1–4)
118
+ "basePort": 4410, // first port tried for pane servers
119
+ "copy": [".env", ".env.local"], // untracked files copied into new worktrees
120
+ "env": {}, // extra env vars for the pane dev command
121
+ // overrides inherited values; PORT etc. are reserved
122
+ "lockfiles": ["package-lock.json"], // override the manifest list if needed
123
+ "widget": true, // false → /widget.js serves a no-op
124
+ "frameProxy": true // false → compare view frames panes directly
125
+ }
126
+ ```
127
+
128
+ Recipes:
129
+
130
+ | Stack | `dev` | `install` |
131
+ | --- | --- | --- |
132
+ | Next.js | `npm run dev` | `npm install` |
133
+ | Vite | `npx vite --port {port}` | `pnpm install` |
134
+ | Django | `python3 manage.py runserver 127.0.0.1:{port}` | `pip install -r requirements.txt` |
135
+ | Rails | `bin/rails server -p {port}` | `bundle install` |
136
+ | Static | `python3 -m http.server {port} --bind 127.0.0.1` | *(empty)* |
137
+
138
+ The daemon never parses server output. A pane is "ready" when its port answers
139
+ the probe, which also catches servers that silently pick a different port than
140
+ asked.
141
+
142
+ ## Complex apps: multiple processes, databases, and credentials
143
+
144
+ sidebranch runs one process per pane and injects one port. Real apps often
145
+ need more — a database or emulator, an auth proxy, a background worker,
146
+ credentials for cloud services. That takes one architectural idea plus two
147
+ config levers.
148
+
149
+ ### The one rule: per-pane process, shared singletons
150
+
151
+ Your `dev` command is the only thing sidebranch runs per pane, and it can only
152
+ vary one port. So anything that is a **singleton** — a database, an emulator
153
+ on a fixed port, an auth proxy — must run once, outside sidebranch, shared by
154
+ every pane and your own dev session. Keep the per-pane command down to the
155
+ single server that renders the branch.
156
+
157
+ If your normal dev command is a bundle like `concurrently "db" "web" "proxy"`,
158
+ don't point sidebranch at it — two panes would each try to start the db and
159
+ proxy on the same fixed ports and collide. Instead:
160
+
161
+ - point `dev` at just the web server (`"dev": "npm run dev:web"`),
162
+ - run the db/emulator/proxy once yourself, and
163
+ - tell each pane how to reach them with `env`.
164
+
165
+ ### Two levers for everything a pane needs
166
+
167
+ | Need | Lever | How |
168
+ | --- | --- | --- |
169
+ | An untracked **file** the app reads (`.env`, a service-account key, a cert) | `copy` | Copied from your main tree into each pane once, at creation. |
170
+ | An **environment variable** (point at a shared service, flip a mode, unset a stale path) | `env` | Injected into the pane's dev command; overrides inherited values. |
171
+ | A credential in a **machine-wide, out-of-tree location** (`~/.config/gcloud`, `~/.aws`, `~/.netrc`) | *(nothing)* | Panes run as you, so home-dir credentials resolve for free. |
172
+
173
+ The third row is the one people miss: only in-repo, relative-path credentials
174
+ break in a pane, because they resolve against the pane's own directory.
175
+ Home-directory credentials need no `copy` and no `env`.
176
+
177
+ ### `env`: precedence and the empty-string unset
178
+
179
+ `env` values layer on top of the inherited environment, so they override what
180
+ your shell exported and what a copied `.env` would set. The exception is the
181
+ vars sidebranch owns — `PORT`, `BROWSER`, `FORCE_COLOR`, `SIDEBRANCH` —
182
+ because overriding `PORT` would break port injection. Those are dropped from
183
+ `env` if you set them.
184
+
185
+ Setting a var to the **empty string** unsets an inherited value:
186
+
187
+ ```jsonc
188
+ "env": { "GOOGLE_APPLICATION_CREDENTIALS": "" }
189
+ ```
190
+
191
+ That's the fix when a copied `.env` points a credential at an in-repo relative
192
+ path: blank it in the pane and let the SDK fall back to your machine's default
193
+ credentials.
194
+
195
+ ### Worked example: web app + database emulator + cloud credentials
196
+
197
+ An app whose `npm run dev` starts an emulator, the web server, and a cloud
198
+ auth proxy, and whose `.env` sets a relative-path service-account key:
199
+
200
+ 1. Keep the emulator running once (your normal dev session already starts it).
201
+ 2. Point the pane at just the web server, tell it to use the shared emulator,
202
+ and blank the in-repo key so cloud SDKs fall back to machine ADC:
203
+
204
+ ```jsonc
205
+ {
206
+ "dev": "npm run dev:web", // web server only — NOT the bundle
207
+ "install": "npm install",
208
+ "copy": [".env", ".env.local"], // carry app config/secrets, but not the key file
209
+ "env": {
210
+ "FIRESTORE_EMULATOR_HOST": "127.0.0.1:8080", // reach the shared emulator
211
+ "GOOGLE_APPLICATION_CREDENTIALS": "" // fall back to ~/.config/gcloud ADC
212
+ }
213
+ }
214
+ ```
215
+
216
+ No credential file is copied into any pane, every pane points at the one
217
+ shared emulator, and other cloud calls authenticate via machine-wide ADC.
218
+ (Prerequisite: you've run your cloud CLI's "application default login" — the
219
+ same setup your app already needs locally.)
220
+
221
+ ### Checklist for porting sidebranch into an app
222
+
223
+ 1. **What does your dev command start?** If it's more than one server, split
224
+ off the singletons.
225
+ 2. **Run the singletons once** (or confirm your normal dev session does).
226
+ 3. **Point `dev` at the single per-pane server**; make sure it honors `PORT`
227
+ (or use `{port}`).
228
+ 4. **`copy` the untracked files** the app needs — except secrets you can reach
229
+ another way.
230
+ 5. **For each service the app talks to**, choose: shared instance (`env` points
231
+ at it), machine-wide credential (free), or copied file (`copy`).
232
+ 6. **Blank any in-repo relative credential paths** with `"env": { "VAR": "" }`.
233
+ 7. **`npx sidebranch doctor`**, then `start`, then open a pane.
234
+
235
+ ### Prompt your coding agent to do it
236
+
237
+ Paste this into Claude Code / Cursor / your agent of choice, from your repo
238
+ root — it produces a `.sidebranch.json` tailored to your stack:
239
+
240
+ ```text
241
+ Integrate sidebranch (a local PR-review sidecar) into this app. Steps:
242
+ 1. Read package.json (or the equivalent) and identify the dev command(s).
243
+ List every process and fixed port the normal dev workflow starts —
244
+ web server, database/emulator, proxies, workers.
245
+ 2. Identify which of those are singletons (fixed ports, shared state) vs the
246
+ single web server that actually renders the app.
247
+ 3. Find every credential/secret the app loads locally and where it comes
248
+ from: in-repo relative-path files, machine-wide locations (~/.config,
249
+ ~/.aws), or environment variables.
250
+ 4. Write a .sidebranch.json where:
251
+ - "dev" runs ONLY the web server, honoring the PORT env var (or {port}),
252
+ - "copy" lists the untracked files the app needs at runtime,
253
+ - "env" points the pane at the shared singletons and blanks ("") any
254
+ in-repo relative credential paths so SDKs fall back to machine defaults.
255
+ 5. Add the widget to the app's HTML/layout, gated to development only:
256
+ <script src="http://localhost:49400/widget.js" defer></script>
257
+ 6. Tell me exactly which singleton processes I must run once myself before
258
+ using panes, and any one-time credential setup (e.g. cloud ADC login).
259
+ Do not modify my existing dev scripts or app code beyond adding the widget tag.
260
+ ```
261
+
262
+ ## Compare view
263
+
264
+ `http://localhost:49400/shell` (or the **Compare A/B** button):
265
+
266
+ - **Side by side** — both panes live, one width control driving both viewports
267
+ in lockstep (slider + 375/768/1280 presets).
268
+ - **Blend diff** — panes stacked with `mix-blend-mode: difference`; identical
269
+ pixels go black, any change glows.
270
+ - **Layer diff** — stacked with an opacity slider on the top pane.
271
+ - **⇄ Swap** exchanges the panes' branches.
272
+
273
+ Scroll and interaction sync between frames is out of scope: the stacked modes
274
+ answer "did anything move?" and side-by-side answers "how does it behave?",
275
+ without proxying or injecting script into your app.
276
+
277
+ ### Apps that refuse to be framed
278
+
279
+ Panes and the compare view run on different ports, so they're different
280
+ origins — an app sending `X-Frame-Options: SAMEORIGIN` or a restrictive
281
+ `frame-ancestors` would render as a blank frame.
282
+
283
+ So the compare view doesn't frame panes directly. Each pane gets a **view
284
+ port**: a local pass-through proxy that deletes those two framing headers and
285
+ nothing else, and names what it removed in a `Sidebranch-Removed-Headers`
286
+ response header. Your app's code, its other headers, and the direct pane port
287
+ (used by **Open in new tab**) are untouched, and your production config is
288
+ never involved. Set `"frameProxy": false` to turn this off; a refusing app
289
+ then gets an explanation in the pane instead of an embed. Details and
290
+ safeguards: [SECURITY.md](SECURITY.md).
291
+
292
+ ## Non-goals and guarantees
293
+
294
+ - sidebranch never runs a mutating git command against your primary working
295
+ tree. No stash, no reset, no checkout — reads only.
296
+ - A dirty *pane* (someone edited a review worktree by hand) fails safe: the
297
+ switch is refused until you opt into discarding, and only files inside that
298
+ pane are affected.
299
+ - Worktrees live under `~/.sidebranch/`, outside your repo, so file watchers
300
+ and tooling in your main tree never see them.
301
+
302
+ ## Stopping and cleanup
303
+
304
+ `sidebranch stop` shuts down the daemon and its pane dev servers from any
305
+ terminal, so you don't have to find the tab it's running in:
306
+
307
+ ```sh
308
+ sidebranch stop
309
+ ```
310
+
311
+ Pane worktrees survive a stop, so the next `start` reuses them instead of
312
+ re-installing everything. That means a pane still holds the branch it last
313
+ checked out, and git refuses to check out a branch already checked out
314
+ elsewhere:
315
+
316
+ ```
317
+ fatal: 'main' is already used by worktree at '/Users/you/.sidebranch/projects/.../panes/a'
318
+ ```
319
+
320
+ `sidebranch clean` removes those worktrees:
321
+
322
+ ```sh
323
+ sidebranch clean # lists panes, asks to confirm, then removes them
324
+ sidebranch clean --pane a # target a single pane
325
+ sidebranch clean --yes # skip the confirmation prompt (for scripts/agents)
326
+ ```
327
+
328
+ `clean` refuses to run while a daemon is serving this repo, since removing a
329
+ worktree out from under a running dev server would leave it serving a
330
+ directory that no longer exists. Run `stop` first; `clean` will say so if you
331
+ haven't.
332
+
333
+ Both commands work from a record the daemon writes to
334
+ `~/.sidebranch/projects/<repo>/daemon.json`. If a daemon is killed outright
335
+ (`kill -9`, a closed terminal), that record is left behind, and the next
336
+ `stop`, `start`, or `doctor` clears it. Neither command signals a process it
337
+ hasn't confirmed is a sidebranch daemon, so a stale record whose pid the OS
338
+ has recycled is harmless.
339
+
340
+ ## Commands
341
+
342
+ ```
343
+ sidebranch init write starter config
344
+ sidebranch start run the daemon (--port N, default 49400)
345
+ sidebranch stop stop the daemon serving this repo
346
+ sidebranch clean remove stale pane worktrees for this repo (--pane, --yes)
347
+ sidebranch doctor environment checks (including daemon status)
348
+ ```
349
+
350
+ ## Development
351
+
352
+ ```sh
353
+ node --test test/*.test.js
354
+ ```
355
+
356
+ The suite covers the security gauntlet (token, Host/Origin gating, hostile ref
357
+ names), the full worktree lifecycle against real fixture repos, and an
358
+ end-to-end run that boots two panes on two branches and asserts both serve.
359
+
360
+ ## License
361
+
362
+ MIT — see [LICENSE](LICENSE).
363
+
364
+ One exception: the bundled UI font (`src/assets/geist-pixel.woff2`) is a
365
+ modified build of [Geist Pixel](https://github.com/vercel/geist-font) under
366
+ the SIL Open Font License 1.1, not MIT. Its license, copyright notices, and
367
+ the modifications made are recorded in
368
+ [`src/assets/geist-pixel.LICENSE.txt`](src/assets/geist-pixel.LICENSE.txt).
package/SECURITY.md ADDED
@@ -0,0 +1,161 @@
1
+ # Security model
2
+
3
+ sidebranch runs commands (`git`, your dev command, your install command) on
4
+ behalf of a browser page. As designed, **nothing ever leaves the local machine and nothing
5
+ non-local can ever reach in**.
6
+
7
+ ## Invariants (not configurable)
8
+
9
+ 1. **Loopback bind.** The daemon listens on `127.0.0.1` only. There is no
10
+ flag to bind elsewhere.
11
+ 2. **Peer verification.** Every request additionally checks the socket's
12
+ remote address is loopback (defense in depth against local forwarders).
13
+ 3. **Host header allowlist.** Requests whose `Host` is not
14
+ `localhost`/`127.x`/`[::1]` are rejected with 403. This defeats **DNS
15
+ rebinding**, where an attacker's domain resolves to 127.0.0.1 so a remote
16
+ page's requests arrive on a loopback socket — but with the attacker's
17
+ hostname in `Host`.
18
+ 4. **Origin allowlist.** Browser requests carrying an `Origin` are rejected
19
+ unless that origin is itself a loopback http(s) origin. `Origin: null`
20
+ (sandboxed iframes, `file://`) is rejected. Non-loopback origins never
21
+ receive CORS headers, so even the responses they can't be blocked from
22
+ *requesting* are unreadable to them.
23
+ 5. **Bearer token on every API call.** A 256-bit token is generated at
24
+ daemon start (rotates each run) and required — with constant-time
25
+ comparison — on all `/api/*` routes, including reads and the event
26
+ stream.
27
+ 6. **No shell, ever.** All child processes use `execFile`/`spawn` with
28
+ argument arrays. Branch names are validated twice (a conservative
29
+ allowlist regex, then `git check-ref-format --branch`) before reaching
30
+ git; names shaped like options (`-D`), paths (`..`), or containing any
31
+ shell-significant byte are rejected at the API boundary with 400.
32
+ 7. **No filesystem routing.** The daemon serves three embedded, fixed-path
33
+ assets (`widget.js`, `/shell`, and a bundled font used by both), a
34
+ credential bootstrap (`/handshake`), a liveness probe (`/healthz`), and
35
+ JSON APIs. Every servable path is a hardcoded route, not derived from
36
+ the request URL, so there is no traversal surface no matter how many
37
+ fixed routes that list grows to.
38
+ 8. **The user's working tree is read-only territory.** The daemon never
39
+ runs a mutating git command outside its own worktrees under
40
+ `~/.sidebranch/`.
41
+
42
+ ## Token delivery
43
+
44
+ The token reaches a browser two ways, and both are unauthenticated by
45
+ necessity — they are what *bootstrap* auth:
46
+
47
+ 1. **Embedded** into `widget.js` and `/shell` at response time, for the
48
+ `<script src>` integration.
49
+ 2. **Fetched** from `GET /handshake`, for the browser extension. An
50
+ extension cannot use route 1 at all: Manifest V3 forbids executing
51
+ remotely-fetched code, so the extension ships the widget in its own
52
+ package and has nowhere for a substituted token to arrive.
53
+
54
+ Route 2 discloses nothing route 1 does not. Any caller that clears the gate
55
+ below can already read the token out of the `widget.js` response body — the
56
+ two routes are the same disclosure to the same audience, differing only in
57
+ shape. Both are safe for the same reasons:
58
+
59
+ (The bundled font both of them load via `@font-face` is unauthenticated for
60
+ a different, simpler reason: a browser's font fetch cannot carry a custom
61
+ `Authorization` header at all, so gating it by token was never an option.
62
+ It carries no secret and no per-run state, so there's nothing at stake in
63
+ it being fetchable by anything that already clears the loopback/Host/Origin
64
+ gate below.)
65
+
66
+ - They are only reachable from a loopback socket with a loopback Host
67
+ (invariants 1–3), so only local software can request them at all.
68
+ - A **remote** page cannot read them: `fetch()` from a non-loopback origin
69
+ is rejected (invariant 4), and including `<script src="http://localhost:49400/widget.js">`
70
+ executes the code but cannot read its source — the token lives in a
71
+ closure, is never attached to `window`, the DOM, storage, cookies, or
72
+ URLs, and the widget exits before touching the token when the embedding
73
+ page is not loopback.
74
+ - Local software on your machine could read them — but local software can
75
+ already run `git` as you directly. sidebranch does not attempt to defend
76
+ you from your own machine; no local tool can.
77
+
78
+ ## "What if the snippet ships to production?"
79
+
80
+ Designed to be a non-event, twice over:
81
+
82
+ 1. The widget's first statement checks `location.hostname`; on any
83
+ non-loopback page it returns before creating DOM, globals, or network
84
+ traffic. Visitors see nothing and their browser sends nothing.
85
+ 2. Even a hand-modified copy that skipped that check would be talking to
86
+ the *visitor's* `localhost:49400`. If they don't run sidebranch, the
87
+ request fails. If they do, their daemon rejects the request because the
88
+ page's Origin (your production domain) is not loopback — before any
89
+ token check is even consulted.
90
+
91
+ There is no third party in the path in any scenario: the script src is
92
+ loopback, the API is loopback, and there is no telemetry, no analytics, no
93
+ update check, and no outbound network code anywhere in the package.
94
+
95
+ ## Supply chain
96
+
97
+ Zero runtime dependencies. The daemon, widget, and shell are Node builtins
98
+ and vanilla browser APIs only. What you audit in this repo is everything
99
+ that runs.
100
+
101
+ ## The shell page
102
+
103
+ `/shell` is served with a strict CSP (`default-src 'none'` plus loopback
104
+ allowances for frames and fetch), `X-Frame-Options: DENY`, `nosniff`, and
105
+ `no-referrer`. It embeds only loopback iframes and talks only to the daemon.
106
+
107
+ ## View ports (the compare view's proxy)
108
+
109
+ When `frameProxy` is on (the default), each pane gets a second loopback port —
110
+ a pass-through proxy the compare view frames instead of the pane itself. It
111
+ deletes exactly two things from responses: `X-Frame-Options` and the
112
+ `frame-ancestors` CSP directive, and declares what it removed in a
113
+ `Sidebranch-Removed-Headers` header. Bodies are streamed, never parsed or
114
+ rewritten; every other header passes through untouched.
115
+
116
+ Safeguards, mirroring the daemon's own gate:
117
+
118
+ - Binds `127.0.0.1` only; non-loopback peers and non-loopback `Host` headers
119
+ are rejected (on websocket upgrades too).
120
+ - `Sec-Fetch-Site: cross-site` is rejected, so a remote page cannot frame a
121
+ pane through it — the protection the stripped header was providing.
122
+ - The target is fixed at construction to its own pane's port. Nothing in the
123
+ request selects where traffic goes, so it cannot proxy to anything else.
124
+ - Dev only, local only, and off with `"frameProxy": false` — the compare view
125
+ then falls back to explaining a refused embed instead of hiding it.
126
+
127
+ ## Residual risks
128
+
129
+ - **Your dev/install commands are trusted**, exactly like `npm run dev` is:
130
+ checking out and building a branch executes that branch's build tooling.
131
+ Reviewing a malicious PR locally is risky with or without sidebranch;
132
+ panes give you process isolation per branch but not a sandbox. If a PR is
133
+ untrusted, read the diff before you build it — with any tool.
134
+ - **`copy` files** (e.g. `.env`) are duplicated into pane worktrees on your
135
+ own disk under `~/.sidebranch/`. Destroying a pane removes its worktree.
136
+ - **HTTPS dev servers**: an https page cannot load the http widget (mixed
137
+ content). Run the daemon behind a locally-trusted cert if you need this;
138
+ do not weaken the invariants to work around it.
139
+ - **The browser extension**, where used, runs on every page served from
140
+ `localhost`/`127.0.0.1` — that is the scope it requests and the only
141
+ scope it requests. It holds no permission for any other origin, so it
142
+ cannot see, and is never injected into, ordinary browsing. Within that
143
+ scope it does what the script tag does: talk to your daemon, on your
144
+ machine. All of its daemon traffic is issued from the content script, so
145
+ those requests carry the loopback page's own `Origin` and are admitted
146
+ by invariant 4 unchanged — no extension origin is allowlisted, and the
147
+ invariants above are not relaxed for it in any way.
148
+
149
+ ## Reporting
150
+
151
+ Please report vulnerabilities privately, not as a public issue:
152
+
153
+ - **Preferred:** [open a private security advisory](https://github.com/cristobalwee/sidebranch/security/advisories/new)
154
+ on the repository.
155
+ If you cannot use GitHub, open an issue asking for a private channel —
156
+ without any details of the vulnerability itself — and one will be
157
+ arranged.
158
+
159
+ Expect an acknowledgement within a few days. This is a small project
160
+ maintained by one person; there is no bounty program, but credit is given
161
+ in the changelog for any report that leads to a fix.
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+ import { main } from "../src/cli.js";
3
+
4
+ // `sidebranch stop | head -1` must not crash on EPIPE when the pipe closes.
5
+ process.stdout.on("error", (e) => { if (e.code === "EPIPE") process.exit(0); throw e; });
6
+
7
+ main(process.argv.slice(2))
8
+ .then((code) => { if (typeof code === "number" && code !== 0) process.exit(code); })
9
+ .catch((err) => {
10
+ process.stderr.write(`sidebranch: ${err.message}\n`);
11
+ process.exit(1);
12
+ });