feat(desktop): bake the EXTERNALLY-MANAGED marker into the app (+ approval-chain and SEL deny-path fixes) - #8799
Conversation
UX Review (Fable 5) — ✅ PASSUX-level review of The full picture is clear: this PR touches only the Electron main process (marker provenance logic, managed-command environment hardening), the build script, developer docs, and backend import/event-loop fixes. Nothing under UX-Verdict: PASS No user-visible surface changes: main-process provenance logic, build tooling, and backend fixes only — existing update UI reappears unchanged for edition installs. [UX-REVIEWED] 141c664 |
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS Provenance-class reasoning is sound: a marker sealed in the same archive as the code reading it needs no probe, and the fail-loud build gate mirrors the fail-safe reader. The baked/loose split gives each on-disk shape a named trust basis (build-time provenance vs. ownership probe) without touching the #7959 loose-marker contract, closes the per-user-install and Windows gaps the loose gate refused by construction, and the build-side validation deliberately rejects exactly what the reader would silently degrade. The folded-in fixes (approval-chain ImportError, SEL deny-path hop, decrypt off-loop, COMSPEC/PYTHONNOUSERSITE pinning) are each disclosed, independently tested, and consistent with the constructed-environment design rather than smuggled scope. [DESIGN-REVIEWED] 141c664 |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All verification is done. The findings: the change inventory is fully declared in the description (unusually complete), the headline feature is derived from a named defect (#7959 refusing every user-owned install), the hardening riders each protect a named boundary — but First-Principles-Verdict: CONCERNS Every item has a named cause, but What this change shipsIntent: let editions built by a package manager's own owner keep in-app managed updates, which #7959's provenance gate structurally refuses — an ADDITION, with three declared fixes folded in.
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 141c664 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsBoth candidates fail falsification:
No grounded Step 2 finding survives falsification. No findings. [OPUS-REVIEWED] 141c664 Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsFINDING -- packaging/build-desktop.sh:809 -- backend provisioning can fail before stale False positive or not applicable? A repository writer can comment: |
|
AI-review override not recorded: |
…enied #8608 warmed the SecurityEventLog singleton at startup and dropped the per-call thread hops, so log_api_access is a plain enqueue on every normal start. The warm is best-effort by design, and when it FAILS the next sel() retries _init_locked -- trust-dir creation, key load, a tail read of the log -- on the calling thread. _audit_denied runs on the event loop for every refused request, so on that degraded start every denial blocked the loop on file I/O (GPT review on #8799, upheld by adjudication against AUTOSDE's no-blocking-call-on-event-loop rule). Gate on the new sel.sel_is_warm() (two attribute reads): direct enqueue when the warm succeeded, asyncio.to_thread only when it did not. The healthy path keeps #8608's shape; the degraded one never blocks the loop. The source pin in test_api_health that forbade any to_thread in the helper now pins the refined property (no hop before the gate, the hop inside the cold branch); two behavioural tests drive both branches through the real helper and the best-effort swallow on each.
5ec17f2 to
df76620
Compare
1b4741b to
97737ee
Compare
|
59e9879 to
3ffc73a
Compare
|
961e334 to
74e87e1
Compare
|
|
|
AI-review override not recorded: keep the reason to 500 characters or fewer. |
74e87e1 to
95944cf
Compare
|
95944cf to
678b38f
Compare
|
|
|
Per item, for the 🟡 CONCERNS on Items 6, 7, 9 undeclared — fixed (description): the PR body now has a "Managed-command lane hardening" block declaring |
|
|
/ai-review override gpt 678b38f: The |
|
Per item, for the 🟡 CONCERNS on Item 9 / move |
Human judgment recorded@bolichen97 marked the gpt AI finding as false positive, not applicable, or explicitly accepted for
This decision applies only to this commit. A new push requires a new judgment. |
…ck the approval chain and the SEL deny path Three fixes that ship together so the insider cherry-pick is one PR. 1. Bake the EXTERNALLY-MANAGED marker into app.asar. #7959 made the loose <resources>/EXTERNALLY-MANAGED marker's commands conditional on file provenance: neither the marker nor its directory may be owned by the app's euid, and Windows fails closed by declaration. That is the right rule for a file dropped beside the app after the build, and it refuses every per-user install by construction -- a Toolbox, Homebrew or ~/Applications tree is owned by the user down to main.js. An edition built BY the package manager's owner therefore lost its in-app "update via <manager>" flow, its 30s/4h background check, and (through the About panel's precedence) the visibility of the gateway's own command provider, on every platform. The distinction that matters is who put the file there. A marker shipped in app.asar next to main.js has the application's own provenance: anyone able to rewrite it can already rewrite the code that reads it, so an ownership probe adds nothing. readExternallyManaged now reads that BAKED marker first, trusts it as code on every platform (Windows included), and only then falls back to the loose marker with #7959's gate unchanged. A baked marker outranks a loose one. Degenerate baked bodies keep the fail-safe shape (managed, nothing to run). build-desktop.sh gains KIROCREW_MANAGED_INSTALL_MARKER: the named JSON is validated as the reader will see it (object of string fields, trimmed, under the reader's 8 KiB / 128 / 512 caps, with an updateCommand -- a marker that disables updates while offering none fails the build instead of shipping silently) and copied to website/electron/EXTERNALLY-MANAGED, now in package.json build.files so electron-builder packs it; an EXIT trap unstages it so the copy never outlives the run. The file is gitignored; the two build.files staleness tests learn that a declared build-time input is not a renamed-away file. 2. Keep the tool-approval import chain free of the cryptography wheel. hooks.on_tool_call lazily imports kiro_crew.slack.gateway for _is_read_only_tool. At import time that module reaches wecom.media and weixin.media (through channels) and secrets.vault (through autonudge -> irq -> cron_script), and all three imported cryptography.hazmat at module top. On a host whose `cryptography` native wheel does not load (a platform-mismatched build: the AL2 x86_64 wheel on an Apple-silicon Mac) EVERY tool approval raised ImportError. The three imports move inside the functions that use them; a subprocess test blocks `cryptography` and asserts the chain loads, each decrypt path still fails naming the dependency, and a static AST check keeps a top-level import from growing back on the chain. 3. Keep a failed SEL warm off the event loop in _audit_denied. #8608 warmed the SecurityEventLog singleton at startup and dropped the per-call thread hops. The warm is best-effort, and when it FAILS the next sel() retries _init_locked -- blocking file I/O -- on the calling thread; _audit_denied runs on the event loop for every refused request. New sel.sel_is_warm() (two attribute reads) gates the path: direct enqueue when warm, asyncio.to_thread only when not. The source pin in test_api_health now pins that refined property; two behavioural tests drive both branches. Tests: six baked-marker node:test cases; test_build_desktop_managed_marker.py (15) drives the build step's accept/reject/unstage branches; test_approval_chain_no_cryptography.py (3); test_sel_startup_warm.py (+2).
678b38f to
141c664
Compare
Summary
#7959 gated the loose
<resources>/EXTERNALLY-MANAGEDmarker'supdateCommand/checkCommandon file provenance (not owned by the app's euid; Windows fail-closed by declaration). Right for a file a repackager drops beside the app — and, by construction, a refusal of every per-user install: a Homebrew /~/Applicationstree is owned by the user down tomain.js. An edition built by the package manager's own owner therefore lost, on every platform:<manager>" flow and its 30 s / 4 h background check (managedLaunchTimer/managedPollTimerare only created on the with-metadata branch), andisExternallyManaged ? passive : …gwManagedByCommand…), the visibility of the gateway's own command provider — including for a remote gateway the desktop is connected to.The distinction that matters is who put the file there. A marker shipped inside
app.asarnext tomain.jshas the application's own provenance: anyone positioned to rewrite it is already positioned to rewrite the code that reads it, so no ownership probe adds anything. On macOS it is sealed by codesign for free.Changes
website/electron/auto-update.js—readExternallyManagedreads a baked marker (__dirname/EXTERNALLY-MANAGED, i.e. inside the asar) first and trusts it as code on every platform, Windows included; falls back to the loose marker with fix(desktop): establish EXTERNALLY-MANAGED marker provenance before shelling it #7959's gate unchanged. Baked outranks loose when both exist. Degenerate baked bodies keep the fail-safe shape (managed, nothing to run). The dev env seam still wins and stays a loose read.packaging/build-desktop.sh— new step 3b:KIROCREW_MANAGED_INSTALL_MARKER=<json>is validated (object of string fieldsmanagedBy/updateCommand/checkCommand, ≤ 8 KiB, with anupdateCommand— a marker that turns updates off while offering none fails the build instead of shipping silently) and copied towebsite/electron/EXTERNALLY-MANAGED. Unset removes a leftover from a previous local build.website/electron/package.json—EXTERNALLY-MANAGEDadded tobuild.files;.gitignorecovers the placed file; the twobuild.filesstaleness tests learn that a declared build-time input is not a renamed-away file.docs/build/desktop-app.md— "Baking the marker into the app (editions)" subsection; Windows paragraph now scoped to the loose marker; note that no app environment variable reaches the commands (Windows passes an undefined%VAR%through literally) and document the derivedKIROCREW_MANAGED_ARGV0.Tests
node:testcases inauto-update.test.js: probe never consulted for a baked marker; honored with the realcanRewriteMarkeron a user-owned tree (the Windows shape — the precondition asserts the probe would refuse the file); baked outranks loose; absent baked leaves the loose contract intact (both probe verdicts); degenerate bodies; env seam precedence.test/test_build_desktop_managed_marker.py(18 cases) extracts step 3b from the shipped script and drives accept / each reject branch / unset-removes-leftover / package.json packs it.npm testinwebsite/electron: 1669 pass, 0 fail.bash -n, eslint, flake8 clean.Not in this PR
Editions wire the variable in their own lanes. Note for them: the managed commands run under the constructed environment from #7959 — no app environment variable reaches them (not
HOME, not anything the edition's wrapper exported before launch), so a command must not rely on$HOME/~or on an inherited%VAR%. On Windows an undefined%VAR%is passed to the command as the literal text, not as empty — a marker argument like"%SOME_VAR%"arrives as that string and a wrapper that reads it back as its relaunch target will fail its identity check. The one derived value the command does get isKIROCREW_MANAGED_ARGV0=process.execPath(the launching executable's absolute path, taken from the process, never from the environment), added in this PR for exactly that relaunch-verification need.Also in this PR (folded in from #8819 and a review finding, so one PR cherry-picks to insider)
Managed-command lane hardening (surfaced by review once the baked marker made these commands live on every platform):
managedEnv()setsPYTHONNOUSERSITE=1--HOMEis withheld so a plantedsitecustomize.pycannot ride a Python updater, but on Windows the user-site directory derives fromAPPDATA, which cmd.exe tooling needs and so is passed through; telling the interpreter directly closes both.%SystemRoot%\System32\cmd.exe(managedShell()) andCOMSPECis no longer inherited --shell: truewould resolve the interpreter from the user-levelComSpec, the same injection class the constructed environment exists to close. POSIX keepsshell: true(Node resolves/bin/shby path).KIROCREW_MANAGED_ARGV0=process.execPathis the one value the constructed environment derives for the command (never read fromprocess.env), so an edition's wrapper can verify its relaunch target without inheritance. Consumer: edition wrappers (external by design).download_mediarun their decrypt throughasyncio.to_thread-- the lazycryptographyimport's first native-module load and the CPU-bound AES pass over a multi-megabyte body both come off the event loop (no-blocking-call-on-event-loop).SKIP_ELECTRONearly exit, so no interrupted or backend-only build can leave a marker for a hand-run electron-builder to pack. The twobuild.filesstaleness tests share oneBUILD_TIME_INPUTSmodule.fix(hooks): keep the tool-approval import chain free of thecryptographywheel.hooks.on_tool_calllazily importskiro_crew.slack.gateway; at import time that reacheswecom.media/weixin.media(viachannels) andsecrets.vault(viaautonudge → irq → cron_script), which importedcryptography.hazmatat module top. On a host whose native wheel does not load (the AL2 x86_64 build on an Apple-silicon Mac) every tool approval raisedImportError. The three imports move into the functions that use them;test/test_approval_chain_no_cryptography.pyblockscryptographyin a subprocess and asserts the chain loads, each decrypt path still fails naming the dependency, and (static AST) no top-level import grows back onwecom/,weixin/,slack/,secrets/,channels.py.fix(dashboard): keep a failed SEL warm off the event loop in_audit_denied. The GPT review on this PR flaggeddashboard/server.py(code from #8741, not in the original diff; adjudication upheld it againstno-blocking-call-on-event-loop). #8608's startup warm is best-effort; when it fails, the nextsel()runs_init_locked(blocking file I/O) on the caller's thread — and_audit_deniedruns on the loop for every refused request. Newsel.sel_is_warm()(two attribute reads) gates the path: direct enqueue when warm,asyncio.to_threadonly when not. The healthy path keeps #8608's shape; the source pin intest_api_health.pynow pins that refined property and two behavioural tests drive both branches.Pattern harvest
access(W_OK); the next step is that a file shipped in the same archive as the code reading it needs no probe at all, because no write primitive reaches it that does not already reachmain.js. Reusable wherever a "config that names commands" file exists: ship it with the code, or gate it on ownership — never onchmod.readExternallyManageddeliberately degrades a malformed marker to "managed, nothing to run"; that is the right runtime answer and the wrong build answer. The build step rejects what the reader would silently swallow (including a bare marker with noupdateCommand), so the mistake surfaces in the lane log rather than as a missing button on a user's machine.build.filesis checked both ways (everyrequirelisted; every entry exists). A build-time-placed file breaks the second check in a checkout by design; naming it in aBUILD_TIME_INPUTSset keeps the "left behind by a rename" guard intact for everything else.Rule candidate: a file that names commands to run is either shipped inside the code archive that reads it (trusted as code) or gated on file OWNERSHIP -- never on current mode bits, and never trusted from a user-owned tree.
Rule candidate: when a runtime reader degrades malformed input to a safe no-op, the build step that produces that input must reject the same malformations loudly.
Rule candidate: a startup warm that is best-effort needs its consumers to check whether it succeeded before assuming the cheap path; gate the hop on that check instead of removing it.