# App-command lockdown via Tauri AppManifest — design Date: 2026-09-22 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` 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 five `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-*/`. - 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. - `capabilities/default.json` is `windows: ["main"]` and grants five plugin/core permissions. `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-` (`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/.toml` (`tauri-utils/src/acl/build.rs:289-317`): ```toml [[permission]] identifier = "allow-" commands.allow = [""] [[permission]] identifier = "deny-" commands.deny = [""] ``` 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: `" 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. `" not allowed. Permissions associated with this command: allow-"` or `" not allowed on window \"file-viewer-3\", … allowed on: [windows: \"main\", …]"`. Release builds get `"Command 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 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-` entry 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). `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. 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 (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: | 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 | | 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 | 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. 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: ```rust pub fn registered_commands(lib_rs: &str) -> Option>; // 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, pub bare: Vec } pub fn capability_file(name: &str, json: &str) -> Result; pub fn check(commands: &[String], files: &[CapabilityFile]) -> Vec; // 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. 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 `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/.toml` whose `` 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: - every registered command's `allow-` appears in **exactly one** capability file (missing → names the command and the `windows` of the file it belongs in; more than one → names the files); - every bare identifier is `allow-` 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). 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). `serde_json` is already a dependency and is added to `[build-dependencies]` (already in `Cargo.lock`, so `--offline` still resolves). 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 `expected_windows`, not a silent widening. ### 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`. 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, and does so by parsing every file with the TypeScript compiler (`ts.createSourceFile`), not a regex scan, so comments, strings, template substitutions and regex literals are the parser's problem rather than ours: - **Only `lib/tauri-commands.ts` may import `@tauri-apps/api/core`**, checked against *every* code file under `src/` — tests included, not just the files the two closures below cover, because the viewer's closure can reach anything a relative import can. A specifier this scan cannot read as a literal (a computed `import()`/`require()` argument) is treated as an offender too, so an un-auditable dynamic import fails the same way a literal one to `@tauri-apps/api/core` would. That is what makes the rest of the test complete: no other file can invoke. - The wrapper map is read from `tauri-commands.ts`'s AST: each exported `const NAME = …` must call `invoke` exactly once, inside its own function body (not at module load, not inside an IIFE, not as a default argument), with a string-literal command name matching Rust's `[a-z0-9_]+` (115 wrappers, 115 commands today). Zero or more than one call, a non-literal argument, `invoke` referenced other than as a direct call, or any dynamic `import()`/`require()` in the file at all, fails the test. - Every other module edge is resolved the way Vite 6 actually resolves it, in Vite's own order: the exact relative path if it names a file; else, for a `.js`/`.mjs`/`.cjs`/`.jsx` path, its TypeScript twin (`.js`→`.ts`, then `.tsx`); else `path + ext` over `resolve.extensions` (`.mjs .js .mts .ts .jsx .tsx .json`) in that order; else, for a directory, `index + ext` in the same order. A path alias, a query/fragment suffix (`?worker`, `?raw`), a directory import that would switch Vite to package-entry resolution, or a relative import that resolves outside `src/`, all fail the test rather than being silently skipped or misresolved — resolution is fail-closed, not best-effort. - A wrapper counts as used when a file names it directly (a named import, a named re-export by name) or reaches it through a namespace import (`import * as X from ".../tauri-commands"`) **used only as member access**, `X.name` (including `typeof X.name` in a type position). Any other use of that namespace binding — passed to a function, spread, indexed, aliased again — fails closed rather than being read as "no wrapper used"; `export *`/`export * as` of the wrappers file is refused outright, since it cannot be resolved to specific names. - The **viewer closure** is the transitive set of module targets reachable from `src/viewer/main.tsx` under that same fail-closed resolution (static imports, `export … from`, literal `import()`; tests excluded, non-code assets 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 reach; `M` = commands of the wrappers reached by every other non-test file under `src/`. - Assert `M ∩ V = ∅`; `allow-(V)` **equals** the bare set of `file-viewer.json` (no over-grant to the untrusted window); `allow-(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-` ∪ `deny-` 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. ### 3.5 Documentation updates (same change) - `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-` 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. - `.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-` 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/.toml` after a command is removed | A capability could still reference a dead permission and validate | §3.2 step 3 selectively prunes only the files whose `` is no longer a registered command, every build (never `remove_dir_all` — Decision 9); live files are left alone so the build settles; 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 `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) | | `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 --offline`: §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 --offline` clean, `npm run build`. - Negative checks, each done once by hand and then reverted, to prove the checks bite: (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. 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, "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 `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 — 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)`. |