From 7c8ad62da4784790eed2e8af4c3af8d27851745a Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Tue, 22 Sep 2026 22:12:25 -0700 Subject: [PATCH] docs(acl): implementation plan for the AppManifest lockdown Six tasks: shared command_census module with unit tests; the atomic build.rs + grants + census-test commit with negative proofs; the embedded-manifest read-back test; the vitest import-closure check; the threat-model and CLAUDE.md rewrite; full verification and hand-off. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../plans/2026-09-22-app-manifest-lockdown.md | 1534 +++++++++++++++++ 1 file changed, 1534 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-22-app-manifest-lockdown.md diff --git a/docs/superpowers/plans/2026-09-22-app-manifest-lockdown.md b/docs/superpowers/plans/2026-09-22-app-manifest-lockdown.md new file mode 100644 index 0000000..09b90e7 --- /dev/null +++ b/docs/superpowers/plans/2026-09-22-app-manifest-lockdown.md @@ -0,0 +1,1534 @@ +# AppManifest Lockdown Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Every app command in `generate_handler!` becomes ACL-gated per window — the `main` window may invoke exactly the 110 commands it uses today, a `file-viewer-*` window exactly its five `viewer_*` commands — and a command left out of a capability file fails `cargo check`, not a feature at runtime. + +**Architecture:** `build.rs` parses the `generate_handler![ … ]` block, declares a Tauri `AppManifest` from it (which generates `allow-`/`deny-` permissions and makes tauri 2.11 enforce the ACL on app commands), and refuses to build unless every command has exactly one bare `allow-*` grant in the capability file its name says it belongs to. The parser and the rules live in one module, `src/command_census.rs`, compiled into both the build script and the test build, so they are unit-tested. A vitest test then proves the code that runs in each window only imports wrappers that window is granted. + +**Tech Stack:** Rust (tauri 2.11.0, tauri-build 2.6.0, tauri-utils 2.9.0, serde_json), Vitest (Node `fs`), plain JSON capability files. + +**Spec:** `docs/superpowers/specs/2026-09-22-app-manifest-lockdown-design.md` — read it first, in particular §2 (how tauri's mechanism actually behaves), §3 (the design), "Decisions made during review" and §7 (the spike that verified §2 in this environment). + +## Global Constraints + +- **Allow-lists only.** No bare `deny-*` anywhere (global in tauri 2.11.0), no bare `default`, no permission sets, no hand-written files under `app/src-tauri/permissions/`. +- **One file per window, decided by name.** `viewer_*` commands are granted only in `capabilities/file-viewer.json` (`windows: ["file-viewer-*"]`); every other command only in `capabilities/default.json` (`windows: ["main"]`). The rule is `command_census::expected_windows`; changing it is a design change. +- **Permission identifier = `allow-` + command name with every `_` replaced by `-`.** The command name inside stays snake_case. tauri-utils replaces only `_`. +- **The existing five plugin/core grants in `default.json` and four in `file-viewer.json` do not change.** +- **`browser-view-*` (the pop-out) stays in no capability file.** No `webviews` patterns anywhere. +- **`command_census.rs` may reference only `std` and `serde_json`** — it is compiled into the build script with `#[path]`. +- **`build.rs`, both capability files and the two existing census tests change in one commit (Task 2).** Bare grants without the manifest fail the build; the manifest without grants builds an app in which every command is denied. +- **Every `#[tauri::command]` stays in `generate_handler![]`, one fully-qualified path per line** — the parser is line-based. +- **`gen/schemas/*.json` are tracked and regenerated by every build; commit them.** Expect ~2,700 added lines across `desktop-schema.json` and `linux-schema.json`. +- **Use `--offline` for cargo** (the crate cache is complete; `serde_json` is already in `Cargo.lock`). +- **Commit after every task** with a `refactor:`/`feat:`/`test:`/`docs:` message ending in the line `Co-Authored-By: Claude Opus 5.5 (1M context) `. Do not push. +- **Out of scope** (controller rulings): adding `cargo test`/vitest steps to CI, `removeUnusedCommands`, argument scoping. + +## What works in this environment (verified 2026-09-22) + +| Command | Status | +|---|---| +| `cd app/src-tauri && cargo check --offline` | works; ~3 s when only `triple-c` recompiles. After a run that writes a `.toml`, the *next* run re-runs the build script once more, then it settles (spec §7.I). | +| `cd app/src-tauri && cargo test --offline [filter]` | works (baseline at `bf22910`: 652 tests, ~30 s to build) | +| `cd app/src-tauri && cargo clippy --offline --all-targets` | works | +| `cd app && npx vitest run [path]` / `npm run test` | works (baseline: 74 files, 969 tests, ~6 s) | +| `cd app && npx tsc --noEmit` / `npm run build` | works | +| `npx tauri dev` / `npx tauri build` | **not verifiable here** (no display). The manual checklist in Task 6 runs on the user's machine. | +| Fresh `git worktree` | needs `app/dist` to exist (`cd app && npm run build`) or `generate_context!` panics on `frontendDist` before any ACL code runs. | + +A failed build-script check appears under `--- stderr` in cargo's `error: failed to run custom build command` output; tauri's own `Permission … not found` appears under `--- stdout`. + +--- + +## File Structure + +**Create:** +- `app/src-tauri/src/command_census.rs` — `registered_commands` (the handler-list parser, moved from the `lib.rs` test), `allow_permission`, `expected_windows`, `CapabilityFile` + `capability_file` (JSON → bare grants), `check` (every rule, returns all violations), and their unit tests. +- `app/src/test/capabilities.test.ts` — wrapper map from `lib/tauri-commands.ts`, viewer import closure, per-window set checks, exact plugin/core lists. + +**Modify:** +- `app/src-tauri/build.rs` — read `src/lib.rs`, run `check`, prune stale `permissions/autogenerated/*.toml`, `try_build` with the `AppManifest`. +- `app/src-tauri/Cargo.toml` — `serde_json` under `[build-dependencies]` (`Cargo.lock` follows). +- `app/src-tauri/capabilities/default.json` — 110 bare `allow-*` grants; description. +- `app/src-tauri/capabilities/file-viewer.json` — 5 bare `allow-viewer-*` grants; description. +- `app/src-tauri/src/lib.rs` — `#[cfg(test)] mod command_census;`; `every_command_is_registered_exactly_once` uses the shared parser; `the_capability_grants_are_the_ones_that_were_reviewed` splits bare/prefixed; new `the_generated_app_manifest_matches_the_handler_list`. +- `app/src-tauri/src/file_viewer/mod.rs` — exact grant list in `the_viewer_capability_grants_exactly_the_reviewed_windows_and_permissions`; module doc. +- `app/src-tauri/gen/schemas/{acl-manifests,capabilities,desktop-schema,linux-schema}.json` — regenerated. +- `.gitignore` — `app/src-tauri/permissions/autogenerated/`. +- `CLAUDE.md` — Key Conventions bullet (lines 598-600) and Backend Structure (`file_viewer/` bullet lines 179-180, plus a `build.rs` bullet). + +## Dependency and parallel map + +``` +Task 1 (census module + parser refactor) — no behaviour change; safe alone + └─► Task 2 (build.rs + grants + census tests + gen/schemas) — THE atomic change + ├─► Task 3 (embedded-manifest test, lib.rs) ┐ + ├─► Task 4 (vitest capabilities.test.ts) ├─ independent of each other; may run in parallel + └─► Task 5 (descriptions, CLAUDE.md, mod.rs doc) ┘ (disjoint files: lib.rs / src/test / json descriptions+md) + └─► Task 6 (full verification, negative checks, manual checklist hand-off) +``` + +Tasks 3, 4 and 5 touch disjoint files, so three subagents in the same worktree can run them concurrently as long as each commits only its own files (`git add `, never `git add -A`). Task 5 edits the `description` field of both capability files; Task 2 edits their `permissions` array — that is why 5 waits for 2. + +--- + +## Interfaces (the contract every task codes against) + +`app/src-tauri/src/command_census.rs` (Task 1), used by `build.rs` (Task 2) and the tests (Tasks 2, 3): + +```rust +pub fn registered_commands(lib_rs: &str) -> Option>; // handler list, in order, duplicates kept; None = no block +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, pub bare: Vec } +pub fn capability_file(name: &str, json: &str) -> Result; +pub fn check(commands: &[String], files: &[CapabilityFile]) -> Vec; // every violation; empty = pass +``` + +In `lib.rs` the module is `crate::command_census` (test builds only). In `build.rs` it is `command_census` via `#[path = "src/command_census.rs"]`. + +The five viewer commands: `viewer_get_state`, `viewer_choose_file`, `viewer_read_file`, `viewer_poll_file`, `viewer_write_file`. Their grants: `allow-viewer-get-state`, `allow-viewer-choose-file`, `allow-viewer-read-file`, `allow-viewer-poll-file`, `allow-viewer-write-file`. + +--- + +### Task 1: `command_census.rs` — the shared parser and rules, unit-tested + +**Files:** +- Create: `app/src-tauri/src/command_census.rs` +- Modify: `app/src-tauri/src/lib.rs:1-12` (module list), `app/src-tauri/src/lib.rs:849-870` and `:895-920` (the parser and the duplicate loop inside `every_command_is_registered_exactly_once`) + +**Interfaces:** +- Consumes: nothing. +- Produces: everything in "Interfaces" above. + +- [ ] **Step 1: Write the failing tests** + +Create `app/src-tauri/src/command_census.rs` with only the tests and stub signatures, so the tests compile and fail: + +```rust +//! The command census shared by `build.rs` and the `cargo test` suite. +//! +//! `build.rs` pulls this file in with `#[path = "src/command_census.rs"]` and `lib.rs` with +//! `#[cfg(test)] mod command_census;`, so the parser that decides what the Tauri `AppManifest` +//! declares is the parser the tests exercise, and the rules that decide whether the build +//! passes have unit tests. Nothing here may reference the crate: only `std` and `serde_json` +//! (a dependency of both the crate and the build script). +//! +//! Spec: `docs/superpowers/specs/2026-09-22-app-manifest-lockdown-design.md` §3.2. + +use std::collections::{BTreeMap, BTreeSet}; + +pub fn registered_commands(lib_rs: &str) -> Option> { + let _ = lib_rs; + None +} + +pub fn allow_permission(command: &str) -> String { + command.to_string() +} + +pub fn expected_windows(command: &str) -> &'static [&'static str] { + let _ = command; + &[] +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CapabilityFile { + pub name: String, + pub windows: Vec, + pub bare: Vec, +} + +pub fn capability_file(name: &str, json: &str) -> Result { + let _ = (name, json, BTreeMap::::new(), BTreeSet::::new()); + Err("unimplemented".to_string()) +} + +pub fn check(commands: &[String], files: &[CapabilityFile]) -> Vec { + let _ = (commands, files); + vec!["unimplemented".to_string()] +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cmds(names: &[&str]) -> Vec { + names.iter().map(|n| n.to_string()).collect() + } + + fn file(name: &str, windows: &[&str], bare: &[&str]) -> CapabilityFile { + CapabilityFile { + name: name.to_string(), + windows: windows.iter().map(|w| w.to_string()).collect(), + bare: bare.iter().map(|b| b.to_string()).collect(), + } + } + + /// The two files as they must look after the lockdown, for a three-command app. + fn good_files() -> Vec { + vec![ + file("default.json", &["main"], &["allow-check-docker", "allow-open-file-viewer"]), + file("file-viewer.json", &["file-viewer-*"], &["allow-viewer-read-file"]), + ] + } + + const THREE: &[&str] = &["check_docker", "open_file_viewer", "viewer_read_file"]; + + #[test] + fn the_parser_reads_the_handler_list_in_order_and_ignores_comments() { + let lib_rs = r#" + .invoke_handler(tauri::generate_handler![ + // Docker + commands::docker_commands::check_docker, + commands::docker_commands::build_image, // trailing comment is not a command + url_open::open_url_external, + + // Viewer + commands::file_viewer_commands::viewer_read_file + ]) + .run(tauri::generate_context!()) + "#; + assert_eq!( + registered_commands(lib_rs).unwrap(), + cmds(&["check_docker", "build_image", "open_url_external", "viewer_read_file"]) + ); + } + + #[test] + fn the_parser_keeps_duplicates_so_the_caller_can_report_them() { + let lib_rs = "generate_handler![\n a::x,\n b::x,\n])"; + assert_eq!(registered_commands(lib_rs).unwrap(), cmds(&["x", "x"])); + } + + #[test] + fn the_parser_returns_none_without_a_handler_block() { + assert_eq!(registered_commands("fn main() {}"), None); + assert_eq!(registered_commands("generate_handler![ a::b, "), None, "unterminated"); + } + + #[test] + fn permission_identifiers_replace_only_underscores() { + assert_eq!(allow_permission("check_docker"), "allow-check-docker"); + assert_eq!(allow_permission("viewer_read_file"), "allow-viewer-read-file"); + assert_eq!(allow_permission("aws_sso_refresh"), "allow-aws-sso-refresh"); + } + + #[test] + fn viewer_commands_belong_to_the_viewer_windows_and_nothing_else_does() { + assert_eq!(expected_windows("viewer_read_file"), ["file-viewer-*"]); + assert_eq!(expected_windows("open_file_viewer"), ["main"]); + assert_eq!(expected_windows("check_docker"), ["main"]); + } + + #[test] + fn a_capability_file_yields_its_windows_and_bare_grants_only() { + let json = r#"{ + "identifier": "default", + "description": "x", + "windows": ["main"], + "permissions": [ + "core:event:allow-listen", + { "identifier": "fs:allow-read", "allow": [{ "path": "$APPDATA/*" }] }, + "allow-check-docker", + { "identifier": "allow-list-projects" } + ] + }"#; + let parsed = capability_file("default.json", json).unwrap(); + assert_eq!(parsed.name, "default.json"); + assert_eq!(parsed.windows, vec!["main"]); + assert_eq!(parsed.bare, vec!["allow-check-docker", "allow-list-projects"]); + } + + #[test] + fn a_capability_file_without_windows_or_permissions_is_an_error() { + assert!(capability_file("x.json", r#"{"permissions": []}"#).unwrap_err().contains("windows")); + assert!(capability_file("x.json", r#"{"windows": ["main"]}"#).unwrap_err().contains("permissions")); + assert!(capability_file("x.json", "not json").unwrap_err().contains("x.json")); + } + + #[test] + fn a_correct_census_has_no_problems() { + assert_eq!(check(&cmds(THREE), &good_files()), Vec::::new()); + } + + #[test] + fn an_empty_command_list_is_refused_because_it_would_disable_the_acl() { + let problems = check(&[], &good_files()); + assert_eq!(problems.len(), 1); + assert!(problems[0].contains("no commands"), "{problems:?}"); + } + + #[test] + fn a_command_without_a_grant_is_named_together_with_the_file_it_belongs_in() { + let files = vec![ + file("default.json", &["main"], &["allow-check-docker"]), + file("file-viewer.json", &["file-viewer-*"], &["allow-viewer-read-file"]), + ]; + let problems = check(&cmds(THREE), &files); + assert_eq!(problems.len(), 1, "{problems:?}"); + assert!(problems[0].contains("open_file_viewer")); + assert!(problems[0].contains("allow-open-file-viewer")); + assert!(problems[0].contains("[\"main\"]")); + } + + #[test] + fn a_grant_in_two_files_is_reported_once_naming_both() { + let files = vec![ + file("default.json", &["main"], &["allow-check-docker", "allow-open-file-viewer"]), + file("extra.json", &["main"], &["allow-check-docker"]), + file("file-viewer.json", &["file-viewer-*"], &["allow-viewer-read-file"]), + ]; + let problems = check(&cmds(THREE), &files); + assert_eq!(problems.len(), 1, "{problems:?}"); + assert!(problems[0].contains("allow-check-docker")); + assert!(problems[0].contains("default.json") && problems[0].contains("extra.json")); + } + + #[test] + fn a_grant_that_names_no_command_is_a_typo() { + let mut files = good_files(); + files[0].bare.push("allow-check-dokcer".to_string()); + let problems = check(&cmds(THREE), &files); + assert_eq!(problems.len(), 1, "{problems:?}"); + assert!(problems[0].contains("default.json: allow-check-dokcer")); + assert!(problems[0].contains("no registered command")); + } + + #[test] + fn deny_grants_are_refused_with_the_reason() { + let mut files = good_files(); + files[1].bare.push("deny-check-docker".to_string()); + let problems = check(&cmds(THREE), &files); + assert_eq!(problems.len(), 1, "{problems:?}"); + assert!(problems[0].contains("file-viewer.json: deny-check-docker")); + assert!(problems[0].contains("global")); + } + + #[test] + fn other_bare_identifiers_are_refused() { + let mut files = good_files(); + files[0].bare.push("default".to_string()); + let problems = check(&cmds(THREE), &files); + assert_eq!(problems.len(), 1, "{problems:?}"); + assert!(problems[0].contains("default.json: default")); + } + + #[test] + fn a_grant_in_the_wrong_file_is_refused_even_though_it_is_granted_exactly_once() { + let files = vec![ + file("default.json", &["main"], &["allow-check-docker", "allow-open-file-viewer", "allow-viewer-read-file"]), + file("file-viewer.json", &["file-viewer-*"], &[]), + ]; + let problems = check(&cmds(THREE), &files); + assert_eq!(problems.len(), 1, "{problems:?}"); + assert!(problems[0].contains("allow-viewer-read-file")); + assert!(problems[0].contains("[\"file-viewer-*\"]")); + } + + #[test] + fn a_widened_windows_list_is_the_wrong_file_too() { + let files = vec![ + file("default.json", &["main", "file-viewer-*"], &["allow-check-docker", "allow-open-file-viewer"]), + file("file-viewer.json", &["file-viewer-*"], &["allow-viewer-read-file"]), + ]; + let problems = check(&cmds(THREE), &files); + assert_eq!(problems.len(), 2, "{problems:?}"); + } + + #[test] + fn bad_names_and_duplicate_registrations_are_refused() { + let commands = cmds(&["check_docker", "Check-Docker", "check_docker", "open_file_viewer", "viewer_read_file"]); + let problems = check(&commands, &good_files()); + assert!(problems.iter().any(|p| p.contains("\"Check-Docker\"") && p.contains("[a-z0-9_]+")), "{problems:?}"); + assert!(problems.iter().any(|p| p.contains("check_docker is registered more than once")), "{problems:?}"); + } + + #[test] + fn every_problem_is_reported_in_one_pass() { + let files = vec![ + file("default.json", &["main"], &["allow-check-docker", "allow-nope", "deny-check-docker"]), + file("file-viewer.json", &["file-viewer-*"], &[]), + ]; + let problems = check(&cmds(THREE), &files); + // typo, deny, open_file_viewer missing, viewer_read_file missing + assert_eq!(problems.len(), 4, "{problems:?}"); + } +} +``` + +Register the module in `app/src-tauri/src/lib.rs`, after `mod browser_view;` (line 2): + +```rust +mod browser_view; +#[cfg(test)] +mod command_census; +mod commands; +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cd /workspace/triple-c/app/src-tauri && cargo test --offline command_census 2>&1 | tail -30` +Expected: compiles; `test result: FAILED` with every test except `the_parser_returns_none_without_a_handler_block` failing (the stubs return `None`/empty/error). + +- [ ] **Step 3: Implement the module** + +Replace the stub bodies in `command_census.rs` (keep the module doc, the `use`, the struct and the tests): + +```rust +/// The command names inside `generate_handler![ … ])` in `lib.rs`, in registration order, +/// duplicates kept (the caller decides whether that is an error). `None` if the block is +/// missing or unterminated. +/// +/// Line-based, not `split(',')`: the list is grouped under `// Docker` style comments, and +/// splitting on commas glues each comment to the command that follows it. A +/// `starts_with("//")` filter then drops that command — silently, and once per group. +pub fn registered_commands(lib_rs: &str) -> Option> { + let (_, rest) = lib_rs.split_once("generate_handler![")?; + let (inside, _) = rest.split_once("])")?; + Some( + inside + .lines() + .map(str::trim) + .filter(|l| !l.is_empty() && !l.starts_with("//")) + .filter_map(|l| { + // `a::b::name, // comment` → `name` + let code = l.split("//").next().unwrap_or("").trim(); + code.trim_end_matches(',') + .rsplit("::") + .next() + .map(|n| n.trim().to_string()) + }) + .filter(|n| !n.is_empty()) + .collect(), + ) +} + +/// `viewer_read_file` → `allow-viewer-read-file`. tauri-utils 2.9.0 (`acl/build.rs:290`) +/// replaces only `_`; permission identifiers may not contain `_`, but the command name inside +/// the generated permission stays snake_case. +pub fn allow_permission(command: &str) -> String { + format!("allow-{}", command.replace('_', "-")) +} + +/// The `windows` list of the one capability file that may grant `command`. A command that +/// must be callable from both windows is a design change: make it here, visibly, rather than +/// by widening a capability file. +pub fn expected_windows(command: &str) -> &'static [&'static str] { + if command.starts_with("viewer_") { + &["file-viewer-*"] + } else { + &["main"] + } +} + +/// One `capabilities/*.json`, reduced to what the census checks. Plugin and core grants +/// (anything with a `:`) are not this module's business; the exact-set tests in `lib.rs` and +/// `file_viewer/mod.rs` pin those. +pub fn capability_file(name: &str, json: &str) -> Result { + let value: serde_json::Value = + serde_json::from_str(json).map_err(|e| format!("{name}: not valid JSON: {e}"))?; + let windows = value["windows"] + .as_array() + .ok_or_else(|| format!("{name}: `windows` must be an array"))? + .iter() + .map(|w| { + w.as_str() + .map(str::to_string) + .ok_or_else(|| format!("{name}: `windows` entries must be strings")) + }) + .collect::, _>>()?; + let mut bare = Vec::new(); + for grant in value["permissions"] + .as_array() + .ok_or_else(|| format!("{name}: `permissions` must be an array"))? + { + let id = match grant { + serde_json::Value::String(s) => s.as_str(), + serde_json::Value::Object(o) => o + .get("identifier") + .and_then(|i| i.as_str()) + .ok_or_else(|| format!("{name}: a scoped grant needs a string `identifier`"))?, + _ => return Err(format!("{name}: a grant is a string or an object")), + }; + if !id.contains(':') { + bare.push(id.to_string()); + } + } + Ok(CapabilityFile { name: name.to_string(), windows, bare }) +} + +/// Everything that must hold between the handler list and the capability files. Returns every +/// violation rather than the first, so a batch of forgotten grants is one build failure; an +/// empty vector is a pass. +pub fn check(commands: &[String], files: &[CapabilityFile]) -> Vec { + let mut problems = Vec::new(); + if commands.is_empty() { + problems.push( + "no commands were parsed out of generate_handler! — an empty AppManifest would \ + silently leave every app command ungated" + .to_string(), + ); + return problems; + } + + let mut seen: BTreeSet<&str> = BTreeSet::new(); + for c in commands { + if !c.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_') { + problems.push(format!("{c:?} is not a command name ([a-z0-9_]+)")); + } + if !seen.insert(c.as_str()) { + problems.push(format!("{c} is registered more than once")); + } + } + + let known: BTreeMap = + seen.iter().map(|c| (allow_permission(c), *c)).collect(); + for f in files { + let windows: Vec<&str> = f.windows.iter().map(String::as_str).collect(); + for id in &f.bare { + match known.get(id) { + Some(command) => { + let want = expected_windows(command); + if windows.as_slice() != want { + problems.push(format!( + "{}: {id} must be granted in the capability file whose windows are \ + {want:?}, not {windows:?}", + f.name + )); + } + } + None if id.starts_with("deny-") => problems.push(format!( + "{}: {id}: deny-* is global in tauri 2.11 — it would deny the command for \ + every window, not just this one; use allow-lists only", + f.name + )), + None if id.starts_with("allow-") => problems.push(format!( + "{}: {id} names no registered command (the identifier is allow- \ + with every `_` replaced by `-`)", + f.name + )), + None => problems.push(format!( + "{}: {id}: only allow- app grants are permitted as bare identifiers", + f.name + )), + } + } + } + + for c in &seen { + let id = allow_permission(c); + let holders: Vec<&str> = files + .iter() + .filter(|f| f.bare.iter().any(|b| b == &id)) + .map(|f| f.name.as_str()) + .collect(); + match holders.len() { + 0 => problems.push(format!( + "{c} is registered but no capability file grants {id}; add it to the file \ + whose windows are {:?}", + expected_windows(c) + )), + 1 => {} + _ => problems.push(format!( + "{id} is granted in more than one capability file: {holders:?}" + )), + } + } + problems +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cd /workspace/triple-c/app/src-tauri && cargo test --offline command_census 2>&1 | tail -5` +Expected: `test result: ok. 18 passed`. + +- [ ] **Step 5: Make `every_command_is_registered_exactly_once` use the shared parser** + +In `app/src-tauri/src/lib.rs`, replace the block that begins `// The registration list, read from this file rather than from a macro` and ends with the `.collect();` of `registered` (currently lines 847-870) with: + +```rust + // The registration list, read from this file by the same parser `build.rs` uses to + // declare the AppManifest — so if this test can see a command, the ACL can too. + let ordered = crate::command_census::registered_commands(include_str!("lib.rs")) + .expect("lib.rs should contain a generate_handler! list"); + let registered: BTreeSet = ordered.iter().cloned().collect(); +``` + +and replace the duplicate loop (currently the `let mut seen … for line in handler … assert!(duplicated.is_empty(), …)` block, lines ~895-920) with: + +```rust + // "exactly once" was in this test's name and not in its body: both + // sides were sets, so registering the same command twice in a + // hand-maintained 118-line list compiled, warned about nothing, and + // passed here. + let mut seen: Vec<&str> = Vec::new(); + let mut duplicated: Vec<&str> = Vec::new(); + for name in &ordered { + if seen.contains(&name.as_str()) { + duplicated.push(name); + } else { + seen.push(name); + } + } + assert!( + duplicated.is_empty(), + "these are registered more than once: {:?}", + duplicated + ); +``` + +Delete the now-unused `let this = include_str!("lib.rs");` and `handler` bindings. + +- [ ] **Step 6: Run the census tests and clippy** + +Run: `cd /workspace/triple-c/app/src-tauri && cargo test --offline every_command_is_registered_exactly_once 2>&1 | tail -5 && cargo clippy --offline --all-targets 2>&1 | grep -E "^(warning|error)" | head` +Expected: `1 passed`; no new warnings. + +- [ ] **Step 7: Commit** + +```bash +cd /workspace/triple-c && git add app/src-tauri/src/command_census.rs app/src-tauri/src/lib.rs +git commit -m "refactor(acl): shared command census module for build.rs and tests + +Moves the generate_handler! parser out of the lib.rs test into +src/command_census.rs and adds the capability rules (one allow-* grant per +command, in the file its name says, no deny-*) with unit tests. No +behaviour change yet: build.rs does not use it until the next commit. + +Co-Authored-By: Claude Opus 5.5 (1M context) " +``` + +--- + +### Task 2: `build.rs` declares the AppManifest; both capability files grant; census tests updated + +This is the atomic change. Do every step before running `cargo check`; until Step 5 the build fails by design. + +**Files:** +- Modify: `app/src-tauri/build.rs` (whole file), `app/src-tauri/Cargo.toml` (`[build-dependencies]`), `app/src-tauri/capabilities/default.json` (`permissions`), `app/src-tauri/capabilities/file-viewer.json` (`permissions`), `app/src-tauri/src/lib.rs:924-975` (`the_capability_grants_are_the_ones_that_were_reviewed`), `app/src-tauri/src/file_viewer/mod.rs:63-82`, `.gitignore` +- Regenerated: `app/src-tauri/gen/schemas/*.json`, `app/src-tauri/Cargo.lock` + +**Interfaces:** +- Consumes: `command_census::{registered_commands, allow_permission, expected_windows, capability_file, check}` (Task 1). +- Produces: `permissions/autogenerated/.toml` (ignored), `gen/schemas/acl-manifests.json` with an `__app-acl__` key (Task 3 reads it), the bare grants in both capability files (Task 4 reads them). + +- [ ] **Step 1: Update the two census tests to expect the grants (red)** + +In `app/src-tauri/src/lib.rs`, replace the body of `the_capability_grants_are_the_ones_that_were_reviewed` from `let mut sorted = listed.clone();` through the `assert_eq!(sorted, expected, …);` with: + +```rust + // Plugin and core grants: the exact reviewed list, unchanged by the lockdown. + let (bare, prefixed): (Vec, Vec) = + listed.iter().cloned().partition(|g| !g.contains(':')); + let mut sorted = prefixed; + sorted.sort(); + let mut expected = vec![ + "core:event:allow-listen", + "core:event:allow-unlisten", + "core:webview:allow-internal-toggle-devtools", + "dialog:allow-open", + "dialog:allow-save", + ]; + expected.sort(); + assert_eq!( + sorted, expected, + "the plugin/core capability set changed. That is allowed — but it is the IPC \ + surface a compromised webview can call, so update this list \ + deliberately rather than to make the test pass." + ); + + // App commands: since build.rs declares the AppManifest, the bare `allow-*` grants + // are the complete list of app commands the main window may call. `build.rs` already + // fails the build when they disagree with generate_handler!; this keeps the reviewed + // rule ("every non-viewer command, exactly") visible where the plugin census lives. + let registered = crate::command_census::registered_commands(include_str!("lib.rs")) + .expect("lib.rs should contain a generate_handler! list"); + let mut expected_bare: Vec = registered + .iter() + .filter(|c| crate::command_census::expected_windows(c) == ["main"]) + .map(|c| crate::command_census::allow_permission(c)) + .collect(); + expected_bare.sort(); + let mut bare = bare; + bare.sort(); + assert_eq!( + bare, expected_bare, + "default.json's app-command grants must be exactly the main-window commands" + ); + assert!(bare.len() >= 100, "the census found {} app grants; the parser has stopped seeing the list", bare.len()); +``` + +Leave the `for grant in &listed { … :default … store: … }` loop as it is. + +In `app/src-tauri/src/file_viewer/mod.rs`, replace the `assert_eq!(permissions, vec![ … ]);` in `the_viewer_capability_grants_exactly_the_reviewed_windows_and_permissions` with: + +```rust + assert_eq!( + permissions, + vec![ + // App commands (bare): the five viewer commands, and nothing else — build.rs + // refuses any other bare grant in this file. + "allow-viewer-choose-file", + "allow-viewer-get-state", + "allow-viewer-poll-file", + "allow-viewer-read-file", + "allow-viewer-write-file", + // Plugin/core grants, unchanged. + "core:event:allow-listen", + "core:event:allow-unlisten", + "core:webview:allow-internal-toggle-devtools", + "core:window:allow-destroy", + ] + ); +``` + +(Sorted order: `a` < `c`.) + +- [ ] **Step 2: Run the two tests to verify they fail** + +Run: `cd /workspace/triple-c/app/src-tauri && cargo test --offline the_capability_grants 2>&1 | grep -E "^test |panicked" | head` +Expected: both `the_capability_grants_are_the_ones_that_were_reviewed` and `the_viewer_capability_grants_exactly_the_reviewed_windows_and_permissions` FAIL on the `assert_eq!` (no bare grants yet). + +- [ ] **Step 3: Add `serde_json` to the build script and write `build.rs`** + +`app/src-tauri/Cargo.toml`, `[build-dependencies]`: + +```toml +[build-dependencies] +tauri-build = { version = "2", features = [] } +# build.rs reads capabilities/*.json to cross-check them against generate_handler!. +serde_json = "1" +``` + +Replace `app/src-tauri/build.rs` entirely: + +```rust +//! Declares the Tauri `AppManifest`, so every app command is ACL-gated per window, and refuses +//! to build unless every registered command is granted in exactly one capability file — the +//! file whose `windows` the command's name says it belongs to. Without an app manifest, tauri +//! 2.11 skips the ACL for app commands entirely (`webview/mod.rs:1794`), so any local window +//! could call any command. +//! +//! The parser and the rules live in `src/command_census.rs`, which `cargo test` also compiles, +//! so they have unit tests. Spec: `docs/superpowers/specs/2026-09-22-app-manifest-lockdown-design.md`. + +#[path = "src/command_census.rs"] +mod command_census; + +use std::path::Path; + +fn fail(problems: &[String]) -> ! { + eprintln!(); + eprintln!( + "capabilities do not match generate_handler! ({} problem{}):", + problems.len(), + if problems.len() == 1 { "" } else { "s" } + ); + for p in problems { + eprintln!(" - {p}"); + } + eprintln!(); + eprintln!( + "Every app command needs exactly one bare `allow-` grant: \ + `viewer_*` commands in capabilities/file-viewer.json, everything else in \ + capabilities/default.json. See src/command_census.rs." + ); + eprintln!(); + std::process::exit(1); +} + +fn main() { + // tauri-build already emits rerun-if-changed for `capabilities` and `permissions`. + println!("cargo:rerun-if-changed=src/lib.rs"); + println!("cargo:rerun-if-changed=src/command_census.rs"); + + let lib_rs = std::fs::read_to_string("src/lib.rs") + .expect("build.rs runs with CWD = src-tauri, so src/lib.rs must be readable"); + let Some(commands) = command_census::registered_commands(&lib_rs) else { + fail(&["src/lib.rs has no `generate_handler![ … ])` block to derive the AppManifest from" + .to_string()]); + }; + + let mut files = Vec::new(); + for entry in std::fs::read_dir("capabilities").expect("capabilities/ must exist") { + let path = entry.expect("readable entry in capabilities/").path(); + if path.extension().is_some_and(|e| e == "json") { + let name = path + .file_name() + .expect("a file name") + .to_string_lossy() + .into_owned(); + let json = + std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{name}: {e}")); + match command_census::capability_file(&name, &json) { + Ok(file) => files.push(file), + Err(problem) => fail(&[problem]), + } + } + } + files.sort_by(|a, b| a.name.cmp(&b.name)); + + let problems = command_census::check(&commands, &files); + if !problems.is_empty() { + fail(&problems); + } + + prune_permissions(&commands); + + // `AppManifest::commands` takes `&'static [&'static str]` and the struct is `Copy`, so + // there is no owned form; leaking is fine in a process that exits right after. + let leaked: Vec<&'static str> = commands + .into_iter() + .map(|c| &*Box::leak(c.into_boxed_str())) + .collect(); + let leaked: &'static [&'static str] = Box::leak(leaked.into_boxed_slice()); + let attributes = tauri_build::Attributes::new() + .app_manifest(tauri_build::AppManifest::new().commands(leaked)); + if let Err(error) = tauri_build::try_build(attributes) { + // Same shape as `tauri_build::build()`: message on stdout, then exit 1. + println!("{error:#}"); + std::process::exit(1); + } +} + +/// tauri-build writes `permissions/autogenerated/.toml` for every manifest command +/// and never deletes one, so a command removed from `lib.rs` would leave a permission a +/// capability could still reference (and the build would pass). Delete only the stale files: +/// tauri-build also emits `rerun-if-changed=permissions`, so regenerating everything would +/// touch every mtime and re-run this script — and recompile the crate — on every cargo +/// invocation. Anything else under `permissions/` is a hand-written grant the census cannot +/// see, so it is refused. +fn prune_permissions(commands: &[String]) { + let root = Path::new("permissions"); + let Ok(entries) = std::fs::read_dir(root) else { + return; + }; + for entry in entries { + let path = entry.expect("readable entry in permissions/").path(); + if path.file_name().is_some_and(|n| n == "autogenerated") && path.is_dir() { + for file in std::fs::read_dir(&path).expect("readable permissions/autogenerated") { + let file = file.expect("readable entry").path(); + let stem = file.file_stem().and_then(|s| s.to_str()).unwrap_or(""); + let live = file.extension().is_some_and(|e| e == "toml") + && commands.iter().any(|c| c == stem); + if !live { + std::fs::remove_file(&file) + .unwrap_or_else(|e| panic!("cannot delete stale {}: {e}", file.display())); + } + } + } else { + fail(&[format!( + "{} is not generated by build.rs; hand-written permissions are not allowed \ + (every grant is a bare allow-* string in a capability file)", + path.display() + )]); + } + } +} +``` + +Add to `.gitignore` (after `app/src-tauri/target/`): + +``` +# Written by build.rs (tauri-build AppManifest); gen/schemas/acl-manifests.json is the +# tracked, reviewable form of the same information. +app/src-tauri/permissions/autogenerated/ +``` + +- [ ] **Step 4: Add the grants to both capability files** + +`app/src-tauri/capabilities/file-viewer.json` — `permissions` becomes: + +```json + "permissions": [ + "core:event:allow-listen", + "core:event:allow-unlisten", + "core:window:allow-destroy", + "core:webview:allow-internal-toggle-devtools", + "allow-viewer-get-state", + "allow-viewer-choose-file", + "allow-viewer-read-file", + "allow-viewer-poll-file", + "allow-viewer-write-file" + ] +``` + +`app/src-tauri/capabilities/default.json` — `permissions` becomes the five existing grants followed by the 110 main-window commands in `generate_handler!` order (grouped like `lib.rs`; the build check does not care about order, reviewers do): + +```json + "permissions": [ + "core:event:allow-listen", + "core:event:allow-unlisten", + "core:webview:allow-internal-toggle-devtools", + "dialog:allow-open", + "dialog:allow-save", + "allow-check-docker", + "allow-check-image-exists", + "allow-build-image", + "allow-get-container-info", + "allow-list-projects", + "allow-add-project", + "allow-remove-project", + "allow-update-project", + "allow-start-project-container", + "allow-stop-project-container", + "allow-rebuild-project-container", + "allow-reconcile-project-statuses", + "allow-list-notes", + "allow-save-note", + "allow-delete-note", + "allow-get-container-staleness", + "allow-migrate-project-to-base", + "allow-confirm-migration", + "allow-rollback-migration", + "allow-get-migration-state", + "allow-set-auth-bridge-enabled", + "allow-get-auth-bridge-status", + "allow-set-browser-view-enabled", + "allow-get-browser-view-status", + "allow-check-browser-view-support", + "allow-install-browser-view-support", + "allow-install-browser-view-browser", + "allow-open-browser-view-popout", + "allow-close-browser-view-popout", + "allow-get-browser-view-popout-state", + "allow-set-browser-view-popout-always-on-top", + "allow-open-page-in-container-browser", + "allow-set-container-page-viewport", + "allow-get-container-page-state", + "allow-close-container-page", + "allow-set-browser-view-match-window", + "allow-get-browser-view-match-window", + "allow-acquire-claude-token", + "allow-submit-claude-token-code", + "allow-cancel-claude-token", + "allow-has-claude-token", + "allow-clear-claude-token", + "allow-sweep-claude-token-snapshots", + "allow-get-settings", + "allow-update-settings", + "allow-pull-image", + "allow-detect-aws-config", + "allow-inspect-ca-cert-path", + "allow-list-aws-profiles", + "allow-detect-host-timezone", + "allow-export-settings", + "allow-preview-settings-import", + "allow-apply-settings-import", + "allow-open-terminal-session", + "allow-terminal-input", + "allow-terminal-resize", + "allow-close-terminal-session", + "allow-paste-image-to-terminal", + "allow-upload-host-file-to-terminal", + "allow-start-audio-bridge", + "allow-send-audio-data", + "allow-stop-audio-bridge", + "allow-list-container-files", + "allow-download-container-backup", + "allow-download-container-file", + "allow-upload-files-to-container", + "allow-read-container-file", + "allow-rename-container-path", + "allow-create-container-directory", + "allow-open-file-viewer", + "allow-aws-sso-refresh", + "allow-get-app-version", + "allow-check-for-updates", + "allow-check-image-update", + "allow-get-help-content", + "allow-open-url-external", + "allow-detect-install-options", + "allow-run-docker-install", + "allow-start-web-terminal", + "allow-stop-web-terminal", + "allow-get-web-terminal-status", + "allow-regenerate-web-terminal-token", + "allow-get-stt-status", + "allow-start-stt", + "allow-stop-stt", + "allow-build-stt-image", + "allow-pull-stt-image", + "allow-transcribe-audio", + "allow-get-gateway-status", + "allow-start-gateway", + "allow-stop-gateway", + "allow-check-gateway-health", + "allow-build-gateway-image", + "allow-pull-gateway-image", + "allow-set-gateway-api-key", + "allow-clear-gateway-api-key", + "allow-get-gateway-auth-token", + "allow-regenerate-gateway-auth-token", + "allow-list-claude-sessions", + "allow-resume-session-command", + "allow-list-container-capabilities", + "allow-list-scheduled-tasks", + "allow-add-scheduled-task", + "allow-update-scheduled-task", + "allow-get-scheduled-task-log", + "allow-set-scheduled-task-enabled", + "allow-run-scheduled-task-now", + "allow-remove-scheduled-task", + "allow-get-scheduler-notifications", + "allow-clear-scheduler-notifications" + ] +``` + +Do not trust the list above blindly — the build check is the authority. If `cargo check` in Step 5 reports a missing or unknown grant, the handler list has moved since this plan was written; fix the JSON to match `lib.rs`, not the other way round. This one-liner prints what the list must contain: + +```bash +cd /workspace/triple-c/app/src-tauri && awk '/generate_handler!\[/,/^ \]\)/' src/lib.rs | grep -v '^\s*//' | grep '::' | sed 's/,$//; s/.*:://' | grep -v '^viewer_' | sed 's/_/-/g; s/^/ "allow-/; s/$/",/' +``` + +- [ ] **Step 5: Build, and confirm the manifest was embedded** + +Run: `cd /workspace/triple-c/app/src-tauri && cargo check --offline 2>&1 | tail -3` +Expected: `Finished` (the first run compiles the build script with `serde_json`; a second `cargo check --offline` may re-run the script once because the `.toml` files are new, then it settles — run it a third time and confirm it prints only `Finished` with no `Compiling`). + +Then: + +```bash +cd /workspace/triple-c/app/src-tauri && ls permissions/autogenerated | wc -l # 115 +python3 -c "import json; m=json.load(open('gen/schemas/acl-manifests.json'))['__app-acl__']; print(len(m['permissions']), sorted(m['permissions'])[:3])" # 230 ['allow-acquire-claude-token', 'allow-add-project', 'allow-add-scheduled-task'] +python3 -c "import json; c=json.load(open('gen/schemas/capabilities.json')); print(len([p for p in c['default']['permissions'] if ':' not in p]), len([p for p in c['file-viewer']['permissions'] if ':' not in p]))" # 110 5 +git status --short # M build.rs, Cargo.toml, Cargo.lock, capabilities/*.json, gen/schemas/*.json, src/lib.rs, src/file_viewer/mod.rs, .gitignore — and NOT permissions/ +``` + +- [ ] **Step 6: Run the census tests to verify they pass** + +Run: `cd /workspace/triple-c/app/src-tauri && cargo test --offline capability 2>&1 | grep -E "^test |test result"` +Expected: `the_capability_grants_are_the_ones_that_were_reviewed … ok`, `the_viewer_capability_grants_exactly_the_reviewed_windows_and_permissions … ok`, `the_default_capability_is_scoped_to_the_main_window_only … ok`. + +- [ ] **Step 7: Prove the build check bites (then restore)** + +Each of these must fail `cargo check --offline` with the message shown; restore the file after each (`git checkout -- capabilities/default.json` etc.): + +```bash +cd /workspace/triple-c/app/src-tauri +# (a) forgotten grant +sed -i '/"allow-check-docker",/d' capabilities/default.json && cargo check --offline 2>&1 | grep -A3 "problem"; git checkout -- capabilities/default.json +# expect: "check_docker is registered but no capability file grants allow-check-docker; add it to the file whose windows are [\"main\"]" +# (b) typo +sed -i 's/"allow-check-docker"/"allow-check-dokcer"/' capabilities/default.json && cargo check --offline 2>&1 | grep -A4 "problem"; git checkout -- capabilities/default.json +# expect two problems: the typo "names no registered command" and check_docker "no capability file grants" +# (c) deny +sed -i 's/"allow-viewer-write-file"/"allow-viewer-write-file",\n "deny-check-docker"/' capabilities/file-viewer.json && cargo check --offline 2>&1 | grep -A3 "problem"; git checkout -- capabilities/file-viewer.json +# expect: "file-viewer.json: deny-check-docker: deny-* is global in tauri 2.11 …" +# (d) wrong file +sed -i '/"allow-viewer-read-file",/d' capabilities/file-viewer.json && sed -i 's/"allow-check-docker",/"allow-check-docker",\n "allow-viewer-read-file",/' capabilities/default.json && cargo check --offline 2>&1 | grep -A3 "problem"; git checkout -- capabilities/ +# expect: "default.json: allow-viewer-read-file must be granted in the capability file whose windows are [\"file-viewer-*\"], not [\"main\"]" +# (f) empty parse → must not build an unguarded app +sed -i 's/let (inside, _) = rest.split_once("])")?;/let (inside, _) = rest.split_once("])")?; let inside = "";/' src/command_census.rs && grep -c 'let inside = "";' src/command_census.rs && cargo check --offline 2>&1 | grep -A2 "problem"; git checkout -- src/command_census.rs +# expect: "no commands were parsed out of generate_handler! …" +# stale-file pruning +touch permissions/autogenerated/zzz_removed.toml && cargo check --offline >/dev/null 2>&1; ls permissions/autogenerated/zzz_removed.toml 2>&1 +# expect: "No such file or directory" +# hand-written permission refused +mkdir -p permissions/extra && echo '[[permission]]' > permissions/extra/x.toml && cargo check --offline 2>&1 | grep "hand-written"; rm -r permissions/extra +# expect: "permissions/extra is not generated by build.rs; hand-written permissions are not allowed …" +cargo check --offline 2>&1 | tail -1 # Finished — the tree is back to green +git status --short # same list as Step 5, nothing else +``` + +- [ ] **Step 8: Commit (one commit, all of it)** + +```bash +cd /workspace/triple-c && git add .gitignore app/src-tauri/build.rs app/src-tauri/Cargo.toml app/src-tauri/Cargo.lock app/src-tauri/capabilities/default.json app/src-tauri/capabilities/file-viewer.json app/src-tauri/src/lib.rs app/src-tauri/src/file_viewer/mod.rs app/src-tauri/gen/schemas +git commit -m "feat(acl): gate every app command per window via a Tauri AppManifest + +build.rs now derives an AppManifest from generate_handler!, which makes +tauri 2.11 apply the ACL to app commands (it skips them entirely without +one). default.json grants the 110 main-window commands, file-viewer.json +the five viewer_* commands, and build.rs refuses to build on a missing, +misspelled, duplicated, misfiled or deny-* grant, or on a hand-written +permission file. Stale autogenerated permissions are pruned per build. + +Closes the residual risk recorded by the terminal file viewer: a +compromised viewer window could invoke any app command. + +Co-Authored-By: Claude Opus 5.5 (1M context) " +``` + +--- + +### Task 3: End-to-end test — the embedded manifest equals the handler list + +**Files:** +- Modify: `app/src-tauri/src/lib.rs` (append a test after `the_capability_grants_are_the_ones_that_were_reviewed`) + +**Interfaces:** +- Consumes: `gen/schemas/acl-manifests.json` written by `build.rs` (Task 2); `crate::command_census::{registered_commands, allow_permission}`. + +- [ ] **Step 1: Write the test** + +```rust + /// `build.rs` derives the AppManifest from the handler list and this reads back what + /// tauri-build actually embedded. `cargo test` runs the build script first, so + /// `gen/schemas/acl-manifests.json` is fresh. Independent of the shared parser: if + /// `registered_commands` ever lost half the list, `build.rs` would declare half a + /// manifest and this would still compare it against… the same half. So the count is + /// pinned too, from a source that is not the parser: the `#[tauri::command]` scan in + /// `every_command_is_registered_exactly_once` guarantees definitions == registrations, + /// and here the embedded set must match the number of registrations that scan found. + #[test] + fn the_generated_app_manifest_matches_the_handler_list() { + use std::collections::BTreeSet; + + let path = concat!(env!("CARGO_MANIFEST_DIR"), "/gen/schemas/acl-manifests.json"); + let raw = std::fs::read_to_string(path) + .expect("gen/schemas/acl-manifests.json is written by build.rs on every build"); + let manifests: serde_json::Value = + serde_json::from_str(&raw).expect("acl-manifests.json must parse"); + let app = manifests.get("__app-acl__").expect( + "build.rs must declare an AppManifest — without it tauri skips the ACL for every \ + app command", + ); + let embedded: BTreeSet = app["permissions"] + .as_object() + .expect("the app manifest has a permissions map") + .keys() + .cloned() + .collect(); + + let registered = crate::command_census::registered_commands(include_str!("lib.rs")) + .expect("lib.rs should contain a generate_handler! list"); + let expected: BTreeSet = registered + .iter() + .flat_map(|c| { + let allow = crate::command_census::allow_permission(c); + let deny = format!("deny-{}", &allow["allow-".len()..]); + [allow, deny] + }) + .collect(); + + assert!(registered.len() >= 100, "the parser sees {} commands", registered.len()); + assert_eq!( + embedded, expected, + "the embedded app manifest and generate_handler! disagree: build.rs and \ + tauri-build should have produced the same list" + ); + assert!( + app["permission_sets"].as_object().is_some_and(|s| s.is_empty()), + "no permission sets: every grant is a literal allow-* string in a capability file" + ); + assert!(app["default_permission"].is_null(), "no app `default` permission set"); + } +``` + +If the last two assertions do not match the file's actual shape (check with `python3 -c "import json; m=json.load(open('gen/schemas/acl-manifests.json'))['__app-acl__']; print({k: type(v).__name__ for k, v in m.items()})"`), adjust them to assert the equivalent "empty / absent" condition for the shape that is there; do not drop them. + +- [ ] **Step 2: Run it** + +Run: `cd /workspace/triple-c/app/src-tauri && cargo test --offline the_generated_app_manifest 2>&1 | grep -E "^test |test result"` +Expected: `ok. 1 passed`. + +- [ ] **Step 3: Verify it fails when the manifest is stale** + +```bash +cd /workspace/triple-c/app/src-tauri +python3 - <<'EOF' +import json; p='gen/schemas/acl-manifests.json'; m=json.load(open(p)); del m['__app-acl__']['permissions']['allow-check-docker']; json.dump(m, open(p,'w')) +EOF +cargo test --offline the_generated_app_manifest 2>&1 | grep -E "^test |disagree" | head -3 +``` +Expected: FAILED, message contains `disagree`. Then `cargo check --offline` (rewrites the file) and `git status --short app/src-tauri/gen` must show it clean again. + +- [ ] **Step 4: Commit** + +```bash +cd /workspace/triple-c && git add app/src-tauri/src/lib.rs +git commit -m "test(acl): the embedded app manifest equals generate_handler! + +Co-Authored-By: Claude Opus 5.5 (1M context) " +``` + +--- + +### Task 4: Vitest — each window's code reaches only the wrappers it is granted + +**Files:** +- Create: `app/src/test/capabilities.test.ts` + +**Interfaces:** +- Consumes: `app/src-tauri/capabilities/{default,file-viewer}.json` with bare grants (Task 2); `app/src/lib/tauri-commands.ts` (`export const NAME = … invoke("cmd", …)`, one literal per wrapper); `app/src/viewer/main.tsx` as the viewer entry. + +- [ ] **Step 1: Write the test** + +```ts +import { describe, it, expect } from "vitest"; +import { existsSync, readdirSync, readFileSync, statSync } from "fs"; +import { dirname, join, relative, resolve } from "path"; + +/** + * The capability files are the IPC ACL. Since the AppManifest lockdown, a window can only + * invoke the app commands its file grants; `build.rs` proves every command is granted in the + * file its name says it belongs to. This proves the other half: the code that *runs* in each + * window imports only wrappers that window is granted. A wrapper imported on the wrong side + * fails here, not with `Command … not allowed by ACL` in a release build. + * + * It works on imports rather than `invoke(` literals because the viewer never calls invoke: + * everything goes through `lib/tauri-commands.ts`, which is the only file allowed to import + * `@tauri-apps/api/core` (that rule is what makes this test complete). + */ +const srcDir = resolve(__dirname, ".."); +const capDir = resolve(srcDir, "../src-tauri/capabilities"); +const WRAPPERS = resolve(srcDir, "lib/tauri-commands.ts"); +const VIEWER_ENTRY = resolve(srcDir, "viewer/main.tsx"); + +const toPermission = (command: string) => `allow-${command.replace(/_/g, "-")}`; + +function readCapability(file: string) { + const cap = JSON.parse(readFileSync(resolve(capDir, file), "utf-8")) as { + windows: string[]; + permissions: (string | { identifier: string })[]; + }; + const ids = cap.permissions.map((p) => (typeof p === "string" ? p : p.identifier)); + return { + windows: cap.windows, + bare: ids.filter((id) => !id.includes(":")).sort(), + prefixed: ids.filter((id) => id.includes(":")).sort(), + }; +} + +/** Every non-test .ts/.tsx under src/, excluding src/test. */ +function sourceFiles(dir: string, out: string[] = []): string[] { + for (const name of readdirSync(dir)) { + const path = join(dir, name); + if (statSync(path).isDirectory()) { + if (name !== "test") sourceFiles(path, out); + } else if (/\.tsx?$/.test(name) && !/\.test\.tsx?$/.test(name) && !name.endsWith(".d.ts")) { + out.push(path); + } + } + return out; +} + +/** `export const NAME = … invoke("command"` → NAME → command. Exactly one literal per wrapper. */ +function wrapperCommands(): Map { + const text = readFileSync(WRAPPERS, "utf-8"); + const [head, ...chunks] = text.split(/^export const /m); + expect(head.match(/\binvoke(?:<[^>]*>)?\(/g) ?? [], "invoke calls outside an export const wrapper").toHaveLength(0); + const map = new Map(); + for (const chunk of chunks) { + const name = /^(\w+)/.exec(chunk)?.[1] ?? ""; + const calls = chunk.match(/\binvoke(?:<[^>]*>)?\(/g) ?? []; + const literals = [...chunk.matchAll(/\binvoke(?:<[^>]*>)?\(\s*"([a-z_]+)"/g)].map((m) => m[1]); + expect(calls, `${name} must call invoke exactly once`).toHaveLength(1); + expect(literals, `${name} must invoke a string literal (a computed name cannot be audited)`).toHaveLength(1); + map.set(name, literals[0]); + } + expect(map.size).toBeGreaterThan(100); + return map; +} + +function resolveRelativeImport(from: string, spec: string): string | null { + if (!spec.startsWith(".")) return null; // a package + const base = resolve(dirname(from), spec); + if (/\.(css|svg|png|json)$/.test(base)) return null; + for (const candidate of [base, `${base}.ts`, `${base}.tsx`, join(base, "index.ts"), join(base, "index.tsx")]) { + if (existsSync(candidate) && statSync(candidate).isFile()) return candidate; + } + throw new Error(`${relative(srcDir, from)}: cannot resolve import "${spec}"`); +} + +const IMPORT_SPEC = /^import\b[^;]*?\bfrom\s*["']([^"']+)["']|^import\s*["']([^"']+)["']/gm; +const WRAPPER_IMPORT = /import\s*(?:type\s*)?\{([^}]*)\}\s*from\s*["'][^"']*\/lib\/tauri-commands["']/g; + +function importSpecs(file: string): string[] { + const text = readFileSync(file, "utf-8"); + return [...text.matchAll(IMPORT_SPEC)].map((m) => m[1] ?? m[2]); +} + +function wrapperNamesImportedBy(file: string): string[] { + const text = readFileSync(file, "utf-8"); + return [...text.matchAll(WRAPPER_IMPORT)].flatMap((m) => + m[1] + .split(",") + .map((s) => s.trim().replace(/^type\s+/, "").split(/\s+as\s+/)[0].trim()) + .filter((s) => s.length > 0), + ); +} + +/** Transitive relative-import closure from the viewer entry. */ +function viewerClosure(): Set { + const seen = new Set(); + const queue = [VIEWER_ENTRY]; + while (queue.length > 0) { + const file = queue.pop()!; + if (seen.has(file)) continue; + seen.add(file); + for (const spec of importSpecs(file)) { + const target = resolveRelativeImport(file, spec); + if (target && !seen.has(target)) queue.push(target); + } + } + return seen; +} + +describe("capability files match the code each window runs", () => { + const defaultCap = readCapability("default.json"); + const viewerCap = readCapability("file-viewer.json"); + const files = sourceFiles(srcDir); + + it("only lib/tauri-commands.ts imports @tauri-apps/api/core", () => { + const offenders = files + .filter((f) => f !== WRAPPERS) + .filter((f) => /from\s*["']@tauri-apps\/api\/core["']/.test(readFileSync(f, "utf-8"))) + .map((f) => relative(srcDir, f)); + expect(offenders).toEqual([]); + }); + + it("the windows lists are the reviewed ones", () => { + expect(defaultCap.windows).toEqual(["main"]); + expect(viewerCap.windows).toEqual(["file-viewer-*"]); + }); + + it("the plugin/core grants are the reviewed ones", () => { + expect(defaultCap.prefixed).toEqual([ + "core:event:allow-listen", + "core:event:allow-unlisten", + "core:webview:allow-internal-toggle-devtools", + "dialog:allow-open", + "dialog:allow-save", + ]); + expect(viewerCap.prefixed).toEqual([ + "core:event:allow-listen", + "core:event:allow-unlisten", + "core:webview:allow-internal-toggle-devtools", + "core:window:allow-destroy", + ]); + }); + + it("the viewer window imports exactly the wrappers file-viewer.json grants", () => { + const wrappers = wrapperCommands(); + const closure = viewerClosure(); + expect(closure.has(WRAPPERS), "the viewer reaches tauri-commands.ts").toBe(true); + const viewerCommands = new Set(); + for (const file of closure) { + for (const name of wrapperNamesImportedBy(file)) { + const command = wrappers.get(name); + expect(command, `${relative(srcDir, file)} imports unknown wrapper ${name}`).toBeDefined(); + viewerCommands.add(command!); + } + } + const granted = [...viewerCommands].map(toPermission).sort(); + expect(granted).toEqual(viewerCap.bare); + }); + + it("the main window imports only wrappers default.json grants, and none of the viewer's", () => { + const wrappers = wrapperCommands(); + const closure = viewerClosure(); + const mainCommands = new Set(); + for (const file of files) { + if (closure.has(file)) continue; + for (const name of wrapperNamesImportedBy(file)) { + const command = wrappers.get(name); + expect(command, `${relative(srcDir, file)} imports unknown wrapper ${name}`).toBeDefined(); + mainCommands.add(command!); + } + } + expect(mainCommands.size).toBeGreaterThan(50); + const ungranted = [...mainCommands].map(toPermission).filter((p) => !defaultCap.bare.includes(p)).sort(); + expect(ungranted, "main-window code imports wrappers default.json does not grant").toEqual([]); + const crossed = [...mainCommands].filter((c) => viewerCap.bare.includes(toPermission(c))).sort(); + expect(crossed, "main-window code imports viewer-only wrappers").toEqual([]); + }); + + it("every wrapper's command is granted in exactly one capability file", () => { + const wrappers = wrapperCommands(); + const both: string[] = []; + const neither: string[] = []; + for (const command of new Set(wrappers.values())) { + const p = toPermission(command); + const inDefault = defaultCap.bare.includes(p); + const inViewer = viewerCap.bare.includes(p); + if (inDefault && inViewer) both.push(command); + if (!inDefault && !inViewer) neither.push(command); + } + expect(both).toEqual([]); + expect(neither, "granted nowhere — cargo check would fail too, but you may not have run it").toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Run it** + +Run: `cd /workspace/triple-c/app && npx vitest run src/test/capabilities.test.ts 2>&1 | tail -15` +Expected: 6 passed. If `resolveRelativeImport` throws on a path alias or an extension not listed, add the extension to the candidate list — do not swallow the error. + +- [ ] **Step 3: Verify it fails on a wrapper crossing sides (then restore)** + +```bash +cd /workspace/triple-c/app +sed -i 's/import { viewerChooseFile, viewerGetState } from "..\/lib\/tauri-commands";/import { listProjects, viewerChooseFile, viewerGetState } from "..\/lib\/tauri-commands";/' src/viewer/ViewerApp.tsx +npx vitest run src/test/capabilities.test.ts 2>&1 | grep -E "✓|×|FAIL|expected" | head +git checkout -- src/viewer/ViewerApp.tsx +``` +Expected: "the viewer window imports exactly the wrappers file-viewer.json grants" FAILS (the granted list gains `allow-list-projects`). If `listProjects` is not the wrapper's name, pick any wrapper from `tauri-commands.ts` that invokes a main-window command. + +- [ ] **Step 4: Run the whole frontend suite and tsc** + +Run: `cd /workspace/triple-c/app && npm run test 2>&1 | grep -E "Test Files|Tests " && npx tsc --noEmit && echo tsc-ok` +Expected: all files pass (previous count + 1 file, + 6 tests); `tsc-ok`. + +- [ ] **Step 5: Commit** + +```bash +cd /workspace/triple-c && git add app/src/test/capabilities.test.ts +git commit -m "test(acl): each window's code imports only the wrappers it is granted + +Co-Authored-By: Claude Opus 5.5 (1M context) " +``` + +--- + +### Task 5: Documentation — the threat model of record says the risk is closed + +**Files:** +- Modify: `app/src-tauri/capabilities/default.json` (`description` only), `app/src-tauri/capabilities/file-viewer.json` (`description` only), `CLAUDE.md:179-180` and `:598-600`, `app/src-tauri/src/file_viewer/mod.rs:1-5` (module doc) + +**Interfaces:** +- Consumes: the behaviour delivered by Task 2. No code. + +- [ ] **Step 1: `default.json` description** + +Two edits inside the `description` string (it is one long JSON string; keep it valid JSON — escape nothing new, the text below contains no `"`): + +(a) Replace `verified against tauri 2.11.0's \`PLUGINS\` table in \`build.rs\`, not assumed from a plugin's \`default\` set` with: + +``` +plugin and core grants verified against tauri 2.11.0's `PLUGINS` table in tauri's own `build.rs` rather than assumed from a plugin's `default` set, app-command grants (the bare `allow-*` entries) cross-checked by this crate's `build.rs` against `generate_handler!` +``` + +(b) Replace the sentence run from `App commands stay ungated by capability in both files` through `not yet built.` with: + +``` +App commands are gated by this file too. `build.rs` declares a Tauri `AppManifest` listing every command in `generate_handler!`, which is what makes tauri 2.11.0 apply the ACL to app commands at all (`webview/mod.rs:1794` skips it when no app manifest exists), and the bare `allow-` entries below are the complete list of app commands the main window may call. `build.rs` refuses to build unless every registered command has exactly one such grant, in the file whose `windows` its name says it belongs to (`viewer_*` in `file-viewer.json`, everything else here), and unless every bare entry names a registered command — so a forgotten, misspelled, duplicated or misfiled grant is a failed `cargo check`, not a feature that dies at runtime with `not allowed by ACL`. `deny-*` is banned by the same check: in tauri 2.11.0 a deny matches regardless of window or origin, so a deny meant for the viewer would deny main too. Hand-written files under `permissions/` are refused for the same reason — they would be grants this census cannot see. The pop-out stays capability-less. The Rust label gates in `commands/file_viewer_commands.rs` remain, because the ACL says *which* window may call a command and the label says *whose* registry entry it acts on; they are not redundant. The rules live in `src/command_census.rs`, which is unit-tested, and `src/test/capabilities.test.ts` checks the other direction: that the code that runs in each window imports only the wrappers that window is granted. +``` + +- [ ] **Step 2: `file-viewer.json` description** + +Replace `Same rules as \`default.json\`: app commands need no entry here and are gated by label inside \`commands/file_viewer_commands.rs\`; this file is the plugin-command surface a compromised viewer webview could reach, and it is the smallest one that lets the window work.` with: + +``` +Same rules as `default.json`. The five bare `allow-viewer-*` grants are the only app commands a viewer window can invoke: `build.rs` declares the AppManifest that makes tauri enforce that, and refuses any other bare grant in this file. The label gate inside `commands/file_viewer_commands.rs` is still what stops window A acting on window B's registry entry, because the ACL only decides which window may call. The rest of this file is the plugin-command surface a compromised viewer webview could reach, and it is the smallest one that lets the window work. +``` + +- [ ] **Step 3: `CLAUDE.md`** + +Replace the Key Conventions bullet (lines 598-600): + +```markdown +- `capabilities/default.json` grants permissions for **plugin** commands only (`core:`, `dialog:`, + `store:`, `opener:`). Application commands registered through `generate_handler!` do **not** + need an entry there — adding one is not required and none exists for any app command. +``` + +with: + +```markdown +- **A new command needs three things:** `#[tauri::command]`, a `generate_handler!` entry in + `lib.rs`, and a bare `allow-` entry in the one capability file for the + window that calls it — `viewer_*` commands in `capabilities/file-viewer.json`, everything else + in `capabilities/default.json`. `build.rs` declares a Tauri `AppManifest` from the handler list + (without one, tauri 2.11 does not apply the ACL to app commands at all) and fails `cargo + check`/`tauri build` on a missing, misspelled, duplicated or misfiled grant, a `deny-*`, or a + hand-written file under `permissions/`. `src/test/capabilities.test.ts` fails if code that runs + in a window imports a `tauri-commands.ts` wrapper that window is not granted. Only `_` becomes + `-` in the identifier; `permissions/autogenerated/` is generated and ignored, and + `gen/schemas/*.json` is regenerated by every build and committed. +``` + +In Backend Structure, replace the end of the `file_viewer/` bullet (lines 179-180): + +```markdown + viewer command accepts a path. The residual risk that any local window can call any app command + is deliberate and documented; the AppManifest lockdown spec closes it. +``` + +with: + +```markdown + viewer command accepts a path. Which window may *call* each command is the ACL's job: the + `file-viewer-*` capability grants exactly the five `viewer_*` commands (see `build.rs`). +- **`build.rs` + `src/command_census.rs`** — the build declares a Tauri `AppManifest` from the + `generate_handler!` list and refuses to build unless every command has exactly one bare + `allow-*` grant in the capability file its name says it belongs to. The parser and rules are + in `command_census.rs`, compiled into both the build script and the test build, so they are + unit-tested; `the_generated_app_manifest_matches_the_handler_list` reads back what tauri + embedded. Design: `docs/superpowers/specs/2026-09-22-app-manifest-lockdown-design.md`. +``` + +- [ ] **Step 4: `file_viewer/mod.rs` module doc** + +After the line `//! the caller's own entry, which is why nothing here takes a path from a window.` add: + +```rust +//! +//! `file-viewer-*` is also the `windows` glob of `capabilities/file-viewer.json`, which grants +//! exactly the five `viewer_*` commands and nothing else. Labels are minted only here; a window +//! created anywhere else with a matching label would inherit those grants. +``` + +- [ ] **Step 5: Verify** + +Run: `cd /workspace/triple-c/app/src-tauri && cargo test --offline capability 2>&1 | grep "test result" && cd ../ && npx vitest run src/test/capabilities.test.ts 2>&1 | grep -E "Tests " && grep -c "do \*\*not\*\* need an entry" /workspace/triple-c/CLAUDE.md` +Expected: Rust `ok`, vitest `6 passed` (both files still parse), grep prints `0`. + +- [ ] **Step 6: Commit** + +```bash +cd /workspace/triple-c && git add app/src-tauri/capabilities/default.json app/src-tauri/capabilities/file-viewer.json CLAUDE.md app/src-tauri/src/file_viewer/mod.rs +git commit -m "docs(acl): threat model and conventions say app commands are ACL-gated + +Co-Authored-By: Claude Opus 5.5 (1M context) " +``` + +--- + +### Task 6: Full verification and hand-off + +**Files:** none modified (unless something fails). + +- [ ] **Step 1: The complete automated set** + +```bash +cd /workspace/triple-c/app/src-tauri && cargo test --offline 2>&1 | grep -E "test result|FAILED|panicked" +cd /workspace/triple-c/app/src-tauri && cargo clippy --offline --all-targets 2>&1 | grep -cE "^(warning|error)" +cd /workspace/triple-c/app && npm run test 2>&1 | grep -E "Test Files|Tests " +cd /workspace/triple-c/app && npx tsc --noEmit && echo tsc-ok +cd /workspace/triple-c/app && npm run build 2>&1 | tail -3 +cd /workspace/triple-c && git status --short +``` +Expected: every `test result: ok`, with the Rust count = 652 + 18 (`command_census`) + 1 (manifest test) = 671 and the vitest count = 969 + 6 = 975 in 75 files; clippy `0`; `tsc-ok`; vite `built in`; `git status` clean (a `cargo` run must not dirty `gen/schemas` — if it does, something is nondeterministic; investigate before finishing). + +- [ ] **Step 2: Settled rebuild check** + +Run `cd /workspace/triple-c/app/src-tauri && cargo check --offline 2>&1 | tail -2` **twice**. The second run must print only `Finished` — no `Compiling triple-c`. If it recompiles every time, `prune_permissions` is touching live files; fix it, because that would make every `tauri dev` restart and every `cargo test` pay for a full crate rebuild. + +- [ ] **Step 3: Hand-off note for the user (put this in the final report, not in a file)** + +Manual verification runs on the user's machine, from spec §5 "Manual", and is the only thing not done here: +1. `npx tauri dev`: cold start renders projects, Docker status, settings, help; open a terminal and type/resize/paste/drop; Files tab list/view/rename/upload/save-to-host/backup; browser view enable/pop-out/close; file viewer click → open, choose, edit, save, conflict banner, live reload; settings export/import, gateway, STT, web terminal, scheduler — one action each. +2. Viewer devtools: `window.__TAURI_INTERNALS__.invoke("list_projects")` rejects with `not allowed on window "file-viewer-1"`; main devtools: `invoke("viewer_read_file")` rejects likewise; pop-out devtools: `invoke("list_projects")` rejects (no capability). +3. Watch the log for `not allowed` during the pass — any occurrence is a missed grant (it cannot be, per the build check, unless a window label other than `main`/`file-viewer-*` is invoking). +4. Recommendation (out of scope here): add `npm run test` and `cargo test` steps to `.gitea/workflows/build-app-preview.yml`; the build-time check runs in CI, the two test halves do not. + +--- + +## Self-review against the spec + +- §2 mechanism: reproduced by the spike (spec §7); nothing in the plan relies on an unverified claim. +- §3.1 grants: Task 2 Step 4 (110 + 5, exact lists; five plugin/core and four core unchanged). +- §3.2 build.rs (parse, fail-closed, selective prune, four rules, `Box::leak`, hand-written refusal): Task 1 (rules + tests), Task 2 Step 3 (wiring), Step 7 (negative proofs a–d, f, prune, hand-written). +- §3.3 vitest (core import rule, wrapper map, closure, `M ∩ V = ∅`, equality for viewer, subset for main, exact prefixed lists): Task 4. +- §3.4 tests (split census, viewer census, manifest read-back, shared parser, unit tests): Tasks 1, 2 Step 1, 3. +- §3.5 docs (both descriptions, CLAUDE.md conventions + backend, mod.rs doc, .gitignore): Task 5 and Task 2 Step 3. +- §3.6 not done: nothing in the plan enables `removeUnusedCommands`, writes `permissions/*.toml` by hand, or uses `webviews`. +- §4 risks: each row's "how it is caught" maps to Task 1 tests, Task 2 Step 7, Task 3 Step 3, Task 4 Step 3. +- §5 automated: Task 6 Step 1; negative (e): Task 4 Step 3; manual: Task 6 Step 3. +- Type consistency: `registered_commands → Option>`, `check → Vec`, `CapabilityFile { name, windows, bare }` used identically in Tasks 1, 2, 3; `toPermission`/`allow_permission` both replace only `_`.