Files
Triple-C/docs/superpowers/specs/2026-09-22-app-manifest-lockdown-design.md
T
shadowdaoandClaude Opus 5.5 71ba5076db
Secret Scan / scan (push) Successful in 6s
Build App (Preview) / compute-version (pull_request) Successful in 5s
Secret Scan / scan (pull_request) Successful in 7s
Build App (Preview) / create-release (pull_request) Successful in 3s
Build App (Preview) / build-macos (pull_request) Successful in 2m49s
Build App (Preview) / build-linux (pull_request) Successful in 5m16s
Build App (Preview) / build-windows (pull_request) Successful in 10m4s
Build App (Preview) / prune-previews (pull_request) Successful in 9s
docs(acl): reconcile spec prose with the shipped implementation
Final-wave cleanups from the whole-branch review (final-review.md Minor
1-5): spec §4 now says selective pruning, not "deletes the directory
every build"; spec §3.3 now describes the TypeScript-AST scan
(fail-closed Vite-order resolution, namespace imports as member access
only, the every-code-file boundary check) instead of the old
regex/chunk description; the viewer spec's historical "every command
is callable from every window" line gets a dated "closed by the
AppManifest lockdown" note; the lib.rs doc comment on
the_generated_app_manifest_matches_the_handler_list no longer claims
independence from the shared parser it actually reuses; and the vitest
command-name regex now allows digits, matching Rust's [a-z0-9_]+.

Also adds a cargo test backstop
(the_tauri_config_capability_check_runs_against_the_real_tree) that
runs build.rs's tauri-config capability check against the real
app/src-tauri tree on every `cargo test`, closing the gap where a new
tauri.<platform>.conf.json on an already-built tree only gets checked
by build.rs on a clean build.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-22 23:25:50 -07:00

537 lines
40 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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-<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 — 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<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.
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/<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:
- every registered command's `allow-<slug>` 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-<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).
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-<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.
### 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-<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.
- `.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 selectively prunes only the files whose `<name>` 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)`. |