brane 0.0.2 → 0.3.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/AGENTS.md +362 -0
- package/CHANGELOG.md +33 -0
- package/CONTRIBUTING.md +91 -0
- package/DESIGN.md +402 -0
- package/Dockerfile +61 -0
- package/LICENSE +656 -17
- package/README.md +174 -2
- package/SECURITY.md +205 -0
- package/dist/absorb/brane-id.js +11 -0
- package/dist/absorb/chatgpt.js +161 -0
- package/dist/absorb/claude-code.js +504 -0
- package/dist/absorb/index.js +543 -0
- package/dist/absorb/organelle.js +345 -0
- package/dist/absorb/run.js +520 -0
- package/dist/absorb/sources.js +168 -0
- package/dist/absorb/types.js +3 -0
- package/dist/agent-env.js +45 -0
- package/dist/agents.js +1168 -0
- package/dist/claude-live-session.js +124 -0
- package/dist/cli.js +50 -0
- package/dist/codex-tool-log.js +144 -0
- package/dist/commands/absorb.js +291 -0
- package/dist/commands/attach.js +31 -0
- package/dist/commands/certify.js +62 -0
- package/dist/commands/config.js +116 -0
- package/dist/commands/cost.js +126 -0
- package/dist/commands/daemon.js +350 -0
- package/dist/commands/doctor.js +262 -0
- package/dist/commands/fold.js +95 -0
- package/dist/commands/list.js +89 -0
- package/dist/commands/mcp.js +84 -0
- package/dist/commands/meld.js +332 -0
- package/dist/commands/memory.js +215 -0
- package/dist/commands/revive.js +74 -0
- package/dist/commands/run.js +85 -0
- package/dist/commands/spawn.js +1094 -0
- package/dist/commands/token.js +153 -0
- package/dist/copilot-tool-log.js +145 -0
- package/dist/cost/pricing.js +116 -0
- package/dist/cost/reprice.js +136 -0
- package/dist/crush-tool-log.js +293 -0
- package/dist/daemon/agent-scope.js +116 -0
- package/dist/daemon/awaiting.js +54 -0
- package/dist/daemon/cert-manager.js +349 -0
- package/dist/daemon/client.js +262 -0
- package/dist/daemon/gated-routes.js +112 -0
- package/dist/daemon/keep-awake.js +103 -0
- package/dist/daemon/local-socket.js +145 -0
- package/dist/daemon/presence-gate.js +206 -0
- package/dist/daemon/server.js +6349 -0
- package/dist/daemon/signal-source.js +35 -0
- package/dist/daemon/tailscale-rebind.js +58 -0
- package/dist/daemon/token-ring.js +263 -0
- package/dist/daemon/token-routes.js +190 -0
- package/dist/db/events.js +152 -0
- package/dist/db/mentions.js +179 -0
- package/dist/db/schema.js +633 -0
- package/dist/db/tokens.js +583 -0
- package/dist/db/tool-uses.js +647 -0
- package/dist/diagram/index.js +85 -0
- package/dist/diagram/label.js +455 -0
- package/dist/diagram/layout.js +476 -0
- package/dist/diagram/links.js +50 -0
- package/dist/diagram/metrics.js +152 -0
- package/dist/diagram/parse.js +487 -0
- package/dist/diagram/svg.js +389 -0
- package/dist/diagram/types.js +9 -0
- package/dist/export.js +629 -0
- package/dist/files/archive-types.js +2 -0
- package/dist/files/archive.js +182 -0
- package/dist/files/browse.js +327 -0
- package/dist/files/bytes.js +162 -0
- package/dist/files/codecs.js +33 -0
- package/dist/files/find.js +212 -0
- package/dist/files/tar.js +223 -0
- package/dist/files/zip.js +209 -0
- package/dist/folds.js +72 -0
- package/dist/grammars/ebnf.js +92 -0
- package/dist/highlight.js +250 -0
- package/dist/ids.js +24 -0
- package/dist/markdown-diagnostics.js +233 -0
- package/dist/math.js +168 -0
- package/dist/mcp/server.js +886 -0
- package/dist/memory/embedder.js +145 -0
- package/dist/memory/embedding-worker.js +71 -0
- package/dist/memory/embeddings.js +91 -0
- package/dist/memory/index.js +232 -0
- package/dist/memory/storage.js +587 -0
- package/dist/memory/wiki.js +688 -0
- package/dist/memory/wordpiece.js +247 -0
- package/dist/mentions-scan.js +40 -0
- package/dist/presence.js +259 -0
- package/dist/session-affinity.js +185 -0
- package/dist/shared/api-request.js +124 -0
- package/dist/shared/block-glyphs.js +114 -0
- package/dist/shared/diagram-css.js +17 -0
- package/dist/shared/export-selection.js +101 -0
- package/dist/shared/find-in-text.js +110 -0
- package/dist/shared/glow-hues.js +225 -0
- package/dist/shared/hex-dump.js +62 -0
- package/dist/shared/hex-pages.js +52 -0
- package/dist/shared/hit-drum.js +79 -0
- package/dist/shared/known-nids.js +58 -0
- package/dist/shared/list-scroll-keys.js +56 -0
- package/dist/shared/markdown-lists.js +72 -0
- package/dist/shared/markdown-math.js +485 -0
- package/dist/shared/markdown-mentions.js +79 -0
- package/dist/shared/markdown-nids.js +41 -0
- package/dist/shared/markdown-protocols.js +53 -0
- package/dist/shared/markdown-refs.js +123 -0
- package/dist/shared/membrane-invasion.js +342 -0
- package/dist/shared/membrane-motion.js +83 -0
- package/dist/shared/membrane-palette.js +253 -0
- package/dist/shared/membrane-terrain.js +212 -0
- package/dist/shared/message-selection.js +123 -0
- package/dist/shared/mo-glyph.js +57 -0
- package/dist/shared/narrative-format.js +237 -0
- package/dist/shared/narrative-menu.js +82 -0
- package/dist/shared/narrative-sort.js +47 -0
- package/dist/shared/narrative-status.js +52 -0
- package/dist/shared/no-pan.js +85 -0
- package/dist/shared/pane-layout.js +50 -0
- package/dist/shared/payload-inline.js +83 -0
- package/dist/shared/speaker-color.js +61 -0
- package/dist/shared/token-rotation.js +14 -0
- package/dist/shared/view-url.js +197 -0
- package/dist/shared/wiki-links.js +70 -0
- package/dist/shared/wiki-window.js +4 -0
- package/dist/tailscale.js +55 -0
- package/dist/test/absorb-claude.test.js +892 -0
- package/dist/test/agent-scope.test.js +193 -0
- package/dist/test/agents.test.js +55 -0
- package/dist/test/api-request.test.js +122 -0
- package/dist/test/awaiting-glow.test.js +54 -0
- package/dist/test/awaiting.test.js +122 -0
- package/dist/test/bind-token.test.js +88 -0
- package/dist/test/block-glyphs.test.js +68 -0
- package/dist/test/cert-manager.test.js +262 -0
- package/dist/test/claude-live-session.test.js +110 -0
- package/dist/test/codex-tool-log.test.js +568 -0
- package/dist/test/config.test.js +71 -0
- package/dist/test/copilot-tool-log.test.js +93 -0
- package/dist/test/cors-preflight.test.js +49 -0
- package/dist/test/cost-linger.test.js +31 -0
- package/dist/test/cost-pricing.test.js +72 -0
- package/dist/test/cost-reprice.test.js +139 -0
- package/dist/test/cost-sessions.test.js +301 -0
- package/dist/test/crush-tool-log.test.js +289 -0
- package/dist/test/daemon-client.test.js +109 -0
- package/dist/test/daemon-socket.test.js +293 -0
- package/dist/test/diagram-arrows.test.js +205 -0
- package/dist/test/diagram-export.test.js +210 -0
- package/dist/test/diagram-labels.test.js +313 -0
- package/dist/test/diagram-parse.test.js +191 -0
- package/dist/test/diagram-render.test.js +175 -0
- package/dist/test/diagram-review.test.js +288 -0
- package/dist/test/diagram-self-loop-labels.test.js +303 -0
- package/dist/test/embedder.test.js +84 -0
- package/dist/test/events.test.js +123 -0
- package/dist/test/export-selection.test.js +102 -0
- package/dist/test/export.test.js +505 -0
- package/dist/test/files-browse.test.js +393 -0
- package/dist/test/files-find.test.js +272 -0
- package/dist/test/find-in-text.test.js +86 -0
- package/dist/test/first-paint-viewport.test.js +59 -0
- package/dist/test/fixtures/embedding-golden.js +23 -0
- package/dist/test/fixtures/wordpiece-golden.js +167 -0
- package/dist/test/glow-hues.test.js +202 -0
- package/dist/test/harness-cost.test.js +423 -0
- package/dist/test/helpers.js +21 -0
- package/dist/test/hex-pages.test.js +68 -0
- package/dist/test/highlight.test.js +69 -0
- package/dist/test/hit-drum.test.js +71 -0
- package/dist/test/hook-config.test.js +262 -0
- package/dist/test/json-gzip.test.js +70 -0
- package/dist/test/key-routing.test.js +58 -0
- package/dist/test/known-nids.test.js +106 -0
- package/dist/test/link-preview.test.js +67 -0
- package/dist/test/list-scroll-keys.test.js +66 -0
- package/dist/test/listen-own-words.test.js +147 -0
- package/dist/test/listen-timeout.test.js +33 -0
- package/dist/test/markdown-diagnostics.test.js +121 -0
- package/dist/test/markdown-lists.test.js +182 -0
- package/dist/test/markdown-math.test.js +256 -0
- package/dist/test/markdown-nids.test.js +146 -0
- package/dist/test/markdown-protocols.test.js +139 -0
- package/dist/test/mcp-bridge-cancel.test.js +98 -0
- package/dist/test/mcp-bridge-reconnect.test.js +121 -0
- package/dist/test/mcp-narrative.test.js +193 -0
- package/dist/test/mcp.test.js +548 -0
- package/dist/test/meld.test.js +85 -0
- package/dist/test/membrane-invasion.test.js +482 -0
- package/dist/test/membrane-motion.test.js +101 -0
- package/dist/test/membrane-palette.test.js +166 -0
- package/dist/test/membrane-terrain.test.js +170 -0
- package/dist/test/memory-log.test.js +202 -0
- package/dist/test/mentions-e2e.test.js +261 -0
- package/dist/test/mentions-ledger.test.js +265 -0
- package/dist/test/mentions-listen.test.js +265 -0
- package/dist/test/mentions.test.js +238 -0
- package/dist/test/message-selection.test.js +114 -0
- package/dist/test/mo-glyph.test.js +22 -0
- package/dist/test/model-resolution.test.js +166 -0
- package/dist/test/narrate-validate.test.js +77 -0
- package/dist/test/narrate-wait.test.js +160 -0
- package/dist/test/narrative-format.test.js +219 -0
- package/dist/test/narrative-menu.test.js +137 -0
- package/dist/test/narrative-sort.test.js +75 -0
- package/dist/test/no-pan.test.js +60 -0
- package/dist/test/pane-layout.test.js +153 -0
- package/dist/test/paths.test.js +37 -0
- package/dist/test/payload-inline.test.js +91 -0
- package/dist/test/precache-fonts.test.js +72 -0
- package/dist/test/presence-gate.test.js +440 -0
- package/dist/test/presence.test.js +160 -0
- package/dist/test/read-events.test.js +220 -0
- package/dist/test/remote-meld.test.js +115 -0
- package/dist/test/revive-cli.test.js +93 -0
- package/dist/test/search-floor.test.js +206 -0
- package/dist/test/search-fusion.test.js +168 -0
- package/dist/test/search.test.js +205 -0
- package/dist/test/security.test.js +48 -0
- package/dist/test/selection-wiring.test.js +61 -0
- package/dist/test/session-affinity.test.js +295 -0
- package/dist/test/signal-source.test.js +32 -0
- package/dist/test/socket-account.test.js +268 -0
- package/dist/test/spawn-toml.test.js +72 -0
- package/dist/test/speaker-color.test.js +90 -0
- package/dist/test/tailscale-rebind.test.js +79 -0
- package/dist/test/terminal-cursor.test.js +61 -0
- package/dist/test/terminal-resync.test.js +23 -0
- package/dist/test/thin-cli.test.js +382 -0
- package/dist/test/token-bootstrap.test.js +272 -0
- package/dist/test/token-devices.test.js +587 -0
- package/dist/test/token-lapse.test.js +94 -0
- package/dist/test/token-ring.test.js +188 -0
- package/dist/test/token-rotate-cli.test.js +58 -0
- package/dist/test/token-routes.test.js +95 -0
- package/dist/test/tool-uses.test.js +1006 -0
- package/dist/test/usage-ledger.test.js +126 -0
- package/dist/test/view-url.test.js +581 -0
- package/dist/test/wiki-links.test.js +57 -0
- package/dist/test/wiki-sources.test.js +85 -0
- package/dist/test/wiki-window.test.js +31 -0
- package/dist/test/wiki.test.js +399 -0
- package/dist/test/wordpiece.test.js +68 -0
- package/dist/tls.js +148 -0
- package/dist/tmux.js +202 -0
- package/dist/user-agent.js +46 -0
- package/docker-entrypoint.sh +141 -0
- package/docs/README.md +3 -0
- package/docs/absorb.md +231 -0
- package/docs/cost-ledger.md +125 -0
- package/docs/daemon-api.md +189 -0
- package/docs/diagrams.md +163 -0
- package/docs/doctor.md +36 -0
- package/docs/export-scope.md +69 -0
- package/docs/files-view.md +133 -0
- package/docs/link-previews.md +38 -0
- package/docs/math.md +32 -0
- package/docs/mcp.md +69 -0
- package/docs/memory-search.md +75 -0
- package/docs/mentions.md +101 -0
- package/docs/multi-brane.md +14 -0
- package/docs/passkeys.md +165 -0
- package/docs/presence.md +202 -0
- package/docs/releasing.md +53 -0
- package/docs/rendering-warnings.md +22 -0
- package/docs/request-layer.md +72 -0
- package/docs/selection-store.md +50 -0
- package/docs/session-affinity.md +61 -0
- package/docs/terminal-mirroring.md +211 -0
- package/docs/token-rotation.md +413 -0
- package/docs/view-urls.md +114 -0
- package/docs/web-app.md +23 -0
- package/docs/wiki.md +43 -0
- package/expo/dist/_expo/static/js/web/entry-e56efc1291dca7258ea00b8108b033a4.js +1097 -0
- package/expo/dist/apple-touch-icon.png +0 -0
- package/expo/dist/favicon.svg +3 -0
- package/expo/dist/index.html +177 -0
- package/expo/dist/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- package/expo/dist/katex/katex.min.css +1 -0
- package/expo/dist/manifest.json +16 -0
- package/expo/dist/metadata.json +1 -0
- package/expo/dist/og/membrane.v1.png +0 -0
- package/expo/dist/pdf.min.mjs +28 -0
- package/expo/dist/pdf.worker.min.mjs +28 -0
- package/expo/dist/sw.js +154 -0
- package/package.json +81 -23
- package/protocols/LIVE_loop.md +407 -0
- package/protocols/bebrief.md +41 -0
- package/protocols/digest.md +231 -0
- package/protocols/fly.md +266 -0
- package/protocols/idle.md +81 -0
- package/protocols/keeper.md +829 -0
- package/protocols/markdown.md +80 -0
- package/protocols/memory.md +419 -0
- package/protocols/mentions.md +93 -0
- package/protocols/payloads.md +53 -0
- package/protocols/prelude.md +91 -0
- package/protocols/ramify.md +105 -0
- package/protocols/review.md +356 -0
- package/protocols/rrr.md +232 -0
- package/protocols/scribe.md +429 -0
- package/protocols/summarize.md +203 -0
- package/protocols/twin.md +174 -0
- package/protocols/wiki.md +438 -0
- package/protocols/worker.md +38 -0
- package/wiki/shared/append-only-event-log.md +148 -0
- package/wiki/shared/index.md +48 -0
- package/wiki/shared/owning-your-narratives.md +161 -0
- package/wiki/shared/premature-ontologies.md +435 -0
- package/wiki/shared/sedimentary-superintelligence.md +128 -0
- package/wiki/shared/sequestering-the-narrative.md +143 -0
- package/wiki/shared/why-wikis-work.md +216 -0
- package/.npmignore +0 -2
- package/.travis.yml +0 -5
- package/index.js +0 -8
- package/index.js~ +0 -12
package/README.md
CHANGED
|
@@ -1,3 +1,175 @@
|
|
|
1
|
-
# brane
|
|
1
|
+
# brane
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A TypeScript CLI and userspace daemon for managing narrative agent sessions.
|
|
4
|
+
|
|
5
|
+
"Brane" is short for **membrane**, evoking a [_eukaryotic_](https://en.wikipedia.org/wiki/Eukaryote) metaphor for agent management, inherited from [organelle](https://github.com/benjamn/organelle): the daemon is the nucleus (which has a membrane), agents are organelles (which in turn have their own membranes), and narrative event logs trace everything each organelle has encountered and done (biologically implausible, but very useful).
|
|
6
|
+
|
|
7
|
+
I am not paid to work on brane, except by your donations. Brane is not a product. No one is watching you use it, and no one can stand in the way of your continued use of the software as downloaded, even if all maintenance ceases.
|
|
8
|
+
|
|
9
|
+
You can use brane from any web-capable device, anywhere in the world, but you must do so via [Tailscale](https://tailscale.com), which makes your private global network fully invisible/useless to anyone else, the best possible network security posture. Requiring you to install Tailscale first would be a dubious pitch for any consumer product, but brane is not a product.
|
|
10
|
+
|
|
11
|
+
If you find brane useful, perhaps because your tastes align with mine and I have aligned brane relentlessly to my tastes, please consider supporting brane's development so it may remain unencumbered by centralization, surveillance, or required payment:
|
|
12
|
+
|
|
13
|
+
[](https://github.com/sponsors/benjamn)
|
|
14
|
+
|
|
15
|
+
## What it does
|
|
16
|
+
|
|
17
|
+
Brane runs long-lived coding agents (Claude Code, Codex, Gemini, Goose and
|
|
18
|
+
others, or any command) in tmux on your own machine, keeps everything they
|
|
19
|
+
say and hear in one SQLite database, and lets you read, steer and search
|
|
20
|
+
them from a phone or any browser on your tailnet.
|
|
21
|
+
|
|
22
|
+
Four ideas cover most of it:
|
|
23
|
+
|
|
24
|
+
- **Fold**: a directory you've registered as a place agents may be spawned.
|
|
25
|
+
- **Narrative**: one agent session's append-only log of everything it said
|
|
26
|
+
and everything said to it. Every message has a stable address
|
|
27
|
+
(`claude-4c7289d1:42`) you can link to from anywhere.
|
|
28
|
+
- **The LIVE loop**: the protocol agents follow to talk to you through an
|
|
29
|
+
MCP server brane injects into each session: listen, interpret, vocalize,
|
|
30
|
+
execute.
|
|
31
|
+
- **Meld**: a QR code that signs a phone or browser into your daemon.
|
|
32
|
+
|
|
33
|
+
## Quick start
|
|
34
|
+
|
|
35
|
+
0. **Install [Tailscale](https://tailscale.com/download)** and join your
|
|
36
|
+
tailnet on every trusted device. Skip this only if you're staying on
|
|
37
|
+
localhost: Tailscale is the path to encrypted access from your phone and
|
|
38
|
+
other machines.
|
|
39
|
+
|
|
40
|
+
1. **Install brane:**
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
npm install -g brane
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
2. **Start the daemon:**
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
brane daemon start
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
If Tailscale is running, the daemon provisions a Let's Encrypt cert via
|
|
53
|
+
`tailscale cert` on first start and renews it automatically before
|
|
54
|
+
expiry, so you never run `brane certify` by hand. This needs "HTTPS for
|
|
55
|
+
your tailnet" enabled in the Tailscale admin console; the daemon log
|
|
56
|
+
tells you if you need to flip it.
|
|
57
|
+
|
|
58
|
+
3. **Register a project directory as a fold** (a spawn point):
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
brane fold ~/dev/myproject
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Or run `brane fold` from inside the directory.
|
|
65
|
+
|
|
66
|
+
4. **Spawn an agent** from inside the fold:
|
|
67
|
+
|
|
68
|
+
```sh
|
|
69
|
+
cd ~/dev/myproject
|
|
70
|
+
brane spawn claude --meld
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`--meld` prints a QR code that opens the new narrative on your phone.
|
|
74
|
+
Spawn each agent type once from the CLI, so any first-run login lands in
|
|
75
|
+
a real terminal.
|
|
76
|
+
|
|
77
|
+
Other useful commands once you're up:
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
brane spawn claude --model sonnet --role coordinator --attach
|
|
81
|
+
brane spawn claude -- --continue # anything after -- goes to the agent
|
|
82
|
+
|
|
83
|
+
# List active sessions
|
|
84
|
+
brane list
|
|
85
|
+
|
|
86
|
+
# Attach to a running session
|
|
87
|
+
brane attach claude-4c7289d1
|
|
88
|
+
|
|
89
|
+
# Restart a dead session
|
|
90
|
+
brane revive claude-4c7289d1
|
|
91
|
+
|
|
92
|
+
# Check the install
|
|
93
|
+
brane doctor
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
When attached to a tmux session (via `--attach` or `brane attach`), press
|
|
97
|
+
**`Ctrl-b` then `d`** to detach without killing the session — the agent
|
|
98
|
+
keeps running in the background and you can re-attach anytime.
|
|
99
|
+
|
|
100
|
+
`brane --help` lists every command, and `brane <command> --help` its options.
|
|
101
|
+
|
|
102
|
+
### Enfolding an existing session
|
|
103
|
+
|
|
104
|
+
Already have a Claude session running outside brane? You can **enfold** it —
|
|
105
|
+
stop the session and resume it inside brane with full LIVE loop capability:
|
|
106
|
+
|
|
107
|
+
1. **Register the project directory as a fold**, if it isn't one already:
|
|
108
|
+
|
|
109
|
+
```sh
|
|
110
|
+
brane fold
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
2. **From inside the running Claude session**, have the agent run:
|
|
114
|
+
|
|
115
|
+
```sh
|
|
116
|
+
brane spawn claude -- --resume <session-id>
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
3. The agent exits, and its session continues inside brane.
|
|
120
|
+
|
|
121
|
+
The agent constructs the resume command itself (it knows its own session ID)
|
|
122
|
+
and hands off to brane. The resumed session gets tmux wrapping, terminal
|
|
123
|
+
mirroring, MCP tools, and the LIVE loop — all from a single `spawn` call.
|
|
124
|
+
|
|
125
|
+
## Security
|
|
126
|
+
|
|
127
|
+
Brane's security model has two layers: **network isolation** via Tailscale
|
|
128
|
+
and **token authentication**, with a token of its own for every device you
|
|
129
|
+
meld and every agent session. This machine's CLI needs none: it reaches the
|
|
130
|
+
daemon over a Unix socket only your account can open. Melding a device asks
|
|
131
|
+
you to confirm you're at the machine (Touch ID, Windows Hello, polkit).
|
|
132
|
+
|
|
133
|
+
**On public WiFi, Tailscale is required.** Without it, `brane meld` will only advertise `http://127.0.0.1:port` (same-machine only) unless you opt into a LAN address with `--host <ip>`. Even then, any device on the same subnet can reach the daemon — `brane meld` without Tailscale is a trusted-network feature only. Tailscale makes your daemon invisible to other devices on the LAN: they can't even route packets to your Tailscale IP. TLS on top of that encrypts all traffic — and the daemon handles that for you.
|
|
134
|
+
|
|
135
|
+
See [SECURITY.md](SECURITY.md) for the full threat model.
|
|
136
|
+
|
|
137
|
+
## Privacy
|
|
138
|
+
|
|
139
|
+
Brane is operator-owned by design. All narrative history, events, payloads,
|
|
140
|
+
embeddings, and config live in `~/.brane/` on your own machine. There is no
|
|
141
|
+
hosted control plane, no telemetry, no analytics, no third-party sync — the
|
|
142
|
+
daemon talks to your devices over Tailscale and to your chosen LLM provider.
|
|
143
|
+
Semantic search runs a small embedding model locally, so your history is
|
|
144
|
+
never sent anywhere to be indexed. If you delete `~/.brane/`, the data is
|
|
145
|
+
gone; if you back it up, it's yours.
|
|
146
|
+
|
|
147
|
+
The one unavoidable trust boundary is **inference**: every prompt you send
|
|
148
|
+
to Claude or GPT or any other hosted model leaves your machine and is
|
|
149
|
+
subject to that provider's data policies. Brane doesn't filter or redact
|
|
150
|
+
this traffic — the agent process talks directly to the provider, the same
|
|
151
|
+
way it would if you ran it without brane. Local models would close this
|
|
152
|
+
gap, but the agentic-coding competence gap between frontier hosted models
|
|
153
|
+
and anything that fits on a laptop is still wide enough that brane treats
|
|
154
|
+
hosted inference as the default. Run a local model with a brane-compatible
|
|
155
|
+
CLI whenever that calculus changes for you.
|
|
156
|
+
|
|
157
|
+
## Further reading
|
|
158
|
+
|
|
159
|
+
- [DESIGN.md](DESIGN.md): architecture, storage, the ID scheme, threading
|
|
160
|
+
- [docs/](docs/): one page per subsystem, from the daemon's API to terminal
|
|
161
|
+
mirroring
|
|
162
|
+
- [protocols/](protocols/): the protocols agents follow, starting with
|
|
163
|
+
[the LIVE loop](protocols/LIVE_loop.md)
|
|
164
|
+
|
|
165
|
+
## Contributing
|
|
166
|
+
|
|
167
|
+
Brane is developed by one maintainer working with coding agents. Commits an agent writes carry a disclosure trailer; responsibility for every change stays with a human, who reviews, signs off, and merges it. Sign-off is human-only: an agent never certifies a contribution. See [CONTRIBUTING.md](CONTRIBUTING.md) for the rules, including the DCO sign-off asked of outside contributors, and for working from a clone.
|
|
168
|
+
|
|
169
|
+
## License
|
|
170
|
+
|
|
171
|
+
GNU Affero General Public License v3.0 or later (AGPL-3.0-or-later). See [LICENSE](LICENSE) for the full text.
|
|
172
|
+
|
|
173
|
+
Brane is designed to be run as a standalone daemon by individual operators, not embedded into other products. The AGPL's network-use clause ensures that anyone who modifies brane and offers it as a hosted service must share their modifications back.
|
|
174
|
+
|
|
175
|
+
> Note: the `brane` package was previously published on npm under MIT in 2020. This codebase is a complete rewrite that supersedes that release; any future npm publication would ship as a new major version under AGPL-3.0-or-later.
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# Brane Security Model
|
|
2
|
+
|
|
3
|
+
Brane is a local-first system. Your data never leaves your machine unless you explicitly connect a device.
|
|
4
|
+
|
|
5
|
+
## Network isolation by default
|
|
6
|
+
|
|
7
|
+
The daemon binds **only to loopback and Tailscale addresses**. The listen sockets themselves are the first line of defense: by default, the daemon is attached only to `127.0.0.1`, `::1`, and whatever IPs `tailscale status --json` reports (typically one IPv4 and one IPv6 in the 100.x / fd7a::/8 range). **The machine's LAN IP is not bound.** Same-subnet port scans see nothing on the daemon's port — the kernel rejects those SYNs with RST before any userspace code runs.
|
|
8
|
+
|
|
9
|
+
As a second line of defense, a **TCP-level connection filter** runs on every accepted socket. Even if a connection reached a listener it shouldn't have, the filter would drop it before any HTTP or WebSocket processing. The filter accepts:
|
|
10
|
+
|
|
11
|
+
- **Localhost** (`127.0.0.1`, `::1`)
|
|
12
|
+
- **Your Tailscale IPs** (auto-detected from `tailscale status --json`)
|
|
13
|
+
- **RFC 1918 private ranges** (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)
|
|
14
|
+
- **CGNAT range** (100.64.0.0/10, covering Tailscale peer addresses)
|
|
15
|
+
|
|
16
|
+
There is no code path — not HTTP, not WebSocket, not even a TCP handshake completion — for a public internet IP to reach the daemon. This holds even if the machine has a public IP, port forwarding, or a misconfigured firewall.
|
|
17
|
+
|
|
18
|
+
Use `--bind <address>` to override the default bind list (e.g., `--bind 0.0.0.0` to listen on every interface; `--bind 192.168.1.42` to deliberately expose a specific LAN IP). The explicit override also disables the TCP-level filter, since the operator is asking to hear from whatever the kernel lets through.
|
|
19
|
+
|
|
20
|
+
## Tailscale as the trust boundary
|
|
21
|
+
|
|
22
|
+
The recommended setup puts Tailscale between you and brane. Tailscale IPs (100.x.y.z) are **only routable within your tailnet** — they're CGNAT addresses that don't exist on the public internet. An attacker who captures your bearer token still can't use it unless they're on your tailnet, which requires either:
|
|
23
|
+
|
|
24
|
+
- Compromising your Tailscale account (game over regardless of brane)
|
|
25
|
+
- Root access to your machine to install their own Tailscale client (game over regardless)
|
|
26
|
+
- Social engineering you into sharing your node (deliberate user action)
|
|
27
|
+
|
|
28
|
+
This means the daemon's bearer token is a **second factor**, not the sole gate. Network-level access is the first.
|
|
29
|
+
|
|
30
|
+
## The meld QR is safe to display in public
|
|
31
|
+
|
|
32
|
+
`brane meld` will not auto-advertise your machine's LAN IP. The QR encodes one of:
|
|
33
|
+
|
|
34
|
+
- **A Tailscale hostname** — `https://your-host.your-tailnet.ts.net/#token=…`. Only routable inside your tailnet.
|
|
35
|
+
- **`http://127.0.0.1:port/#token=…`** — only useful from the same machine the daemon is on.
|
|
36
|
+
|
|
37
|
+
The token rides in the URL **fragment**, not the query string. A fragment is never sent to any server: not to `brane.organelle.dev` (Cloudflare serves the app bundle, and a query string would have been part of that request), not to a chat app's link-preview fetcher when a meld URL is pasted, and not into the daemon's own request log. The app reads it from `location.hash` and removes it from the address bar as soon as it has been used. Old links with `?token=` are still honored.
|
|
38
|
+
|
|
39
|
+
A stranger who photographs your QR from across the coffee shop gets a token they can't use, because the address it's paired with is dead in their network: a Tailscale hostname won't resolve outside your tailnet, and `127.0.0.1` points at *their own* loopback where nothing is listening. This holds whether or not Tailscale is running and whether or not the WiFi you're on is hostile. The QR is single-glance-useless by construction, not by operator vigilance.
|
|
40
|
+
|
|
41
|
+
To opt in to LAN reach on a trusted home network, pass `brane meld --host <lan-ip>` paired with a matching `--bind`. That is an explicit choice — the over-the-shoulder threat reappears for the duration of that session, but only because the operator asked for it.
|
|
42
|
+
|
|
43
|
+
## Authentication
|
|
44
|
+
|
|
45
|
+
Every API and WebSocket request over TCP requires a 128-bit UUID bearer token, passed via `Authorization: Bearer` header or query parameter. Brute-forcing 128 bits of entropy is not feasible. The one exception is a request over the daemon's Unix socket, which needs no token and acts as this machine's account (below).
|
|
46
|
+
|
|
47
|
+
**Tokens are stored as hashes only.** The `tokens` table holds the SHA-256 of each token, never the token, so the daemon's own record of who may connect authenticates nothing by itself, and the daemon cannot print a token back. Each melded device gets its own token, minted when its QR is made, whether by `brane meld` or by the app's Meld item; that QR is the only copy. The device showing an in-app Meld QR never hands over its own token, and opening the sheet mints a fresh one each time. A minted token nobody uses within 10 minutes lapses, so an unscanned or photographed QR stops working. This machine's CLI and MCP bridges hold no token: they reach the daemon over its Unix socket, `~/.brane/daemon.sock`, and present nothing. `~/.brane` is `0700` and the database is `0600`, so other accounts on the machine can read none of it, and cannot connect to the socket either: reaching it takes search permission on that directory. That is why a request over the socket needs no token: only this account can have sent it, and it acts as this machine's `local` row, which no token opens. This machine's standing therefore exists only on the socket and cannot be copied off the machine. Older branes kept a local token in `~/.brane/.daemon_token` instead, which worked from any address the IP filter admits; the daemon revokes it at its first start and empties the file, and `brane doctor` reports the empty leftover, which is yours to remove. Where the daemon cannot listen on the socket (a `~/.brane` path too long for one, a filesystem that cannot hold one), the shell CLI cannot reach it and says why; agents and devices, which hold tokens, still can. The daemon checks the directory is still owned by it and closed to group and other before every such request, and refuses them if it is not. It also refuses a tokenless request that carries an `Origin` header or a `Host` other than `localhost`, which is what a browser's request looks like when something relays a port to the socket (`ssh -L`, socat, a devcontainer forward). Even so, a relay to `daemon.sock` extends "this account" to whoever can reach the relay: don't run one.
|
|
48
|
+
|
|
49
|
+
**What hashing does not cover.** Two things.
|
|
50
|
+
|
|
51
|
+
- *A process that can already act as you.* Every process running as your user, agents included, can connect to `daemon.sock` and write to `brane.db`.
|
|
52
|
+
- *A token that was written down.* The database is also a record of what was said and done, kept verbatim and never redacted: narrations and signals as they were written, and every captured tool call with its input and output. A token that reaches that record is stored in the clear. It gets there when an agent prints its own token, runs `curl` with a bearer header, or quotes a meld URL in a narration; the agent harness's own session logs hold the same text. Daemons from before hashing also printed their token into `~/.brane/daemon.log` at every start.
|
|
53
|
+
|
|
54
|
+
So a copy of `brane.db` (a backup, a debugging copy) is not safe to hand out merely because the `tokens` table is hashed: treat it as you would the token. The remedy for a token that has been written down anywhere is rotation. A revoked token is a dead string wherever it was recorded.
|
|
55
|
+
|
|
56
|
+
### Who the operator is
|
|
57
|
+
|
|
58
|
+
The **operator** is any human who can pass this machine's local authentication for the Unix account brane runs as, at the moment brane asks: Touch ID, a paired Watch or the login password on macOS; Windows Hello; polkit's own-password check on Linux (`src/presence.ts`, [Presence Check](docs/presence.md)). It names an account, not a person: anyone with a finger enrolled on that account, or its password, passes. And it means passing *then*: being logged in, over SSH say, is not enough when the prompt appears on a screen nobody is at.
|
|
59
|
+
|
|
60
|
+
Two things follow.
|
|
61
|
+
|
|
62
|
+
- **A process is never the operator**, even one running as the operator's account. Agents run as that account, but none can answer the prompt, which the daemon raises and reads itself, with wording it builds from the act being approved. What a process running as the account can do is what the account can already do with its own files and tmux, and brane does not pretend otherwise: an agent token names an agent and makes it revocable, it does not contain one. Brane offers no sandbox.
|
|
63
|
+
- **Every credential should trace back to an operator act**: a device token to a meld, an agent token to a spawn or revive. Acts that reach off this machine or cut the operator off — minting a device token, revoking a device, signing out every device — are the operator's: asked from this machine's shell they are gated by the presence check — the daemon holds the request until the operator passes the OS prompt (`src/daemon/presence-gate.ts`, [Presence Check](docs/presence.md#the-gate)) — and asked with an agent's token they are refused. A device token is the operator's delegate, minted by an operator act, and is not prompted. The gate is one more act an agent has to get past, not containment: a process that can already mint itself a device token, or edit `dist/`, is not stopped by it. The local row is the exception, since nobody approved it beyond starting the daemon: it is what a tokenless request over the socket acts as. It is not a credential, though: no token opens it, so it cannot be carried off this machine.
|
|
64
|
+
|
|
65
|
+
### Rotating the token
|
|
66
|
+
|
|
67
|
+
"Sign out other devices", at the foot of the Devices list in the app's brane switcher, replaces that device's token. The device that asked keeps its access; every other web client is refused from that moment and must scan a fresh `brane meld` QR. Local processes are unaffected — the CLI and MCP bridges hold no token, and agent sessions authenticate per narrative. `brane token rotate` in a shell signs out every device: the shell has no token of its own to replace, so once the operator passes the presence prompt the daemon revokes every device token in that one request.
|
|
68
|
+
|
|
69
|
+
For a device, the daemon retires the old token on the **first use** of the new one rather than on the rotate request itself, so a lost response cannot lock every device out; an unused replacement lapses after 60 seconds. The cost of that is a window of at most 60 seconds during which both tokens are valid, and only if the requesting device vanished before confirming. Rotation revokes credentials, not sessions on the host: a tmux session someone already attached to stays attached.
|
|
70
|
+
|
|
71
|
+
**What rotation guarantees, and what it cannot.** Once your rotation completes, holders of the old token are out. It cannot stop someone who already holds the old token from calling the rotate route themselves — that adds nothing, since the token already grants keyboard input to every tmux session — but the daemon is careful never to *help* them: a pending token is issued exactly once, in the response that minted it, and a second rotate within the window voids the first rather than repeating it. If an attacker rotates after you, your confirm fails visibly and the daemon logs that a pending rotation was replaced before use. If you suspect the token is being used by someone else *right now*, rotate from the shell: `brane token rotate` signs every device out in the one request it makes, so there is no window at all.
|
|
72
|
+
|
|
73
|
+
### Agent sessions authenticate per narrative
|
|
74
|
+
|
|
75
|
+
Each agent session has a token of its own. At spawn and revive the daemon mints an **agent token** for that narrative (kept only as a hash, in `tokens`, bound to the narrative) and puts it in the session's tmux environment as `BRANE_NARRATIVE_SECRET` beside `BRANE_NARRATIVE_ID`; each launch's token revokes the previous one. The MCP bridge authenticates with it, the `brane` CLI inside the session uses it, and the agent-harness hook presents it on every tool record. An agent token can bind only as its own narrative. It shows in the Devices list and can be revoked there, which closes its bridge (4401) and leaves its tmux session alone; a revoked agent comes back only by being revived. A claim that cannot prove itself is refused — the connection is bound instead to a fresh `external-*` narrative of its own, and a `binding_rejected` event is written on the narrative that was claimed, so the attempt is visible to its owner rather than only to the log.
|
|
76
|
+
|
|
77
|
+
**What an agent token does not do.** The agent runs as your Unix user, so it can drop its token, reach `daemon.sock` with none, and act as this machine instead. A per-agent token makes each agent nameable, attributable and revocable; it does not contain one that goes around it. What no process can do any more is take this machine's standing somewhere else: there is no local token to copy.
|
|
78
|
+
|
|
79
|
+
**What an agent token can do.** Less than a device token. Its routes are a default-deny list (`src/daemon/agent-scope.ts`): it may read brane (narratives, events, files, wiki, search), use the renderers, spawn and revive sessions, update the memory index, and bind the MCP bridge as its own narrative. It may not open the app's channels (a narrative's terminal, the control socket), send a `human_signal`, start a utility session (`POST /api/run`), see the Devices list, or do the operator's housekeeping (registering or archiving folds, archiving narratives, absorb, cost backfill and reprice); the gated acts are refused it as before. A closed route answers 403 and says to run the command from a utility session, which the operator starts from the app's spawn dialog and whose environment carries no agent token. And it works **only from this machine**, over `daemon.sock` or loopback, on every route including the agent-harness hook: every MCP bridge, spawned or external, runs here and dials the socket, so a copy of an agent token that leaks off the machine opens nothing.
|
|
80
|
+
|
|
81
|
+
**Who a signal is from is the daemon's to say.** `POST /api/narratives/:id/signal` and the control socket's `signal` message stamp `source` from the token that sent them — `web` for a device, `local` for this account over the socket — and ignore any `source` the request names; an agent's token is refused (`src/daemon/signal-source.ts`). An agent that wants to speak into another narrative narrates into it, which records an `agent_signal` under its own narrative. So a `human_signal` comes from a device or this account's shell. It does not say which device: signals are not yet tied to the token row that sent them.
|
|
82
|
+
|
|
83
|
+
All of this is hygiene, not containment, for the reasons in the paragraph above: an agent can still go around its token to `daemon.sock`. It is not handed a sanctioned way around, though: a utility session (`POST /api/run`) is one whose environment carries no agent token, so starting one is closed to agents, and so is reviving or prompting one (a prompt typed into it, or its command replayed, would run as this account). There is no `brane run` command either: the operator starts those sessions from the app's spawn dialog, so no `brane` verb an agent can type at its shell exists whose job is to start a session as this account. `brane attach <nid>` still attaches to one the operator started. tmux, too, lets any process running as you type into any session, agents included; that is known and accepted.
|
|
84
|
+
|
|
85
|
+
This is what keeps a crossed `BRANE_NARRATIVE_ID` — two processes sharing a fold, a copied env, a reused id — from narrating into a session that isn't theirs. It is not a second line of defence against an outsider: a device token's holder can already type into every tmux session. It is a guarantee about *attribution*, which is the thing a narrative log is for.
|
|
86
|
+
|
|
87
|
+
An unauthenticated client is still a first-class participant. It simply gets a narrative the daemon mints for it, with an agent token of its own returned in the connection's `welcome`, so its later reconnects are authenticated like everyone else's and it stays on one narrative rather than fragmenting into a new one per reconnect.
|
|
88
|
+
|
|
89
|
+
Narratives spawned before per-narrative secrets existed have no token, and for a time they were allowed to bind without one — a migration grace, so that upgrading the daemon would not sever a session already in flight. That grace is gone. A tokenless row can no longer be bound at all. Reviving a *dead* one mints it a token first, which is the supported way back. A tokenless session that is still running cannot be rescued in place — `revive` answers `already_running` without minting, and a running bridge could not pick up a new environment variable anyway — so it has to be ended (its tmux session killed) and then revived.
|
|
90
|
+
|
|
91
|
+
## No cloud dependency
|
|
92
|
+
|
|
93
|
+
Brane has zero cloud backend. `brane.organelle.dev` is a static Expo web bundle served from Cloudflare — it's a convenience for loading the PWA. The browser then connects **directly to your daemon** over your Tailscale network. If you don't trust the hosted app, `brane meld --local` serves the same bundle from the daemon itself. There is no telemetry, no account system, no data relay.
|
|
94
|
+
|
|
95
|
+
## Dual HTTP + HTTPS when TLS is available
|
|
96
|
+
|
|
97
|
+
When TLS certificates are present (the daemon auto-provisions them via `tailscale cert` if Tailscale is running, and otherwise lets you provision them manually with `brane certify`), the daemon serves HTTPS on the main port (for Tailscale hostname access) and simultaneously starts an HTTP fallback server on a separate ephemeral port. The HTTP server is **always** restricted to private/local IPs only — even if `--bind` is used to open the HTTPS server. This ensures that:
|
|
98
|
+
|
|
99
|
+
- **Tailscale clients** connect over HTTPS using the valid hostname certificate
|
|
100
|
+
- **Local and LAN clients** (localhost, RFC 1918, CGNAT) can still reach the daemon over plain HTTP without TLS certificate hostname mismatches
|
|
101
|
+
- The HTTP fallback port is stored in SQLite as `daemon_http_port`, and deliberately survives shutdown so meld URLs stay stable across daemon restarts
|
|
102
|
+
- WebSocket upgrade requests on the HTTP server are forwarded to the main server's handler, so terminal streaming and MCP connections work over either transport
|
|
103
|
+
|
|
104
|
+
When TLS is not configured, the daemon serves HTTP only on a single port, with the standard IP filtering applied.
|
|
105
|
+
|
|
106
|
+
## Daemon singleton guarantee
|
|
107
|
+
|
|
108
|
+
A SQLite `BEGIN EXCLUSIVE` transaction prevents race conditions when starting the daemon. Two processes cannot simultaneously claim the daemon role — the second one sees the first's PID and exits cleanly. Version mismatches between CLI and daemon are detected and surfaced with actionable warnings.
|
|
109
|
+
|
|
110
|
+
## The bundled tmux binary is integrity-checked
|
|
111
|
+
|
|
112
|
+
Brane needs `tmux` to run agent sessions. If `tmux` is already on your `PATH` — the common case for the developers brane targets — that system binary is used and **nothing is fetched**. Only when no system `tmux` exists does the daemon download a static build, and it does so **lazily on daemon startup**, never as an `npm` lifecycle script. There is no `postinstall` hook: `npm install` performs no network fetch and runs no brane code, so install-time script policies (`--ignore-scripts`, sandboxed CI) neither break brane nor are needed to contain it.
|
|
113
|
+
|
|
114
|
+
When a fetch does happen, the archive is pulled over **HTTPS** from the official `tmux/tmux-builds` GitHub releases and then verified against a **SHA-256 digest pinned in brane's source** before it is extracted or made executable. Verification is **fail-closed**: any mismatch, an unrecognized platform, or a truncated/corrupted download causes the archive to be deleted and the install abandoned — brane never extracts or runs a binary it did not pin. A compromised CDN, a re-uploaded release asset, or a network-level tamper therefore cannot result in an unverified `tmux` executing; the worst case is no `tmux`, which surfaces as an actionable "install tmux" error rather than a silent compromise.
|
|
115
|
+
|
|
116
|
+
The fetch never blocks startup and a failure is recoverable by construction: restarting the daemon re-attempts it from scratch, and a failed attempt leaves no partial binary behind. The pinned digests come from GitHub's release-API asset digests; as with any vendored-binary pin, this is trust-on-first-use for the *initial* value and a hard integrity guarantee for every fetch thereafter — you are guaranteed the exact bytes brane pinned, not a moving target.
|
|
117
|
+
|
|
118
|
+
## Why Tailscale matters on untrusted networks
|
|
119
|
+
|
|
120
|
+
By default the daemon doesn't bind to your LAN IP, so devices on the same coffee-shop / hotel / airport WiFi can't reach the daemon's port at all. The default is safe on hostile networks **without** any operator vigilance.
|
|
121
|
+
|
|
122
|
+
The threat reappears only when an operator opts in to LAN reach by running with `--bind 0.0.0.0` (or `--bind <lan-ip>`, or `brane meld --host <lan-ip>` paired with an open bind), typically on a trusted home network. Once that opt-in happens, the RFC 1918 filter accepts every device on the subnet — which is the same range every coffee shop, hotel, and airport WiFi uses. On a shared LAN with that configuration, every device on the subnet can reach your daemon's port, and the bearer token is the only remaining gate.
|
|
123
|
+
|
|
124
|
+
In that opted-in configuration, without TLS, the token travels in cleartext HTTP headers and URL query parameters. An attacker on the same WiFi can capture it by sniffing traffic. Worse, `brane meld --host <lan-ip>` displays the token alongside a LAN-reachable URL in a QR code — anyone who photographs the screen from across the room has the address and the token in a single frame.
|
|
125
|
+
|
|
126
|
+
### Tailscale provides network-level isolation
|
|
127
|
+
|
|
128
|
+
Tailscale assigns your devices addresses in the 100.x.y.z CGNAT range, routable **only within your tailnet**. Other devices on the coffee shop WiFi cannot even send packets to your Tailscale IP — your daemon is invisible to them, not just authenticated against them.
|
|
129
|
+
|
|
130
|
+
This means the QR code from `brane meld` becomes **two-factor by construction**:
|
|
131
|
+
1. **Something you know** — the bearer token
|
|
132
|
+
2. **Somewhere you are** — on the tailnet (requires a device authenticated to your Tailscale account)
|
|
133
|
+
|
|
134
|
+
Even if someone photographs the QR code, they get a hostname they can't resolve and an IP they can't route to. The token is useless without network access.
|
|
135
|
+
|
|
136
|
+
Without Tailscale, the QR code is **single-factor**: one glance gives an attacker everything they need. No amount of encryption or auth hardening changes this — the secret is displayed on screen and the network path is open.
|
|
137
|
+
|
|
138
|
+
### TLS adds encryption (and the daemon handles it for you)
|
|
139
|
+
|
|
140
|
+
Once Tailscale is running, the daemon auto-provisions a Let's Encrypt cert for your Tailscale hostname on its next start (via `tailscale cert`) and re-issues it before expiry. This encrypts all traffic between your phone and the daemon, preventing even other tailnet members from reading your data in transit. You never have to run `brane certify` to keep things working — the command remains as a manual override.
|
|
141
|
+
|
|
142
|
+
The recommended setup:
|
|
143
|
+
```sh
|
|
144
|
+
brane daemon start # First start with Tailscale up auto-provisions the cert.
|
|
145
|
+
# The daemon log records the issuance and every renewal.
|
|
146
|
+
# The one operator-side prerequisite is enabling "HTTPS
|
|
147
|
+
# for your tailnet" in the Tailscale admin console; the
|
|
148
|
+
# daemon log will point you there if it isn't already on.
|
|
149
|
+
|
|
150
|
+
# Connect your phone
|
|
151
|
+
brane meld # Generates https://hostname.tailnet.ts.net/#token=...
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
With both Tailscale and TLS active, you get:
|
|
155
|
+
- **Isolation** — only your devices can reach the daemon
|
|
156
|
+
- **Encryption** — traffic is unreadable even if intercepted
|
|
157
|
+
- **Authentication** — 128-bit bearer token as a second factor
|
|
158
|
+
|
|
159
|
+
### Without Tailscale
|
|
160
|
+
|
|
161
|
+
Without Tailscale, `brane meld` emits `http://127.0.0.1:port` — a QR that is **safe to display in public** (a stranger photographing it points at their own loopback, where nothing is listening) but only usable from the same machine the daemon is running on. That's the deliberate tradeoff: cross-device reach requires a trust boundary, and Tailscale is the supported one. `brane meld` will not auto-advertise your LAN IP, because on a hostile network that QR would hand the bearer token to anyone on the same subnet (the RFC 1918 filter accepts all of them). If you need LAN reach on a trusted home network, pass `--host <ip>` explicitly with a matching `--bind` — that's an opt-in you make with knowledge of the network you're on.
|
|
162
|
+
|
|
163
|
+
**On public WiFi, no LAN-reachable flavor of meld is safe.** Tailscale is the only supported cross-device path.
|
|
164
|
+
|
|
165
|
+
## Mentions add an unlogged *push*
|
|
166
|
+
|
|
167
|
+
A narrative's log has never been a complete account of what shaped that session, and it was never designed to be. `narrative_search`, `narrative_info`, `list_peers`, reading another narrative through a multi-narrative cursor, protocol documents, and everything else in the terminal scrollback all reach an agent's context without appearing anywhere in its own log. Any of them can change what it does next.
|
|
168
|
+
|
|
169
|
+
What mentions add is not unlogged *influence* — that is the normal case — but unlogged influence a **third party initiates**. Every vector above is pulled by the agent: it decided to search, it decided to read. A mention is pushed, by someone else, and can resolve a parked `listen`. The one existing push, foreign narration, is unauthenticated within a brane but leaves an event in the recipient's history; a mention deliberately does not, because not writing into someone else's conversation is the entire point of the feature.
|
|
170
|
+
|
|
171
|
+
So the narrow new property is: **another narrative can act on X, and X's log will not show it.** That is worth knowing when reading a transcript to reconstruct why an agent did something.
|
|
172
|
+
|
|
173
|
+
It adds no new *authority*: anyone who can mention could already narrate into that narrative outright, which is louder. What it adds is a quieter channel, and the mitigations are correspondingly modest:
|
|
174
|
+
|
|
175
|
+
- The receipt carries **pointers, never prose** — `{id, src, seq, author, time}`. A mention cannot exfiltrate text across a brane boundary; the reader has to go and read the source narrative, through the same access it already had.
|
|
176
|
+
- Cross-brane mentions are allowed (the handle namespace is already brane-wide — `list_peers` has no `brane_id` filter, so blocking them would make an id you were just shown un-mentionable), but the receipt stamps `brane_id` when it differs, so a cross-brane wake is legible rather than unexplained.
|
|
177
|
+
- Handles resolve only to narratives that exist, and only outside code spans, fences, links and URLs — so a pasted log cannot be used to page an agent invisibly.
|
|
178
|
+
- A mention **never revives**. It cannot start a process; it waits for the operator.
|
|
179
|
+
- `mentions.enabled=false` disables recording entirely. You cannot hotfix a fleet of running agents, so the kill switch is not optional.
|
|
180
|
+
|
|
181
|
+
Surfacing mentions on the recipient's own timeline — so a transcript reader can see the push the log doesn't record — is not yet done.
|
|
182
|
+
|
|
183
|
+
## What brane doesn't protect against
|
|
184
|
+
|
|
185
|
+
- An attacker with root on your machine (nothing can)
|
|
186
|
+
- A process running as your Unix account, agents included: it can reach `daemon.sock` and act as this machine, or use tmux directly (see [Who the operator is](#who-the-operator-is)). An agent token's narrower scope stops an agent that follows the rules, not one that goes around it
|
|
187
|
+
- A compromised Tailscale account (same as any tailnet service)
|
|
188
|
+
- A compromised brane.organelle.dev deployment (use `--local` to eliminate this)
|
|
189
|
+
- Independent auditing of the upstream `tmux` build (the pinned SHA-256 guarantees you receive exactly the bytes brane vetted, not that those bytes were audited beyond trust-on-first-use)
|
|
190
|
+
|
|
191
|
+
## Dependency advisories
|
|
192
|
+
|
|
193
|
+
`npm audit` on the root package is clean, with and without dev dependencies: zero known vulnerabilities in anything the daemon or CLI installs or runs, and none in the build and deploy tooling (TypeScript, wrangler).
|
|
194
|
+
|
|
195
|
+
One of those zeros is held by an `overrides` entry in `package.json` (and in `expo/package.json`), which pins `katex` to a single copy. `micromark-extension-math` still declares `katex@^0.16`, which carries a low-severity prototype-pollution advisory; brane imports only that package's syntax extension and renders math through the top-level katex 0.18, so the nested copy is never loaded. npm applies overrides only to this repository's own install, so `npm install brane` still reports that one nested katex (as "3 low") until `micromark-extension-math` releases against katex 0.18. `stream-json`, which `brane absorb` uses to stream a ChatGPT export, is on the 3.x line for the same reason: the 1.x and 2.x lines carry a prototype-pollution advisory and two denial-of-service ones, all reachable only through the operator's own export file.
|
|
196
|
+
|
|
197
|
+
The Expo web app carries residual advisories (5 moderate, 23 high at last check), all inside the **build-time** toolchain — metro, jest, react-native's CLI — and all rooted in three packages that have no patched version: `braces` (stack exhaustion on deeply nested glob patterns), `node-forge` (an RSA signature-verification flaw, under expo's code-signing certificates), and `sprintf-js` (unbounded precision specifiers, under an old `argparse`). None of that code ships in the daemon, and none of it runs in the served web bundle; it runs on the developer's machine, on the project's own files, when producing the bundle. npm's only offered "fix" for each is a major downgrade of expo or react-native, which is not happening.
|
|
198
|
+
|
|
199
|
+
All of this is re-examined whenever expo or a root dependency is upgraded; if any of it grows a non-breaking fix, take it.
|
|
200
|
+
|
|
201
|
+
## The practical summary
|
|
202
|
+
|
|
203
|
+
Install Tailscale, start the daemon, then `brane meld`. The daemon auto-provisions and renews its TLS cert via Tailscale, so HTTPS is on by default once Tailscale is up. Your daemon is reachable only from your own devices, encrypted in transit, and authenticated by a 128-bit token. The attack surface is equivalent to running any other localhost service behind Tailscale — a well-understood and battle-tested security model.
|
|
204
|
+
|
|
205
|
+
On public WiFi, Tailscale is not optional — it **is** the security model.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
/** `brane-<provider>-<6 hex of sha256(provider:identity)>` — deterministic so
|
|
3
|
+
* a second import of the same account targets the same brane. */
|
|
4
|
+
export function deriveBraneId(provider, identityId) {
|
|
5
|
+
const hash = createHash("sha256")
|
|
6
|
+
.update(`${provider}:${identityId}`)
|
|
7
|
+
.digest("hex")
|
|
8
|
+
.slice(0, 6);
|
|
9
|
+
return `brane-${provider}-${hash}`;
|
|
10
|
+
}
|
|
11
|
+
//# sourceMappingURL=brane-id.js.map
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/** ChatGPT export parser for brane absorb */
|
|
2
|
+
/**
|
|
3
|
+
* Extract all messages from the conversation tree, preserving DAG topology.
|
|
4
|
+
*
|
|
5
|
+
* Strategy: DFS the entire tree from root. All messages get linearized by
|
|
6
|
+
* timestamp (seq order), but branch points are preserved via re: references.
|
|
7
|
+
* Each message's re: points to its parent message's eventual seq position.
|
|
8
|
+
*/
|
|
9
|
+
function linearize(conv) {
|
|
10
|
+
// Find root node (no parent)
|
|
11
|
+
let rootId = null;
|
|
12
|
+
for (const [id, node] of Object.entries(conv.mapping)) {
|
|
13
|
+
if (!node.parent) {
|
|
14
|
+
rootId = id;
|
|
15
|
+
break;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
if (!rootId)
|
|
19
|
+
return [];
|
|
20
|
+
// DFS to collect all messages
|
|
21
|
+
const allMessages = [];
|
|
22
|
+
const stack = [rootId];
|
|
23
|
+
while (stack.length > 0) {
|
|
24
|
+
const nodeId = stack.pop();
|
|
25
|
+
const node = conv.mapping[nodeId];
|
|
26
|
+
if (!node)
|
|
27
|
+
continue;
|
|
28
|
+
if (node.message) {
|
|
29
|
+
if (!node.message.metadata?.is_visually_hidden_from_conversation) {
|
|
30
|
+
const role = normalizeRole(node.message.author.role);
|
|
31
|
+
if (role) {
|
|
32
|
+
const { text, assetPointers } = extractContent(node.message.content);
|
|
33
|
+
// Include message if it has text content or asset pointers
|
|
34
|
+
if (text || assetPointers.length > 0) {
|
|
35
|
+
const time = node.message.create_time
|
|
36
|
+
? Math.round(node.message.create_time * 1000)
|
|
37
|
+
: Math.round(conv.create_time * 1000);
|
|
38
|
+
const msg = {
|
|
39
|
+
role,
|
|
40
|
+
content: text || "",
|
|
41
|
+
time,
|
|
42
|
+
model: node.message.metadata?.model_slug,
|
|
43
|
+
_nodeId: node.id,
|
|
44
|
+
_parentNodeId: node.parent,
|
|
45
|
+
};
|
|
46
|
+
if (assetPointers.length > 0) {
|
|
47
|
+
msg.assetPointers = assetPointers;
|
|
48
|
+
}
|
|
49
|
+
allMessages.push(msg);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// Push children in reverse so first child is processed first
|
|
55
|
+
for (let i = node.children.length - 1; i >= 0; i--) {
|
|
56
|
+
stack.push(node.children[i]);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// Sort by timestamp for linear seq ordering
|
|
60
|
+
allMessages.sort((a, b) => a.time - b.time);
|
|
61
|
+
// Build nodeId → seq mapping
|
|
62
|
+
const nodeToSeq = new Map();
|
|
63
|
+
for (let i = 0; i < allMessages.length; i++) {
|
|
64
|
+
nodeToSeq.set(allMessages[i]._nodeId, i + 1); // seq is 1-based
|
|
65
|
+
}
|
|
66
|
+
// Convert to AbsorbedMessage with re: references
|
|
67
|
+
const result = [];
|
|
68
|
+
for (let i = 0; i < allMessages.length; i++) {
|
|
69
|
+
const msg = allMessages[i];
|
|
70
|
+
const { _nodeId: _, _parentNodeId, ...clean } = msg;
|
|
71
|
+
// Add re: reference to parent message (if parent produced a message)
|
|
72
|
+
if (_parentNodeId) {
|
|
73
|
+
const parentSeq = nodeToSeq.get(_parentNodeId);
|
|
74
|
+
// Only add re: for non-sequential parents (branch points)
|
|
75
|
+
// If parent is the immediately preceding message, re: is redundant
|
|
76
|
+
if (typeof parentSeq === "number" && parentSeq !== i) {
|
|
77
|
+
clean.re = [parentSeq];
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
result.push(clean);
|
|
81
|
+
}
|
|
82
|
+
return result;
|
|
83
|
+
}
|
|
84
|
+
function normalizeRole(role) {
|
|
85
|
+
switch (role) {
|
|
86
|
+
case "user": return "user";
|
|
87
|
+
case "assistant": return "assistant";
|
|
88
|
+
case "system": return "system";
|
|
89
|
+
case "tool": return "tool";
|
|
90
|
+
default: return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function extractContent(content) {
|
|
94
|
+
if (!content.parts || content.parts.length === 0)
|
|
95
|
+
return { text: null, assetPointers: [] };
|
|
96
|
+
const textParts = [];
|
|
97
|
+
const assetPointers = [];
|
|
98
|
+
for (const part of content.parts) {
|
|
99
|
+
if (typeof part === "string") {
|
|
100
|
+
textParts.push(part);
|
|
101
|
+
}
|
|
102
|
+
else if (typeof part === "object" && part !== null) {
|
|
103
|
+
const obj = part;
|
|
104
|
+
// Image and audio asset pointers
|
|
105
|
+
if (typeof obj.asset_pointer === "string") {
|
|
106
|
+
assetPointers.push(obj.asset_pointer);
|
|
107
|
+
}
|
|
108
|
+
// Audio transcriptions — extract as text
|
|
109
|
+
if (obj.content_type === "audio_transcription" && typeof obj.text === "string") {
|
|
110
|
+
textParts.push(obj.text);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
text: textParts.join("\n") || null,
|
|
116
|
+
assetPointers,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/** Count model usage across all messages */
|
|
120
|
+
function countModels(messages) {
|
|
121
|
+
const counts = {};
|
|
122
|
+
for (const msg of messages) {
|
|
123
|
+
if (msg.model) {
|
|
124
|
+
counts[msg.model] = (counts[msg.model] || 0) + 1;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return counts;
|
|
128
|
+
}
|
|
129
|
+
/** Find the most common model in a conversation */
|
|
130
|
+
function dominantModel(messages) {
|
|
131
|
+
const counts = countModels(messages);
|
|
132
|
+
let best;
|
|
133
|
+
let bestCount = 0;
|
|
134
|
+
for (const [model, count] of Object.entries(counts)) {
|
|
135
|
+
if (count > bestCount) {
|
|
136
|
+
best = model;
|
|
137
|
+
bestCount = count;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return best;
|
|
141
|
+
}
|
|
142
|
+
export const chatgptProvider = {
|
|
143
|
+
name: "chatgpt",
|
|
144
|
+
parseOne(raw) {
|
|
145
|
+
const conv = raw;
|
|
146
|
+
if (!conv.mapping || !conv.current_node)
|
|
147
|
+
return null;
|
|
148
|
+
const messages = linearize(conv);
|
|
149
|
+
if (messages.length === 0)
|
|
150
|
+
return null;
|
|
151
|
+
return {
|
|
152
|
+
externalId: conv.id,
|
|
153
|
+
title: conv.title || null,
|
|
154
|
+
createdAt: Math.round(conv.create_time * 1000),
|
|
155
|
+
updatedAt: Math.round(conv.update_time * 1000),
|
|
156
|
+
messages,
|
|
157
|
+
model: dominantModel(messages),
|
|
158
|
+
};
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
//# sourceMappingURL=chatgpt.js.map
|