docs(acl): revise lockdown spec for the implemented viewer and review rulings
Five viewer commands (viewer_poll_file was added), both existing census tests, the wrapper-import shape of the frontend, controller rulings on CI scope / Box::leak / webviews, and a spike (§7) that reproduced every mechanism claim against tauri-build 2.6.0 in a throwaway worktree. One design change from the spike: stale autogenerated permissions are pruned selectively rather than by remove_dir_all, because tauri-build emits rerun-if-changed=permissions and wholesale regeneration would rebuild the crate on every cargo invocation. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,13 +1,15 @@
|
||||
# App-command lockdown via Tauri AppManifest — design
|
||||
|
||||
Date: 2026-09-22
|
||||
Status: approved in conversation (user), pending plan
|
||||
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 four `viewer_*` commands and nothing
|
||||
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."
|
||||
@@ -22,14 +24,29 @@ validation stays in Rust as today), changing any plugin grant, `removeUnusedComm
|
||||
- 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.
|
||||
- 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.
|
||||
`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>`
|
||||
`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**
|
||||
@@ -152,19 +169,21 @@ with a message naming the permission.
|
||||
### 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).
|
||||
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` (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.
|
||||
`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 (all 109 existing registrations go to **main**; nothing but the
|
||||
viewer's four goes to **file-viewer**):
|
||||
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 |
|
||||
|---|---|---|
|
||||
@@ -188,8 +207,8 @@ viewer's four goes to **file-viewer**):
|
||||
| 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 |
|
||||
| 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
|
||||
@@ -201,98 +220,155 @@ 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:
|
||||
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:
|
||||
|
||||
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")`.
|
||||
```rust
|
||||
pub fn registered_commands(lib_rs: &str) -> Option<Vec<String>>; // handler list, in order, duplicates kept
|
||||
pub fn allow_permission(command: &str) -> String; // "viewer_read_file" → "allow-viewer-read-file"
|
||||
pub fn expected_windows(command: &str) -> &'static [&'static str]; // "viewer_*" → ["file-viewer-*"], else ["main"]
|
||||
pub struct CapabilityFile { pub name: String, pub windows: Vec<String>, pub bare: Vec<String> }
|
||||
pub fn capability_file(name: &str, json: &str) -> Result<CapabilityFile, String>;
|
||||
pub fn check(commands: &[String], files: &[CapabilityFile]) -> Vec<String>; // every violation, or empty
|
||||
```
|
||||
|
||||
`build.rs` becomes:
|
||||
|
||||
1. `registered_commands` is the line-based extraction the existing
|
||||
`every_command_is_registered_exactly_once` test uses today (`lib.rs:849-870`), moved
|
||||
verbatim; that test calls it too. `println!("cargo:rerun-if-changed=…")` for `src/lib.rs`,
|
||||
`src/command_census.rs` and the `capabilities` directory.
|
||||
2. **Fail closed on an empty or malformed parse.** An empty list makes `.commands(&[])` a no-op
|
||||
and silently restores today's unguarded state, so `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:
|
||||
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 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.
|
||||
(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).
|
||||
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]`.
|
||||
`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 the check, not a
|
||||
silent widening. The check has a single `SHARED: &[&str] = &[]` list to make that edit explicit.
|
||||
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`:
|
||||
`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:
|
||||
|
||||
- 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.
|
||||
- **Only `lib/tauri-commands.ts` may import `@tauri-apps/api/core`** (true today for every
|
||||
non-test file). That is what makes the rest of the test complete: no other file can invoke.
|
||||
- Build the wrapper map from `tauri-commands.ts`: each `export const NAME = …` chunk contains
|
||||
exactly one `invoke<…>("<literal>"` (115 wrappers, 115 commands today). A chunk with zero or
|
||||
more than one literal, or a non-literal first argument, fails the test.
|
||||
- The **viewer closure** is the transitive set of relative imports from `src/viewer/main.tsx`
|
||||
(`.ts`/`.tsx`, tests excluded, `.css` 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 import; `M` = commands of the wrappers
|
||||
imported 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.
|
||||
|
||||
### 3.4 Rust test updates (`lib.rs` 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.
|
||||
|
||||
- `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.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): 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`.
|
||||
- `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
|
||||
@@ -315,9 +391,11 @@ silent widening. The check has a single `SHARED: &[&str] = &[]` list to make tha
|
||||
| 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) |
|
||||
| `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 |
|
||||
|
||||
@@ -327,14 +405,16 @@ 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/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` clean.
|
||||
- `npx tsc --noEmit`, `cargo clippy --offline` clean, `npm run build`.
|
||||
- Negative checks, each done once by hand and then reverted, to prove the checks bite:
|
||||
(a) remove one `allow-*` from `default.json` → `cargo check` 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.
|
||||
(a) remove one `allow-*` from `default.json` → `cargo check --offline` fails naming the
|
||||
command; (b) misspell one → fails naming it; (c) add `deny-check-docker` → fails; (d) move
|
||||
`allow-viewer-read-file` into `default.json` → fails the `expected_windows` assertion;
|
||||
(e) import `listProjects` from `lib/tauri-commands` in `src/viewer/ViewerApp.tsx` → vitest
|
||||
fails; (f) temporarily make `registered_commands` return `Some(vec![])` → the build fails
|
||||
on the empty-list rule rather than building an unguarded app.
|
||||
|
||||
Manual (`npm run tauri dev`, then a release `tauri build` on Linux for the AppImage):
|
||||
1. Cold start: projects list, Docker status, settings, help all render (each is an app command
|
||||
@@ -344,8 +424,9 @@ Manual (`npm run tauri dev`, then a release `tauri build` on Linux for the AppIm
|
||||
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
|
||||
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,
|
||||
@@ -353,12 +434,78 @@ Manual (`npm run tauri dev`, then a release `tauri build` on Linux for the AppIm
|
||||
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
|
||||
## 6. Open questions — resolved
|
||||
|
||||
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.
|
||||
1. **CI test steps: out of scope** (controller ruling). CI runs no test suite (§1). The
|
||||
build-time check (§3.2) covers the security property on every merge because it runs inside
|
||||
`tauri build`; the vitest half (§3.3) and the `cargo test` half (§3.4) only run on a
|
||||
developer's machine. Recommendation to the user, not part of this change: add
|
||||
`cd app && npm run test` and `cd app/src-tauri && cargo test` steps to
|
||||
`build-app-preview.yml` after the dependency install.
|
||||
2. **`Box::leak` in `build.rs`: acceptable** (controller ruling; either was allowed). Chosen
|
||||
over a generated include file because it is two lines, needs no `OUT_DIR` plumbing, and the
|
||||
build script exits immediately afterwards.
|
||||
3. **No `webviews` patterns** (controller ruling). One webview per window; `windows` globs
|
||||
only.
|
||||
|
||||
## Decisions made during review
|
||||
|
||||
Checked against the tree at `bf22910` (the completed viewer) and the tauri-build 2.6.0 /
|
||||
tauri-utils 2.9.0 / tauri 2.11.0 sources pinned by `Cargo.lock`. The user-approved goal
|
||||
(allow-lists only, one file per window, the build fails rather than a feature breaking at
|
||||
runtime) is unchanged. What changed:
|
||||
|
||||
1. **Five viewer commands, not four (§3.1).** The implemented viewer polls with
|
||||
`viewer_poll_file`, so `file-viewer.json` gets five `allow-viewer-*` grants and `default.json`
|
||||
gets 110, for 115 registered commands.
|
||||
2. **The window rule is a function (§3.2).** `expected_windows(command)` (`viewer_*` →
|
||||
`file-viewer-*`, else `main`) replaces both the "no `viewer_*` in main / only `viewer_*` in
|
||||
the viewer file" policy assertion and the `SHARED` list. A command that must be callable from
|
||||
both windows is an edit to that function, which is the visible design change the earlier
|
||||
draft wanted; the extra list was a second place to say the same thing.
|
||||
3. **The check logic is a module, not inline `build.rs` (§3.2, §3.4).** A build script cannot
|
||||
be unit-tested, and the empty-list, typo, `deny-*` and wrong-file rules are exactly the code
|
||||
whose failure mode is "builds an unguarded app". `command_census.rs` is compiled into both
|
||||
`build.rs` and the test build, so the rules get ordinary `cargo test` coverage and the
|
||||
handler-list parser the census test already used becomes the one the build uses.
|
||||
4. **The vitest test follows imports, not directories (§3.3).** The viewer never calls `invoke`;
|
||||
it imports wrappers from the shared `lib/tauri-commands.ts`. So the test computes the
|
||||
viewer's transitive import closure from `viewer/main.tsx` and takes the wrappers that closure
|
||||
imports; a directory split would have put every viewer command in the "main" bucket and
|
||||
proved nothing.
|
||||
5. **Both census tests change, not one (§3.4).** The viewer feature added its own exact-set
|
||||
test in `file_viewer/mod.rs`; the earlier draft only knew about the `lib.rs` one.
|
||||
6. **`gen/schemas/*.json` is committed with the change.** It is tracked, tauri-build rewrites it
|
||||
on every build, and after this change it contains the `__app-acl__` manifest and the
|
||||
resolved grants — the reviewable evidence of what the binary embeds. Expect a large diff:
|
||||
`desktop-schema.json` and `linux-schema.json` gain an enum entry per generated permission
|
||||
(about 12 lines per command, ~2,700 lines across the two files for 115 commands).
|
||||
9. **Stale-permission cleanup is selective, not `remove_dir_all` (§3.2 step 3).** Found by the
|
||||
spike: wholesale regeneration would re-run the build script on every `cargo` call.
|
||||
7. **Atomicity (§4).** A bare `allow-*` in a capability file with no app manifest is a build
|
||||
error (`Permission allow-… not found`), and a manifest with no grants builds an app in which
|
||||
every command is denied. `build.rs`, both capability files and the census-test updates are
|
||||
therefore one commit; the shared module and its tests land before it, and the end-to-end
|
||||
manifest test, the vitest and the docs after it.
|
||||
8. **Spike (see §7).** Every claim in §2 that the design leans on was reproduced in a throwaway
|
||||
worktree before the plan was written.
|
||||
|
||||
## 7. Spike results (2026-09-22, throwaway worktree at `bf22910`, then removed)
|
||||
|
||||
Environment: tauri-build 2.6.0 / tauri 2.11.0 / tauri-utils 2.9.0 from `Cargo.lock`,
|
||||
`cargo check --offline` with the shared `target/` dir; each run ~3 s (only `triple-c`
|
||||
recompiles). A fresh worktree needs `app/dist` to exist (`npm run build`) or
|
||||
`generate_context!` panics on `frontendDist` before any ACL code runs.
|
||||
|
||||
| # | Experiment | Result |
|
||||
|---|---|---|
|
||||
| A | Untouched tree | Builds. `acl-manifests.json` has only plugin keys (`core`, `core:*`, `dialog`, `opener`); no `__app-acl__`. |
|
||||
| B | `build.rs` with `AppManifest::new().commands(Box::leak(…))` for three names | Builds. `permissions/autogenerated/{check_docker,list_projects,viewer_read_file}.toml` written (`allow-check-docker` / `deny-check-docker`, `commands.allow = ["check_docker"]`, header `# Automatically generated - DO NOT EDIT!`). `acl-manifests.json` gains `__app-acl__` with the six identifiers. `desktop-schema.json` and `linux-schema.json` each gain 36 lines (enum entries). `capabilities.json` unchanged until a file references a grant. Nothing else written. |
|
||||
| C | Bare `allow-check-docker`, `allow-list-projects` in `default.json`; `allow-viewer-read-file` in `file-viewer.json` | Builds. `gen/schemas/capabilities.json` shows the grants under each capability. |
|
||||
| D | Bare `allow-does-not-exist` | **Fails** (exit 101): `Permission allow-does-not-exist not found, expected one of allow-check-docker, …` — one ~10 KB line listing every identifier, no fuzzy hint. Hence the project's own check with a readable message. |
|
||||
| E | Typo `allow-check-dokcer` | Fails, same shape. |
|
||||
| F | Bare `deny-check-docker` in `file-viewer.json` | **Builds** and appears in `capabilities.json` — tauri does not object; only §3.2's ban does. |
|
||||
| G | Drop `list_projects` from the manifest list, keep its grant | **Builds**: the stale `list_projects.toml` is still globbed. After deleting `permissions/autogenerated`, fails with `Permission allow-list-projects not found`. Stale files are real. |
|
||||
| H | Original `tauri_build::build()` plus one bare grant | **Fails**: `Permission allow-check-docker not found, expected one of core:default, …`. The message goes to the build script's **stdout** (`println!`). Capability grants cannot land before the manifest. |
|
||||
| I | Rerun semantics | tauri-build emits `cargo:rerun-if-changed=capabilities` unconditionally (`acl.rs:427`) and `=permissions` when the dir exists (`acl.rs:300-305`). Editing a capability file alone re-runs the script and refreshes `gen/schemas`. Writing a `.toml` makes the *next* run re-run once more, then it settles because `write_if_changed` leaves unchanged files alone. |
|
||||
| J | Signatures | `AppManifest::commands(mut self, commands: &'static [&'static str]) -> Self` (`acl.rs:100`); `AppManifest` is `Copy`, so no owned alternative — `Box::leak` it is. `try_build(Attributes) -> anyhow::Result<()>`; `build()` prints `{error:#}` and `exit(1)`. |
|
||||
|
||||
Reference in New Issue
Block a user