2026-09-22 20:39:38 -07:00
# App-command lockdown via Tauri AppManifest — design
Date: 2026-09-22
2026-09-22 22:12:25 -07:00
Status: approved in conversation (user); revised against the implemented viewer (`bf22910` ) and
the review rulings in "Decisions made during review"; plan at
`docs/superpowers/plans/2026-09-22-app-manifest-lockdown.md`
2026-09-22 20:39:38 -07:00
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
2026-09-22 22:12:25 -07:00
window, so a `file-viewer-*` window can invoke exactly its five `viewer_*` commands and nothing
2026-09-22 20:39:38 -07:00
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-*/` .
2026-09-22 22:12:25 -07:00
- 115 commands are registered in `generate_handler!` (`lib.rs:469-607` ): the 109 that predate
the viewer plus its six (`open_file_viewer` , `viewer_get_state` , `viewer_choose_file` ,
`viewer_read_file` , `viewer_poll_file` , `viewer_write_file` ). All 115 are invoked from
`app/src` , every one through an `export const` wrapper in `lib/tauri-commands.ts` , which is
the only non-test file that imports `@tauri-apps/api/core` (the earlier direct
`invoke("terminal_input")` in `hooks/useTerminal.ts` is gone; only a comment mentions it).
No invoke name exists without a registration. None uses `#[command(rename = …)]` , so the
invoke name is always the function identifier.
2026-09-22 20:39:38 -07:00
- `capabilities/default.json` is `windows: ["main"]` and grants five plugin/core permissions.
2026-09-22 22:12:25 -07:00
`capabilities/file-viewer.json` is `windows: ["file-viewer-*"]` and grants four core
permissions. `tauri.conf.json` has no `app.security.capabilities` key, so every file under
`capabilities/` is active (tauri-build `acl.rs:424-429` ).
- Both files already have an exact-set census test: `the_capability_grants_are_the_ones_that_were_reviewed`
(`lib.rs:924` ) for `default.json` , and `the_viewer_capability_grants_exactly_the_reviewed_windows_and_permissions`
plus `the_default_capability_is_scoped_to_the_main_window_only` (`file_viewer/mod.rs:63,89` )
for `file-viewer.json` and the `windows` lists. All three will fail as written the moment
bare `allow-*` grants appear and are updated in the same change (§3.4).
- The viewer's five `viewer_*` commands take `window: tauri::Window` and gate on the caller's
label inside `commands/file_viewer_commands.rs` ; `open_file_viewer` is main-only by the same
mechanism. The `default.json` description, `file-viewer.json` description and `CLAUDE.md`
(Backend Structure and Key Conventions) all currently describe app commands as ungated by
capability and point at this spec as the pending fix; all three are rewritten here (§3.5).
- The only other non-main window is the browser-view pop-out, label `browser-view-<id>`
2026-09-22 20:39:38 -07:00
(`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
2026-09-22 22:12:25 -07:00
per main-window command — 110: the 109 that predate the viewer plus ` open_file_viewer`. The
existing five plugin/core grants stay exactly as they are. The ` description` is amended (§3.5).
2026-09-22 20:39:38 -07:00
2026-09-22 22:12:25 -07:00
` capabilities/file-viewer.json` (exists, ` windows: ["file-viewer-*"]`, four core grants)
gains exactly ` allow-viewer-get-state`, ` allow-viewer-choose-file`, ` allow-viewer-read-file`,
` allow-viewer-poll-file`, ` allow-viewer-write-file`, beside its four core grants.
2026-09-22 20:39:38 -07:00
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.
2026-09-22 22:12:25 -07:00
Assignment of every command (110 go to **main**; nothing but the viewer's five goes to
**file-viewer**). The rule is a function, not a table: a command whose name starts with
` viewer_` belongs to ` file-viewer-*`, every other command belongs to ` main`
(` command_census::expected_windows`, §3.2). The table is the rule applied to today's list:
2026-09-22 20:39:38 -07:00
| 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 |
2026-09-22 22:12:25 -07:00
| Terminal file viewer | ` open_file_viewer` | main |
| Terminal file viewer | ` viewer_get_state`, ` viewer_choose_file`, ` viewer_read_file`, ` viewer_poll_file`, ` viewer_write_file` | file-viewer |
2026-09-22 20:39:38 -07:00
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
2026-09-22 22:12:25 -07:00
The manifest must not be a second hand-maintained list. The logic lives in a new
` src/command_census.rs` — plain functions over strings and ` serde_json::Value`, no reference to
the crate — that is compiled into **both** the build script (` #[path = "src/command_census.rs"]
mod command_census;` in ` build.rs`) and the test build (` #[cfg(test)] mod command_census;` in
` lib.rs`), so the parser the build uses is the parser the tests test and the two cannot drift.
Its surface:
2026-09-22 20:39:38 -07:00
2026-09-22 22:12:25 -07:00
` ``rust
pub fn registered_commands(lib_rs: &str) -> Option<Vec<String>>; // handler list, in order, duplicates kept
pub fn allow_permission(command: &str) -> String; // "viewer_read_file" → "allow-viewer-read-file"
pub fn expected_windows(command: &str) -> &'static [&'static str]; // "viewer_*" → ["file-viewer-*"], else ["main"]
pub struct CapabilityFile { pub name: String, pub windows: Vec<String>, pub bare: Vec<String> }
pub fn capability_file(name: &str, json: &str) -> Result<CapabilityFile, String>;
pub fn check(commands: &[String], files: &[CapabilityFile]) -> Vec<String>; // every violation, or empty
` ``
` build.rs` becomes:
1. ` registered_commands` is the line-based extraction the existing
` every_command_is_registered_exactly_once` test uses today (` lib.rs:849-870`), moved
verbatim; that test calls it too. ` println!("cargo:rerun-if-changed=…")` for ` src/lib.rs`,
` src/command_census.rs` and the ` capabilities` directory.
2026-09-22 20:39:38 -07:00
2. **Fail closed on an empty or malformed parse.** An empty list makes ` .commands(&[])` a no-op
2026-09-22 22:12:25 -07:00
and silently restores today's unguarded state, so ` check` reports (and ` build.rs` exits 1 on)
an empty list, a name outside ` [a-z0-9_]+`, or a duplicate.
3. Delete every ` permissions/autogenerated/<name>.toml` whose ` <name>` is not a registered
command before calling tauri-build, so a command removed from ` lib.rs` cannot leave a stale
permission behind. **Not** ` remove_dir_all`: tauri-build emits
` cargo:rerun-if-changed=permissions` (spike §7.I), so regenerating every file on every build
would give them fresh mtimes and re-run the build script — and recompile the crate — on
every ` cargo` invocation forever. Live files are left alone; tauri-utils only rewrites them
when their content changes (` write_if_changed`, ` acl/build.rs:308`), so the build settles.
Also refuse anything under ` permissions/` other than ` autogenerated/`: tauri-build globs
hand-written permission files into the manifest, which would be a second source of grants
that the census does not see. Add ` app/src-tauri/permissions/autogenerated/` to
` .gitignore`; the reviewable artifact is ` gen/schemas/acl-manifests.json`, already tracked.
4. Read every ` capabilities/*.json` with ` capability_file` (bare = identifiers without ` :`;
scoped-object grants are read by their ` identifier`), and ` check` asserts:
2026-09-22 20:39:38 -07:00
- every registered command's ` allow-<slug>` appears in **exactly one** capability file
2026-09-22 22:12:25 -07:00
(missing → names the command and the ` windows` of the file it belongs in; more than one →
names the files);
- every bare identifier is ` allow-<slug>` of a registered command (a typo is named; tauri's
own ` validate_capabilities` would also fail, but its message dumps every generated
` allow-*`/` deny-*` identifier);
- no bare ` deny-*` (global, §2.3) and nothing else bare that is not ` allow-*` (so no bare
` default`);
- the file that grants a command has ` windows` equal to ` expected_windows(command)` — the
"which side does this belong to" rule decided in one place, and the reason the ` viewer_*`
naming prefix is now load-bearing.
All violations are reported together, so ten forgotten grants are one build failure.
5. ` tauri_build::try_build(Attributes::new().app_manifest(AppManifest::new().commands(leaked)))`
where ` leaked: &'static [&'static str]` comes from ` Box::leak` (ruling: acceptable; the
process is short-lived and it is two lines), propagating the error the way
` tauri_build::build()` does (print and exit 1).
2026-09-22 20:39:38 -07:00
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).
2026-09-22 22:12:25 -07:00
` serde_json` is already a dependency and is added to ` [build-dependencies]` (already in
` Cargo.lock`, so ` --offline` still resolves).
2026-09-22 20:39:38 -07:00
Why "exactly one" rather than "at least one": today no command is shared between windows, and
2026-09-22 22:12:25 -07:00
a command that genuinely needs both is a design change worth a visible edit to
` expected_windows`, not a silent widening.
2026-09-22 20:39:38 -07:00
### 3.3 Frontend check (vitest)
` app/src/test/capabilities.test.ts`, following ` icon-config.test.ts`'s pattern of reading
2026-09-22 22:12:25 -07:00
` src-tauri/` files with ` readFileSync`. The earlier draft split invoke literals by directory;
that is wrong for the tree as built, because the viewer does not call ` invoke` itself — it
imports wrappers from the shared ` lib/tauri-commands.ts` (` viewerGetState`, ` viewerChooseFile`
in ` ViewerApp.tsx`; ` viewerReadFile`, ` viewerPollFile`, ` viewerWriteFile` in ` EditorPane.tsx`).
So the test follows imports instead:
- **Only ` lib/tauri-commands.ts` may import ` @tauri -apps/api/core`** (true today for every
non-test file). That is what makes the rest of the test complete: no other file can invoke.
- Build the wrapper map from ` tauri-commands.ts`: each ` export const NAME = …` chunk contains
exactly one ` invoke<…>("<literal>"` (115 wrappers, 115 commands today). A chunk with zero or
more than one literal, or a non-literal first argument, fails the test.
- The **viewer closure** is the transitive set of relative imports from ` src/viewer/main.tsx`
(` .ts`/` .tsx`, tests excluded, ` .css` ignored). Today it reaches ` lib/tauri-commands`,
` lib/types`, ` components/ui/{Button,StatusIndicator,unavailable}` and
` components/projects/home/filePreview`, none of which import a wrapper except the two viewer
files. ` V` = commands of the wrappers those files import; ` M` = commands of the wrappers
imported by every other non-test file under ` src/`.
- Assert ` M ∩ V = ∅`; ` allow-<slug>(V)` **equals** the bare set of ` file-viewer.json` (no
over-grant to the untrusted window); ` allow-<slug>(M)` is a **subset** of the bare set of
` default.json`; and every wrapper's command is in exactly one of the two files (a cheap mirror
of the Rust rule, so a developer running only vitest sees the same failure).
- ` default.json`'s five plugin/core grants and ` file-viewer.json`'s four core grants are
asserted as exact lists, the same census style as the Rust tests.
This is the test that turns "a forgotten command silently breaks a feature at runtime" into a
red test on the developer's machine for the *window* dimension: the Rust build check (§3.2)
proves every command is granted in the file its name says it belongs in; this one proves the
code that runs in each window only reaches wrappers that window is granted.
### 3.4 Rust test updates
- ` lib.rs` ` 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_permission` over the registered commands whose ` expected_windows` is ` ["main"]`
(110 today). Keep the ` :default` and ` store:` refusals.
- ` file_viewer/mod.rs` ` the_viewer_capability_grants_exactly_the_reviewed_windows_and_permissions`:
the exact list becomes the four core grants plus ` allow-viewer-get-state`,
` allow-viewer-choose-file`, ` allow-viewer-read-file`, ` allow-viewer-poll-file`,
` allow-viewer-write-file`. ` the_default_capability_is_scoped_to_the_main_window_only` is
unchanged.
- New ` lib.rs` test ` 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
permission identifiers equal ` allow-<slug>` ∪ ` deny-<slug>` over 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 calling
` command_census::registered_commands` instead of its inline copy.
- ` command_census.rs` carries its own unit tests for ` check` (missing grant, duplicate grant,
typo, ` deny-*`, wrong file, empty list, bad name) — these are the tests that make the build
check trustworthy, and they run under ` cargo test` without needing a failing build.
2026-09-22 20:39:38 -07:00
### 3.5 Documentation updates (same change)
2026-09-22 22:12:25 -07:00
- ` capabilities/default.json` ` description` (the threat model of record): replace the sentence
run that begins "App commands stay ungated by capability in both files" and ends "not yet
built" with the closed state: ` build.rs` declares an ` AppManifest`, so every app command is
ACL-gated per window; 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!` (the
build fails on a missing, misspelled, duplicated or misfiled grant); the ` file-viewer-*`
windows are confined to ` capabilities/file-viewer.json`; ` deny-*` is global in tauri 2.11.0
and is therefore banned by the same check; the pop-out remains capability-less; and the
Rust label gates in ` file_viewer_commands.rs` stay because the ACL says *which* window and
the label says *whose* entry. Amend the opening "verified against tauri 2.11.0's ` PLUGINS`
table" framing so it no longer implies app commands are outside this file.
- ` capabilities/file-viewer.json` ` description`: replace "app commands need no entry here and
are gated by label inside ` commands/file_viewer_commands.rs`" with: the five ` allow-viewer-*`
grants are the only app commands this window can reach, the label gate is still what stops
window A acting on window B's entry, and ` build.rs` refuses any other bare grant here.
- ` CLAUDE.md` "Key Conventions" (` CLAUDE.md:598-600`): 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 (` viewer_*` → ` file-viewer.json`, everything else →
` default.json`); ` cargo check`/` tauri build` fail otherwise, and
` src/test/capabilities.test.ts` fails if the code that runs in a window imports a wrapper that
window is not granted. Note the ` _`→` -` rule, that ` deny-*` is off-limits, and that
` permissions/autogenerated/` is generated and ignored.
- ` CLAUDE.md` Backend Structure (` CLAUDE.md:179-180`): the ` file_viewer/` bullet's "residual
risk … the AppManifest lockdown spec closes it" becomes "closed by ` build.rs`'s
` AppManifest`"; add one bullet for ` build.rs` + ` command_census.rs`.
- ` file_viewer/mod.rs` module doc: one sentence that ` file-viewer-*` is also the capability
glob, so labels are minted only here.
2026-09-22 20:39:38 -07:00
- ` .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-*` |
2026-09-22 22:12:25 -07:00
| ` file-viewer.json` grants a main-only command | The viewer window gains reach | §3.2 ` expected_windows` assertion and §3.3 equality check |
| Main-window code imports a ` viewer*` wrapper (or the viewer imports a main one) | Runtime denial in that window | §3.3 ` M ∩ V = ∅` and the per-side set checks |
| A future window label that happens to match ` file-viewer-*` | Inherits the viewer grants | Labels are minted only in ` file_viewer/`; note in that module's doc comment |
| Existing census tests (` lib.rs:924`, ` file_viewer/mod.rs:63`) | Fail as written once bare grants appear | Updated in the same commit (§3.4) |
| Capability grants and ` build.rs` land in different commits | Bare grants without a manifest fail the build; a manifest without grants builds but denies every command at runtime | One commit carries ` build.rs`, both capability files and the census-test updates (plan Task 2) |
2026-09-22 20:39:38 -07:00
| ` 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):
2026-09-22 22:12:25 -07:00
- ` cd app/src-tauri && cargo test --offline`: §3.4 tests plus the existing census tests.
2026-09-22 20:39:38 -07:00
- ` cd app && npx vitest run src/test/capabilities.test.ts` and the full ` npm run test`.
2026-09-22 22:12:25 -07:00
- ` npx tsc --noEmit`, ` cargo clippy --offline` clean, ` npm run build`.
2026-09-22 20:39:38 -07:00
- Negative checks, each done once by hand and then reverted, to prove the checks bite:
2026-09-22 22:12:25 -07:00
(a) remove one ` allow-*` from ` default.json` → ` cargo check --offline` 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 ` expected_windows` assertion;
(e) import ` listProjects` from ` lib/tauri-commands` in ` src/viewer/ViewerApp.tsx` → vitest
fails; (f) temporarily make ` registered_commands` return ` Some(vec![])` → the build fails
on the empty-list rule rather than building an unguarded app.
2026-09-22 20:39:38 -07:00
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.
2026-09-22 22:12:25 -07:00
5. File viewer: click a path → window opens, reads, "Choose" list when ambiguous, edits, saves,
conflict banner, live reload on an external change (that is ` viewer_poll_file`); then in
the viewer's devtools run
2026-09-22 20:39:38 -07:00
` 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.
2026-09-22 22:12:25 -07:00
## 6. Open questions — resolved
1. **CI test steps: out of scope** (controller ruling). CI runs no test suite (§1). The
build-time check (§3.2) covers the security property on every merge because it runs inside
` tauri build`; the vitest half (§3.3) and the ` cargo test` half (§3.4) only run on a
developer's machine. Recommendation to the user, not part of this change: add
` cd app && npm run test` and ` cd app/src-tauri && cargo test` steps to
` build-app-preview.yml` after the dependency install.
2. **` Box::leak` in ` build.rs`: acceptable** (controller ruling; either was allowed). Chosen
over a generated include file because it is two lines, needs no ` OUT_DIR` plumbing, and the
build script exits immediately afterwards.
3. **No ` webviews` patterns** (controller ruling). One webview per window; ` windows` globs
only.
## Decisions made during review
Checked against the tree at ` bf22910` (the completed viewer) and the tauri-build 2.6.0 /
tauri-utils 2.9.0 / tauri 2.11.0 sources pinned by ` Cargo.lock`. The user-approved goal
(allow-lists only, one file per window, the build fails rather than a feature breaking at
runtime) is unchanged. What changed:
1. **Five viewer commands, not four (§3.1).** The implemented viewer polls with
` viewer_poll_file`, so ` file-viewer.json` gets five ` allow-viewer-*` grants and ` default.json`
gets 110, for 115 registered commands.
2. **The window rule is a function (§3.2).** ` expected_windows(command)` (` viewer_*` →
` file-viewer-*`, else ` main`) replaces both the "no ` viewer_*` in main / only ` viewer_*` in
the viewer file" policy assertion and the ` SHARED` list. A command that must be callable from
both windows is an edit to that function, which is the visible design change the earlier
draft wanted; the extra list was a second place to say the same thing.
3. **The check logic is a module, not inline ` build.rs` (§3.2, §3.4).** A build script cannot
be unit-tested, and the empty-list, typo, ` deny-*` and wrong-file rules are exactly the code
whose failure mode is "builds an unguarded app". ` command_census.rs` is compiled into both
` build.rs` and the test build, so the rules get ordinary ` cargo test` coverage and the
handler-list parser the census test already used becomes the one the build uses.
4. **The vitest test follows imports, not directories (§3.3).** The viewer never calls ` invoke`;
it imports wrappers from the shared ` lib/tauri-commands.ts`. So the test computes the
viewer's transitive import closure from ` viewer/main.tsx` and takes the wrappers that closure
imports; a directory split would have put every viewer command in the "main" bucket and
proved nothing.
5. **Both census tests change, not one (§3.4).** The viewer feature added its own exact-set
test in ` file_viewer/mod.rs`; the earlier draft only knew about the ` lib.rs` one.
6. **` gen/schemas/*.json` is committed with the change.** It is tracked, tauri-build rewrites it
on every build, and after this change it contains the ` __app-acl__` manifest and the
resolved grants — the reviewable evidence of what the binary embeds. Expect a large diff:
` desktop-schema.json` and ` linux-schema.json` gain an enum entry per generated permission
(about 12 lines per command, ~2,700 lines across the two files for 115 commands).
9. **Stale-permission cleanup is selective, not ` remove_dir_all` (§3.2 step 3).** Found by the
spike: wholesale regeneration would re-run the build script on every ` cargo` call.
7. **Atomicity (§4).** A bare ` allow-*` in a capability file with no app manifest is a build
error (` Permission allow-… not found`), and a manifest with no grants builds an app in which
every command is denied. ` build.rs`, both capability files and the census-test updates are
therefore one commit; the shared module and its tests land before it, and the end-to-end
manifest test, the vitest and the docs after it.
8. **Spike (see §7).** Every claim in §2 that the design leans on was reproduced in a throwaway
worktree before the plan was written.
## 7. Spike results (2026-09-22, throwaway worktree at ` bf22910`, then removed)
Environment: tauri-build 2.6.0 / tauri 2.11.0 / tauri-utils 2.9.0 from ` Cargo.lock`,
` cargo check --offline` with the shared ` target/` dir; each run ~3 s (only ` triple-c`
recompiles). A fresh worktree needs ` app/dist` to exist (` npm run build`) or
` generate_context!` panics on ` frontendDist` before any ACL code runs.
| # | Experiment | Result |
|---|---|---|
| A | Untouched tree | Builds. ` acl-manifests.json` has only plugin keys (` core`, ` core:*`, ` dialog`, ` opener`); no ` __app-acl__`. |
| B | ` build.rs` with ` AppManifest::new().commands(Box::leak(…))` for three names | Builds. ` permissions/autogenerated/{check_docker,list_projects,viewer_read_file}.toml` written (` allow-check-docker` / ` deny-check-docker`, ` commands.allow = ["check_docker"]`, header ` # Automatically generated - DO NOT EDIT!`). ` acl-manifests.json` gains ` __app-acl__` with the six identifiers. ` desktop-schema.json` and ` linux-schema.json` each gain 36 lines (enum entries). ` capabilities.json` unchanged until a file references a grant. Nothing else written. |
| C | Bare ` allow-check-docker`, ` allow-list-projects` in ` default.json`; ` allow-viewer-read-file` in ` file-viewer.json` | Builds. ` gen/schemas/capabilities.json` shows the grants under each capability. |
| D | Bare ` allow-does-not-exist` | **Fails** (exit 101): ` Permission allow-does-not-exist not found, expected one of allow-check-docker, …` — one ~10 KB line listing every identifier, no fuzzy hint. Hence the project's own check with a readable message. |
| E | Typo ` allow-check-dokcer` | Fails, same shape. |
| F | Bare ` deny-check-docker` in ` file-viewer.json` | **Builds** and appears in ` capabilities.json` — tauri does not object; only §3.2's ban does. |
| G | Drop ` list_projects` from the manifest list, keep its grant | **Builds**: the stale ` list_projects.toml` is still globbed. After deleting ` permissions/autogenerated`, fails with ` Permission allow-list-projects not found`. Stale files are real. |
| H | Original ` tauri_build::build()` plus one bare grant | **Fails**: ` Permission allow-check-docker not found, expected one of core:default, …`. The message goes to the build script's **stdout** (` println!`). Capability grants cannot land before the manifest. |
| I | Rerun semantics | tauri-build emits ` cargo:rerun-if-changed=capabilities` unconditionally (` acl.rs:427`) and ` =permissions` when the dir exists (` acl.rs:300-305`). Editing a capability file alone re-runs the script and refreshes ` gen/schemas`. Writing a ` .toml` makes the *next* run re-run once more, then it settles because ` write_if_changed` leaves unchanged files alone. |
| J | Signatures | ` AppManifest::commands(mut self, commands: &'static [&'static str]) -> Self` (` acl.rs:100`); ` AppManifest` is ` Copy`, so no owned alternative — ` Box::leak` it is. ` try_build(Attributes) -> anyhow::Result<()>`; ` build()` prints ` {error:#}` and ` exit(1)`. |