365 lines
25 KiB
Markdown
365 lines
25 KiB
Markdown
# App-command lockdown via Tauri AppManifest — design
|
|||
|
|
|
||
|
|
Date: 2026-09-22
|
||
|
|
Status: approved in conversation (user), pending plan
|
||
|
|
Follows: `2026-09-22-terminal-file-viewer-design.md` (the viewer windows this exists to confine)
|
||
|
|
|
||
|
|
## Goal
|
||
|
|
|
||
|
|
Make every application command (the `generate_handler!` list in `lib.rs`) permission-gated per
|
||
|
|
window, so a `file-viewer-*` window can invoke exactly its four `viewer_*` commands and nothing
|
||
|
|
else, and the `main` window keeps exactly what it invokes today. Close the residual risk the
|
||
|
|
viewer spec records in its §6: "a compromised viewer window can still invoke other app commands,
|
||
|
|
because `build.rs` does not restrict app commands per window."
|
||
|
|
|
||
|
|
Non-goals: scoping arguments of app commands (the ACL only allows/denies command names; argument
|
||
|
|
validation stays in Rust as today), changing any plugin grant, `removeUnusedCommands` (see §3.6).
|
||
|
|
|
||
|
|
## 1. Current state (verified)
|
||
|
|
|
||
|
|
- `app/src-tauri/build.rs` is `tauri_build::build()`, i.e. `try_build(Attributes::default())`
|
||
|
|
with an empty `AppManifest` (tauri-build 2.6.0 `src/lib.rs:454`).
|
||
|
|
- Versions from `Cargo.lock`: tauri 2.11.0, tauri-build 2.6.0, tauri-utils 2.9.0,
|
||
|
|
tauri-codegen 2.6.0, tauri-macros 2.6.0. Everything below is read from those sources under
|
||
|
|
`~/.cargo/registry/src/index.crates.io-*/`.
|
||
|
|
- 109 commands are registered in `generate_handler!` (`lib.rs:467-598`). All 109 are invoked
|
||
|
|
from `app/src` (108 through `lib/tauri-commands.ts`, plus `terminal_input` invoked directly in
|
||
|
|
`hooks/useTerminal.ts`); no invoke name exists without a registration. None uses
|
||
|
|
`#[command(rename = …)]`, so the invoke name is always the function identifier.
|
||
|
|
- `capabilities/default.json` is `windows: ["main"]` and grants five plugin/core permissions.
|
||
|
|
`tauri.conf.json` has no `app.security.capabilities` key, so every file under `capabilities/`
|
||
|
|
is active (tauri-build `acl.rs:424-429`).
|
||
|
|
- The only non-main window today is the browser-view pop-out, label `browser-view-<id>`
|
||
|
|
(`browser_view/popout.rs:71,104`), `WebviewUrl::External`. No capability names it and no
|
||
|
|
capability has `remote`, so it has no IPC at all — before and after this change.
|
||
|
|
- `gen/schemas/acl-manifests.json` and `gen/schemas/capabilities.json` are **tracked in git**
|
||
|
|
and rewritten by tauri-build on every build; the app manifest will show up there as a
|
||
|
|
reviewable diff.
|
||
|
|
- CI (`.gitea/workflows/build-app-preview.yml`, `build-app.yml`) runs `npm run build`
|
||
|
|
(`tsc && vite build`) and `tauri build`. It runs **neither `cargo test` nor `vitest`**. Any
|
||
|
|
check that must hold on every merge therefore has to fail the *build*, not a test.
|
||
|
|
|
||
|
|
## 2. Mechanism (tauri-build 2.6.0 / tauri-utils 2.9.0 / tauri 2.11.0)
|
||
|
|
|
||
|
|
### 2.1 What `AppManifest::commands` generates
|
||
|
|
|
||
|
|
```rust
|
||
|
|
tauri_build::try_build(
|
||
|
|
tauri_build::Attributes::new()
|
||
|
|
.app_manifest(tauri_build::AppManifest::new().commands(&["check_docker", /* … */])),
|
||
|
|
)
|
||
|
|
```
|
||
|
|
|
||
|
|
- `commands` takes `&'static [&'static str]` (`tauri-build/src/acl.rs:87-114`).
|
||
|
|
- `app_manifest_permissions` (`acl.rs:265-335`) calls
|
||
|
|
`tauri_utils::acl::build::autogenerate_command_permissions`, which for each command writes
|
||
|
|
**into the source tree** at `src-tauri/permissions/autogenerated/<command>.toml`
|
||
|
|
(`tauri-utils/src/acl/build.rs:289-317`):
|
||
|
|
|
||
|
|
```toml
|
||
|
|
[[permission]]
|
||
|
|
identifier = "allow-<command with _ → ->"
|
||
|
|
commands.allow = ["<command verbatim>"]
|
||
|
|
[[permission]]
|
||
|
|
identifier = "deny-<command with _ → ->"
|
||
|
|
commands.deny = ["<command verbatim>"]
|
||
|
|
```
|
||
|
|
|
||
|
|
Naming: only `_` is replaced by `-` (`build.rs:290`). `viewer_read_file` →
|
||
|
|
`allow-viewer-read-file`. Identifiers may not contain `_` (`identifier.rs:96-109`), so the
|
||
|
|
kebab form is mandatory in capability files; the command name inside stays snake_case.
|
||
|
|
- Then `permissions/**/*` is globbed too (default `permissions_path_pattern`), so any hand-written
|
||
|
|
`.toml`/`.json` under `permissions/` is also part of the app manifest. **Stale files are never
|
||
|
|
deleted**: a command dropped from `.commands()` leaves its `.toml` behind, still defining a
|
||
|
|
permission a capability can reference. §3.2 handles this.
|
||
|
|
- No app `default` permission set is generated (`acl.rs` has no such logic; only
|
||
|
|
`InlinedPlugin` has `DefaultPermissionRule`).
|
||
|
|
- The manifest is stored under key `__app-acl__` (`APP_ACL_KEY`, `tauri-utils/src/acl/mod.rs:50`)
|
||
|
|
in `gen/schemas/acl-manifests.json`, copied to `OUT_DIR/acl-manifests.json`, and embedded by
|
||
|
|
`generate_context!` (`tauri-codegen/src/context.rs:393-417`, `Resolved::resolve`).
|
||
|
|
- The app manifest exists iff it has at least one permission, permission set or default
|
||
|
|
(`acl.rs:400-443`, `has_app_manifest`). `.commands(&[])` is the same as no manifest.
|
||
|
|
|
||
|
|
### 2.2 How capabilities reference app permissions
|
||
|
|
|
||
|
|
- Bare identifier, no prefix: `"allow-viewer-read-file"`. Resolution takes
|
||
|
|
`permission_id.get_prefix().unwrap_or(APP_ACL_KEY)` (`resolved.rs:340-372`); `core:event:…`
|
||
|
|
and `dialog:…` keep working unchanged.
|
||
|
|
- `windows` / `webviews` are glob patterns via `glob::Pattern` (`resolved.rs:199-208`,
|
||
|
|
matched in `authority.rs:460-461`), so `"file-viewer-*"` is valid. Labels are **not**
|
||
|
|
validated against configured windows at build time (no such check in tauri-build or
|
||
|
|
tauri-codegen).
|
||
|
|
- `local` defaults to `true`; both the bundled `tauri://localhost` document and the Vite
|
||
|
|
`devUrl` count as local (`webview/mod.rs:1698-1740`, `is_local_url`: tauri protocol, or
|
||
|
|
relative to `devUrl`/`frontendDist`). The viewer's `viewer.html` is served the same way as
|
||
|
|
`index.html`, so it is local in both dev and release.
|
||
|
|
|
||
|
|
### 2.3 Runtime enforcement
|
||
|
|
|
||
|
|
`Webview::on_message` (`tauri-2.11.0/src/webview/mod.rs:1794-1849`):
|
||
|
|
|
||
|
|
```rust
|
||
|
|
// we only check ACL on plugin commands or if the app defined its ACL manifest
|
||
|
|
if (plugin_command.is_some() || has_app_acl_manifest)
|
||
|
|
&& request.cmd != crate::ipc::channel::FETCH_CHANNEL_DATA_COMMAND
|
||
|
|
&& invoke.acl.is_none()
|
||
|
|
{ /* reject */ }
|
||
|
|
```
|
||
|
|
|
||
|
|
- **Today** (`has_app_acl_manifest == false`): app commands skip the ACL entirely. That is the
|
||
|
|
hole.
|
||
|
|
- **With a manifest**: `resolve_access` (`ipc/authority.rs:439-471`) must return an allow whose
|
||
|
|
capability `windows`/`webviews` glob matches the caller and whose context matches the origin.
|
||
|
|
Otherwise the invoke is rejected **before** the `generate_handler!` closure runs; the
|
||
|
|
`#[command]` wrappers do no allow/deny logic of their own.
|
||
|
|
- **Unlisted command = denied.** A registered command with no `allow-*` permission anywhere never
|
||
|
|
enters `allowed_commands`, so `resolve_access` returns `None`. Debug message:
|
||
|
|
`"<cmd> not allowed. Command not found"`. There is no build-time warning for this case.
|
||
|
|
- **Listed but not registered** = a permission nobody can use; no build error. If granted and
|
||
|
|
invoked, the handler's fallthrough returns "unhandled" — harmless, but §3.2 rejects it anyway.
|
||
|
|
- **`deny-*` is global.** `resolve_access` tests `denied_commands.get(cmd).map(..).is_some()`,
|
||
|
|
which is true for any deny entry regardless of window or origin. Never grant a `deny-*` in the
|
||
|
|
viewer capability expecting it to stay confined; the design uses allow-lists only, and the
|
||
|
|
build check bans `deny-*` outright.
|
||
|
|
- Denial text: debug builds get `resolve_access_message` (`authority.rs:229-437`), e.g.
|
||
|
|
`"<cmd> not allowed. Permissions associated with this command: allow-<slug>"` or
|
||
|
|
`"<cmd> not allowed on window \"file-viewer-3\", … allowed on: [windows: \"main\", …]"`.
|
||
|
|
Release builds get `"Command <cmd> not allowed by ACL"`. The frontend's `refusalText.ts`
|
||
|
|
wrapper-stripping already tolerates the `invoke failed:`/`Error:` prefixes.
|
||
|
|
|
||
|
|
### 2.4 Build-time failure modes
|
||
|
|
|
||
|
|
- Unknown bare permission in a capability file (typo, or a command missing from the manifest):
|
||
|
|
`validate_capabilities` (`tauri-build/src/acl.rs:353-392`) bails with
|
||
|
|
`Permission <id> not found, expected one of …`; `build()` exits 1. This also fires for any
|
||
|
|
bare identifier when there is no app manifest at all.
|
||
|
|
- Capabilities inlined in `tauri.conf.json` are *not* validated there and fail later in
|
||
|
|
`generate_context!` as a panic (`failed to resolve ACL`). Keep capabilities as files.
|
||
|
|
- Duplicate capability identifiers across files are an error (`build.rs:204-244`).
|
||
|
|
- A bad glob in `windows` is a resolve-time error.
|
||
|
|
- The build script's CWD is `src-tauri`; the `./permissions/autogenerated` path is relative to it.
|
||
|
|
|
||
|
|
### 2.5 Dev vs release
|
||
|
|
|
||
|
|
Enforcement is identical. `cfg(debug_assertions)` changes only the error text and whether the
|
||
|
|
raw ACL map is retained for messages (`authority.rs:29-30, 77-101`). `tauri dev` versus `tauri
|
||
|
|
build` differs only in `custom-protocol`, which does not touch the ACL. A command missing from a
|
||
|
|
capability is therefore caught the first time anyone opens the affected screen in `tauri dev`,
|
||
|
|
with a message naming the permission.
|
||
|
|
|
||
|
|
## 3. Design
|
||
|
|
|
||
|
|
### 3.1 Capability files
|
||
|
|
|
||
|
|
`capabilities/default.json` (unchanged `windows: ["main"]`) gains one bare `allow-<slug>` entry
|
||
|
|
per main-window command — the 109 existing ones plus `open_file_viewer`. The existing five
|
||
|
|
plugin/core grants stay exactly as they are. The `description` is amended (§3.5).
|
||
|
|
|
||
|
|
`capabilities/file-viewer.json` (created by the viewer feature, `windows: ["file-viewer-*"]`)
|
||
|
|
gains exactly `allow-viewer-read-file`, `allow-viewer-write-file`, `allow-viewer-choose-file`,
|
||
|
|
`allow-viewer-get-state`, beside the core grants the viewer spec lists.
|
||
|
|
|
||
|
|
No permission sets, no `default`, no hand-written files under `permissions/`. The project's
|
||
|
|
existing test already refuses `*:default` aliases because they expand to sets the file does
|
||
|
|
not spell out; the same rule applies to app permissions. Enumeration is the point.
|
||
|
|
|
||
|
|
Assignment of every command (all 109 existing registrations go to **main**; nothing but the
|
||
|
|
viewer's four goes to **file-viewer**):
|
||
|
|
|
||
|
|
| Group (`lib.rs` comment) | Commands | Window |
|
||
|
|
|---|---|---|
|
||
|
|
| Docker | `check_docker`, `check_image_exists`, `build_image`, `get_container_info` | main |
|
||
|
|
| Projects | `list_projects`, `add_project`, `remove_project`, `update_project`, `start_project_container`, `stop_project_container`, `rebuild_project_container`, `reconcile_project_statuses` | main |
|
||
|
|
| Notes | `list_notes`, `save_note`, `delete_note` | main |
|
||
|
|
| Migration | `get_container_staleness`, `migrate_project_to_base`, `confirm_migration`, `rollback_migration`, `get_migration_state` | main |
|
||
|
|
| Auth bridge | `set_auth_bridge_enabled`, `get_auth_bridge_status` | main |
|
||
|
|
| Browser view | `set_browser_view_enabled`, `get_browser_view_status`, `check_browser_view_support`, `install_browser_view_support`, `install_browser_view_browser`, `open_browser_view_popout`, `close_browser_view_popout`, `get_browser_view_popout_state`, `set_browser_view_popout_always_on_top`, `open_page_in_container_browser`, `set_container_page_viewport`, `get_container_page_state`, `close_container_page`, `set_browser_view_match_window`, `get_browser_view_match_window` | main |
|
||
|
|
| Claude token | `acquire_claude_token`, `submit_claude_token_code`, `cancel_claude_token`, `has_claude_token`, `clear_claude_token`, `sweep_claude_token_snapshots` | main |
|
||
|
|
| Settings | `get_settings`, `update_settings`, `pull_image`, `detect_aws_config`, `inspect_ca_cert_path`, `list_aws_profiles`, `detect_host_timezone` | main |
|
||
|
|
| Settings export/import | `export_settings`, `preview_settings_import`, `apply_settings_import` | main |
|
||
|
|
| Terminal | `open_terminal_session`, `terminal_input`, `terminal_resize`, `close_terminal_session`, `paste_image_to_terminal`, `upload_host_file_to_terminal`, `start_audio_bridge`, `send_audio_data`, `stop_audio_bridge` | main |
|
||
|
|
| Files | `list_container_files`, `download_container_backup`, `download_container_file`, `upload_files_to_container`, `read_container_file`, `rename_container_path`, `create_container_directory` | main |
|
||
|
|
| AWS | `aws_sso_refresh` | main |
|
||
|
|
| Updates | `get_app_version`, `check_for_updates`, `check_image_update` | main |
|
||
|
|
| Help | `get_help_content` | main |
|
||
|
|
| URL open | `open_url_external` | main |
|
||
|
|
| Install helper | `detect_install_options`, `run_docker_install` | main |
|
||
|
|
| Web terminal | `start_web_terminal`, `stop_web_terminal`, `get_web_terminal_status`, `regenerate_web_terminal_token` | main |
|
||
|
|
| STT | `get_stt_status`, `start_stt`, `stop_stt`, `build_stt_image`, `pull_stt_image`, `transcribe_audio` | main |
|
||
|
|
| Gateway | `get_gateway_status`, `start_gateway`, `stop_gateway`, `check_gateway_health`, `build_gateway_image`, `pull_gateway_image`, `set_gateway_api_key`, `clear_gateway_api_key`, `get_gateway_auth_token`, `regenerate_gateway_auth_token` | main |
|
||
|
|
| Inspect | `list_claude_sessions`, `resume_session_command`, `list_container_capabilities`, `list_scheduled_tasks`, `add_scheduled_task`, `update_scheduled_task`, `get_scheduled_task_log`, `set_scheduled_task_enabled`, `run_scheduled_task_now`, `remove_scheduled_task`, `get_scheduler_notifications`, `clear_scheduler_notifications` | main |
|
||
|
|
| File viewer (new, from the viewer spec) | `open_file_viewer` | main |
|
||
|
|
| File viewer (new) | `viewer_read_file`, `viewer_write_file`, `viewer_choose_file`, `viewer_get_state` | file-viewer |
|
||
|
|
|
||
|
|
Windows other than main: `browser-view-*` (pop-out) invokes nothing and must stay listed in no
|
||
|
|
capability — the pane's iframe inside main is a remote origin and has no IPC either. The web
|
||
|
|
terminal is a WebSocket server, not IPC, and is unaffected.
|
||
|
|
|
||
|
|
The Rust label checks in the viewer commands (`open_file_viewer` main-only; `viewer_*`
|
||
|
|
caller's-own-label-only) stay. The ACL says *which* window may call; the label check says *whose*
|
||
|
|
registry entry it operates on. They are not redundant.
|
||
|
|
|
||
|
|
### 3.2 `build.rs`: manifest derived from `generate_handler!`, checked against capabilities
|
||
|
|
|
||
|
|
The manifest must not be a second hand-maintained list. `build.rs` becomes:
|
||
|
|
|
||
|
|
1. Parse the `generate_handler![ … ]` block out of `src/lib.rs` into the set of registered
|
||
|
|
command names, using the same line-based extraction the existing
|
||
|
|
`every_command_is_registered_exactly_once` test uses (`lib.rs:838-861`). Move that extractor
|
||
|
|
into `src/command_census.rs` (plain `fn`, no crate deps) and pull it into both places with
|
||
|
|
`#[path = "src/command_census.rs"] mod command_census;` in `build.rs` and
|
||
|
|
`#[cfg(test)] #[path = "command_census.rs"] mod command_census;` in `lib.rs`, so the two
|
||
|
|
parsers cannot drift. `println!("cargo:rerun-if-changed=src/lib.rs")`.
|
||
|
|
2. **Fail closed on an empty or malformed parse.** An empty list makes `.commands(&[])` a no-op
|
||
|
|
and silently restores today's unguarded state, so `build.rs` panics if the list is empty,
|
||
|
|
contains a name outside `[a-z0-9_]+`, or contains a duplicate.
|
||
|
|
3. `std::fs::remove_dir_all("permissions/autogenerated")` (ignore NotFound) before calling
|
||
|
|
tauri-build, so a command removed from `lib.rs` cannot leave a stale permission behind.
|
||
|
|
Add `permissions/autogenerated/` to `.gitignore`; the reviewable artifact is
|
||
|
|
`gen/schemas/acl-manifests.json`, which is already tracked.
|
||
|
|
4. Read every `capabilities/*.json`, collect the **bare** permission identifiers per file
|
||
|
|
(strings without `:`; scoped-object grants are read by their `identifier`), and assert:
|
||
|
|
- every registered command's `allow-<slug>` appears in **exactly one** capability file
|
||
|
|
(missing → names the command and the file it probably belongs in; more than one → names
|
||
|
|
both files);
|
||
|
|
- every bare identifier is `allow-<slug>` of a registered command (catches typos with a
|
||
|
|
message that names the nearest command; tauri's own `validate_capabilities` would also
|
||
|
|
fail, but its message dumps every generated `allow-*`/`deny-*` identifier);
|
||
|
|
- no bare `deny-*` (global, §2.3) and no bare `default`;
|
||
|
|
- the file for `windows: ["main"]` contains no `viewer_*` grant and the `file-viewer-*` file
|
||
|
|
contains only `viewer_*` grants — a one-line policy assertion so that "which side does this
|
||
|
|
belong to" is decided in one place.
|
||
|
|
5. `tauri_build::try_build(Attributes::new().app_manifest(AppManifest::new().commands(
|
||
|
|
Box::leak(names))))`, propagating the error the way `tauri_build::build()` does (print and
|
||
|
|
exit 1). `Box::leak` is the honest way to satisfy `&'static [&'static str]` in a build script.
|
||
|
|
|
||
|
|
Because this runs inside the build script, it fails `cargo check`, `cargo test`, `tauri dev`
|
||
|
|
and CI's `tauri build` alike — the one place a check is guaranteed to run on every merge (§1).
|
||
|
|
The rule set is small enough to live in `build.rs` directly; `serde_json` is already a
|
||
|
|
dependency and can be added to `[build-dependencies]`.
|
||
|
|
|
||
|
|
Why "exactly one" rather than "at least one": today no command is shared between windows, and
|
||
|
|
a command that genuinely needs both is a design change worth a visible edit to the check, not a
|
||
|
|
silent widening. The check has a single `SHARED: &[&str] = &[]` list to make that edit explicit.
|
||
|
|
|
||
|
|
### 3.3 Frontend check (vitest)
|
||
|
|
|
||
|
|
`app/src/test/capabilities.test.ts`, following `icon-config.test.ts`'s pattern of reading
|
||
|
|
`src-tauri/` files with `readFileSync`:
|
||
|
|
|
||
|
|
- Extract every `invoke("<name>"` / `invoke<T>("<name>"` literal from `app/src/**` excluding
|
||
|
|
tests, split by tree: `src/viewer/**` (the viewer bundle) versus everything else (the main
|
||
|
|
bundle). A non-literal first argument to `invoke` fails the test — it would be un-auditable.
|
||
|
|
- Every main-bundle name must have `allow-<slug>` in `capabilities/default.json`, and every
|
||
|
|
viewer-bundle name in `capabilities/file-viewer.json`. This is the test that turns "a forgotten
|
||
|
|
command silently breaks a feature at runtime" into a red test on the developer's machine: the
|
||
|
|
Rust build check (§3.2) proves every command is granted *somewhere*; this one proves it is
|
||
|
|
granted to the window whose code calls it.
|
||
|
|
- The reverse for the viewer file: every `allow-*` in `file-viewer.json` is invoked from
|
||
|
|
`src/viewer/**` — no over-grant to the untrusted window. (Not asserted for `default.json`: a
|
||
|
|
main-window command invoked from a hook that a test cannot see statically is possible, and
|
||
|
|
the Rust side already proves it is registered and invoked.)
|
||
|
|
- `default.json`'s five plugin/core grants and `file-viewer.json`'s core grants are asserted as
|
||
|
|
exact lists, the same census style as the Rust test.
|
||
|
|
|
||
|
|
### 3.4 Rust test updates (`lib.rs` tests)
|
||
|
|
|
||
|
|
- `the_capability_grants_are_the_ones_that_were_reviewed`: split `listed` into prefixed and
|
||
|
|
bare. The prefixed census stays the exact five-element list. The bare set must equal
|
||
|
|
`allow-<slug>` over (registered commands minus the viewer's four). Extend it to read
|
||
|
|
`capabilities/file-viewer.json` with the same shape: exact core list plus exactly the four
|
||
|
|
viewer grants. Keep the `:default` and `store:` refusals.
|
||
|
|
- New `the_generated_app_manifest_matches_the_handler_list`: `cargo test` runs `build.rs`, so
|
||
|
|
`gen/schemas/acl-manifests.json` is fresh; assert its `__app-acl__` entry's `allow-*`
|
||
|
|
identifiers equal the registered set exactly. This is the end-to-end proof that the parse in
|
||
|
|
`build.rs` produced what tauri embedded, independent of the parser it shares with §3.2.
|
||
|
|
- `every_command_is_registered_exactly_once` is unchanged apart from using the shared extractor.
|
||
|
|
|
||
|
|
### 3.5 Documentation updates (same change)
|
||
|
|
|
||
|
|
- `capabilities/default.json` `description` (the threat model of record): add a paragraph
|
||
|
|
stating that `build.rs` now declares an `AppManifest` so every app command is ACL-gated per
|
||
|
|
window; that the bare `allow-*` entries are the complete list of app commands the main window
|
||
|
|
may call and are cross-checked by `build.rs` against `generate_handler!`; that the
|
||
|
|
`file-viewer-*` windows are confined to `capabilities/file-viewer.json`; that `deny-*` is
|
||
|
|
global in tauri 2.11.0 and is therefore banned; and that the pop-out remains capability-less.
|
||
|
|
Replace the earlier "verified against tauri 2.11.0's `PLUGINS` table" framing where it implies
|
||
|
|
app commands are outside this file.
|
||
|
|
- `CLAUDE.md` "Key Conventions": the bullet saying `capabilities/default.json` grants plugin
|
||
|
|
commands only and app commands "do not need an entry there" becomes false. Replace with: a new
|
||
|
|
command needs (1) `#[tauri::command]`, (2) a `generate_handler!` entry, (3) an
|
||
|
|
`allow-<kebab-name>` entry in the one capability file for the window that calls it; the build
|
||
|
|
fails otherwise, and the vitest check fails if the calling bundle and the capability disagree.
|
||
|
|
Note the `_`→`-` rule, that `deny-*` is off-limits, and that `permissions/autogenerated/` is
|
||
|
|
generated and ignored.
|
||
|
|
- `CLAUDE.md` Backend Structure: one line on `build.rs` and `command_census.rs`.
|
||
|
|
- `.gitignore`: `app/src-tauri/permissions/autogenerated/`.
|
||
|
|
|
||
|
|
### 3.6 Deliberately not done
|
||
|
|
|
||
|
|
- `build.removeUnusedCommands` stays off. It compiles out commands no capability grants
|
||
|
|
(`tauri-macros/src/command/handler.rs:92-142`), but only when the CLI sets
|
||
|
|
`REMOVE_UNUSED_COMMANDS`, so `cargo test` and `tauri build` would compile different handler
|
||
|
|
lists, and it matches on the function identifier rather than a `rename`. The §3.2 check gives
|
||
|
|
the same guarantee at build time without a second code path.
|
||
|
|
- No `permissions/*.toml` written by hand, no permission sets. Every grant is a literal
|
||
|
|
`allow-<slug>` string in a capability file, so the census tests stay a flat string compare.
|
||
|
|
- No `webviews` patterns: this app has one webview per window.
|
||
|
|
|
||
|
|
## 4. Migration risks
|
||
|
|
|
||
|
|
| Risk | Where it bites | How it is caught |
|
||
|
|
|---|---|---|
|
||
|
|
| A registered command is left out of `default.json` | Runtime: that feature's invoke rejected with `not allowed. Permissions associated with this command: allow-…` (release: `Command … not allowed by ACL`) | `build.rs` §3.2 fails the build before any binary exists; the vitest §3.3 fails locally |
|
||
|
|
| Typo in an `allow-*` string | Build | tauri's `validate_capabilities` and the §3.2 check, both at build time |
|
||
|
|
| Parser regression yields an empty command list | Would silently restore the unguarded state | §3.2 step 2 panics on empty; §3.4 asserts `acl-manifests.json` has the full set |
|
||
|
|
| Stale `permissions/autogenerated/<cmd>.toml` after a command is removed | A capability could still reference a dead permission and validate | §3.2 step 3 deletes the directory every build; the directory is gitignored |
|
||
|
|
| A `deny-*` added "for the viewer only" | Denies the command for main too (global) | §3.2 rejects any bare `deny-*` |
|
||
|
|
| `file-viewer.json` grants a main-only command | The viewer window gains reach | §3.2 policy assertion (`viewer_*` only) and §3.3 reverse check |
|
||
|
|
| A future window label that happens to match `file-viewer-*` | Inherits the viewer grants | Labels are minted only in `file_viewer.rs`; note in that module's doc comment |
|
||
|
|
| Existing `the_capability_grants_are_the_ones_that_were_reviewed` test | Fails as written once bare grants appear | Updated in the same commit (§3.4) |
|
||
|
|
| `gen/schemas/acl-manifests.json` churn in diffs | Noise | Accepted; it is the reviewable evidence of what got embedded |
|
||
|
|
| Frontend invoked via a computed name | Un-auditable | §3.3 fails on a non-literal `invoke` argument |
|
||
|
|
|
||
|
|
Dev vs release: no behavioural difference (§2.5), so `tauri dev` is a faithful rehearsal. The
|
||
|
|
only visible difference is the error text.
|
||
|
|
|
||
|
|
## 5. Testing and manual verification
|
||
|
|
|
||
|
|
Automated (all must be green):
|
||
|
|
- `cd app/src-tauri && cargo test`: §3.4 tests plus the existing census tests.
|
||
|
|
- `cd app && npx vitest run src/test/capabilities.test.ts` and the full `npm run test`.
|
||
|
|
- `npx tsc --noEmit`, `cargo clippy` clean.
|
||
|
|
- Negative checks, each done once by hand and then reverted, to prove the checks bite:
|
||
|
|
(a) remove one `allow-*` from `default.json` → `cargo check` fails naming the command;
|
||
|
|
(b) misspell one → fails naming it; (c) add `deny-check-docker` → fails; (d) move
|
||
|
|
`allow-viewer-read-file` into `default.json` → fails the policy assertion; (e) invoke a
|
||
|
|
main-only command from `src/viewer` → vitest fails.
|
||
|
|
|
||
|
|
Manual (`npm run tauri dev`, then a release `tauri build` on Linux for the AppImage):
|
||
|
|
1. Cold start: projects list, Docker status, settings, help all render (each is an app command
|
||
|
|
on the main window).
|
||
|
|
2. Open a terminal, type, resize, paste an image, drop a file — the terminal group.
|
||
|
|
3. Files tab: list, view, rename, upload, save-to-host; Backup.
|
||
|
|
4. Browser view: enable, pop out, resize, close pop-out; in the pop-out's devtools,
|
||
|
|
`window.__TAURI_INTERNALS__?.invoke("list_projects")` must reject (remote origin, no
|
||
|
|
capability) exactly as before this change.
|
||
|
|
5. File viewer: click a path → window opens, reads, edits, saves, conflict banner; then in the
|
||
|
|
viewer's devtools run
|
||
|
|
`window.__TAURI_INTERNALS__.invoke("list_projects")` and
|
||
|
|
`…invoke("read_container_file", {...})` — both must reject with
|
||
|
|
`not allowed on window "file-viewer-1"`. From the main window's devtools,
|
||
|
|
`invoke("viewer_read_file")` must reject likewise.
|
||
|
|
6. Settings export/import, gateway, STT, web terminal, scheduler tabs — one action each.
|
||
|
|
7. Watch the app log for `not allowed` during the whole pass; any occurrence is a missed grant.
|
||
|
|
|
||
|
|
## 6. Open questions
|
||
|
|
|
||
|
|
1. CI runs no test suite (§1). The build-time check covers the security property, but the
|
||
|
|
vitest half (§3.3) only runs on a developer's machine. Adding `cargo test` and `npm run test`
|
||
|
|
steps to `build-app-preview.yml` is a separate decision; this spec does not depend on it.
|
||
|
|
2. `Box::leak` in `build.rs` versus generating a `commands.rs` include file: leaking is simpler
|
||
|
|
and the process is short-lived; flagging in case a reviewer prefers the include.
|
||
|
|
3. Whether the four `viewer_*` grants should also require a `webviews` match. Not needed with
|
||
|
|
one webview per window; revisit only if the viewer ever hosts a child webview.
|