The PR check now runs vitest and `cargo test --locked` in a `test` job that runs alongside the platform builds. That job is the merge-time guard for the app-command ACL census. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
40 KiB
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.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-*/. - 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 fromapp/src, every one through anexport constwrapper inlib/tauri-commands.ts, which is the only non-test file that imports@tauri-apps/api/core(the earlier directinvoke("terminal_input")inhooks/useTerminal.tsis 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.jsoniswindows: ["main"]and grants five plugin/core permissions.capabilities/file-viewer.jsoniswindows: ["file-viewer-*"]and grants four core permissions.tauri.conf.jsonhas noapp.security.capabilitieskey, so every file undercapabilities/is active (tauri-buildacl.rs:424-429).- Both files already have an exact-set census test:
the_capability_grants_are_the_ones_that_were_reviewed(lib.rs:924) fordefault.json, andthe_viewer_capability_grants_exactly_the_reviewed_windows_and_permissionsplusthe_default_capability_is_scoped_to_the_main_window_only(file_viewer/mod.rs:63,89) forfile-viewer.jsonand thewindowslists. All three will fail as written the moment bareallow-*grants appear and are updated in the same change (§3.4). - The viewer's five
viewer_*commands takewindow: tauri::Windowand gate on the caller's label insidecommands/file_viewer_commands.rs;open_file_vieweris main-only by the same mechanism. Thedefault.jsondescription,file-viewer.jsondescription andCLAUDE.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 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. (2026-09-23: no longer true —build-app-preview.ymlgained atestjob that runsvitestandcargo test --lockedon every PR. The build-time check stays the backstop that runs inside everytauri build, release builds included.)
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 — 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:
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:
registered_commandsis the line-based extraction the existingevery_command_is_registered_exactly_oncetest uses today (lib.rs:849-870), moved verbatim; that test calls it too.println!("cargo:rerun-if-changed=…")forsrc/lib.rs,src/command_census.rsand thecapabilitiesdirectory.- Fail closed on an empty or malformed parse. An empty list makes
.commands(&[])a no-op and silently restores today's unguarded state, socheckreports (andbuild.rsexits 1 on) an empty list, a name outside[a-z0-9_]+, or a duplicate. - Delete every
permissions/autogenerated/<name>.tomlwhose<name>is not a registered command before calling tauri-build, so a command removed fromlib.rscannot leave a stale permission behind. Notremove_dir_all: tauri-build emitscargo: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 everycargoinvocation 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 underpermissions/other thanautogenerated/: tauri-build globs hand-written permission files into the manifest, which would be a second source of grants that the census does not see. Addapp/src-tauri/permissions/autogenerated/to.gitignore; the reviewable artifact isgen/schemas/acl-manifests.json, already tracked. - Read every
capabilities/*.jsonwithcapability_file(bare = identifiers without:; scoped-object grants are read by theiridentifier), andcheckasserts:- every registered command's
allow-<slug>appears in exactly one capability file (missing → names the command and thewindowsof 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 ownvalidate_capabilitieswould also fail, but its message dumps every generatedallow-*/deny-*identifier); - no bare
deny-*(global, §2.3) and nothing else bare that is notallow-*(so no baredefault); - the file that grants a command has
windowsequal toexpected_windows(command)— the "which side does this belong to" rule decided in one place, and the reason theviewer_*naming prefix is now load-bearing. All violations are reported together, so ten forgotten grants are one build failure.
- every registered command's
tauri_build::try_build(Attributes::new().app_manifest(AppManifest::new().commands(leaked)))whereleaked: &'static [&'static str]comes fromBox::leak(ruling: acceptable; the process is short-lived and it is two lines), propagating the error the waytauri_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.tsmay import@tauri-apps/api/core, checked against every code file undersrc/— 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 computedimport()/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/corewould. 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 exportedconst NAME = …must callinvokeexactly 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,invokereferenced other than as a direct call, or any dynamicimport()/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/.jsxpath, its TypeScript twin (.js→.ts, then.tsx); elsepath + extoverresolve.extensions(.mjs .js .mts .ts .jsx .tsx .json) in that order; else, for a directory,index + extin 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 outsidesrc/, 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(includingtypeof X.namein 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 * asof 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.tsxunder that same fail-closed resolution (static imports,export … from, literalimport(); tests excluded, non-code assets ignored). Today it reacheslib/tauri-commands,lib/types,components/ui/{Button,StatusIndicator,unavailable}andcomponents/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 undersrc/. - Assert
M ∩ V = ∅;allow-<slug>(V)equals the bare set offile-viewer.json(no over-grant to the untrusted window);allow-<slug>(M)is a subset of the bare set ofdefault.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 andfile-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.rsthe_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_permissionover the registered commands whoseexpected_windowsis["main"](110 today). Keep the:defaultandstore:refusals.file_viewer/mod.rsthe_viewer_capability_grants_exactly_the_reviewed_windows_and_permissions: the exact list becomes the four core grants plusallow-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_onlyis unchanged.- New
lib.rstestthe_generated_app_manifest_matches_the_handler_list:cargo testrunsbuild.rs, sogen/schemas/acl-manifests.jsonis fresh; assert its__app-acl__entry's permission identifiers equalallow-<slug>∪deny-<slug>over 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 callingcommand_census::registered_commandsinstead of its inline copy.command_census.rscarries its own unit tests forcheck(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 undercargo testwithout needing a failing build.
3.5 Documentation updates (same change)
capabilities/default.jsondescription(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.rsdeclares anAppManifest, so every app command is ACL-gated per window; the bareallow-*entries are the complete list of app commands the main window may call and are cross-checked bybuild.rsagainstgenerate_handler!(the build fails on a missing, misspelled, duplicated or misfiled grant); thefile-viewer-*windows are confined tocapabilities/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 infile_viewer_commands.rsstay because the ACL says which window and the label says whose entry. Amend the opening "verified against tauri 2.11.0'sPLUGINStable" framing so it no longer implies app commands are outside this file.capabilities/file-viewer.jsondescription: replace "app commands need no entry here and are gated by label insidecommands/file_viewer_commands.rs" with: the fiveallow-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, andbuild.rsrefuses any other bare grant here.CLAUDE.md"Key Conventions" (CLAUDE.md:598-600): 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 (viewer_*→file-viewer.json, everything else →default.json);cargo check/tauri buildfail otherwise, andsrc/test/capabilities.test.tsfails if the code that runs in a window imports a wrapper that window is not granted. Note the_→-rule, thatdeny-*is off-limits, and thatpermissions/autogenerated/is generated and ignored.CLAUDE.mdBackend Structure (CLAUDE.md:179-180): thefile_viewer/bullet's "residual risk … the AppManifest lockdown spec closes it" becomes "closed bybuild.rs'sAppManifest"; add one bullet forbuild.rs+command_census.rs.file_viewer/mod.rsmodule doc: one sentence thatfile-viewer-*is also the capability glob, so labels are minted only here..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 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.tsand the fullnpm run test.npx tsc --noEmit,cargo clippy --offlineclean,npm run build.- Negative checks, each done once by hand and then reverted, to prove the checks bite:
(a) remove one
allow-*fromdefault.json→cargo check --offlinefails naming the command; (b) misspell one → fails naming it; (c) adddeny-check-docker→ fails; (d) moveallow-viewer-read-fileintodefault.json→ fails theexpected_windowsassertion; (e) importlistProjectsfromlib/tauri-commandsinsrc/viewer/ViewerApp.tsx→ vitest fails; (f) temporarily makeregistered_commandsreturnSome(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):
- 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, "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 runwindow.__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 — resolved
- 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 thecargo testhalf (§3.4) only run on a developer's machine. Recommendation to the user, not part of this change: addcd app && npm run testandcd app/src-tauri && cargo teststeps tobuild-app-preview.ymlafter the dependency install. (Done 2026-09-23: thetestjob inbuild-app-preview.yml.) Box::leakinbuild.rs: acceptable (controller ruling; either was allowed). Chosen over a generated include file because it is two lines, needs noOUT_DIRplumbing, and the build script exits immediately afterwards.- No
webviewspatterns (controller ruling). One webview per window;windowsglobs 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:
- Five viewer commands, not four (§3.1). The implemented viewer polls with
viewer_poll_file, sofile-viewer.jsongets fiveallow-viewer-*grants anddefault.jsongets 110, for 115 registered commands. - The window rule is a function (§3.2).
expected_windows(command)(viewer_*→file-viewer-*, elsemain) replaces both the "noviewer_*in main / onlyviewer_*in the viewer file" policy assertion and theSHAREDlist. 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. - 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.rsis compiled into bothbuild.rsand the test build, so the rules get ordinarycargo testcoverage and the handler-list parser the census test already used becomes the one the build uses. - The vitest test follows imports, not directories (§3.3). The viewer never calls
invoke; it imports wrappers from the sharedlib/tauri-commands.ts. So the test computes the viewer's transitive import closure fromviewer/main.tsxand takes the wrappers that closure imports; a directory split would have put every viewer command in the "main" bucket and proved nothing. - 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 thelib.rsone. gen/schemas/*.jsonis 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.jsonandlinux-schema.jsongain an enum entry per generated permission (about 12 lines per command, ~2,700 lines across the two files for 115 commands).- 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 everycargocall. - 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. - 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). |