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

1535 lines
74 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.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 <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):
```rust
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:
```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<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):
```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<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:
```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<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:
```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) <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:
```rust
// 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:
```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-<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.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) <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**
```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<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**
```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) <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**
```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<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)**
```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) <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):
```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-<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):
```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) <noreply@anthropic.com>"
```
---
### 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 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 `_`.