Files
Triple-C/docs/superpowers/plans/2026-09-22-app-manifest-lockdown.md
T
shadowdaoandClaude Opus 5.5 7c8ad62da4 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) <noreply@anthropic.com>
2026-09-22 22:12:25 -07:00

74 KiB
Raw Blame History

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-<cmd>/deny-<cmd> 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) <noreply@anthropic.com>. 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.rsregistered_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.tomlserde_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.
  • .gitignoreapp/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 <paths>, 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):

pub fn registered_commands(lib_rs: &str) -> Option<Vec<String>>;   // 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<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; 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:

//! 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<Vec<String>> {
    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<String>,
    pub bare: Vec<String>,
}

pub fn capability_file(name: &str, json: &str) -> Result<CapabilityFile, String> {
    let _ = (name, json, BTreeMap::<String, String>::new(), BTreeSet::<String>::new());
    Err("unimplemented".to_string())
}

pub fn check(commands: &[String], files: &[CapabilityFile]) -> Vec<String> {
    let _ = (commands, files);
    vec!["unimplemented".to_string()]
}

#[cfg(test)]
mod tests {
    use super::*;

    fn cmds(names: &[&str]) -> Vec<String> {
        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<CapabilityFile> {
        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::<String>::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):

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

/// 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<Vec<String>> {
    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<CapabilityFile, String> {
    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::<Result<Vec<_>, _>>()?;
    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<String> {
    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<String, &str> =
        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-<command> \
                     with every `_` replaced by `-`)",
                    f.name
                )),
                None => problems.push(format!(
                    "{}: {id}: only allow-<command> 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:

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

        // "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
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) <noreply@anthropic.com>"

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

        // Plugin and core grants: the exact reviewed list, unchanged by the lockdown.
        let (bare, prefixed): (Vec<String>, Vec<String>) =
            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<String> = 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:

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

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

//! 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-<command-with-dashes>` 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/<command>.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.jsonpermissions becomes:

  "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.jsonpermissions 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):

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

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:

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.):

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)
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) <noreply@anthropic.com>"

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

    /// `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<String> = 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<String> = 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
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
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) <noreply@anthropic.com>"

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<T>("cmd", …), one literal per wrapper); app/src/viewer/main.tsx as the viewer entry.

  • Step 1: Write the test

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<T>("command"` → NAME → command. Exactly one literal per wrapper. */
function wrapperCommands(): Map<string, string> {
  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<string, string>();
  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<string> {
  const seen = new Set<string>();
  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<string>();
    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<string>();
    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)
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
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) <noreply@anthropic.com>"

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-<command>` 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):

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

- **A new command needs three things:** `#[tauri::command]`, a `generate_handler!` entry in
  `lib.rs`, and a bare `allow-<name-with-dashes>` 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):

  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:

  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:

//!
//! `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
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) <noreply@anthropic.com>"

Task 6: Full verification and hand-off

Files: none modified (unless something fails).

  • Step 1: The complete automated set
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 ad, 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<Vec<String>>, check → Vec<String>, CapabilityFile { name, windows, bare } used identically in Tasks 1, 2, 3; toPermission/allow_permission both replace only _.