Files
Triple-C/docs/superpowers/specs/2026-09-22-app-manifest-lockdown-design.md
T
shadowdaoandClaude Opus 5.5 508ff6d037 docs: design spec for per-window app-command lockdown via AppManifest
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>
2026-09-22 20:39:38 -07:00

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.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-*/.
  • 109 commands are registered in generate_handler! (lib.rs:467-598). All 109 are invoked from app/src (108 through lib/tauri-commands.ts, plus terminal_input invoked directly in hooks/useTerminal.ts); 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. tauri.conf.json has no app.security.capabilities key, so every file under capabilities/ is active (tauri-build acl.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 has remote, so it has no IPC at all — before and after this change.
  • gen/schemas/acl-manifests.json and gen/schemas/capabilities.json are tracked in git and rewritten by tauri-build on every build; the app manifest will show up there as a reviewable diff.
  • CI (.gitea/workflows/build-app-preview.yml, build-app.yml) runs npm run build (tsc && vite build) and tauri build. It runs neither cargo test nor vitest. Any check that must hold on every merge therefore has to fail the build, not a test.

2. Mechanism (tauri-build 2.6.0 / tauri-utils 2.9.0 / tauri 2.11.0)

2.1 What AppManifest::commands generates

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

  1. Parse the generate_handler![ … ] block out of src/lib.rs into the set of registered command names, using the same line-based extraction the existing every_command_is_registered_exactly_once test uses (lib.rs:838-861). Move that extractor into src/command_census.rs (plain fn, no crate deps) and pull it into both places with #[path = "src/command_census.rs"] mod command_census; in build.rs and #[cfg(test)] #[path = "command_census.rs"] mod command_census; in lib.rs, so the two parsers cannot drift. println!("cargo:rerun-if-changed=src/lib.rs").
  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 build.rs panics if the list is empty, contains a name outside [a-z0-9_]+, or contains a duplicate.
  3. std::fs::remove_dir_all("permissions/autogenerated") (ignore NotFound) before calling tauri-build, so a command removed from lib.rs cannot leave a stale permission behind. Add permissions/autogenerated/ to .gitignore; the reviewable artifact is gen/schemas/acl-manifests.json, which is already tracked.
  4. Read every capabilities/*.json, collect the bare permission identifiers per file (strings without :; scoped-object grants are read by their identifier), 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 own validate_capabilities would also fail, but its message dumps every generated allow-*/deny-* identifier);
    • no bare deny-* (global, §2.3) and no bare default;
    • the file for windows: ["main"] contains no viewer_* grant and the file-viewer-* file contains only viewer_* grants — a one-line policy assertion so that "which side does this belong to" is decided in one place.
  5. tauri_build::try_build(Attributes::new().app_manifest(AppManifest::new().commands( Box::leak(names)))), propagating the error the way tauri_build::build() does (print and exit 1). Box::leak is 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 from app/src/** excluding tests, split by tree: src/viewer/** (the viewer bundle) versus everything else (the main bundle). A non-literal first argument to invoke fails the test — it would be un-auditable.
  • Every main-bundle name must have allow-<slug> in capabilities/default.json, and every viewer-bundle name in capabilities/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-* in file-viewer.json is invoked from src/viewer/** — no over-grant to the untrusted window. (Not asserted for default.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 and file-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: split listed into prefixed and bare. The prefixed census stays the exact five-element list. The bare set must equal allow-<slug> over (registered commands minus the viewer's four). Extend it to read capabilities/file-viewer.json with the same shape: exact core list plus exactly the four viewer grants. Keep the :default and store: refusals.
  • New 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 allow-* identifiers equal 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 using the shared extractor.

3.5 Documentation updates (same change)

  • capabilities/default.json description (the threat model of record): add a paragraph stating that build.rs now declares an AppManifest so every app command is ACL-gated per window; that 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!; that the file-viewer-* windows are confined to capabilities/file-viewer.json; that deny-* 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's PLUGINS table" framing where it implies app commands are outside this file.
  • CLAUDE.md "Key Conventions": 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; the build fails otherwise, and the vitest check fails if the calling bundle and the capability disagree. Note the _- rule, that deny-* is off-limits, and that permissions/autogenerated/ is generated and ignored.
  • CLAUDE.md Backend Structure: one line on build.rs and command_census.rs.
  • .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 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.ts and the full npm run test.
  • npx tsc --noEmit, cargo clippy clean.
  • Negative checks, each done once by hand and then reverted, to prove the checks bite: (a) remove one allow-* from default.jsoncargo check 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 policy assertion; (e) invoke a main-only command from src/viewer → vitest fails.

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, 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 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

  1. 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 test and npm run test steps to build-app-preview.yml is a separate decision; this spec does not depend on it.
  2. Box::leak in build.rs versus generating a commands.rs include file: leaking is simpler and the process is short-lived; flagging in case a reviewer prefers the include.
  3. Whether the four viewer_* grants should also require a webviews match. Not needed with one webview per window; revisit only if the viewer ever hosts a child webview.