Terminal file viewer/editor + per-window app-command lockdown #60

Merged
jknapp merged 37 commits from feat/terminal-file-viewer into main 2026-09-23 17:05:50 +00:00
4 changed files with 65 additions and 4 deletions
Showing only changes of commit 90991fee32 - Show all commits
+15
View File
@@ -73,6 +73,12 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
- **`hooks/`** — All Tauri IPC calls are encapsulated in hooks (`useTerminal`, `useProjects`, `useDocker`, `useSettings`)
- **`lib/tauri-commands.ts`** — Typed `invoke()` wrappers; TypeScript types in `lib/types.ts` must match Rust models
- **`components/terminal/TerminalView.tsx`** — xterm.js integration with WebGL rendering, URL detection for OAuth flow
- **`viewer/`** — the terminal file viewer's window (second Vite entry `viewer.html`
`src/viewer/main.tsx`; CodeMirror 6). `lib/filePathLinks.ts` decides what a path is;
`components/terminal/filePathLinkProvider.ts` registers it with xterm. The OSC 8 handler now
runs with `allowNonHttpProtocols` on and dispatches `file:` to the viewer, so every other scheme
must be refused *there*. `viewer.html` must never carry an inline `<style>` — Tauri would add a
style nonce and CodeMirror's injected styles would stop applying.
- **`components/layout/`** — TopBar, MainTabs (the unified tab strip), Sidebar, StatusBar
- **`components/projects/`** — `ProjectRow` (select-only list row), `ProjectList`, `AddProjectDialog`,
and the editors reused by Project Home
@@ -161,6 +167,13 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
Beyond docker/project/settings/terminal: `inspect_commands.rs` (read-only views into a
container — Claude sessions, installed capabilities, scheduler tasks), `auth_bridge_commands.rs`,
`auth_token_commands.rs`.
- **`file_viewer/`** — one window per click (`file-viewer-<n>`), a managed `ViewerRegistry`,
resolution by probing `/workspace/<p>` then `/workspace/<mount>/<p>` in one exec as `claude`,
polling by `sha256sum`, saves staged in `/tmp` and swapped in by a `sh` script as the container
user (spec §5 says why the archive API never writes to the target directory). Commands take
`window: tauri::Window`, gate on the label and act on the caller's own registry entry — no
viewer command accepts a path. The residual risk that any local window can call any app command
is deliberate and documented; the AppManifest lockdown spec closes it.
- **`auth_bridge/`** — Host-side loopback bridge so browser logins run *inside* a container can
complete against the host browser. Discovers listeners by parsing `/proc/net/tcp{,6}` (the image
has no `ss`/`netstat`/`lsof`), binds host `127.0.0.1` **only**, and tunnels in over the Docker
@@ -601,6 +614,8 @@ Anthropic and Bedrock deliberately keep Claude Code's own defaults.
`#[serde(default)]` on a `bool` yields `false`; follow the `default_full_permissions` pattern in
`models/project.rs` for anything that should default to true.
- Cross-platform paths: Docker socket is `/var/run/docker.sock` on Linux/macOS, `//./pipe/docker_engine` on Windows
- A new local window needs its own capability file (`capabilities/file-viewer.json` is the
model), and `lib.rs`'s `on_window_event` stays guarded on `label() == "main"`.
## Secrets
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+48 -2
View File
@@ -45,7 +45,53 @@ mod tests {
assert!(!html.contains("<style"), "an inline <style> makes Tauri add a style nonce, which disables 'unsafe-inline' and breaks CodeMirror");
let vite = std::fs::read_to_string(app_dir.join("vite.config.ts")).expect("vite.config.ts");
assert!(vite.contains("viewer.html"), "vite.config.ts must list viewer.html in build.rollupOptions.input");
let cap = std::fs::read_to_string(app_dir.join("src-tauri/capabilities/file-viewer.json")).expect("capability");
assert!(cap.contains("\"file-viewer-*\"") && cap.contains("core:window:allow-destroy"));
}
#[derive(serde::Deserialize)]
struct Capability {
windows: Vec<String>,
permissions: Vec<String>,
}
/// Task 12: a substring check on the capability JSON (the form this test used to take)
/// only proves a permission string appears *somewhere* in the file — it would not catch
/// `windows` widened past `file-viewer-*`, nor an extra grant slipped in beside the ones
/// this window actually needs. Parse both capability files and pin `windows`/`permissions`
/// exactly, so a later widening of either file is a failing test, not a silent threat-model
/// drift — this file *is* the reviewed threat model of record (see its own description).
#[test]
fn the_viewer_capability_grants_exactly_the_reviewed_windows_and_permissions() {
let app_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
let raw = std::fs::read_to_string(app_dir.join("src-tauri/capabilities/file-viewer.json"))
.expect("capabilities/file-viewer.json");
let cap: Capability = serde_json::from_str(&raw).expect("file-viewer.json must be valid JSON");
assert_eq!(cap.windows, vec!["file-viewer-*"]);
let mut permissions = cap.permissions;
permissions.sort();
assert_eq!(
permissions,
vec![
"core:event:allow-listen",
"core:event:allow-unlisten",
"core:webview:allow-internal-toggle-devtools",
"core:window:allow-destroy",
]
);
}
/// The main window's capability file must stay scoped to `main` — a `windows` list that
/// grew to include `file-viewer-*` would hand every viewer window the dialog/store surface
/// `default.json` grants `main`, which is a much larger IPC surface than the one
/// `file-viewer.json` was deliberately kept small.
#[test]
fn the_default_capability_is_scoped_to_the_main_window_only() {
let app_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
let raw = std::fs::read_to_string(app_dir.join("src-tauri/capabilities/default.json"))
.expect("capabilities/default.json");
let cap: Capability = serde_json::from_str(&raw).expect("default.json must be valid JSON");
assert_eq!(cap.windows, vec!["main"]);
}
}