capabilities/default.json
- Drop every `store:*` grant. `@tauri-apps/plugin-store` has no caller in
`app/src`, and the plugin's `resolve_store_path` is a `PathBuf::push` against
AppData — `push` discards the base for an absolute path, so the grant was an
arbitrary host read/write from the webview.
- Replace `opener:default` with a scoped `opener:allow-open-url` (http/https
only). That drops `reveal_item_in_dir`, which the plugin does not scope-check
and nothing here calls, and the unused mailto:/tel: scope.
- Record the unscopable `drag:allow-start-drag` residual risk in `description`.
tauri.conf.json
- Add `form-action 'none'`, `base-uri 'none'`, `object-src 'none'`.
`form-action` has no `default-src` fallback, so an injected auto-submitting
form was unblocked even though `script-src 'self'` blocks XSS.
- Remove the dead `asset:` / `https://asset.localhost` img-src and `data:`
font-src grants; `blob:` stays (the file viewer uses it).
auth_bridge
- The reserved-port set covered only this project's mappings and the two
browser-view ranges. It now also covers the gateway, STT and web-terminal
host ports (configured value and shipped default, read off the settings
models) and every other project's published host port. A container binding
container-loopback 4000 / 9876 / 7681 while those services were stopped had
that port mirrored onto the host, unauthenticated, within one poll.
- Gate the host listener on fetch metadata: refuse a request that is a
cross-site sub-resource, allow navigations (the OAuth redirect) and anything
without `Sec-Fetch-*`. Non-HTTP connections are classified from their first
line and forwarded verbatim. Residual risk is spelled out in the module docs.
- Bound the forwards: max concurrent connections per port, a first-byte
deadline enforced before any `docker exec` is created, and an idle timeout.
browser_view/mod.rs
- `pick_viewer_port` reads procfs with `/usr/bin/cat`, not a bare `cat` the
container can shim via its writable PATH entry.
- Treat port choice as check-then-bind: walk to the next free candidate when
the viewer does not come up, instead of failing the start.
BrowserTab.tsx
- Sandbox the viewer iframe. Container-controlled content could `top.location`
the app's webview away. `allow-top-navigation*` and
`allow-popups-to-escape-sandbox` are deliberately absent.
HelpDialog.tsx
- Escape the quote characters in the entity pass and escape captured attribute
values. `href="$2"` with `$2` = `[^)]+` let remote GitHub markdown close the
attribute and open another, in a document rendered with
`dangerouslySetInnerHTML`.
web_terminal/terminal.html
- SRI hashes plus `crossorigin` on the three jsdelivr bundles and the
stylesheet, and a CSP for the page — it is served 0.0.0.0 behind a permissive
CORS layer and nothing else gives it one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
The Files tab could accept a drop but never produce one: getting a file
out meant "Save to host…" and a file picker. This adds the other
direction.
Two constraints shape it. `dragDropEnabled` is on — TerminalView needs
it, since the native drag-drop event is the only one carrying dropped
file paths — and it blocks HTML5 drag inside the webview, so `draggable`
plus `DataTransfer.setData("DownloadURL", …)` was never available. The
gesture is therefore pointer events into `tauri-plugin-drag`, the same
shape and the same reason as the tab strip's drag. And the file being
dragged does not exist on the host at all: it lives in a container, and
the OS can only drag a real host path.
So a drag-out is a copy first and a drag second.
`stage_container_file_for_drag` materialises the file into
`<os-temp>/triple-c-drag-out/<session>/<slot>/<name>` through the same
`fetch_container_file` the download and the viewer use, keeps the
original filename (a dropped `tmp1234` is not a file anyone wants), and
caps at the 256 MiB an upload already caps at, naming "Save to host…" in
the refusal. The path comes from Tauri's path API rather than `/tmp`,
because on Windows it is neither.
The staging directory has a lifecycle, because whole files accumulating
in the host temp dir would be the disk problem this project just fixed,
in a new place: cleared on exit inside the existing teardown (still
guarded on the main window), and reaped at startup for whatever a crash
left behind.
The copy is also an async gap in the middle of a gesture that feels
instantaneous, and the OS only adopts a drag while the button is still
down. Small files beat the pointer; large ones do not — so the staged
path is cached per entry (keyed on size and mtime, so an edited file
re-stages) and the pane says the copy is ready and to drag again, which
is an instruction rather than an apology because the retry is immediate.
A per-file slot keeps `a/notes.txt` and `b/notes.txt` from becoming the
same host path.
"Save to host…" stays exactly as it was. Drag-out is the enhancement;
a platform that refuses `startDrag` says so and points back at it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
Tauri v2 desktop app (React/TypeScript + Rust) for managing
containerized Claude Code environments. Includes Gitea Actions
workflow for building and pushing the sandbox container image,
and a BUILDING.md guide for manual app builds on Linux and Windows.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>