Follow-up to the terminal file viewer spec. Verified against tauri 2.11.0, tauri-build 2.6.0 and tauri-utils 2.9.0 sources: with no app manifest the ACL skips app commands entirely; with one, every command must be granted to the calling window, unlisted commands are denied, and deny-* is global. The spec derives the manifest from generate_handler! in build.rs and fails the build when a command is missing from, or duplicated across, the capability files. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
25 KiB
App-command lockdown via Tauri AppManifest — design
Date: 2026-09-22
Status: approved in conversation (user), pending plan
Follows: 2026-09-22-terminal-file-viewer-design.md (the viewer windows this exists to confine)
Goal
Make every application command (the generate_handler! list in lib.rs) permission-gated per
window, so a file-viewer-* window can invoke exactly its four viewer_* commands and nothing
else, and the main window keeps exactly what it invokes today. Close the residual risk the
viewer spec records in its §6: "a compromised viewer window can still invoke other app commands,
because build.rs does not restrict app commands per window."
Non-goals: scoping arguments of app commands (the ACL only allows/denies command names; argument
validation stays in Rust as today), changing any plugin grant, removeUnusedCommands (see §3.6).
1. Current state (verified)
app/src-tauri/build.rsistauri_build::build(), i.e.try_build(Attributes::default())with an emptyAppManifest(tauri-build 2.6.0src/lib.rs:454).- Versions from
Cargo.lock: tauri 2.11.0, tauri-build 2.6.0, tauri-utils 2.9.0, tauri-codegen 2.6.0, tauri-macros 2.6.0. Everything below is read from those sources under~/.cargo/registry/src/index.crates.io-*/. - 109 commands are registered in
generate_handler!(lib.rs:467-598). All 109 are invoked fromapp/src(108 throughlib/tauri-commands.ts, plusterminal_inputinvoked directly inhooks/useTerminal.ts); no invoke name exists without a registration. None uses#[command(rename = …)], so the invoke name is always the function identifier. capabilities/default.jsoniswindows: ["main"]and grants five plugin/core permissions.tauri.conf.jsonhas noapp.security.capabilitieskey, so every file undercapabilities/is active (tauri-buildacl.rs:424-429).- The only non-main window today is the browser-view pop-out, label
browser-view-<id>(browser_view/popout.rs:71,104),WebviewUrl::External. No capability names it and no capability hasremote, so it has no IPC at all — before and after this change. gen/schemas/acl-manifests.jsonandgen/schemas/capabilities.jsonare 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) runsnpm run build(tsc && vite build) andtauri build. It runs neithercargo testnorvitest. 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
tauri_build::try_build(
tauri_build::Attributes::new()
.app_manifest(tauri_build::AppManifest::new().commands(&["check_docker", /* … */])),
)
-
commandstakes&'static [&'static str](tauri-build/src/acl.rs:87-114). -
app_manifest_permissions(acl.rs:265-335) callstauri_utils::acl::build::autogenerate_command_permissions, which for each command writes into the source tree atsrc-tauri/permissions/autogenerated/<command>.toml(tauri-utils/src/acl/build.rs:289-317):[[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 (defaultpermissions_path_pattern), so any hand-written.toml/.jsonunderpermissions/is also part of the app manifest. Stale files are never deleted: a command dropped from.commands()leaves its.tomlbehind, still defining a permission a capability can reference. §3.2 handles this. -
No app
defaultpermission set is generated (acl.rshas no such logic; onlyInlinedPluginhasDefaultPermissionRule). -
The manifest is stored under key
__app-acl__(APP_ACL_KEY,tauri-utils/src/acl/mod.rs:50) ingen/schemas/acl-manifests.json, copied toOUT_DIR/acl-manifests.json, and embedded bygenerate_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 takespermission_id.get_prefix().unwrap_or(APP_ACL_KEY)(resolved.rs:340-372);core:event:…anddialog:…keep working unchanged. windows/webviewsare glob patterns viaglob::Pattern(resolved.rs:199-208, matched inauthority.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).localdefaults totrue; both the bundledtauri://localhostdocument and the VitedevUrlcount as local (webview/mod.rs:1698-1740,is_local_url: tauri protocol, or relative todevUrl/frontendDist). The viewer'sviewer.htmlis served the same way asindex.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):
// 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 capabilitywindows/webviewsglob matches the caller and whose context matches the origin. Otherwise the invoke is rejected before thegenerate_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 entersallowed_commands, soresolve_accessreturnsNone. 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_accesstestsdenied_commands.get(cmd).map(..).is_some(), which is true for any deny entry regardless of window or origin. Never grant adeny-*in the viewer capability expecting it to stay confined; the design uses allow-lists only, and the build check bansdeny-*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'srefusalText.tswrapper-stripping already tolerates theinvoke 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 withPermission <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.jsonare not validated there and fail later ingenerate_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
windowsis a resolve-time error. - The build script's CWD is
src-tauri; the./permissions/autogeneratedpath is relative to it.
2.5 Dev vs release
Enforcement is identical. cfg(debug_assertions) changes only the error text and whether the
raw ACL map is retained for messages (authority.rs:29-30, 77-101). tauri dev versus tauri build differs only in custom-protocol, which does not touch the ACL. A command missing from a
capability is therefore caught the first time anyone opens the affected screen in tauri dev,
with a message naming the permission.
3. Design
3.1 Capability files
capabilities/default.json (unchanged windows: ["main"]) gains one bare allow-<slug> entry
per main-window command — the 109 existing ones plus open_file_viewer. The existing five
plugin/core grants stay exactly as they are. The description is amended (§3.5).
capabilities/file-viewer.json (created by the viewer feature, windows: ["file-viewer-*"])
gains exactly allow-viewer-read-file, allow-viewer-write-file, allow-viewer-choose-file,
allow-viewer-get-state, beside the core grants the viewer spec lists.
No permission sets, no default, no hand-written files under permissions/. The project's
existing test already refuses *:default aliases because they expand to sets the file does
not spell out; the same rule applies to app permissions. Enumeration is the point.
Assignment of every command (all 109 existing registrations go to main; nothing but the viewer's four goes to file-viewer):
Group (lib.rs comment) |
Commands | Window |
|---|---|---|
| Docker | check_docker, check_image_exists, build_image, get_container_info |
main |
| Projects | list_projects, add_project, remove_project, update_project, start_project_container, stop_project_container, rebuild_project_container, reconcile_project_statuses |
main |
| Notes | list_notes, save_note, delete_note |
main |
| Migration | get_container_staleness, migrate_project_to_base, confirm_migration, rollback_migration, get_migration_state |
main |
| Auth bridge | set_auth_bridge_enabled, get_auth_bridge_status |
main |
| Browser view | set_browser_view_enabled, get_browser_view_status, check_browser_view_support, install_browser_view_support, install_browser_view_browser, open_browser_view_popout, close_browser_view_popout, get_browser_view_popout_state, set_browser_view_popout_always_on_top, open_page_in_container_browser, set_container_page_viewport, get_container_page_state, close_container_page, set_browser_view_match_window, get_browser_view_match_window |
main |
| Claude token | acquire_claude_token, submit_claude_token_code, cancel_claude_token, has_claude_token, clear_claude_token, sweep_claude_token_snapshots |
main |
| Settings | get_settings, update_settings, pull_image, detect_aws_config, inspect_ca_cert_path, list_aws_profiles, detect_host_timezone |
main |
| Settings export/import | export_settings, preview_settings_import, apply_settings_import |
main |
| Terminal | open_terminal_session, terminal_input, terminal_resize, close_terminal_session, paste_image_to_terminal, upload_host_file_to_terminal, start_audio_bridge, send_audio_data, stop_audio_bridge |
main |
| Files | list_container_files, download_container_backup, download_container_file, upload_files_to_container, read_container_file, rename_container_path, create_container_directory |
main |
| AWS | aws_sso_refresh |
main |
| Updates | get_app_version, check_for_updates, check_image_update |
main |
| Help | get_help_content |
main |
| URL open | open_url_external |
main |
| Install helper | detect_install_options, run_docker_install |
main |
| Web terminal | start_web_terminal, stop_web_terminal, get_web_terminal_status, regenerate_web_terminal_token |
main |
| STT | get_stt_status, start_stt, stop_stt, build_stt_image, pull_stt_image, transcribe_audio |
main |
| Gateway | get_gateway_status, start_gateway, stop_gateway, check_gateway_health, build_gateway_image, pull_gateway_image, set_gateway_api_key, clear_gateway_api_key, get_gateway_auth_token, regenerate_gateway_auth_token |
main |
| Inspect | list_claude_sessions, resume_session_command, list_container_capabilities, list_scheduled_tasks, add_scheduled_task, update_scheduled_task, get_scheduled_task_log, set_scheduled_task_enabled, run_scheduled_task_now, remove_scheduled_task, get_scheduler_notifications, clear_scheduler_notifications |
main |
| File viewer (new, from the viewer spec) | open_file_viewer |
main |
| File viewer (new) | viewer_read_file, viewer_write_file, viewer_choose_file, viewer_get_state |
file-viewer |
Windows other than main: browser-view-* (pop-out) invokes nothing and must stay listed in no
capability — the pane's iframe inside main is a remote origin and has no IPC either. The web
terminal is a WebSocket server, not IPC, and is unaffected.
The Rust label checks in the viewer commands (open_file_viewer main-only; viewer_*
caller's-own-label-only) stay. The ACL says which window may call; the label check says whose
registry entry it operates on. They are not redundant.
3.2 build.rs: manifest derived from generate_handler!, checked against capabilities
The manifest must not be a second hand-maintained list. build.rs becomes:
- Parse the
generate_handler![ … ]block out ofsrc/lib.rsinto the set of registered command names, using the same line-based extraction the existingevery_command_is_registered_exactly_oncetest uses (lib.rs:838-861). Move that extractor intosrc/command_census.rs(plainfn, no crate deps) and pull it into both places with#[path = "src/command_census.rs"] mod command_census;inbuild.rsand#[cfg(test)] #[path = "command_census.rs"] mod command_census;inlib.rs, so the two parsers cannot drift.println!("cargo:rerun-if-changed=src/lib.rs"). - Fail closed on an empty or malformed parse. An empty list makes
.commands(&[])a no-op and silently restores today's unguarded state, sobuild.rspanics if the list is empty, contains a name outside[a-z0-9_]+, or contains a duplicate. std::fs::remove_dir_all("permissions/autogenerated")(ignore NotFound) before calling tauri-build, so a command removed fromlib.rscannot leave a stale permission behind. Addpermissions/autogenerated/to.gitignore; the reviewable artifact isgen/schemas/acl-manifests.json, which is already tracked.- Read every
capabilities/*.json, collect the bare permission identifiers per file (strings without:; scoped-object grants are read by theiridentifier), and assert:- every registered command's
allow-<slug>appears in exactly one capability file (missing → names the command and the file it probably belongs in; more than one → names both files); - every bare identifier is
allow-<slug>of a registered command (catches typos with a message that names the nearest command; tauri's ownvalidate_capabilitieswould also fail, but its message dumps every generatedallow-*/deny-*identifier); - no bare
deny-*(global, §2.3) and no baredefault; - the file for
windows: ["main"]contains noviewer_*grant and thefile-viewer-*file contains onlyviewer_*grants — a one-line policy assertion so that "which side does this belong to" is decided in one place.
- every registered command's
tauri_build::try_build(Attributes::new().app_manifest(AppManifest::new().commands( Box::leak(names)))), propagating the error the waytauri_build::build()does (print and exit 1).Box::leakis the honest way to satisfy&'static [&'static str]in a build script.
Because this runs inside the build script, it fails cargo check, cargo test, tauri dev
and CI's tauri build alike — the one place a check is guaranteed to run on every merge (§1).
The rule set is small enough to live in build.rs directly; serde_json is already a
dependency and can be added to [build-dependencies].
Why "exactly one" rather than "at least one": today no command is shared between windows, and
a command that genuinely needs both is a design change worth a visible edit to the check, not a
silent widening. The check has a single SHARED: &[&str] = &[] list to make that edit explicit.
3.3 Frontend check (vitest)
app/src/test/capabilities.test.ts, following icon-config.test.ts's pattern of reading
src-tauri/ files with readFileSync:
- Extract every
invoke("<name>"/invoke<T>("<name>"literal fromapp/src/**excluding tests, split by tree:src/viewer/**(the viewer bundle) versus everything else (the main bundle). A non-literal first argument toinvokefails the test — it would be un-auditable. - Every main-bundle name must have
allow-<slug>incapabilities/default.json, and every viewer-bundle name incapabilities/file-viewer.json. This is the test that turns "a forgotten command silently breaks a feature at runtime" into a red test on the developer's machine: the Rust build check (§3.2) proves every command is granted somewhere; this one proves it is granted to the window whose code calls it. - The reverse for the viewer file: every
allow-*infile-viewer.jsonis invoked fromsrc/viewer/**— no over-grant to the untrusted window. (Not asserted fordefault.json: a main-window command invoked from a hook that a test cannot see statically is possible, and the Rust side already proves it is registered and invoked.) default.json's five plugin/core grants andfile-viewer.json's core grants are asserted as exact lists, the same census style as the Rust test.
3.4 Rust test updates (lib.rs tests)
the_capability_grants_are_the_ones_that_were_reviewed: splitlistedinto prefixed and bare. The prefixed census stays the exact five-element list. The bare set must equalallow-<slug>over (registered commands minus the viewer's four). Extend it to readcapabilities/file-viewer.jsonwith the same shape: exact core list plus exactly the four viewer grants. Keep the:defaultandstore:refusals.- New
the_generated_app_manifest_matches_the_handler_list:cargo testrunsbuild.rs, sogen/schemas/acl-manifests.jsonis fresh; assert its__app-acl__entry'sallow-*identifiers equal the registered set exactly. This is the end-to-end proof that the parse inbuild.rsproduced what tauri embedded, independent of the parser it shares with §3.2. every_command_is_registered_exactly_onceis unchanged apart from using the shared extractor.
3.5 Documentation updates (same change)
capabilities/default.jsondescription(the threat model of record): add a paragraph stating thatbuild.rsnow declares anAppManifestso every app command is ACL-gated per window; that the bareallow-*entries are the complete list of app commands the main window may call and are cross-checked bybuild.rsagainstgenerate_handler!; that thefile-viewer-*windows are confined tocapabilities/file-viewer.json; thatdeny-*is global in tauri 2.11.0 and is therefore banned; and that the pop-out remains capability-less. Replace the earlier "verified against tauri 2.11.0'sPLUGINStable" framing where it implies app commands are outside this file.CLAUDE.md"Key Conventions": the bullet sayingcapabilities/default.jsongrants plugin commands only and app commands "do not need an entry there" becomes false. Replace with: a new command needs (1)#[tauri::command], (2) agenerate_handler!entry, (3) anallow-<kebab-name>entry in the one capability file for the window that calls it; the build fails otherwise, and the vitest check fails if the calling bundle and the capability disagree. Note the_→-rule, thatdeny-*is off-limits, and thatpermissions/autogenerated/is generated and ignored.CLAUDE.mdBackend Structure: one line onbuild.rsandcommand_census.rs..gitignore:app/src-tauri/permissions/autogenerated/.
3.6 Deliberately not done
build.removeUnusedCommandsstays off. It compiles out commands no capability grants (tauri-macros/src/command/handler.rs:92-142), but only when the CLI setsREMOVE_UNUSED_COMMANDS, socargo testandtauri buildwould compile different handler lists, and it matches on the function identifier rather than arename. The §3.2 check gives the same guarantee at build time without a second code path.- No
permissions/*.tomlwritten by hand, no permission sets. Every grant is a literalallow-<slug>string in a capability file, so the census tests stay a flat string compare. - No
webviewspatterns: this app has one webview per window.
4. Migration risks
| Risk | Where it bites | How it is caught |
|---|---|---|
A registered command is left out of default.json |
Runtime: that feature's invoke rejected with not allowed. Permissions associated with this command: allow-… (release: Command … not allowed by ACL) |
build.rs §3.2 fails the build before any binary exists; the vitest §3.3 fails locally |
Typo in an allow-* string |
Build | tauri's validate_capabilities and the §3.2 check, both at build time |
| Parser regression yields an empty command list | Would silently restore the unguarded state | §3.2 step 2 panics on empty; §3.4 asserts acl-manifests.json has the full set |
Stale permissions/autogenerated/<cmd>.toml after a command is removed |
A capability could still reference a dead permission and validate | §3.2 step 3 deletes the directory every build; the directory is gitignored |
A deny-* added "for the viewer only" |
Denies the command for main too (global) | §3.2 rejects any bare deny-* |
file-viewer.json grants a main-only command |
The viewer window gains reach | §3.2 policy assertion (viewer_* only) and §3.3 reverse check |
A future window label that happens to match file-viewer-* |
Inherits the viewer grants | Labels are minted only in file_viewer.rs; note in that module's doc comment |
Existing the_capability_grants_are_the_ones_that_were_reviewed test |
Fails as written once bare grants appear | Updated in the same commit (§3.4) |
gen/schemas/acl-manifests.json churn in diffs |
Noise | Accepted; it is the reviewable evidence of what got embedded |
| Frontend invoked via a computed name | Un-auditable | §3.3 fails on a non-literal invoke argument |
Dev vs release: no behavioural difference (§2.5), so tauri dev is a faithful rehearsal. The
only visible difference is the error text.
5. Testing and manual verification
Automated (all must be green):
cd app/src-tauri && cargo test: §3.4 tests plus the existing census tests.cd app && npx vitest run src/test/capabilities.test.tsand the fullnpm run test.npx tsc --noEmit,cargo clippyclean.- Negative checks, each done once by hand and then reverted, to prove the checks bite:
(a) remove one
allow-*fromdefault.json→cargo checkfails naming the command; (b) misspell one → fails naming it; (c) adddeny-check-docker→ fails; (d) moveallow-viewer-read-fileintodefault.json→ fails the policy assertion; (e) invoke a main-only command fromsrc/viewer→ vitest fails.
Manual (npm run tauri dev, then a release tauri build on Linux for the AppImage):
- Cold start: projects list, Docker status, settings, help all render (each is an app command on the main window).
- Open a terminal, type, resize, paste an image, drop a file — the terminal group.
- Files tab: list, view, rename, upload, save-to-host; Backup.
- 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. - File viewer: click a path → window opens, reads, edits, saves, conflict banner; then in the
viewer's devtools run
window.__TAURI_INTERNALS__.invoke("list_projects")and…invoke("read_container_file", {...})— both must reject withnot allowed on window "file-viewer-1". From the main window's devtools,invoke("viewer_read_file")must reject likewise. - Settings export/import, gateway, STT, web terminal, scheduler tabs — one action each.
- Watch the app log for
not allowedduring the whole pass; any occurrence is a missed grant.
6. Open questions
- CI runs no test suite (§1). The build-time check covers the security property, but the
vitest half (§3.3) only runs on a developer's machine. Adding
cargo testandnpm run teststeps tobuild-app-preview.ymlis a separate decision; this spec does not depend on it. Box::leakinbuild.rsversus generating acommands.rsinclude file: leaking is simpler and the process is short-lived; flagging in case a reviewer prefers the include.- Whether the four
viewer_*grants should also require awebviewsmatch. Not needed with one webview per window; revisit only if the viewer ever hosts a child webview.