diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 19bb43c0..96b3669f 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -103,7 +103,9 @@ "mcp__code-review-graph__list_graph_stats_tool", "mcp__Claude_Code_Remote__send_later", "mcp__code-review-graph__refactor_tool", - "mcp__code-review-graph__build_or_update_graph_tool" + "mcp__code-review-graph__build_or_update_graph_tool", + "Bash(sed -n '/\\\\[features\\\\]/,/\\\\[/p' /root/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/rcgen-0.13.2/Cargo.toml)", + "Read(//root/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/rcgen-0.13.2/src/**)" ] }, "enableAllProjectMcpServers": true, diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index c0da54b9..d3a14e98 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -17,8 +17,15 @@ jobs: steps: - uses: actions/checkout@v4 - name: Install toolchain (rustfmt + clippy) - uses: dtolnay/rust-toolchain@stable + # Pinned, NOT `@stable`. Clippy widens lints between releases, so a + # floating CI toolchain can fail on lints a developer's older local + # toolchain cannot report — a drift only discoverable by pushing. + # Keep this version in step with rust-toolchain.toml, which is what + # cargo actually honours; naming it here just avoids installing a + # second, unused toolchain. + uses: dtolnay/rust-toolchain@master with: + toolchain: "1.97.1" components: rustfmt, clippy - uses: Swatinem/rust-cache@v2 - name: License-header gate (per-crate SPDX on line 1) @@ -43,6 +50,10 @@ jobs: - name: Dependency-direction gate (acyclic, downhill-only) # Spec 01 audit A-13 / ADR-0009. Fails on any uphill internal edge. run: python3 scripts/check-dependency-direction.py + - name: Macro-interpreter purity gate (no I/O deps; not server-linked) + # Macro spec §4.3/§10/§12: loki-basic links only its tiny allow-list and + # no server/headless crate links the interpreter. + run: python3 scripts/check-loki-basic-pure.py - name: Viewport-dimension guard (no assumed screen sizes) # Spec 01 audit A-1. No bare 1280-class literals in editor input/viewport paths. run: python3 scripts/check-no-hardcoded-viewport-dims.py @@ -59,7 +70,10 @@ jobs: steps: - uses: actions/checkout@v4 - name: Install toolchain - uses: dtolnay/rust-toolchain@stable + # Pinned — see the lint job and rust-toolchain.toml. + uses: dtolnay/rust-toolchain@master + with: + toolchain: "1.97.1" - uses: Swatinem/rust-cache@v2 - name: Install conformance tools (xmllint, pdftoppm) # Spec 02 gates, all running as ordinary cargo tests in this job: diff --git a/CLAUDE.md b/CLAUDE.md index ad5b2350..631eb890 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -110,12 +110,31 @@ These conventions apply to all crates in the workspace. - **Checkpoints:** Run `cargo check --workspace` after each logical unit of work. Do not accumulate failures across steps. - **Documentation Sync:** Any change to layout, rendering, or import/export properties must update the living status registry in [docs/fidelity-status.md](docs/fidelity-status.md). -- **Final pass:** `cargo fmt --all` and `cargo clippy --workspace -- -D warnings` - must both pass before any PR or commit is considered complete. +- **Final pass:** `cargo fmt --all --check` and the **exact CI clippy command** + (below) must both pass before any PR or commit is considered complete. + +### Toolchain is pinned + +[`rust-toolchain.toml`](rust-toolchain.toml) pins the workspace toolchain, and +CI installs that same version. Do **not** work around it by switching your local +toolchain: clippy widens lints between releases, so a floating toolchain lets CI +fail on lints a local run cannot report — a drift only discoverable by pushing. +Upgrade the pin deliberately (see the file's header for the procedure). ### Clippy compliance -The entire workspace must pass `cargo clippy --workspace -- -D warnings`. +The whole workspace must pass **the command CI runs** — note `--all-features` +and the two extra deny flags, which a plain `cargo clippy --workspace` does +*not* apply. Running anything narrower is how `.unwrap()`/`.expect()` in library +code reaches CI: + +``` +cargo clippy --workspace --all-features -- -D warnings \ + -D clippy::unwrap_used -D clippy::expect_used +``` + +`clippy.toml` exempts `#[cfg(test)]` code from the panic-accessor lints, so the +gate targets shipped code only. For pre-existing code in `loki-layout`, `loki-odf`, and `loki-ooxml` that required structural changes beyond the scope of the cleanup pass, targeted diff --git a/Cargo.lock b/Cargo.lock index db0f5afa..89570744 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -856,6 +856,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d27c3610c36aee21ce8ac510e6224498de4228ad772a171ed65643a24693a5a8" +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.22.1" @@ -1339,6 +1345,18 @@ dependencies = [ "error-code", ] +[[package]] +name = "cms" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b77c319abfd5219629c45c34c89ba945ed3c5e49fcde9d16b6c3885f118a730" +dependencies = [ + "const-oid", + "der", + "spki", + "x509-cert", +] + [[package]] name = "cobs" version = "0.3.0" @@ -1661,6 +1679,18 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -1831,10 +1861,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid", + "der_derive", + "flagset", "pem-rfc7468", "zeroize", ] +[[package]] +name = "der_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "deranged" version = "0.5.8" @@ -2475,6 +2518,20 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + [[package]] name = "either" version = "1.16.0" @@ -2484,6 +2541,26 @@ dependencies = [ "serde", ] +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "embedded-io" version = "0.4.0" @@ -2757,6 +2834,16 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -2769,6 +2856,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + [[package]] name = "flate2" version = "1.1.9" @@ -3145,6 +3238,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -3343,6 +3437,17 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b40ca9252762c466af32d0b1002e91e4e1bc5398f77455e55474deb466355ff5" +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "guillotiere" version = "0.6.2" @@ -4434,6 +4539,13 @@ dependencies = [ "sys-locale", ] +[[package]] +name = "loki-basic" +version = "0.1.0" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "loki-bench" version = "0.1.0" @@ -4491,6 +4603,7 @@ dependencies = [ "rustc-hash 2.1.3", "serde", "serde_json", + "sha2", "thiserror 2.0.18", "tracing", ] @@ -4581,6 +4694,50 @@ dependencies = [ "tracing", ] +[[package]] +name = "loki-macro-host" +version = "0.1.0" +dependencies = [ + "base64", + "loki-basic", + "loki-doc-model", + "loki-macro-sig", + "loki-sheet-model", + "pkcs8", + "rand 0.8.5", + "rcgen", + "reqwest", + "rsa", + "serde", + "serde_json", + "sha2", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "loki-macro-sig" +version = "0.1.0" +dependencies = [ + "base64", + "cfb", + "cms", + "const-oid", + "der", + "md-5", + "p256", + "pkcs8", + "quick-xml 0.41.0", + "rand 0.8.5", + "rcgen", + "rsa", + "sha1", + "sha2", + "signature", + "spki", + "x509-cert", +] + [[package]] name = "loki-model" version = "0.1.0" @@ -4616,6 +4773,7 @@ dependencies = [ "appthere-color", "appthere-conformance", "base64", + "cfb", "chrono", "indexmap", "loki-doc-model", @@ -4625,6 +4783,7 @@ dependencies = [ "loki-presentation-model", "loki-primitives", "loki-sheet-model", + "loki-vba", "parley 0.10.0", "quick-xml 0.41.0", "read-fonts 0.40.2", @@ -4898,12 +5057,16 @@ dependencies = [ "fontique 0.10.0", "log", "loki-app-shell", + "loki-basic", + "loki-doc-model", "loki-file-access", "loki-fonts", "loki-i18n", + "loki-macro-host", "loki-odf", "loki-ooxml", "loki-sheet-model", + "loki-vba", "loro", "thiserror 2.0.18", "tracing", @@ -4930,6 +5093,7 @@ dependencies = [ "dioxus", "fontique 0.10.0", "futures-channel", + "futures-util", "image", "log", "loki-app-shell", @@ -4939,11 +5103,13 @@ dependencies = [ "loki-fonts", "loki-i18n", "loki-layout", + "loki-macro-host", "loki-odf", "loki-ooxml", "loki-pdf", "loki-renderer", "loki-templates", + "loki-vba", "loro", "thiserror 2.0.18", "tracing", @@ -4951,6 +5117,15 @@ dependencies = [ "webbrowser", ] +[[package]] +name = "loki-vba" +version = "0.1.0" +dependencies = [ + "cfb", + "encoding_rs", + "thiserror 2.0.18", +] + [[package]] name = "loki-vello" version = "0.1.0" @@ -5080,7 +5255,7 @@ dependencies = [ "pest_derive", "postcard", "pretty_assertions", - "rand 0.8.6", + "rand 0.8.5", "rustc-hash 2.1.3", "serde", "serde_columnar", @@ -5134,7 +5309,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aca7180674d0273ddf37049a5efcde4547fd5330d24abb7519bb9d9eb6780d5b" dependencies = [ "once_cell", - "rand 0.8.6", + "rand 0.8.5", "serde", ] @@ -5534,7 +5709,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand 0.8.6", + "rand 0.8.5", "smallvec", "zeroize", ] @@ -6135,6 +6310,18 @@ dependencies = [ "ttf-parser", ] +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + [[package]] name = "page_size" version = "0.6.0" @@ -6359,7 +6546,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared", - "rand 0.8.6", + "rand 0.8.5", ] [[package]] @@ -6630,6 +6817,15 @@ dependencies = [ "yansi", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -6835,9 +7031,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.8.6" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -6933,7 +7129,7 @@ dependencies = [ "once_cell", "paste", "profiling", - "rand 0.8.6", + "rand 0.8.5", "rand_chacha 0.3.1", "simd_helpers", "system-deps", @@ -6983,6 +7179,19 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "yasna", +] + [[package]] name = "read-fonts" version = "0.35.0" @@ -7132,6 +7341,16 @@ dependencies = [ "webpki-roots 1.0.8", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "rfd" version = "0.15.4" @@ -7229,6 +7448,7 @@ dependencies = [ "pkcs1", "pkcs8", "rand_core 0.6.4", + "sha2", "signature", "spki", "subtle", @@ -7443,6 +7663,20 @@ dependencies = [ "tiny-skia", ] +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -8089,7 +8323,7 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rand 0.8.6", + "rand 0.8.5", "rsa", "serde", "sha1", @@ -8129,7 +8363,7 @@ dependencies = [ "md-5", "memchr", "once_cell", - "rand 0.8.6", + "rand 0.8.5", "serde", "serde_json", "sha2", @@ -8749,6 +8983,27 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tls_codec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" +dependencies = [ + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "to_shmem" version = "0.2.0" @@ -10646,6 +10901,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid", + "der", + "spki", + "tls_codec", +] + [[package]] name = "xcursor" version = "0.3.10" @@ -10715,6 +10982,15 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + [[package]] name = "yazi" version = "0.2.1" @@ -10779,7 +11055,7 @@ dependencies = [ "hex", "nix", "ordered-stream", - "rand 0.8.6", + "rand 0.8.5", "serde", "serde_repr", "sha1", diff --git a/Cargo.toml b/Cargo.toml index 7ecf7a69..e874916a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["loki-doc-model","loki-sheet-model","loki-opc","loki-primitives","loki-ooxml","loki-odf","loki-layout","loki-vello","appthere-ui","loki-app-shell","loki-text","loki-spreadsheet","loki-presentation","loki-render-cache","loki-render-cpu","loki-renderer","loki-i18n","appthere-canvas","loki-fonts","loki-graphics","loki-presentation-model","loki-epub","loki-pdf","loki-acid","loki-templates","loki-spell","appthere-conformance","loki-bench","loki-model","loki-crypto","loki-server-audit","loki-server-store","loki-server-collab","loki-server-auth","loki-server-api","loki-server","loki-convert","loki-print","loki-headless"] +members = ["loki-doc-model","loki-sheet-model","loki-opc","loki-primitives","loki-ooxml","loki-odf","loki-layout","loki-vello","appthere-ui","loki-app-shell","loki-text","loki-spreadsheet","loki-presentation","loki-render-cache","loki-render-cpu","loki-renderer","loki-i18n","appthere-canvas","loki-fonts","loki-graphics","loki-presentation-model","loki-epub","loki-pdf","loki-acid","loki-templates","loki-spell","appthere-conformance","loki-bench","loki-model","loki-crypto","loki-server-audit","loki-server-store","loki-server-collab","loki-server-auth","loki-server-api","loki-server","loki-convert","loki-print","loki-headless","loki-basic","loki-vba","loki-macro-host","loki-macro-sig"] [workspace.dependencies] # Shared across appthere-ui and any other workspace member that needs Dioxus. diff --git a/appthere-ui/src/components/infobar.rs b/appthere-ui/src/components/infobar.rs new file mode 100644 index 00000000..053cee00 --- /dev/null +++ b/appthere-ui/src/components/infobar.rs @@ -0,0 +1,158 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! `AtInfobar` — a non-modal, full-width notice strip shown under the ribbon. +//! +//! Used for passive, document-scoped security/status notices that must **not** +//! interrupt the user with a modal (prompt fatigue trains users to click +//! through). The first consumer is the "this document contains macros — macros +//! are disabled" notice (macro spec §9.1): opening a macro-carrying document is +//! never blocked; the infobar states the fact and offers an opt-in action. +//! +//! It is an ordinary in-flow block (not an overlay), so it simply sits between +//! the ribbon and the canvas and pushes content down — no positioned-ancestor +//! contract like [`super::confirm_dialog`]. +//! +//! Touch target: the action and dismiss controls are at least 44×44 logical +//! pixels (`min-height: TOUCH_MIN`, padded width) per WCAG 2.5.8. + +use dioxus::prelude::*; + +use crate::tokens::colors::{ + COLOR_CONTEXTUAL_TAB, COLOR_SURFACE_1, COLOR_TEXT_ON_CHROME, COLOR_TEXT_ON_CHROME_SECONDARY, +}; +use crate::tokens::spacing::{RADIUS_SM, SPACE_1, SPACE_2, SPACE_3, TOUCH_MIN}; +use crate::tokens::typography::{FONT_FAMILY_UI, FONT_SIZE_BODY, FONT_WEIGHT_SEMIBOLD}; + +/// Props for [`AtInfobar`]. All display strings are props (i18n-agnostic). +#[derive(Props, Clone, PartialEq)] +pub struct AtInfobarProps { + /// The notice text (e.g. "This document contains macros. Macros are + /// disabled."). + pub message: String, + /// Label for the optional primary action (e.g. "Enable options…"). When + /// `None`, no action button is shown. + #[props(default)] + pub action_label: Option, + /// Invoked when the action button is clicked. Ignored without + /// `action_label`. + #[props(default)] + pub on_action: Option>, + /// Label for an optional secondary action (e.g. "View macros…"), shown left + /// of the primary action. When `None`, no secondary button is shown. + #[props(default)] + pub secondary_label: Option, + /// Invoked when the secondary action is clicked. Ignored without + /// `secondary_label`. + #[props(default)] + pub on_secondary: Option>, + /// Accessible label for the dismiss (×) control. When `None`, the infobar + /// is not dismissable and no × is shown. + #[props(default)] + pub dismiss_label: Option, + /// Invoked when the dismiss control is clicked. + #[props(default)] + pub on_dismiss: Option>, +} + +/// A passive warning strip. See the module docs for placement (in-flow, under +/// the ribbon) and the 44×44 px touch-target guarantee on its controls. +#[component] +pub fn AtInfobar(props: AtInfobarProps) -> Element { + let button_style = format!( + "min-height: {th}px; box-sizing: border-box; padding: {py}px {px}px; \ + border-radius: {r}px; font-family: {font}; font-size: {fs}px; \ + font-weight: {fw}; background: transparent; border: 1px solid {accent}; \ + color: {accent}; cursor: pointer; display: flex; align-items: center;", + th = TOUCH_MIN, + py = SPACE_1, + px = SPACE_3, + r = RADIUS_SM, + font = FONT_FAMILY_UI, + fs = FONT_SIZE_BODY, + fw = FONT_WEIGHT_SEMIBOLD, + accent = COLOR_CONTEXTUAL_TAB, + ); + + rsx! { + div { + role: "status", + style: format!( + "display: flex; align-items: center; gap: {gap}px; width: 100%; \ + box-sizing: border-box; padding: {py}px {px}px; \ + background: {bg}; border-bottom: 1px solid {accent}; \ + border-left: 3px solid {accent}; \ + font-family: {font}; font-size: {fs}px; color: {fg};", + gap = SPACE_2, + py = SPACE_2, + px = SPACE_3, + bg = COLOR_SURFACE_1, + accent = COLOR_CONTEXTUAL_TAB, + font = FONT_FAMILY_UI, + fs = FONT_SIZE_BODY, + fg = COLOR_TEXT_ON_CHROME, + ), + + // Warning glyph. + span { + style: format!("color: {accent}; font-weight: {fw};", accent = COLOR_CONTEXTUAL_TAB, fw = FONT_WEIGHT_SEMIBOLD), + "aria-hidden": "true", + "⚠" + } + + // Message — takes the remaining width. + span { + style: format!("flex: 1; color: {fg};", fg = COLOR_TEXT_ON_CHROME), + {props.message.clone()} + } + + // Optional secondary action (left of the primary). + if let Some(label) = props.secondary_label.clone() { + button { + style: button_style.clone(), + onclick: move |_| { + if let Some(cb) = &props.on_secondary { + cb.call(()); + } + }, + {label} + } + } + + // Optional primary action. + if let Some(label) = props.action_label.clone() { + button { + style: button_style.clone(), + onclick: move |_| { + if let Some(cb) = &props.on_action { + cb.call(()); + } + }, + {label} + } + } + + // Optional dismiss control. + if let Some(aria) = props.dismiss_label.clone() { + button { + "aria-label": aria, + style: format!( + "min-width: {th}px; min-height: {th}px; box-sizing: border-box; \ + background: transparent; border: none; cursor: pointer; \ + color: {fg}; font-size: {fs}px; display: flex; \ + align-items: center; justify-content: center;", + th = TOUCH_MIN, + fg = COLOR_TEXT_ON_CHROME_SECONDARY, + fs = FONT_SIZE_BODY, + ), + onclick: move |_| { + if let Some(cb) = &props.on_dismiss { + cb.call(()); + } + }, + "×" + } + } + } + } +} diff --git a/appthere-ui/src/components/macro_security/frame.rs b/appthere-ui/src/components/macro_security/frame.rs new file mode 100644 index 00000000..a63ed70c --- /dev/null +++ b/appthere-ui/src/components/macro_security/frame.rs @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! `MacroDialogFrame` — the anti-spoof frame shared by every macro-originated +//! dialog (macro spec §5.5, threat T7). +//! +//! Macro-shown UI (permission prompts, and later `MsgBox`/`InputBox`) renders +//! inside a visually distinct frame that app chrome **never** uses: a violet +//! [`COLOR_MACRO_BADGE`] border and a "Macro: " badge header carrying +//! the host document's title. A malicious macro therefore cannot paint a dialog +//! that looks like a genuine app dialog — the badge and reserved accent are the +//! tell. +//! +//! Mounting contract (same as [`super::super::confirm_dialog`]): the mounting +//! parent (or an ancestor) must be `position: relative` and span the area to +//! dim. `position: fixed` collapses to `absolute` in `stylo_taffy` and must not +//! be used. + +use dioxus::prelude::*; + +use crate::tokens::colors::{ + COLOR_MACRO_BADGE, COLOR_SURFACE_1, COLOR_TEXT_ON_CHROME, COLOR_TEXT_ON_CHROME_SECONDARY, +}; +use crate::tokens::spacing::{RADIUS_MD, RADIUS_SM, SPACE_1, SPACE_2, SPACE_4}; +use crate::tokens::typography::{ + FONT_FAMILY_UI, FONT_SIZE_LABEL, FONT_SIZE_MD, FONT_WEIGHT_BOLD, FONT_WEIGHT_SEMIBOLD, +}; + +/// Width of a macro dialog card in logical pixels (narrow enough for Compact +/// phones; the backdrop centring keeps it on-screen). +const CARD_WIDTH_PX: f32 = 360.0; + +/// Props for [`MacroDialogFrame`]. +#[derive(Props, Clone, PartialEq)] +pub struct MacroDialogFrameProps { + /// The word for "Macro" (i18n), shown in the badge chip. + pub badge_label: String, + /// The macro project's name (from the document), shown after the badge. + pub project_name: String, + /// The host document's title, shown as the frame's secondary identity line. + pub document_title: String, + /// Invoked when the backdrop is clicked (treated as a cancel/deny by the + /// hosting dialog). + pub on_backdrop: EventHandler<()>, + /// The dialog body (message + action buttons). + pub children: Element, +} + +/// The badged backdrop + card. See the module docs for the anti-spoof rationale +/// and the positioned-ancestor mounting contract. +#[component] +pub fn MacroDialogFrame(props: MacroDialogFrameProps) -> Element { + let badge = format!( + "display: inline-flex; align-items: center; gap: {gap}px; \ + padding: {py}px {px}px; border-radius: {r}px; background: {badge_bg}; \ + color: #FFFFFF; font-size: {fs}px; font-weight: {fw}; \ + text-transform: uppercase; letter-spacing: 0.04em;", + gap = SPACE_1, + py = SPACE_1, + px = SPACE_2, + r = RADIUS_SM, + badge_bg = COLOR_MACRO_BADGE, + fs = FONT_SIZE_LABEL, + fw = FONT_WEIGHT_BOLD, + ); + + rsx! { + // Backdrop: dims + click-blocks; clicking cancels. + div { + style: "position: absolute; top: 0; left: 0; width: 100%; height: 100%; \ + z-index: 2100; background: rgba(0, 0, 0, 0.55); \ + display: flex; align-items: center; justify-content: center;", + role: "presentation", + onclick: move |_| props.on_backdrop.call(()), + + // The macro card — violet border marks it as macro-originated. + div { + style: format!( + "width: {w}px; max-width: 92%; box-sizing: border-box; \ + display: flex; flex-direction: column; gap: {gap}px; \ + background: {bg}; border: 2px solid {accent}; \ + border-radius: {r}px; padding: {pad}px; \ + font-family: {font}; color: {fg};", + w = CARD_WIDTH_PX, + gap = SPACE_2, + bg = COLOR_SURFACE_1, + accent = COLOR_MACRO_BADGE, + r = RADIUS_MD, + pad = SPACE_4, + font = FONT_FAMILY_UI, + fg = COLOR_TEXT_ON_CHROME, + ), + role: "dialog", + "aria-label": format!("{}: {}", props.badge_label, props.project_name), + onclick: move |evt| evt.stop_propagation(), + + // Anti-spoof header: badge + project name, then the document title. + div { + style: format!( + "display: flex; flex-direction: row; align-items: center; gap: {gap}px;", + gap = SPACE_2, + ), + span { style: "{badge}", "aria-hidden": "true", "⚡ {props.badge_label}" } + span { + style: format!( + "font-size: {fs}px; font-weight: {fw}; color: {fg}; \ + overflow: hidden; text-overflow: ellipsis; white-space: nowrap;", + fs = FONT_SIZE_MD, + fw = FONT_WEIGHT_SEMIBOLD, + fg = COLOR_TEXT_ON_CHROME, + ), + {props.project_name.clone()} + } + } + div { + style: format!( + "font-size: {fs}px; color: {fg}; overflow: hidden; \ + text-overflow: ellipsis; white-space: nowrap;", + fs = FONT_SIZE_LABEL, + fg = COLOR_TEXT_ON_CHROME_SECONDARY, + ), + {props.document_title.clone()} + } + + // Body supplied by the hosting dialog. + {props.children} + } + } + } +} diff --git a/appthere-ui/src/components/macro_security/mod.rs b/appthere-ui/src/components/macro_security/mod.rs new file mode 100644 index 00000000..469edd89 --- /dev/null +++ b/appthere-ui/src/components/macro_security/mod.rs @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Macro-security dialogs (macro spec §2.3, §5.4, §5.5). +//! +//! Two overlays, both rendered in the anti-spoof [`MacroDialogFrame`] so +//! macro-originated UI can never impersonate app chrome (threat T7): +//! +//! - [`AtMacroTrustDialog`] — the three-choice enable dialog (§2.3); +//! - [`AtPermissionPrompt`] — a first-use capability prompt (§5.4). +//! +//! Both are `appthere_ui`-pure: they take display strings as props and emit an +//! abstract choice enum, so the crate stays free of any macro-host or document +//! dependency (the hosting app maps the choice to a `MacroService` call). + +mod frame; +mod network; +mod permission; +mod trust; + +pub use frame::{MacroDialogFrame, MacroDialogFrameProps}; +pub use network::{AtNetworkPrompt, AtNetworkPromptProps}; +pub use permission::{AtPermissionPrompt, AtPermissionPromptProps}; +pub use trust::{AtMacroTrustDialog, AtMacroTrustDialogProps}; + +use crate::tokens::colors::{COLOR_MACRO_BADGE, COLOR_SURFACE_3, COLOR_TEXT_ON_CHROME}; +use crate::tokens::spacing::{RADIUS_SM, SPACE_2, SPACE_3, TOUCH_MIN}; +use crate::tokens::typography::{FONT_FAMILY_UI, FONT_SIZE_BODY, FONT_WEIGHT_SEMIBOLD}; + +/// The user's answer to the enable dialog (spec §2.3). The hosting app maps this +/// onto the matching `MacroService` call. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MacroTrustChoice { + /// Keep macros disabled (sticky). The safe default; backdrop maps here. + KeepDisabled, + /// Enable for this session only (not persisted). + EnableSession, + /// Persistently trust this document. + TrustAlways, +} + +/// The user's answer to a first-use capability prompt (spec §5.4). Maps onto a +/// `GrantScope` in the hosting app. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MacroGrantChoice { + /// Refuse (default) — the script sees a trappable error. + Deny, + /// Allow for this run only. + AllowOnce, + /// Allow until the document closes. + AllowSession, + /// Allow and persist to the document's trust record. + AlwaysForDocument, +} + +/// Style for a full-width choice button in a macro dialog. `accented` draws the +/// macro-badge border to mark the strongest (most-granting) option. +/// +/// Touch target: `min-height: TOUCH_MIN` (44 px) with generous padding meets +/// WCAG 2.5.8. +#[must_use] +pub(crate) fn choice_button_style(accented: bool) -> String { + let border = if accented { + COLOR_MACRO_BADGE + } else { + COLOR_SURFACE_3 + }; + format!( + "width: 100%; min-height: {th}px; box-sizing: border-box; \ + padding: {py}px {px}px; border-radius: {r}px; \ + background: transparent; border: 1px solid {border}; \ + color: {fg}; font-family: {font}; font-size: {fs}px; \ + font-weight: {fw}; cursor: pointer; text-align: center; \ + display: flex; align-items: center; justify-content: center;", + th = TOUCH_MIN, + py = SPACE_2, + px = SPACE_3, + r = RADIUS_SM, + border = border, + fg = COLOR_TEXT_ON_CHROME, + font = FONT_FAMILY_UI, + fs = FONT_SIZE_BODY, + fw = FONT_WEIGHT_SEMIBOLD, + ) +} diff --git a/appthere-ui/src/components/macro_security/network.rs b/appthere-ui/src/components/macro_security/network.rs new file mode 100644 index 00000000..15fc631f --- /dev/null +++ b/appthere-ui/src/components/macro_security/network.rs @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! `AtNetworkPrompt` — the per-host network-access prompt (ADR-0015 §4.2, §4.6). +//! +//! Distinct from [`super::AtPermissionPrompt`]: `Network` grants are **per +//! origin** and **session-max** (never persisted, ADR-0015 §4.2), so this prompt +//! shows the destination origin verbatim and offers only Deny / Allow-once / +//! Allow-for-this-session — there is no "always for this document". It always +//! carries the **composition warning** (§4.6): because a macro can always read +//! the document (`DocRead` is baseline), granting network is the exfiltration +//! primitive, and the user must see that trade-off before allowing. + +use dioxus::prelude::*; + +use super::frame::MacroDialogFrame; +use super::{choice_button_style, MacroGrantChoice}; +use crate::tokens::colors::{COLOR_STATUS_ERROR_BORDER, COLOR_SURFACE_1, COLOR_TEXT_ON_CHROME}; +use crate::tokens::spacing::{RADIUS_SM, SPACE_1, SPACE_2}; +use crate::tokens::typography::{ + FONT_FAMILY_UI, FONT_SIZE_BODY, FONT_SIZE_MD, FONT_WEIGHT_SEMIBOLD, +}; + +/// Props for [`AtNetworkPrompt`]. All display strings are props; the parent maps +/// the emitted [`MacroGrantChoice`] to a `GrantScope` (clamping to session-max). +#[derive(Props, Clone, PartialEq)] +pub struct AtNetworkPromptProps { + /// The word for "Macro" (badge chip). + pub badge_label: String, + /// The macro project's name. + pub project_name: String, + /// The host document's title. + pub document_title: String, + /// The request headline (e.g. "Allow network access?"). + pub request_title: String, + /// The destination origin, shown **verbatim** (e.g. `https://api.example.com`). + pub origin: String, + /// The composition warning (§4.6) — content can be read and sent to this site. + pub composition_warning: String, + /// Label for the default **Deny** button. + pub deny_label: String, + /// Label for "Allow once". + pub allow_once_label: String, + /// Label for "Allow for this session". + pub allow_session_label: String, + /// Invoked with the user's choice (backdrop click == `Deny`). + pub on_choice: EventHandler, +} + +/// A per-host network-access prompt asked at first request to an origin. +/// **Deny is the default** (safe) and the backdrop maps to it. Rendered in the +/// anti-spoof [`MacroDialogFrame`] (threat T7). +/// +/// Touch targets: every button is at least 44 logical pixels tall (WCAG 2.5.8) +/// via [`choice_button_style`]. +// +// TODO(8B.5-homograph): decode a punycode (`xn--`) authority for display and +// flag mixed-script / homograph origins; today the origin is shown verbatim. +#[component] +pub fn AtNetworkPrompt(props: AtNetworkPromptProps) -> Element { + let on_deny = props.on_choice; + let on_once = props.on_choice; + let on_session = props.on_choice; + let on_backdrop = props.on_choice; + + rsx! { + MacroDialogFrame { + badge_label: props.badge_label.clone(), + project_name: props.project_name.clone(), + document_title: props.document_title.clone(), + on_backdrop: move |()| on_backdrop.call(MacroGrantChoice::Deny), + + // Headline. + div { + style: format!("font-size: {fs}px; font-weight: {fw}; color: {fg};", fs = FONT_SIZE_MD, fw = FONT_WEIGHT_SEMIBOLD, fg = COLOR_TEXT_ON_CHROME), + {props.request_title.clone()} + } + // Destination origin, shown verbatim in a distinct field so a + // look-alike host is legible and can't blend into the body copy. + div { + style: format!( + "font-family: {ui}; font-size: {fs}px; color: {fg}; \ + background: {bg}; padding: {py}px {px}px; border-radius: {r}px; \ + word-break: break-all;", + ui = FONT_FAMILY_UI, fs = FONT_SIZE_BODY, fg = COLOR_TEXT_ON_CHROME, + bg = COLOR_SURFACE_1, py = SPACE_1, px = SPACE_2, r = RADIUS_SM, + ), + {props.origin.clone()} + } + // Composition warning (§4.6) — error-accented so it reads as a caution. + div { + style: format!("font-size: {fs}px; color: {fg};", fs = FONT_SIZE_BODY, fg = COLOR_STATUS_ERROR_BORDER), + {props.composition_warning.clone()} + } + + div { + style: format!("display: flex; flex-direction: column; gap: {gap}px; margin-top: {mt}px;", gap = SPACE_2, mt = SPACE_1), + + // Deny — default/safe (error-accented, listed first). + button { + style: format!( + "{base} border-color: {border}; color: {border};", + base = choice_button_style(false), + border = COLOR_STATUS_ERROR_BORDER, + ), + onclick: move |_| on_deny.call(MacroGrantChoice::Deny), + {props.deny_label.clone()} + } + button { + style: choice_button_style(false), + onclick: move |_| on_once.call(MacroGrantChoice::AllowOnce), + {props.allow_once_label.clone()} + } + // Session is the strongest network grant (never persisted) — accented. + button { + style: choice_button_style(true), + onclick: move |_| on_session.call(MacroGrantChoice::AllowSession), + {props.allow_session_label.clone()} + } + } + } + } +} diff --git a/appthere-ui/src/components/macro_security/permission.rs b/appthere-ui/src/components/macro_security/permission.rs new file mode 100644 index 00000000..59482c63 --- /dev/null +++ b/appthere-ui/src/components/macro_security/permission.rs @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! `AtPermissionPrompt` — a first-use capability prompt (macro spec §5.4). + +use dioxus::prelude::*; + +use super::frame::MacroDialogFrame; +use super::{choice_button_style, MacroGrantChoice}; +use crate::tokens::colors::{ + COLOR_STATUS_ERROR_BORDER, COLOR_TEXT_ON_CHROME, COLOR_TEXT_ON_CHROME_SECONDARY, +}; +use crate::tokens::spacing::{SPACE_1, SPACE_2}; +use crate::tokens::typography::{FONT_SIZE_BODY, FONT_SIZE_MD, FONT_WEIGHT_SEMIBOLD}; + +/// Props for [`AtPermissionPrompt`]. All display strings are props; the parent +/// maps [`MacroGrantChoice`] to a `GrantScope`. +#[derive(Props, Clone, PartialEq)] +pub struct AtPermissionPromptProps { + /// The word for "Macro" (badge chip). + pub badge_label: String, + /// The macro project's name. + pub project_name: String, + /// The host document's title. + pub document_title: String, + /// Human-readable capability name (e.g. "Change this document"). + pub capability_title: String, + /// Plain-language consequence line for the capability. + pub consequence: String, + /// Label for the default **Deny** button. + pub deny_label: String, + /// Label for "Allow once". + pub allow_once_label: String, + /// Label for "Allow for this session". + pub allow_session_label: String, + /// Label for "Always for this document". + pub always_label: String, + /// Invoked with the user's choice (backdrop click == `Deny`). + pub on_choice: EventHandler, +} + +/// A capability prompt asked at first use during a run. **Deny is the default** +/// (safe) action and the backdrop maps to it. +/// +/// Touch targets: every button is at least 44 logical pixels tall (WCAG 2.5.8) +/// via [`choice_button_style`]. +#[component] +pub fn AtPermissionPrompt(props: AtPermissionPromptProps) -> Element { + let on_deny = props.on_choice; + let on_once = props.on_choice; + let on_session = props.on_choice; + let on_always = props.on_choice; + let on_backdrop = props.on_choice; + + rsx! { + MacroDialogFrame { + badge_label: props.badge_label.clone(), + project_name: props.project_name.clone(), + document_title: props.document_title.clone(), + on_backdrop: move |()| on_backdrop.call(MacroGrantChoice::Deny), + + // What is being requested. + div { + style: format!("font-size: {fs}px; font-weight: {fw}; color: {fg};", fs = FONT_SIZE_MD, fw = FONT_WEIGHT_SEMIBOLD, fg = COLOR_TEXT_ON_CHROME), + {props.capability_title.clone()} + } + div { + style: format!("font-size: {fs}px; color: {fg};", fs = FONT_SIZE_BODY, fg = COLOR_TEXT_ON_CHROME_SECONDARY), + {props.consequence.clone()} + } + + div { + style: format!("display: flex; flex-direction: column; gap: {gap}px; margin-top: {mt}px;", gap = SPACE_2, mt = SPACE_1), + + // Deny — default/safe (error-accented, listed first). + button { + style: format!( + "{base} border-color: {border}; color: {border};", + base = choice_button_style(false), + border = COLOR_STATUS_ERROR_BORDER, + ), + onclick: move |_| on_deny.call(MacroGrantChoice::Deny), + {props.deny_label.clone()} + } + button { + style: choice_button_style(false), + onclick: move |_| on_once.call(MacroGrantChoice::AllowOnce), + {props.allow_once_label.clone()} + } + button { + style: choice_button_style(false), + onclick: move |_| on_session.call(MacroGrantChoice::AllowSession), + {props.allow_session_label.clone()} + } + button { + style: choice_button_style(true), + onclick: move |_| on_always.call(MacroGrantChoice::AlwaysForDocument), + {props.always_label.clone()} + } + } + } + } +} diff --git a/appthere-ui/src/components/macro_security/trust.rs b/appthere-ui/src/components/macro_security/trust.rs new file mode 100644 index 00000000..7963b04f --- /dev/null +++ b/appthere-ui/src/components/macro_security/trust.rs @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! `AtMacroTrustDialog` — the three-choice enable dialog (macro spec §2.3). + +use dioxus::prelude::*; + +use super::frame::MacroDialogFrame; +use super::{choice_button_style, MacroTrustChoice}; +use crate::tokens::colors::{COLOR_MACRO_BADGE, COLOR_TEXT_ON_CHROME_SECONDARY}; +use crate::tokens::spacing::{SPACE_1, SPACE_2}; +use crate::tokens::typography::FONT_SIZE_BODY; + +/// Props for [`AtMacroTrustDialog`]. All display strings are props +/// (i18n-agnostic); the parent maps [`MacroTrustChoice`] to a `MacroService` +/// call. +#[derive(Props, Clone, PartialEq)] +pub struct AtMacroTrustDialogProps { + /// The word for "Macro" (badge chip). + pub badge_label: String, + /// The macro project's name. + pub project_name: String, + /// The host document's title. + pub document_title: String, + /// Body text explaining the decision (e.g. macros-trust-message). + pub message: String, + /// Label for "Keep disabled" (the safe default). + pub keep_disabled_label: String, + /// Label for "Enable for this session". + pub session_label: String, + /// Label for "Trust this document". + pub trust_label: String, + /// Invoked with the user's choice (backdrop click == `KeepDisabled`). + pub on_choice: EventHandler, +} + +/// The enable dialog with the three §2.3 choices, rendered in the anti-spoof +/// macro frame. +/// +/// Touch targets: every choice button is a full-width row at least 44 logical +/// pixels tall (WCAG 2.5.8) via [`choice_button_style`]. +#[component] +pub fn AtMacroTrustDialog(props: AtMacroTrustDialogProps) -> Element { + // Cloned handles for each button's move-closure. + let on_keep = props.on_choice; + let on_session = props.on_choice; + let on_trust = props.on_choice; + let on_backdrop = props.on_choice; + + rsx! { + MacroDialogFrame { + badge_label: props.badge_label.clone(), + project_name: props.project_name.clone(), + document_title: props.document_title.clone(), + on_backdrop: move |()| on_backdrop.call(MacroTrustChoice::KeepDisabled), + + div { + style: format!("font-size: {fs}px; color: {fg};", fs = FONT_SIZE_BODY, fg = COLOR_TEXT_ON_CHROME_SECONDARY), + {props.message.clone()} + } + + div { + style: format!("display: flex; flex-direction: column; gap: {gap}px;", gap = SPACE_2), + + // Trust this document (strongest — accented). + button { + style: choice_button_style(true), + onclick: move |_| on_trust.call(MacroTrustChoice::TrustAlways), + {props.trust_label.clone()} + } + // Enable for this session. + button { + style: choice_button_style(false), + onclick: move |_| on_session.call(MacroTrustChoice::EnableSession), + {props.session_label.clone()} + } + // Keep disabled (default / safe). + button { + style: format!( + "{base} border-color: {border};", + base = choice_button_style(false), + border = COLOR_TEXT_ON_CHROME_SECONDARY, + ), + onclick: move |_| on_keep.call(MacroTrustChoice::KeepDisabled), + {props.keep_disabled_label.clone()} + } + } + + // A subtle reminder that "Keep disabled" is the safe path. + div { + style: format!( + "font-size: 11px; color: {fg}; margin-top: {mt}px;", + fg = COLOR_MACRO_BADGE, mt = SPACE_1, + ), + "aria-hidden": "true", + "⚡" + } + } + } +} diff --git a/appthere-ui/src/components/mod.rs b/appthere-ui/src/components/mod.rs index 1d95b2ce..9d247dbf 100644 --- a/appthere-ui/src/components/mod.rs +++ b/appthere-ui/src/components/mod.rs @@ -10,6 +10,8 @@ pub mod confirm_dialog; pub mod document_tab; pub mod home_tab; pub mod icons; +pub mod infobar; +pub mod macro_security; pub mod overlay; pub mod panel_host; pub mod platform; @@ -26,6 +28,12 @@ pub use color_picker::{ pub use confirm_dialog::{AtConfirmDialog, AtConfirmDialogProps}; pub use document_tab::{AtDocumentTab, AtDocumentTabProps}; pub use home_tab::{AtHomeTab, AtHomeTabProps, BuiltinTemplate, RecentDocument}; +pub use infobar::{AtInfobar, AtInfobarProps}; +pub use macro_security::{ + AtMacroTrustDialog, AtMacroTrustDialogProps, AtNetworkPrompt, AtNetworkPromptProps, + AtPermissionPrompt, AtPermissionPromptProps, MacroDialogFrame, MacroDialogFrameProps, + MacroGrantChoice, MacroTrustChoice, +}; pub use overlay::{ use_backdrop, use_provide_backdrop, AtBackdropContext, AtBackdropHost, BACKDROP_Z_INDEX, }; diff --git a/appthere-ui/src/lib.rs b/appthere-ui/src/lib.rs index 2b74f71d..8a164b95 100644 --- a/appthere-ui/src/lib.rs +++ b/appthere-ui/src/lib.rs @@ -52,9 +52,12 @@ pub use components::{ next_zoom, use_backdrop, use_provide_backdrop, AtBackdropContext, AtBackdropHost, AtColorPickerLabels, AtColorPickerPanel, AtColorPickerTrigger, AtColorSwatch, AtConfirmDialog, AtConfirmDialogProps, AtDocumentTab, AtDocumentTabData, AtDocumentTabProps, AtHomeTab, - AtHomeTabProps, AtPanelHost, AtPanelHostProps, AtStatusBar, AtStatusBarProps, AtTabBar, - AtTabBarProps, AtTemplateBrowser, AtTemplateBrowserProps, AtTitleBar, AtTitleBarProps, - BuiltinTemplate, PanelPosture, Platform, RecentDocument, BACKDROP_Z_INDEX, + AtHomeTabProps, AtInfobar, AtInfobarProps, AtMacroTrustDialog, AtMacroTrustDialogProps, + AtNetworkPrompt, AtNetworkPromptProps, AtPanelHost, AtPanelHostProps, AtPermissionPrompt, + AtPermissionPromptProps, AtStatusBar, AtStatusBarProps, AtTabBar, AtTabBarProps, + AtTemplateBrowser, AtTemplateBrowserProps, AtTitleBar, AtTitleBarProps, BuiltinTemplate, + MacroDialogFrame, MacroDialogFrameProps, MacroGrantChoice, MacroTrustChoice, PanelPosture, + Platform, RecentDocument, BACKDROP_Z_INDEX, }; pub use responsive::{ estimate_group_metrics, group_layout, page_fits, required_page_width, resolve_cascade, diff --git a/appthere-ui/src/tokens/colors.rs b/appthere-ui/src/tokens/colors.rs index e11ae9e9..004c5384 100644 --- a/appthere-ui/src/tokens/colors.rs +++ b/appthere-ui/src/tokens/colors.rs @@ -75,6 +75,13 @@ pub const COLOR_TAB_INACTIVE_HOVER: &str = "#363636"; /// Amber — contextual ribbon tab accent (Format, Table, Image, etc.). pub const COLOR_CONTEXTUAL_TAB: &str = "#E0A030"; +/// Reserved violet accent for **macro-originated UI** — the badged frame of +/// macro permission prompts and macro-shown dialogs (macro spec §5.5, threat +/// T7). Deliberately distinct from every chrome/tab accent so a macro dialog +/// can never visually impersonate app chrome. Do **not** reuse this for any +/// app-chrome surface. +pub const COLOR_MACRO_BADGE: &str = "#B15CD1"; + // ── State overlays ──────────────────────────────────────────────────────────── /// CSS opacity value for disabled-state elements. diff --git a/docs/adr/0014-macro-signatures-and-trusted-publishers.md b/docs/adr/0014-macro-signatures-and-trusted-publishers.md new file mode 100644 index 00000000..fb3a4302 --- /dev/null +++ b/docs/adr/0014-macro-signatures-and-trusted-publishers.md @@ -0,0 +1,228 @@ +# ADR-0014: Macro signature verification & trusted publishers (Phase 8, Track A) + +**Status:** Accepted (ratified 2026-07-20) — the design addendum the macro spec +requires before any implementation. Implementation of the §6 phased plan may +proceed. +**Date:** 2026-07-20 +**Deciders:** AppThere engineering +**Resolves:** [`LOKI_MACRO_SCRIPTING_SPEC.md`](LOKI_MACRO_SCRIPTING_SPEC.md) §2.5 +("Signed macros / trusted publishers — deferred (phase 8)") and §14 Phase 8, +which state that signature verification *"requires its own spec addendum before +implementation."* This ADR is that addendum. **No code lands until this is +Accepted.** + +--- + +## 1. Context + +The macro subsystem (Phases 1–7) is complete: preserve-first storage, a +sandboxed interpreter, source-only viewing/editing, a per-document trust store +keyed by payload hash, a closed capability catalog, and a gated runner. Trust +today is **per document**: the user enables a specific document's macros, keyed +by the content hash of its macro payload (spec §2.4). There is no notion of +trusting an *author*. + +Real corpora sign macros. Office supports a **trusted-publisher** tier: a macro +project signed by a certificate the user has added to their trusted-publisher +store runs without the per-document enable click. This ADR designs that tier for +Loki. Signature *parts* are already preserved opaquely (spec §3.2); this ADR adds +*verification* and a *trust decision* on top — it never changes the bytes. + +The spec (§2.5) flags the cost up front: signature verification is *"a large, +security-critical surface (X.509 chains, timestamping, legacy digest agility)."* +The design below is deliberately conservative to keep that surface small. + +## 2. Non-goals + +- **Re-signing.** Loki never creates or repairs a signature. Editing the body or + the macros (Phase 7) invalidates any package/macro signature; we surface that, + we do not re-sign (spec §3.2). +- **Full PKI-to-CA-root trust.** We do **not** grant trust to "anything chaining + to a public CA root." That model is what makes signed malware effective. Trust + is anchored on a **user-pinned publisher certificate** (§4.3). +- **Online revocation in this track.** CRL/OCSP require network, which is Track B + (ADR-0015) and refused until then. Revocation handling is deferred and called + out as residual risk (§7). +- **XLM / p-code / anything on the "never" list.** Unchanged. A signature over a + stomped project does not resurrect p-code execution — we still only run + decompressed source (T5). + +## 3. Threat-model delta + +Signatures interact with the existing threats (spec §2 table). The dominant one: + +- **T10 (trust-metadata forgery) is the whole game.** A document's embedded + certificate is attacker-controlled data. Verifying that "the bytes are signed + by the cert embedded in the bytes" proves **nothing** — an attacker signs their + own malware with their own cert. Therefore: **an embedded signature grants + trust only when its signer certificate matches an entry the user explicitly + added to their local trusted-publisher store.** Verification confirms + *integrity + authorship*; the local pin supplies *trust*. This mirrors §2.4's + rule that trust lives only in the local profile, never in the file. +- **T5 (stomping).** The signature is computed over the source (VBA: the + normalized source per MS-OVBA content hash; ODF: the module XML). We verify the + signature against the *source we will actually run*, so a source/p-code mismatch + either fails verification or is irrelevant (we never run p-code). +- **T9 (parser exploitation).** Signature/certificate parsing is attacker-facing + and runs *before* any trust decision, so it must be `forbid(unsafe_code)`, pure + Rust, fuzzed, and total (malformed → typed `Invalid`, never panic) — the same + bar as the existing CFB/OVBA/XML readers. + +New failure modes to design against: **downgrade** (force the weak legacy digest, +§4.2), **cross-doc signature transplant** (a valid signature blob copied onto +different content — defeated because we verify the signature covers *this* +content), and **expired/rolled-over certs** (§4.4). + +## 4. Decision + +### 4.1 Verification lives in a new isolated crate, `loki-macro-sig` + +A focused crate that takes preserved signature bytes + the source/content they +should cover and returns a typed `SignatureVerdict`. It owns the crypto +dependencies (`cms`/`rasn` for PKCS#7 SignedData, `x509-cert`, `rsa` + `p256`/ +`p384` for RSA/ECDSA, `sha2`; `xml-dsig`-equivalent for ODF). `#![forbid(unsafe_code)]`, +no I/O, fuzzed. `loki-macro-host` consumes the verdict; `loki-basic` never sees it +(dependency direction unchanged; servers/headless never link it). + +``` +enum SignatureVerdict { + Unsigned, + Invalid(Reason), // malformed, digest mismatch, broken chain + ValidUntrusted { signer: CertInfo },// integrity OK, signer not in the local store + ValidTrusted { signer: CertInfo, thumbprint: [u8;32] }, +} +``` + +Only `ValidTrusted` can raise trust; everything else is display-only. + +### 4.2 Digest agility — legacy signatures never grant trust + +VBA carries up to three signatures (MS-OVBA / MS-OSHARED): the **legacy** +(`\x05DigitalSignature`, MD5-based), **agile** (`\x05DigitalSignatureAgile`), and +**V3** (`\x05DigitalSignatureV3`) streams. ODF uses W3C XMLDSig in +`META-INF/macrosignatures.xml` / `documentsignatures.xml`. + +- The **legacy MD5 signature is never honored for trust** — MD5 is broken; a + legacy-only project reads as `ValidUntrusted` at best (displayed "signed with a + legacy method Loki will not trust"). This closes the downgrade attack: presence + of a strong signature is required, and a strong one cannot be spoofed by + substituting a legacy one. +- Only **SHA-2-family agile / V3** (VBA) and **SHA-256+ XMLDSig** (ODF) with + RSA-2048+/ECDSA-P256+ are eligible for `ValidTrusted`. + +### 4.3 Trust anchor = user-pinned publisher, not CA chain + +A new per-user **`TrustedPublisherStore`** (sibling of the existing per-document +`TrustStore`, same JSON-in-profile pattern, same "nothing in a document can +write it" rule) holds `{ thumbprint: [u8;32], display_name, added: u64 }` entries. +A signature is `ValidTrusted` iff its signer cert's SHA-256 thumbprint is in that +store. The chain **is** validated for *display* ("issued by …") and to compute +expiry, but trust is the pin, not the chain — the UI states plainly that +chain-valid ≠ trusted. Chain display uses the platform trust roots +(`rustls-native-certs`, else bundled `webpki-roots`); if roots are unavailable it +degrades to "issuer not validated" and the pin-based trust is unaffected. Adding a +publisher is an explicit, anti-spoof-framed user action (§5), typically "Trust +this publisher" from a `ValidUntrusted` document. + +**Certificate rotation.** A pure leaf pin would break when a publisher renews +their (annual) certificate. The store therefore also records the signer Subject + +issuer; when a *new* leaf appears whose Subject/issuer matches a pinned publisher +but whose thumbprint differs, the document is `ValidUntrusted` with a +*"\ renewed their certificate — trust the new one?"* affordance rather +than silent trust or silent breakage. The trusted blast radius stays exactly one +leaf thumbprint. + +### 4.4 Expiry & timestamps, no online revocation (this track) + +- A signature whose signing cert is **within validity** verifies normally. +- A signature over a cert that has since **expired** is honored **only** if it + carries an RFC-3161 **trusted timestamp** proving it was signed while valid; + otherwise it degrades to `ValidUntrusted` ("publisher certificate expired"). +- **Revocation is out of scope for Track A** (needs network — ADR-0015). Until + then, un-pinning a compromised publisher in the local store is the revocation + mechanism, and this limitation is stated in the UI and §7. + +### 4.5 New trust provenance + decision flow + +`Provenance` (ADR from Phase 7.4: `{External, AuthoredHere}`) gains +**`TrustedPublisher { thumbprint }`**. On document open: + +1. Read the preserved signature (already in the payload). Verify via + `loki-macro-sig` against the source the runner would execute. +2. `ValidTrusted` → `MacroService` reports the document **enabled at open** + without the per-document click, provenance `TrustedPublisher`, **but every + sensitive capability still prompts** (§2.5: signing proves origin, not + safety; `DocWrite`/`Clipboard`/`Print`/`FileRead/Write` gating is unchanged, + `Network` stays refused until ADR-0015). Auto-run-on-open (§5.6) still needs + its own separate opt-in — a trusted publisher does **not** imply auto-run. +3. `ValidUntrusted` → normal disabled-by-default flow **plus** a "signed by + \ — Trust this publisher?" affordance. +4. `Invalid`/`Unsigned` → exactly today's behaviour. + +The per-document `TrustStore` and the publisher store are **independent**: a +document can be trusted per-hash *and/or* publisher-trusted; forgetting one does +not touch the other. + +### 4.6 Interaction with the macro editor (Phase 7) + +Editing a module rewrites the payload → the embedded signature no longer matches +→ verification returns `Invalid`/`Unsigned`. The editor **must warn before the +first edit of a signed project** ("Editing removes \'s signature; the +document will fall back to per-document trust") and, on save, the document +transitions to `AuthoredHere` (Phase 7.4 `reauthor`), never silently re-signed. +This composes cleanly with the existing re-key path. + +## 5. UI + +- The macros infobar/security panel shows signature state: + 🔏 *"Signed by \ (trusted publisher)"* / *"Signed by \ — not trusted"* + / *"Signature invalid — treated as unsigned"* / unsigned. +- "Trust this publisher…" and the trusted-publisher management list (add/remove, + with thumbprint + issuer shown) render in the **anti-spoof badged frame** + (spec §5.5, T7) — never ordinary chrome — because granting publisher trust is + exactly the target of a spoofing attack. +- All strings via `fl!()` in a new `macros-sig-*` group. + +## 6. Implementation phases (once Accepted) + +| 8A.n | Deliverable | +|---|---| +| 8A.1 | `loki-macro-sig` crate skeleton + `SignatureVerdict`/`CertInfo` model; workspace + gate registration; `forbid(unsafe_code)`. | +| 8A.2 | VBA signature parsing (MS-OVBA/MS-OSHARED DigSig streams; legacy/agile/V3 discrimination) — **parse only**, fuzzed, total. | +| 8A.3 | PKCS#7 SignedData + X.509 verification; digest/signature-algo agility; content-hash check against the source. **Gate:** validate the verifier against a real corpus of signed `.docm`/`.odt` samples before it is trusted (RustCrypto `cms`/`x509-cert` are young); if a real-world SignedData quirk defeats it, fall back to `rasn-cms`. | +| 8A.4 | ODF XMLDSig verification (`macrosignatures.xml`). | +| 8A.5 | `TrustedPublisherStore` (persist, T10 tests) + `Provenance::TrustedPublisher`; `MacroService` open-time verdict → decision wiring. | +| 8A.6 | RFC-3161 timestamp handling + expiry policy. | +| 8A.7 | UI: signature state, "Trust this publisher", management list (anti-spoof frame, i18n). | +| 8A.8 | Editor interaction warning; end-to-end tests + fuzz + fidelity-status. | + +## 7. Consequences & residual risk + +- **Positive:** signed macros from a user-pinned publisher run without the + per-document click, matching Office ergonomics, while trust stays anchored in + the local profile (T10 preserved) and capability gating is unchanged. +- **Residual (accepted, documented):** no online revocation in this track — a + compromised-but-not-yet-un-pinned publisher is trusted until the user removes + it (mitigated by: pin is per-leaf-thumbprint not per-CA, so the blast radius is + one publisher; revocation arrives with ADR-0015). Signature verification adds a + vetted crypto dependency surface — contained in one `forbid(unsafe)` crate, + fuzzed, and run before any trust decision. +- **New crate, new per-user store** — additive; no change to existing trust, + capability, or interpreter behaviour when a document is unsigned. + +## 8. Resolved decisions (ratified 2026-07-20) + +1. **Trust anchor (§4.3):** **user-pinned leaf thumbprint**, never trust-issuer — + blast radius stays one certificate. Cert rotation is handled by the + Subject/issuer-match re-prompt (§4.3), not by broadening the anchor. +2. **Chain (§4.3):** **validated for display + expiry, never as the trust + anchor** (chain-valid ≠ trusted, stated in the UI); degrades gracefully when + platform roots are unavailable. +3. **Crypto stack:** **RustCrypto** — `cms` + `x509-cert` + `rsa` + `p256`/`p384` + + `sha2`, versions pinned, crate fuzzed as attacker-facing. `rasn-cms` is the + named fallback. 8A.3 is gated on real-corpus validation. +4. **Timestamp (§4.4):** **honor expired-but-RFC-3161-timestamped** signatures + (a timestamp proves signing-time validity); **refuse expired-without-timestamp**. +5. **ODF scope (§4.5):** **`macrosignatures.xml` is the macro-trust anchor**; + `documentsignatures.xml` is display-only ("document is signed"), not a macro + grant. diff --git a/docs/adr/0015-macro-network-capability.md b/docs/adr/0015-macro-network-capability.md new file mode 100644 index 00000000..803d23a3 --- /dev/null +++ b/docs/adr/0015-macro-network-capability.md @@ -0,0 +1,183 @@ +# ADR-0015: Macro `Network` capability (Phase 8, Track B) + +**Status:** Accepted (ratified 2026-07-20) — the design addendum the macro spec +requires before any implementation. Implementation of the §6 phased plan may +proceed; it stays gated behind the off-by-default `macro-net` flag. +**Date:** 2026-07-20 +**Deciders:** AppThere engineering +**Resolves:** [`LOKI_MACRO_SCRIPTING_SPEC.md`](LOKI_MACRO_SCRIPTING_SPEC.md) §5.2 +(`Network` = *"refused in v1 … v2 at earliest, per-host prompts, no raw +sockets"*), §14 Phase 8, and §15 D2 (`Network` *"v2 at earliest, and only with +its own spec addendum"*). This ADR is that addendum. **No code lands until this +is Accepted.** + +--- + +## 1. Context + +`Network` is the one capability the catalog (spec §5.2) defines but **refuses +unconditionally** in v1: `Capability::Network` exists and `is_refused_in_v1()` +returns `true`, so it can never be prompted or granted. It was deferred because +outbound network access re-opens two threats the v1 design deliberately closed: + +- **T2 — payload download + execution** (`XMLHTTP` fetch → run it), and +- **T4 — data exfiltration** (read the document/clipboard → POST to an attacker). + +This ADR designs a **narrow, opt-in, off-by-default** network capability whose +mitigations keep those threats closed *even with network enabled*, and states the +residual risk that remains. + +The load-bearing observation: **the "never" list (spec §7) is not relaxed.** +Process execution, COM/OLE, FFI, and path-addressed file I/O stay refused. So the +classic T2 chain `download → Shell/CreateObject → execute` is still broken at the +*execute* end regardless of this capability — network can fetch bytes, but nothing +in the sandbox can turn fetched bytes into a running process. That is what makes a +bounded network capability defensible. + +## 2. Non-goals + +- **Raw sockets, arbitrary protocols.** HTTPS request/response only. No TCP/UDP, + no WebSocket, no `Winsock`, no `MSXML2.XMLHTTP` (COM — stays on the never list). +- **Relaxing the never list.** Unchanged. `Network` does not add `Shell`, COM, + FFI, registry, or path-addressed FS. +- **Server / headless macro execution.** Servers and the headless CLI never link + the interpreter (spec workspace policy) and therefore never get this capability. + The network *effect* is provided through the app-only `MacroBackend`/`Host` + seam, exactly like picker-mediated `FileRead`/`FileWrite`. +- **Ambient credentials.** No cookies, no OS proxy auth, no client certificates, + no Kerberos/NTLM — a macro cannot ride the user's existing network identity. + +## 3. Threat-model delta + +| Threat | Status with a bounded `Network` capability | +|---|---| +| **T2 (download+execute)** | Fetch is possible; **execution is not** — process spawn/COM/FFI remain never-listed, so fetched bytes cannot be run. Residual: a macro could write fetched bytes to a **picker-chosen** file (`FileWrite`) that the *user* later runs manually — mitigated by the file-picker consent (T3) and by prompting for `Network` and `FileWrite` separately. | +| **T4 (exfiltration)** | **Re-opened and accepted as the core residual risk.** `DocRead` (baseline) + `Network` = a macro can POST document content to an allowed host. Mitigations: off by default; per-host prompt with the destination shown; `Deny` default; the anti-spoof frame; and (§4.6) an explicit stronger warning when a document holds *both* `Network` and a read capability. This risk is inherent to *any* network capability and is the reason it stays opt-in and per-host. | +| **T7 (dialog/prompt spoofing)** | The per-host grant prompt is a prime spoofing target → rendered in the badged anti-spoof frame (spec §5.5), rate-limited, host shown verbatim (punycode-decoded with a homograph warning). | +| **T8 (DoS)** | Response size cap, connect/read timeouts, a cap on total requests per run, all under the existing fuel/watchdog; every request is cancellable via the always-available **Stop**. | +| **T9 (parser exploitation)** | Response bodies are attacker-controlled input handed back to macro code as bytes/string only — Loki never parses them into a privileged format. TLS/HTTP parsing is delegated to the vetted `reqwest`/`rustls` stack already used elsewhere in the app. | + +## 4. Decision + +### 4.1 A gated object-model shim, not a socket API + +Scripts reach the network only through object-model verbs on the host facade. +**v1 ships read-only `Application.HttpGet(url)`** returning +`HttpResponse { status, headers, bytes }`; `HttpPost` (the sharper exfil edge, a +direct large-payload upload) is **deferred to a later iteration** once GET is +proven. There is **no** `CreateObject("MSXML2.XMLHTTP")` (COM, refused) and no +socket type. Each call gates the `Network` capability through the broker before +doing anything. (GET still admits small exfil via query parameters — the per-host +consent + composition warning is the mitigation regardless of verb.) + +### 4.2 HTTPS-only, per-host allowlist, per-host prompts + +- **Scheme:** `https` only. `http`, `ftp`, `file`, and everything else are + refused (a plaintext or local-file fetch is never allowed). +- **Grant unit = origin** (`scheme + host + port`), never a bare "network on" + switch. The first request to `https://api.example.com` prompts: + *"This macro wants to connect to **api.example.com**. Allow?"* reusing the exact + broker + prompt machinery built in Phase 4. No wildcards; each distinct origin + prompts once. +- **Network grants never persist to disk.** The offered scopes are `Deny` + (default) / `AllowOnce` / `AllowSession` only — **no `AlwaysForDocument`**. + Network is the sharpest exfiltration edge (T4); a persisted grant would let a + forgotten document phone an approved host silently on every future open. + Per-session re-consent keeps the user aware. (This is stricter than the other + capabilities, which may persist.) Persistence can be revisited later behind the + stronger anti-spoof confirmation if a real workflow demands it. +- **Redirects** are followed only to already-allowed origins; a redirect to a + new origin re-prompts (or fails if non-interactive). Redirect count capped. + +### 4.3 No ambient authority + +No cookie jar, no OS/proxy credential reuse, no client certs. The distinction is +**ambient vs. explicit** authority: ambient credentials are forbidden, but a +macro **may set an explicit `Authorization` header** — an API token the author +wrote into the macro is a deliberate, visible credential (visible to anyone who +can open the source viewer; the author's responsibility, which we document), not +ambient authority. The macro may set other request headers **except** a +deny-list: `Host`, `Cookie`/`Set-Cookie`, the client-computed `Content-Length`, +and hop-by-hop headers (`Connection`, `Keep-Alive`, `Proxy-*`, `TE`, `Trailer`, +`Transfer-Encoding`, `Upgrade`) are stripped. This prevents a macro from silently +riding the signed-in user's identity against a first-party service while still +allowing legitimate token-authenticated APIs. + +### 4.4 Bounded execution + +Enforced by the app-side backend, not the interpreter: connect+read timeouts, a +maximum response size (streamed, hard-capped), a maximum number of network calls +per run, and cancellation wired to the run's existing cancel flag so **Stop** +aborts an in-flight request. Network calls happen on the worker thread (Phase 5 +async runner), never blocking the UI. + +### 4.5 Where the effect lives (dependency direction) + +`loki-basic` stays I/O-free (enforced by `check-dependency-direction`). `Network` +is dispatched like every other effect: the interpreter asks its `Host`, the +`loki-macro-host` `ExecutionHost` gates the capability, and the actual request is +performed by an app-provided `MacroBackend` impl (using the app's existing +`reqwest`/`rustls`). The headless/server backends implement it as *always +refused*, so no server path can make outbound calls via a macro. + +### 4.6 Capability composition warning + +Because `Network` + a read capability is the exfiltration primitive (T4), the +grant prompt is **context-aware**: if the document already holds `DocRead` +(always) or a `Clipboard`/`FileRead` grant, the `Network` prompt adds an explicit +line — *"This document can also read your content; allowing network access lets a +macro send it to this site."* (Network grants are session-scoped at most, §4.2, +so this consent is re-taken each session.) + +## 5. Relationship to ADR-0014 (signatures) + +- A **trusted-publisher** signature (ADR-0014) does **not** auto-grant `Network` — + §2.5's rule holds: signing proves origin, not safety. Network always prompts + per host, even for publisher-trusted documents. +- Once ADR-0014 ships, `Network` unlocks **online revocation** (CRL/OCSP) for + signatures — a natural follow-on, gated by the same allowlist against the CA's + published endpoints. This ADR does not depend on 0014; the two compose. + +## 6. Implementation phases (once Accepted) + +| 8B.n | Deliverable | +|---|---| +| 8B.1 | Gate `Capability::Network` behind the off-by-default `macro-net` **build feature** *and* an off-by-default **runtime setting** (both must be on); broker + `GrantScope` plumbing for origin-scoped, **session-max** grants; tests. | +| 8B.2 | `HttpResponse`/request model + the read-only `Application.HttpGet` shim in `loki-macro-host`; interpreter dispatch through the `Host` seam. (`HttpPost` deferred.) | +| 8B.3 | App `MacroBackend` network impl: HTTPS-only, origin allowlist, redirect re-check, header deny-list, no ambient creds (`reqwest`/`rustls`). | +| 8B.4 | Bounds: timeouts, size cap, per-run call cap, Stop-cancels-request; worker-thread wiring. | +| 8B.5 | Context-aware per-host prompt + composition warning in the anti-spoof frame; i18n `macros-net-*`. | +| 8B.6 | Headless/server backend = always-refused; dependency-direction + "never links interpreter" tests; fidelity-status. | + +## 7. Consequences & residual risk + +- **Positive:** macros that legitimately need a bounded HTTPS fetch (data + refresh, licensed API) work, opt-in and per-host, without opening raw sockets, + COM, or process execution. +- **Residual (accepted, documented):** **T4 exfiltration is inherently + re-opened** — a user who grants a document both content-read (baseline) and + network-to-host-X can have content sent to host X. This cannot be fully + eliminated by any network capability; it is bounded by off-by-default, + per-host consent, the composition warning, HTTPS-only, and no ambient + credentials. This residual is the explicit price of the feature and must be + ratified as acceptable. +- **T2** stays structurally closed (no execute primitive), which is the reason a + network capability is defensible at all. + +## 8. Resolved decisions (ratified 2026-07-20) + +1. **Ship gate (§6, 8B.1):** **both** — an off-by-default `macro-net` build + feature (so a distribution can exclude the code entirely) *and* an + off-by-default runtime setting; both must be on for any network call. +2. **Grant persistence (§4.2):** **capped at `AllowSession`** — network grants + never persist to disk. (Stricter than the original draft.) +3. **Verb (§4.1):** **read-only `HttpGet` ships first**; `HttpPost` deferred to a + later iteration. (Stricter than the original draft.) +4. **Header policy (§4.3):** strict deny-list (`Host`, `Cookie`, `Content-Length`, + hop-by-hop); **author-set `Authorization` is allowed** (explicit token, not + ambient authority); ambient credentials (cookies, proxy auth, client certs) + are always forbidden. +5. **iOS / App-Review (§11):** the `macro-net` build feature stays **off in the + iOS build** initially — dynamic code + arbitrary network is a review-rejection + risk that would jeopardise the `macro-exec` strategy. Desktop-first; revisit + per-platform once desktop is proven. (Product sign-off item.) diff --git a/docs/adr/LOKI_MACRO_SCRIPTING_SPEC.md b/docs/adr/LOKI_MACRO_SCRIPTING_SPEC.md new file mode 100644 index 00000000..e2ebb463 --- /dev/null +++ b/docs/adr/LOKI_MACRO_SCRIPTING_SPEC.md @@ -0,0 +1,638 @@ + + +# AppThere Loki — Safe Macro Scripting Spec (VBA & StarBasic) + +**Status:** Ratified (v1, 2026-07-16) — open decisions D1–D5 accepted as recommended +**Series:** AppThere Client, ADRs M001–M012 +**Companions:** ADR-0002 (version-preserving round-trip), ADR-0009 (target +layering), `LOKI_HEADLESS_SERVER_SPEC.md` (C021–C028 — headless policy §10) +**Target edition:** Rust 2024 + +--- + +## 0. Scope + +Real-world documents contain macros: VBA projects in OOXML macro-enabled +formats (`.docm`, `.xlsm`, `.pptm`, `.dotm`, `.xltm`) and StarBasic/Basic +script libraries in ODF packages (`Basic/`, `Scripts/`, +`` event bindings). This spec defines how Loki supports +those documents **without becoming a malware vector** — macros are the +single most abused office-document feature in the wild, and every design +decision below starts from that fact. + +### Prime directive + +> **Security beats compatibility.** Where a VBA/StarBasic feature cannot be +> implemented safely, we break compatibility — deliberately, visibly, and +> permanently. Loki does not aim to run every real-world macro; it aims to +> run the *benign* majority (formatting helpers, data entry, UDFs, mail-merge +> style automation) while making the malicious minority *structurally +> impossible*, not merely prompted-away. + +### Goals + +1. **Stop destroying macros** (today Loki silently strips them on save — + see §3). Preserve macro payloads byte-for-byte across load→edit→save. +2. Execute a curated, capability-gated subset of VBA and StarBasic in a + pure-Rust, `forbid(unsafe_code)`, tree-walking **interpreter** (no JIT — + iOS-compatible by construction). +3. **Disabled by default** for any document the user did not author, with + an explicit, per-document, revocable trust grant. +4. **Capability permissions** for anything beyond reading the open + document: document writes, dialogs, clipboard, file access, printing — + each individually granted, deniable, and auditable. +5. A **"never" list** (§7) of features that are refused permanently + regardless of grants: process spawning, FFI, COM/ActiveX, registry, + p-code execution, Excel 4.0 macro sheets, and others. + +### Non-goals + +- **Bug-for-bug Office/LibreOffice compatibility.** We implement the + documented language (MS-VBAL; OOo Basic grammar), not quirks. +- **UserForms / MS-OFORMS rendering** (v1). Deferred, not refused — see §14. +- **A general UNO bridge** for StarBasic. Refused; a small compat shim maps + the most common idioms onto our object model (§6.4). +- **Macro translation across formats.** Converting `.docm` → `.odt` does not + transpile VBA to StarBasic (or vice versa); payloads are dropped with a + warning (§3.5). +- **Server-side execution.** Macros never run in `loki-server` or + `loki-headless`, ever (§10). + +### Engineering standards + +Inherited from the workspace conventions: 300-line file ceiling, +`#![forbid(unsafe_code)]` in every new crate, `thiserror` typed errors, no +`unwrap()`/`expect()` in library code, SPDX line 1, `fl!()` for all +user-visible strings, audit-first / implement-second. + +--- + +## 1. Threat model + +What macro malware actually does, and which layer of this design stops it: + +| # | Attack class | Real-world example | Stopped by | +|---|---|---|---| +| T1 | **Auto-execution on open** | `AutoOpen`/`Document_Open`/`Workbook_Open` droppers; ODF `office:scripts` `OnLoad` event listeners | §2: macros off by default; §5.6: on-open events need a *separate* grant even in trusted docs | +| T2 | **Payload download + execution** | `XMLHTTP` fetch → `Shell`/`CreateObject("WScript.Shell")` | §7: no process spawning, no COM, ever; network is deny-by-default and v2-deferred | +| T3 | **Filesystem ransomware / droppers** | `FileSystemObject`, `Open ... For Output`, `Kill` | §5: file I/O only through OS picker-mediated handles; no path-addressed ambient FS API | +| T4 | **Data exfiltration** | read doc/clipboard → POST to attacker host | §5: clipboard + network are separate capabilities; network deferred to v2 with per-host prompts | +| T5 | **VBA stomping / p-code abuse** | source stream wiped, malicious compiled p-code executes | §4.4: Loki *only* parses decompressed source; p-code and `PerformanceCache` are never read, never executed | +| T6 | **Excel 4.0 (XLM) macro sheets** | `=EXEC()` in hidden macro sheets | §7: never implemented; sheets preserved as inert data, flagged in UI | +| T7 | **Dialog spoofing / social engineering** | fake "security update" MsgBox chains | §5.5: macro-originated dialogs are rate-limited and rendered in a visually distinct, badged frame that app chrome never uses | +| T8 | **Resource exhaustion (DoS)** | infinite loops, gigabyte string concat | §8: fuel metering, memory caps, watchdog + always-available cancel | +| T9 | **Parser exploitation** | malformed CFB/OVBA/XML crafted to exploit the *reader* | §12: parsing is `forbid(unsafe_code)` pure Rust, fuzzed in CI, and runs before any trust decision — so it must be hardened regardless | +| T10 | **Trust-metadata forgery** | document claims "I am trusted" in its own bytes | §2.4: trust state lives *only* in the local user profile, keyed by payload hash; nothing inside the file can influence trust | +| T11 | **Remote/template macro injection** | `.docx` pointing at remote `.dotm` with macros | §7: attached/remote templates are never fetched; template macros only run from a file the user explicitly opened | +| T12 | **Cross-document worming** | macro copies itself into other open docs / Normal.dotm | §6: the object model exposes *only the host document*; no `Documents` collection write-access, no template store, no `VBProject` self-modification API | + +Residual risk we accept and document: a user can explicitly trust a +malicious document and grant it document-write access, damaging *that +document* (undo + on-disk original mitigate) — the grants UI is designed to +make the blast radius legible before consent. + +--- + +## 2. M001 — Trust model: authored-by-me, else disabled + +### 2.1 Default state + +A document containing a macro payload opens with macros **disabled** — +parsed for display purposes at most, never executed. Opening is never +blocked; there is no modal prompt on open (prompt fatigue trains users to +click "Enable"). Instead a passive, non-modal infobar states that macros +are present and disabled (§9.1). + +### 2.2 What "the user authored" means + +Trust is *never* inferred from the file's own content or metadata (T10). +A document is treated as self-authored only when the **local trust store** +(§2.4) says so: + +- A document **created in Loki** on this machine gets a trust-store entry + at creation time. If the user later adds macros to it *via Loki's macro + editor* (later phase), those macros are self-authored and may run without + the enable step (capability prompts still apply). +- A document that **arrives from anywhere else** (file manager, download, + email, sync folder, collaboration server) has no entry and is untrusted — + even if its metadata claims the user as author. +- Any **externally-made modification** to a trusted document's macro + payload (hash mismatch, §2.4) drops it back to untrusted. + +### 2.3 The enable flow + +From the infobar (or File ▸ Document Security), the user can choose: + +| Choice | Effect | +|---|---| +| **Keep disabled** (default) | Payload preserved; nothing executes. Sticky — the infobar collapses to a status-bar chip on subsequent opens. | +| **Enable for this session** | Trust until the document is closed. Not persisted. | +| **Trust this document** | Persistent trust-store entry bound to the macro-payload hash. Re-prompted if the payload changes. | + +Enabling **only** permits execution of explicitly-invoked macros with the +baseline capability set (§5.2). It does **not** grant on-open auto-run +(§5.6) or any sensitive capability — those are separate decisions. + +### 2.4 The trust store + +A per-user, local, versioned store (same app-data directory family as the +spell-checker dictionary cache), **outside every document**: + +``` +TrustRecord { + doc_key: Sha256, // content hash of the *macro payload* (canonicalised) + origin_path: Option, // advisory display only, never used for matching + decision: Disabled | SessionOnly | Trusted, + auto_run_open: bool, // §5.6 — separate opt-in + capability_grants: Vec<(Capability, GrantScope)>, // §5.4 + created / last_used timestamps, +} +``` + +- Keyed by the **hash of the macro payload**, not the file path: renaming + or copying a trusted file keeps trust; *changing the macros* revokes it. +- Nothing in the store is written into the document; nothing in the + document is read into a trust decision. +- A management UI lists all records with one-click revocation (§9.4). +- The store is advisory data about *local* decisions; it does not sync via + the collaboration server in v1. + +### 2.5 Signed macros / trusted publishers — deferred + +VBA project signatures (MS-OSHARED) and ODF macro signatures could support +a "trusted publisher" tier later. **Deferred** (phase 8): signature +verification is a large, security-critical surface (X.509 chains, +timestamping, legacy digest agility) and the per-document model above is +sufficient for v1. Signature parts are preserved opaquely (consistent with +`loki-opc`'s existing signature policy). + +> **Design addendum (Accepted 2026-07-20):** +> [ADR-0014](0014-macro-signatures-and-trusted-publishers.md) designs the +> verification + trusted-publisher tier; implementation may proceed. + +--- + +## 3. M002 — Storage: preserve first, byte-for-byte + +### 3.1 Today's behaviour is data loss + +The OOXML importer (`docx/import_package.rs`) walks only known relationship +types and `assemble_docx_kind` builds a **fresh** package on export, so +`vbaProject.bin` / `vbaData.xml` are silently destroyed on save. The ODF +reader (`OdfPackage::open`) extracts a fixed part list; `Basic/`, +`Scripts/`, and `` are dropped the same way. Fixing this is +**Phase 1** and is valuable even if execution never ships: Loki must stop +corrupting other people's documents. + +### 3.2 The macro payload lives in the provenance layer + +Following ADR-0002 (`DocumentSource` carries provenance, not document +content), macro payloads attach to `DocumentSource`, **not** to the +document body and **not** to the Loro CRDT: + +```rust +// loki-doc-model — provenance layer +pub struct MacroPayload { + pub kind: MacroPayloadKind, // OoxmlVba | OdfBasic + pub parts: Vec, // name, media type, raw bytes + pub event_bindings: Vec, // detected, for UI/warning only + pub payload_hash: Sha256, // trust-store key (§2.4) +} +``` + +- **OOXML:** `word/vbaProject.bin` (CFB), `word/vbaData.xml`, their + relationship entries, and the content-type overrides (and the + `xl/`-rooted equivalents for XLSX). Preserved verbatim; re-emitted on + export of a macro-enabled kind. +- **ODF:** the `Basic/` and `Scripts/` subtrees, their manifest entries, + the `` element (including `script:event-listener` + bindings), and `Configurations2/` where it references scripts. +- Digital-signature parts remain opaque and untouched, per the existing + `loki-opc` policy. (Editing the document body invalidates a package + signature regardless; we do not attempt re-signing.) + +### 3.3 Format kinds and extensions + +`DocxKind` gains `MacroEnabledDocument` / `MacroEnabledTemplate` (content +types `...document.macroEnabled.main+xml` etc.); XLSX gains the `.xlsm` / +`.xltm` equivalents. Extension ↔ payload consistency is enforced at save: + +- Saving a macro-payload document to a **macro-enabled** extension → + payload re-emitted verbatim. +- Saving to a **macro-free** extension (`.docx`, `.xlsx`, `.odt` chosen + explicitly by the user) → payload stripped, with a save-dialog notice + (matches Office behaviour, prevents extension spoofing where a `.docx` + smuggles a VBA part). +- ODF has no extension split; presence of `Basic/` is governed by the + payload alone. + +### 3.4 Macro editing and write-back (later phase) + +When the macro **editor** ships (phase 7), edited VBA modules are written +back **source-only**: the `PerformanceCache`/p-code streams are omitted and +`_VBA_PROJECT` is emitted with the minimal documented header, forcing +Office to recompile from source. This is exactly what LibreOffice does; it +is also a security feature (an edited project can never carry stale +malicious p-code — T5). Until then, payloads are never modified. + +### 3.5 Conversion policy + +`loki-convert` (headless) and in-app "save as other format" **drop** macro +payloads on cross-family conversion (`.docm` → `.odt`, `.ods` → `.xlsx`, +…) and emit a typed warning (`ConversionWarning::MacrosDropped`). No +transpilation. Same-family conversions that can carry the payload do so. + +--- + +## 4. M003 — Execution engine: one interpreter, two dialects + +### 4.1 `loki-basic`: a pure tree-walking interpreter + +A new foundation-layer crate implementing lexer → parser → AST → +**tree-walking interpreter**. No JIT, no codegen, no `unsafe`, no +dependencies on I/O of any kind: + +- **iOS:** compliant by construction — interpretation only, no runtime + code generation, satisfying the no-JIT constraint. (See §11 for the + App Store *policy* dimension, which is separate from the technical one.) +- **Determinism & auditability:** a tree-walker is slow but simple; for + the macro workloads we target (document automation, UDFs) it is more + than fast enough, and simplicity is a security property here. +- The interpreter is **resumable/suspendable**: execution proceeds by + explicit fuel-metered steps (§8) and can block on a host decision + (permission prompt) or be cancelled between any two steps. + +### 4.2 Two dialect front-ends, one core + +VBA (MS-VBAL) and StarBasic are near-siblings. One AST and evaluator, with +a `Dialect` flag governing the divergences (default `Option Base`, +`ByRef`/`ByVal` defaults, `Option Compatible` semantics, string/date +coercion quirks, dialect-specific built-ins). Language surface for v1: + +- **Types:** `Variant` (dynamic core), Integer/Long/Single/Double/Boolean/ + String/Date/Object/arrays (static + dynamic, `ReDim [Preserve]`), + user-defined `Type` records, `Enum`, `Const`. +- **Procedures:** `Sub`/`Function`/`Property Get/Let/Set`, optional/named + arguments, `ParamArray`, modules + (phase 6) class modules. +- **Control flow:** full set (`If`/`Select Case`/`For`/`For Each`/ + `Do`/`While`/`GoTo` within a procedure, `Exit`, `With`). +- **Error handling:** `On Error Resume Next` / `GoTo label`, `Err` object, + `Error`/`Raise`. +- **Built-ins:** string, math, date/time, conversion, array, and + `Format`-family functions — the pure-compute standard library. + Anything that touches the outside world is *not* a built-in; it is a + host capability (§5) or refused (§7). + +### 4.3 Host interface: the interpreter has no authority + +`loki-basic` defines a single trait boundary: + +```rust +pub trait HostObject { /* late-bound property/method dispatch */ } +pub trait Host { + fn root(&self, name: &str) -> Option; // Application, ThisComponent… + fn request(&mut self, req: HostRequest) -> HostReply; // dialogs, files, everything + fn consume_fuel(&mut self, units: u64) -> FuelVerdict; +} +``` + +The interpreter can evaluate expressions and mutate its own heap; **every** +observable effect goes through `Host::request`. A `loki-basic` embedded +with an empty host is a pure calculator. This is the capability seam: the +broker (§5) *is* the `Host` implementation, and nothing the language does +can bypass it — there is no ambient global, no intrinsic I/O function, no +escape hatch to add one from script. + +### 4.4 VBA container reading: source only, ever + +A new `loki-vba` crate parses `vbaProject.bin`: CFB (compound file) walk → +`dir` stream → per-module MS-OVBA decompression → **source text** (MBCS, +transcoded via the project code page). Hard rules: + +- The **p-code / `PerformanceCache` / `_VBA_PROJECT` compiled streams are + never parsed and never executed** (T5 — VBA stomping). A stomped module + (empty source, live p-code) is treated as an *empty module*, and the + mismatch heuristic (module count/offsets vs. source presence) surfaces a + "project appears tampered" warning in the UI. +- `SRP` streams, designer/OFORMS streams: ignored in v1 (preserved as + bytes in the payload, invisible to execution). +- The parser is fuzzed (§12) and returns typed errors; a malformed project + degrades to "macros unreadable — preserved but cannot be enabled". + +StarBasic sources are plain XML text inside the ODF package +(`Basic/*/…​.xml`, `script-lb.xml`/`script-lc.xml` library manifests) and +are parsed by `loki-odf` with the existing hardened `quick-xml` stack. + +--- + +## 5. M004 — Capability system: deny by default, grant by decision + +### 5.1 Model + +Every effectful operation is mapped to a **capability**. The broker (the +`Host` implementation in `loki-macro-host`) checks each `HostRequest` +against the grant table; a missing grant either raises a **prompt** (first +use, §5.4) or returns a **typed denial** the script sees as a trappable +BASIC runtime error (so well-written macros degrade gracefully). + +### 5.2 Capability catalog + +| Capability | Contents | Default when doc enabled | Notes | +|---|---|---|---| +| `DocRead` | read host document model, selection, metadata | **granted** | the baseline that makes macros useful | +| `DocWrite` | mutate the *host* document via the object model | prompt | all writes batched into CRDT transactions → one undo entry per run (§6.2) | +| `UiDialog` | `MsgBox`, `InputBox`, status text | prompt | badged + rate-limited (§5.5) | +| `Clipboard` | read / write system clipboard | prompt (separate read vs write) | classic exfil/injection channel | +| `FileRead` | read a file **chosen by the user through the OS picker** | picker == consent | no path-string API; see §5.3 | +| `FileWrite` | write to a picker-chosen target | picker == consent | ditto; no overwrite-without-picker | +| `Print` | submit the document to the print flow | prompt | uses the existing print path | +| `Network` | outbound HTTP(S) fetch | **refused in v1** | v2 at earliest, per-host prompts, no raw sockets — see §14 | + +Everything not in this table is **refused** (§7). The catalog is a closed +enum in code; adding a capability is a spec-level change, not a patch. + +### 5.3 File access is picker-mediated, never path-addressed + +The single biggest compat break in the capability design: `Open "C:\…"`, +`FileSystemObject`, `Dir()`, `Kill`, `Name`, `MkDir` **do not exist**. +Scripts that need a file call the object-model equivalents +(`Application.OpenFileForReading(filter…)` shim), which raise the OS file +picker; the user's pick *is* the grant, scoped to that handle, for that +run. This eliminates T3 structurally — a macro cannot enumerate, address, +or touch anything the user didn't hand it — and matches the platform +sandboxing direction on iOS/Android anyway (where the vendored +`loki-file-access` URI-permission patches already work this way). + +### 5.4 Grant scopes and prompting + +Prompts are asked **at first use during a run** (the interpreter suspends; +§4.1), not as an up-front wall — users decide with the macro's purpose in +view. Each prompt offers: + +- **Deny** (default button) → trappable error to the script. +- **Allow once** — this run only. +- **Allow for this session** — until the document closes. +- **Always for this document** — persisted to the trust record (§2.4), + listed and revocable in the management UI. + +There is deliberately **no "always for all documents"** scope. + +### 5.5 Anti-spoofing for macro UI + +Macro-originated dialogs (T7) render inside a visually distinct frame: +a "Macro: " badge header in a reserved accent style that app +chrome never uses, with the host document title. Dialog storms are +rate-limited (token bucket, e.g. 5 dialogs / 10 s; exceeding it suspends +the macro with a "misbehaving macro" infobar offering Stop). `MsgBox` +button results are returned normally so benign flows work. + +### 5.6 Auto-run events are a separate, scarier decision + +Even for a **trusted** document, on-open/auto events (`AutoOpen`, +`AutoExec`, `Document_Open`, `Workbook_Open`, ODF `OnLoad`/`OnStartApp` +listeners, `Auto_Open` in sheets) do **not** fire unless the trust record +has `auto_run_open = true`, set only via an explicit, separately-worded +opt-in ("Run this document's macros automatically when it opens — +recommended only for documents you created"). Explicit invocation (Tools ▸ +Macros ▸ Run, assigned buttons) is the normal path. On-close/on-save +events follow the same flag. This single rule neutralises T1, the vector +behind essentially all macro malware campaigns. + +--- + +## 6. M005 — Object model bridge + +### 6.1 Facades over the neutral model + +`loki-macro-host` exposes per-app object models as `HostObject` facades +over the existing neutral models — **not** over app internals: + +- **Text (`loki-text`):** `Application`, `ActiveDocument` → `Document`, + `Range`, `Selection`, `Paragraphs`, `Characters`, `Find` (phase 6), + basic formatting properties mapping onto `ParaProps`/`CharProps`. +- **Spreadsheet:** `Application`, `ActiveWorkbook`/`ThisWorkbook`, + `Worksheets`, `Range`/`Cells` (`Value`, `Formula`, `NumberFormat`), + `Names`. UDF entry point for cell formulas (§6.3). +- **Presentation:** deferred until the app matures (phase 6+). + +The facades expose **only the host document** (T12): there is no writable +`Documents`/`Workbooks` collection over other open tabs, no template +object, no `VBProject`/`VBE` self-modification API, no `Application.Run` +across documents. + +### 6.2 Writes are CRDT transactions + +All `DocWrite` mutations funnel through the same Loro mutation path the +editor uses (ADR-0006), batched so **one macro run = one undo entry**. +This gives rollback-by-undo for free, keeps collaboration coherent (a +macro edit is an ordinary local edit), and means a runaway-but-permitted +macro is recoverable with ⌘Z. + +### 6.3 Spreadsheet UDFs run compute-only + +A user-defined function referenced from a cell formula executes with +**zero** capabilities — not even `DocRead` beyond its arguments; no +prompts are possible during recalc. A UDF that attempts any `HostRequest` +returns `#MACRO!`. Tight per-call fuel (§8). This keeps recalculation +pure, fast, and unpromptable. + +### 6.4 StarBasic / UNO shim + +No general UNO bridge (`createUnoService` is refused — it is the StarBasic +equivalent of COM). A thin shim maps the *common benign idioms* onto the +same facades: `ThisComponent` → active document, `ThisComponent.getText()` +/ text-cursor enumeration, sheet `getCellByPosition`-family, and +`com.sun.star.awt.MessageBox`-style alerts → `UiDialog`. The shim's +surface is an explicit allowlist that grows by demand, never by default. + +--- + +## 7. M006 — The "never" list (permanent compatibility breaks) + +The following are **refused unconditionally** — no capability, no prompt, +no configuration flag can enable them. Each raises a distinct, documented +runtime error (`ErrFeatureRefused`, with the feature named) so authors +understand the failure. Preserved payloads may *contain* them; they simply +never execute. + +| Refused | VBA / StarBasic surface | Why | +|---|---|---| +| Process execution | `Shell`, `WScript.Shell`, `Environ$` write, `SendKeys` | the dropper endgame (T2) | +| FFI | `Declare Function … Lib`, `DllCall` | arbitrary native code | +| COM / OLE automation | `CreateObject`, `GetObject`, `New` on external ProgIDs, ActiveX | unbounded external surface (T2, T4) | +| UNO service manager | `createUnoService`, `createUnoStruct` (beyond the §6.4 shim) | same, StarBasic flavour | +| Path-addressed file I/O | `Open…For`, `FileSystemObject`, `Dir`, `Kill`, `Name`, `MkDir`, `RmDir`, `FileCopy`, `SetAttr` | replaced by picker-mediated handles (§5.3, T3) | +| Registry / OS settings | `GetSetting`/`SaveSetting`, `RegRead`… | persistence & recon | +| p-code execution | `_VBA_PROJECT`/`PerformanceCache` streams | undocumented, stomping vector (T5) | +| Excel 4.0 XLM macros | macro sheets, `=EXEC()` etc. | legacy pure-malware surface (T6); sheets render as inert data with a warning chip | +| DDE | `DDEInitiate`… | legacy exec vector | +| Remote/attached template code | template macros auto-loaded via `attachedTemplate` URLs | remote macro injection (T11) | +| Timer-based background execution | `Application.OnTime`, `Wait`-loop scheduling | macros run only in a user-visible, cancellable session (§8) | +| Add-in / startup-path loading | global template & add-in directories | nothing executes that didn't arrive in the opened document | +| VBE self-modification | `VBProject`, `CodeModule` object model | self-rewriting malware (T12) | + +This table is normative: the interpreter and broker ship with tests +asserting each row raises `ErrFeatureRefused` (§12). + +--- + +## 8. M007 — Resource limits + +- **Fuel metering:** every AST step consumes fuel; a run gets a default + budget (config constant, order 10⁸ steps) — exhausting it suspends with + a "macro is taking a long time — Continue / Stop" infobar. UDFs get a + much smaller fixed budget with **no** continue option. +- **Memory caps:** interpreter heap (strings, arrays, objects) accounted + and capped (order 256 MiB); exceeding → runtime error, not OOM. +- **Recursion/depth caps** and per-run **wall-clock watchdog**. +- **Threading:** macros execute on a worker thread; the UI thread renders + progress and the always-available **Stop** control. Document mutation + batches apply via the normal signal path on the UI thread. +- **No sleep/background scheduling** (§7) — a macro is always a foreground, + user-attributable activity with a visible stop affordance. + +--- + +## 9. M008 — UI/UX + +All strings via `loki_i18n` — new domain `macros.ftl` (registered in +`DOMAINS`, per the loader convention). Interactive elements meet the +44×44 px touch-target rule. + +1. **Infobar** (new `appthere-ui` component, `AtInfobar`): non-modal strip + under the ribbon — "This document contains macros. Macros are disabled." + with `[Enable options…]` opening the trust dialog (an `AtConfirmDialog` + derivative with the three §2.3 choices, wired like the existing + `loki_spell::Consent` gate). Collapses to a status-bar `notice_chip` + ("⚠ macros disabled") on later opens. +2. **Permission prompts** (§5.4): capability name, plain-language + consequence line, macro + document identity, Deny as default button. +3. **Macro runner:** Tools ▸ Macros — list projects/modules/procedures, + Run, per-run status line, Stop. +4. **Document Security panel:** per-document trust state, granted + capabilities with revoke buttons, auto-run toggle (§5.6), "forget this + document", and the global trust-store list. +5. **Tamper warning** when the VBA project fails the stomping heuristic + (§4.4). +6. **Macro viewer** (read-only source view, phase 3) — visibility before + executability: users (and reviewers) can inspect what a macro does + before enabling anything. + +--- + +## 10. M009 — Server & headless policy + +- `loki-server`, `loki-server-collab`, `loki-headless`, `loki-convert`, + `loki-print`: macro payloads are **opaque bytes**. Preserved through + storage/collab; **never parsed beyond presence detection, never + executed**. There is no server-side interpreter dependency at all — + enforced by keeping `loki-basic`/`loki-macro-host` out of every server + crate's dependency graph (extend `scripts/check-dependency-direction.py` + with a denial edge, §12). +- Headless conversion applies §3.5 (preserve within family, strip with + warning across families). +- Collaboration: the payload rides the document container/provenance + layer, not the Loro op stream, in v1. Trust remains local per user + (§2.4) — a collaborator's "trusted" never propagates. + +--- + +## 11. M010 — Platform notes (iOS foremost) + +- **Technical:** the engine is a pure interpreter (§4.1); there is no JIT + anywhere in the design, so iOS's W^X / no-JIT constraint is satisfied by + construction, with a single codebase for all platforms (no + interpreter-vs-JIT split to maintain). +- **Policy:** App Store Guideline 2.5.2 restricts executing downloaded + code; document macros are exactly that. The execution engine is + therefore behind a **build-time feature flag** (`macro-exec`): the iOS + build can ship *preservation + viewer only* (still a major win — no + data loss, full transparency) if App Review requires, without forking + the codebase. Android/desktop ship with execution enabled. +- Fuel-metered stepping (§8) doubles as the mobile ANR guard. + +--- + +## 12. M011 — Crate layout, and M012 — verification + +### New crates (ADR-0009 layer map additions) + +| Crate | Layer | Deps (internal) | Responsibility | +|---|---|---|---| +| `loki-basic` | L1 | `loki-primitives` | lexer/parser/AST/interpreter, `Host` trait, fuel. Zero I/O deps; `#![forbid(unsafe_code)]`. | +| `loki-vba` | L2 | — (external: a pure-Rust `cfb` reader) | `vbaProject.bin` CFB walk, MS-OVBA decompression, dir-stream parse, source extraction, stomping heuristic. | +| `loki-macro-host` | L5 | `loki-basic`, `loki-doc-model`, `loki-sheet-model` | capability broker (`Host` impl), trust store, object-model facades, `MacroService` (provided via `provide_context`, `SpellService` pattern). | + +StarBasic container parsing lives in `loki-odf`; payload preservation +touches `loki-opc` consumers (`loki-ooxml`, `loki-odf`) and +`loki-doc-model::io::DocumentSource`. UI components land in `appthere-ui` +(`AtInfobar`, permission dialog) and per-app wiring in the three apps. + +### Verification (CI-gated) + +- **Fuzzing:** `cargo-fuzz` targets for the CFB/OVBA reader, the ODF + script-container reader, and the `loki-basic` lexer/parser. Corpus + seeded from real-world macro documents (benign) and CVE-shaped + malformed containers. Run in CI on a schedule. +- **"Never" table tests:** one test per §7 row asserting + `ErrFeatureRefused`. +- **Malware-pattern regression corpus:** sanitised auto-open dropper + skeletons (no live payloads) asserting: not executed on open; T5 stomped + project treated as empty; XLM sheets inert. +- **Capability tests:** every `HostRequest` kind × {no grant → prompt or + typed denial; grant scopes honoured; revocation immediate}. +- **Round-trip goldens:** `.docm`/`.xlsm`/ODT-with-Basic load→save byte + comparison of preserved parts (`loki-acid` fixtures). +- **Dependency gates:** `check-dependency-direction.py` extended: server + crates must not depend on `loki-basic`/`loki-macro-host`; `loki-basic` + must not depend on any I/O-capable crate. +- Interpreter conformance suite: language-semantics tests shared across + both dialects (numeric coercion, error handling, `Variant` edge cases). + +--- + +## 13. Data-loss note on today's behaviour (why Phase 1 is urgent) + +Independent of everything above: **Loki currently strips macros from every +macro-enabled document a user saves**, silently. Even users who never want +macro *execution* are having their files damaged. Phase 1 (preservation + +"macros present" indicator) is a correctness fix and should land ahead of, +and independent from, any execution work. + +--- + +## 14. Implementation phases + +Each phase is independently shippable and independently reviewable; later +phases can be dropped or reordered without stranding earlier ones. + +| Phase | Deliverable | Key crates | Exit criteria | +|---|---|---|---| +| **1. Preserve & detect** | `MacroPayload` on `DocumentSource`; OOXML + ODF payload preservation; macro-enabled `DocxKind`s; extension-strip rule (§3.3); conversion warnings (§3.5); infobar/chip "macros present (not executed)" | `loki-opc` consumers, `loki-doc-model`, `appthere-ui` | round-trip goldens byte-identical; no execution surface exists | +| **2. Interpreter core** | `loki-basic`: full language §4.2, empty-host mode, fuel, suspension; conformance suite; parser fuzzing | `loki-basic` | passes conformance suite; fuzzers clean; zero I/O deps enforced | +| **3. Source extraction & viewer** | `loki-vba` (source-only, stomping heuristic); ODF Basic reader; read-only macro viewer UI | `loki-vba`, `loki-odf`, apps | real-world corpus parses or degrades typed; tamper warning works | +| **4. Trust & capability infrastructure** | trust store, capability broker, permission prompts, Document Security panel, anti-spoof dialog frame | `loki-macro-host`, `appthere-ui` | capability test matrix green; T10 tests green | +| **5. Execution v1 — text + spreadsheet** | object-model facades (§6.1), `DocRead`/`DocWrite`/`UiDialog`/`Clipboard`/`Print`, explicit run only, CRDT-batched undo, Stop control | `loki-macro-host`, apps | "never" table tests green; malware corpus inert; macro run = 1 undo entry | +| **6. Events & UDFs** | button/control-assigned macros; spreadsheet UDFs (compute-only, `#MACRO!`); on-open events behind `auto_run_open` (§5.6); `Find`, class modules | same | T1 regression corpus: nothing fires without the flag | +| **7. Macro editor** | edit + save-back (source-only write, §3.4) for self-authored docs; picker-mediated `FileRead`/`FileWrite` | `loki-vba`, apps | edited projects reopen in Office/LO from source | +| **8. Extended trust (optional)** | signature verification / trusted publishers (§2.5); `Network` capability with per-host prompts — **each requires its own spec addendum before implementation** (Accepted 2026-07-20: [ADR-0014](0014-macro-signatures-and-trusted-publishers.md) signatures, [ADR-0015](0015-macro-network-capability.md) network) | new | — | + +--- + +## 15. Open decisions — resolved (2026-07-16) + +All five decisions were accepted as recommended: + +| # | Decision | Resolution | +|---|---|---| +| D1 | iOS ships execution? | **Accepted:** full engine behind the `macro-exec` build flag; attempt App Review with execution enabled, fall back to preserve+viewer-only if required (§11) | +| D2 | `Network` capability | **Accepted:** refused in v1 (§5.2); v2 at earliest, and only with its own spec addendum ([ADR-0015](0015-macro-network-capability.md), Accepted) | +| D3 | UserForms | **Accepted:** deferred native-widget subset (phase ≥8), not v1 | +| D4 | Trust-store sync across user's devices | **Accepted:** local-only in v1 | +| D5 | Presentation-app object model | **Accepted:** deferred until the app matures; payload preservation still covers presentation-family scripts where the formats are supported | + +--- + +*Once this spec is approved, Phase 1 begins with the preservation work in +§3 — which is a data-integrity fix worth landing regardless of the +execution roadmap.* diff --git a/docs/blitz-textarea-probe.md b/docs/blitz-textarea-probe.md new file mode 100644 index 00000000..7d191642 --- /dev/null +++ b/docs/blitz-textarea-probe.md @@ -0,0 +1,72 @@ +# Blitz `