Files
Triple-C/docs/superpowers/specs/2026-09-22-app-manifest-lockdown-design.md
T
jknappandClaude Opus 5.5 85901d8a80
Secret Scan / scan (push) Successful in 6s
CI: run vitest and cargo test on every PR (#61)
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>
2026-09-23 17:17:14 +00:00

40 KiB
Raw Blame History

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. (2026-09-23: no longer true — build-app-preview.yml gained a test job that runs vitest and cargo test --locked on every PR. The build-time check stays the backstop that runs inside every tauri 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", /* … */])),
)
  • 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):

    [[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_fileallow-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):

// 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:

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.jsoncargo 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. (Done 2026-09-23: the test job in build-app-preview.yml.)
  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).
  7. 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.
  8. 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.
  9. 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).