Repository navigation
Let users approve sidecars outside ALLOWED_SERVICES via a native prompt - #171
Conversation
Adding a plugin that ships a sidecar no longer requires an app release. A name matching devtool-svc-<kebab-case> that is not in ALLOWED_SERVICES is allowed only after the user approves the exact file (path + SHA-256) in a native dialog raised from Rust. The decision is stored in the OS keychain, not app_data, because the webview can write anywhere in app_data (fs:allow-write-file on the appdata scope). It is bound to the SHA-256, so a different binary asks again; uninstalling revokes it. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #171 +/- ##
==========================================
+ Coverage 42.75% 43.92% +1.16%
==========================================
Files 302 303 +1
Lines 20222 20745 +523
Branches 5049 5049
==========================================
+ Hits 8646 9112 +466
- Misses 10535 10592 +57
Partials 1041 1041
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Important
The native consent prompt is a good direction, but the approval is currently bound to a SHA-256 that is computed before the prompt while the file is re-resolved and spawned after it, so the bytes the user approves are not guaranteed to be the bytes that run. Details inline.
Reviewed changes
service_trust.rs(new) — consent store bound to(bin, sha256), with aTrustBackendtrait, a keychain backend (devtool/trusted-services), session-only fallback when the keychain is unavailable, a one-dialoggate,grant/revoke/ensure, and a unit-test suite covering restart persistence, denial, hash change, no-keychain, concurrent calls, revoke, and the dialog message.service_host.rs— replaces "must be inALLOWED_SERVICES" inreject_reasonwith a name-format check (is_valid_service_name), addsask_user(nativetauri-plugin-dialog) andensure_trusted, addsServiceRegistry.trust, and hasget_or_spawnfast-path running bins then consent before spawning.artifact_installer.rs— exposessha256_hexaspub(crate), adds display-onlyinstalled_service_source_url(_at), and revokes trust on service uninstall.main.rs— registers theservice_trustmodule.- Docs + CHANGELOG — rewritten around the two-sources ("bundled allowlist" + "user consent") model.
ℹ️ URL install can still grant execution under an ALLOWED_SERVICES name with no prompt
This is pre-existing behavior, not introduced by this diff, but it directly contradicts the security framing the new docs assert, so it is worth resolving before the "two sources of authority" claim is treated as a real boundary.
Technical details
# The bundled allowlist is bypassable by a URL install
## Affected sites
- `src-tauri/src/service_host.rs:428-430` — `ensure_trusted` returns `Ok(())` for any `ALLOWED_SERVICES` name, skipping the prompt.
- `src-tauri/src/artifact_installer.rs:568-647` — `stage_install_service` never checks `ALLOWED_SERVICES` (or `is_valid_service_name`); any `kind=service` manifest is written to `extensions/index.json`.
- `src-tauri/src/service_host.rs:260-271` — `resolve_sidecar_path` prefers the URL-installed record over the bundled binary.
## Required outcome
Either reject URL installs whose `bin` is an `ALLOWED_SERVICES` name (forcing them through consent), or reflect in `01-architecture.md` / `05-external-install.md` that an `ALLOWED` name can be replaced by a URL install boundary is bypassable, and restore the prompt for URL-installed binaries.
## Open questions for the human (optional)
- Was the "URL install never grants run permission" wording in `05-external-install.md` meant to cover the `ALLOWED`-name case, or only novel names? The current text implies the former but the code only enforces the latter.ℹ️ Nitpicks
src/platform/installer.ts:89and:244still state that a URL-installedbin"vẫn phải nằm trongALLOWED_SERVICES" / is "khoá theobinquaALLOWED_SERVICES". That is no longer true after this PR — the comments should describe the name-format + consent model.
Important
Pullfrog covered this run's model usage. DeepSeek Flash is fast and cheap — expect lighter work than a frontier model. This model allowance is temporary and separate from your Pullfrog plan. Connect a model-provider subscription or API key →
deepseek-v4.1-flash (model usage covered by Pullfrog) | 𝕏
| let bytes = tokio::fs::read(&path) | ||
| .await | ||
| .map_err(|e| format!("Không đọc được \"{}\": {e}", path.display()))?; | ||
| let sha256 = crate::artifact_installer::sha256_hex(&bytes); |
There was a problem hiding this comment.
The consent approval is bound to a SHA-256 computed here, before the prompt, but after the user clicks Allow, get_or_spawn re-resolves sidecar_path and spawns at line 457 with no re-hash. Because the sidecar lives under webview-writable app_data (capabilities/default.json:17,20) and the webview does not block on invoke, it can swap the binary — or index.json's bin_path — while the dialog is open, so the approved bytes are not the spawned bytes. The docs' claim that this is a "rất ngắn" (very short) window is inaccurate: it is however long the modal dialog stays open.
Technical details
# Consent is not bound to the bytes that are spawned
## Affected sites
- `src-tauri/src/service_host.rs:431-441` — `ensure_trusted` resolves the path and hashes the file, then hands control to `trust.ensure`, which blocks on the dialog.
- `src-tauri/src/service_host.rs:450` — `ensure_trusted(...).await?` returns on approval; nothing pins the approved file.
- `src-tauri/src/service_host.rs:457` — `get_or_spawn` calls `sidecar_path(app, bin)` again and spawns whatever is there, without comparing against the trusted digest.
- `docs/plugin-sdk/01-architecture.md:95-97` — states the hash-to-spawn window is "rất ngắn" and that the file is hashed "ngay trước khi spawn"; it is hashed before the prompt instead.
## Required outcome
Spawn exactly the file whose SHA-256 the user approved. A webview that can rewrite `services/**` can currently change the on-disk bytes at any point after approval.
## Suggested approach (optional)
Return the resolved `PathBuf` from `ensure_trusted` and spawn that, rather than re-resolving `sidecar_path` at line 457, then re-read/re-hash that exact path immediately before `Command::new` and abort if it no longer equals the trusted digest. Since the binary sits inside the webview-writable scope, the robust fix is to keep the approved copy somewhere the webview cannot rewrite (or narrow the `fs` scope to exclude `services/` and `extensions/`).- Title is a question naming the program (devtool-svc- prefix dropped); the body is one sentence, three identifying lines — folder, a shortened SHA-256, source — and one reminder; buttons say Allow / Don't Allow. - Follows the language chosen in the app (devtool-locale), English otherwise. - Paths are shortened with both / and \ separators, home written as ~ on macOS/Linux and %USERPROFILE% on Windows; non-ASCII account names cannot panic the shortening. Custom button labels are supported by the dialog backend on all three: NSAlert (macOS), TaskDialog (Windows), GTK (Linux). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
ℹ️ No new issues found in the reviewed commits. This delta reworks only the consent dialog's presentation and localization; the trust/hash path is untouched. The previously raised digest-binding concern on
service_host.rsis unchanged by this commit, so this is not an approval.
Reviewed changes
- Consent dialog extracted into a value type —
consent_messageis replaced byConsentDialog { title, message, allow, deny }built byconsent_dialog(...), andask_usernow consumes that struct to drive the nativetauri-plugin-dialogprompt. - Dialog localized to the app's chosen language — new
Localeenum andlocale_from_settings, plusui_localeinservice_host.rs, which readsdevtool-localefromapp_data_dir/app-settings.json(verified this is the file plugin-store writes) and defaults to English. The webview can change the language but not the answer. - Display shortened for readability —
shorten_home(~/%USERPROFILE%),short_location(~/…/services/<bin>/<version>),short_hash(12…6 hex chars), andshort_source(scheme/query stripped, host + last segment when long). All operate on the display string; trust remains bound to the full SHA-256. - Tests updated — the old message test is replaced by exact-string assertions over the dialog text, path shortening for macOS/Linux/Windows, locale parsing, and short-value passthrough (including a non-ASCII home name that must not panic).
Important
Pullfrog covered this run's model usage. DeepSeek Flash is fast and cheap — expect lighter work than a frontier model. This model allowance is temporary and separate from your Pullfrog plan. Connect a model-provider subscription or API key →
deepseek-v4.1-flash (model usage covered by Pullfrog) | 𝕏
undici, sharp, source-map-js, fast-uri and smol-toml move to their patched versions within the existing semver ranges (no --force, package.json unchanged). The Security workflow's npm audit already failed on main since these advisories were published; full vitest suite (1452 tests) and tsc + vite build pass with the new lockfile. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ensure_trusted is now a thin wrapper over ensure_trusted_with, which takes the directories, home and the way to ask as parameters. New tests cover a bundled sidecar never asking, asking once then remembering, a denial, a changed binary asking again, a missing binary, the dialog's language from app-settings.json, and reading the install source from the index. The AppHandle-only installed_service_source_url wrapper is gone. What stays untested is glue that needs the running app or the OS keychain: showing the native dialog, resolving app directories, the keyring backend. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
There was a problem hiding this comment.
ℹ️ No new issues found in the reviewed commits. This delta extracts the consent decision behind a testable seam and adds an end-to-end suite for it; the trust/hash path itself is unchanged. The previously raised digest-binding concern on
service_host.rsis still open, so this is not an approval.
Reviewed changes
ensure_trustedsplit for testability — theAppHandle-bound function now only gathers app data (dirs, home, locale) and delegates to a newensure_trusted_with(trust, bin, index_dir, beside_dir, app_data, home, ask), which takes the ask callback as a parameter instead of reading aServiceRegistry/AppHandle. Production behavior is unchanged: same resolve → read → hash →trust.ensuresequence.- Shared directory lookup extracted —
sidecar_dirs(app)backs bothsidecar_pathandensure_trusted, so the index/beside-exe resolution cannot drift between the two call sites. ui_localeno longer takes anAppHandle—ui_locale_at(Option<&Path>)readsapp-settings.jsonfrom the passed-inapp_data, letting tests drive locale without an app.installed_service_source_urlapp wrapper removed —installed_service_source_url_atis now the single implementation;ensure_trusted_withpasses its already-resolvedindex_dirrather than re-deriving it.MemoryBackendmoved intoservice_trust.rsbehind#[cfg(test)](pub(crate)) so theservice_hosttests can reuse it without shipping it in release builds.- New end-to-end consent tests —
ensure_trusted_withnow covers: a bundled name never asks (panics if it does), ask-once-then-remembered with exact dialog title and SHA/Source:assertions, denial returning an error followed by a re-ask after the file's bytes change, a missing sidecar erroring without asking, and a Vietnamese dialog title fromapp-settings.json. These assert exact values and fail loudly if the ask callback fires when it shouldn't, so they can genuinely catch a regression. Also adds aninstalled_service_source_url_attest and a tightened bad-name rejection loop (/bin/sh,sh,devtool-svc-../x,devtool-svc-A, …).
Important
Pullfrog covered this run's model usage. DeepSeek Flash is fast and cheap — expect lighter work than a frontier model. This model allowance is temporary and separate from your Pullfrog plan. Connect a model-provider subscription or API key →
deepseek-v4.1-flash (model usage covered by Pullfrog) | 𝕏




Adding a plugin that ships a sidecar no longer requires a DevTool release.
What changes
ALLOWED_SERVICESthat matchesdevtool-svc-<kebab-case>is no longer refused outright. The first time it is about to be spawned, Rust shows a native dialog with the binary's path and SHA-256 and asks the user to allow it. The webview can trigger the question but cannot answer it.devtool/trusted-services), bound to the binary's SHA-256: a different build asks again, and uninstalling the sidecar revokes it. Where no keychain is available the decision lasts for the session only (asks again rather than never)./bin/sh,devtool-svc-../x, …) are still rejected before anything else.ALLOWED_SERVICESkeep running without a prompt.The dialog
Native on every platform — NSAlert (macOS), TaskDialog (Windows), GTK (Linux) — never a webview UI, which the webview could answer itself. In the language chosen in the app:
Why the keychain and not a file
The default capability lets the webview write anywhere in
app_data(fs:allow-write-file+fs:scope-appdata-recursive), so a file there — includingextensions/index.json— could be edited to grant itself permission.Notes
plugin-sdkdocs (01/02/04/05) and the CHANGELOG are updated.service_trust(remembered across restarts, denial, re-ask on a changed hash, one dialog for concurrent calls, no keychain, revoke) and the tightened name check; fullcargo testpasses. The native dialog and the real keychain are not covered by automated tests.Also in this PR
npm audit fix(lockfile only, no--force,package.jsonunchanged): undici, sharp, source-map-js, fast-uri and smol-toml move to patched versions. The Security workflow'snpm audithad been failing onmainsince those advisories were published. Full vitest suite (1452 tests) andtsc && vite buildpass with the new lockfile.🤖 Generated with Claude Code