feat(windows): sandbox Windows subprocess spawns through WSL2 - #7140
feat(windows): sandbox Windows subprocess spawns through WSL2#7140GoZippy wants to merge 4 commits into
Conversation
126875a to
8496482
Compare
8496482 to
b6e26a8
Compare
b6e26a8 to
9a883ac
Compare
First Principles Review (Fable 5, fork) — 🟡 CONCERNSPremise-level review of All facts verified. Composing the review. First-Principles-Verdict: CONCERNS "App install/enable scripts" is the headline harm, but the Not justified as shipped
What this change shipsIntent: give Windows operators real OS-level isolation for POSIX-shell subprocess spawns by routing them through an opt-in WSL2 distribution — an ADDITION.
(Docs, i18n catalogs, and the Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 69f04a4 |
UX Review (Fable 5, fork) — 🟡 CONCERNSUX-level review of I have the full picture: a backend WSL2 sandbox feature whose UI surface is two Settings rows in UX-Verdict: CONCERNS Settings UI follows house patterns cleanly, but the feature's first use after every restart fails by design, and no first-time reader has seen either new control. Watch
Evidence gaps
Suggestions
[UX-REVIEWED] 69f04a4 |
GPT 5.6 Review (fork) — 🔴 changes requested (blocking)Reviewed 2 of 2 blocking finding(s) are security-class and were withheld from adjudication, so the blocking verdict stands. BLOCKING -- src/kiro_crew/sandbox.py:5144 -- absent Windows-side ceilings remain writable BLOCKING -- src/kiro_crew/sandbox.py:5185 -- writable staged launcher permits pre-sandbox replacement [BLOCK-MERGE] 69f04a4 Adjudication (Opus 4.8) — is blocking on each finding proportionate?Both fenced findings target the new WSL2 sandbox backend. I opened the diff and the base code they call into. F1 — F2 — the launcher is staged at Neither condition combination is extreme enough to justify a pre-drafted acceptance argument. |
Design Review (Fable 5, fork) — 🔴 BLOCK (blocking)Design-level review of I've reviewed the patch (sandbox.py WSL2 backend, call-site changes, dashboard schema, docs, tests) and verified the load-bearing claims against the base tree — in particular how the native Linux backend protects its staged launcher ( Design-Verdict: BLOCK The guest staging dir BlockersSandboxed sibling can rewrite a staged launcher before it executes, escaping to unconfined DrvFs access. Watch
[DESIGN-REVIEWED] 69f04a4 |
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed Review detailsOnly Candidate 1 survives falsification. Candidate 2 requires a non-drive-rooted Candidate 1 is confirmed: Advisory only: the wsl2 command-cron path — the PR's headline Windows capability — never actually routes through WSL2 and stays refused. FINDING — src/kiro_crew/cron_script.py:2049 — the shell probe calls [OPUS-REVIEWED] 69f04a4 |
0d949db to
d066988
Compare
d066988 to
f8a2ab7
Compare
Audit note — part of this has already landed; the rest has notThis PR is not a duplicate and is not finished by anything on Already landedConfirmed merged (the issue/PR reference check + the landed-commit index for Which parts
|
813bc36 to
79b6993
Compare
Open PR relationship auditThis is a consolidated, point-in-time code-level audit note. It compares complete merge-base diffs and current/merged code; it does not treat a shared topic as duplication or partial coverage as completion. Relationship findings
No PR, Issue, label, branch, or review state was changed by the relationship-note portion of this audit. |
|
@bolichen97 — both required changes landed in 1. The guest launcher now pins wrapped += ["--", "python3", *_LAUNCHER_INTERPRETER_FLAGS, staged_path, *argv]The comment above it records the reason in your terms: without 2. The Script-cron-jobs row now states the opposite of what it claimed —
That matches One further commit, unrelated to your review ( Resolved the way Verified locally before pushing: black gate passes, Worth flagging as a repo-level issue independent of this PR: any fork PR that black-formats a baselined file hits the same deadlock, and the only escapes are reverting the formatting or a maintainer override label. |
1a19e9c to
18b35a6
Compare
|
@GoZippy Heads-up on an overlap with the newer #7669 (@rnoack1), which rewrites the same function this PR edits. The shared file is Concrete suggestion: land #7669 first, since it rewrites the function wholesale and pins the resulting shape with tests, then rebase this PR's three hunks onto the new body and guard the wsl2 reap the same way. The reverse order is harder, because #7669's rewrite would then have to carry the wsl2 branch through itself. Neither PR references the other today, and this one is already 99 commits behind main with Posted from the 2026-09-08 open-PR relationship audit (read-only, one auditor per PR); reply here if any of this is wrong. |
Windows has no OS-level sandbox backend of its own, so app lifecycle scripts, script hooks, and command cron jobs either run completely unsandboxed or refuse to run at all (command cron jobs are refused outright, since Git-for-Windows's sh.exe performs brace expansion that hides credential paths from the vet gate). Adds agent.sandbox: "wsl2" (opt-in, never auto-selected) as a third backend on Windows, reusing the existing Linux namespace launcher script unmodified and executing it inside a WSL2 distribution's real kernel. Includes a live distro picker (agent.sandbox_wsl_distro) that filters out Docker Desktop's own WSL2 utility instances, and dashboard Settings wiring that follows the same live-values pattern already established for agent.acp_backend. Live-verified end to end against a real WSL2 host, including genuine credential-path isolation (not just process exit status).
Three independent AI reviewers on the original PR converged on the same finding: the wsl2 backend's credential-hiding only masked the WSL2 guest's own (empty) home directory. WSL2's default DrvFs mount makes the operator's REAL Windows filesystem reachable inside the guest at /mnt/<drive>/..., including .aws/.ssh and the governance keystone under config_dir() -- none of it was masked, so a sandboxed app-lifecycle script, command cron, or hook could read or overwrite the operator's actual credentials and the "un-disableable ceiling" security.py's _SENSITIVE_HOME_DIRS exists to protect. The PR's own manual verification had only planted markers in the guest's own home, missing this entirely. wsl_namespace_argv() now computes the same sensitive-directory set _build_launcher_script would mask on native Linux, resolves it against the REAL Windows home, translates each entry through DrvFs, and passes it in via the existing extra_hidden_dirs mechanism. DrvFs verification now runs unconditionally (not only when a cwd is given) and the function fails closed if it cannot confirm the mount. Live-verified against a real WSL2/Ubuntu host with a synthetic Windows home (never the operator's real one): planted .aws/credentials and .ssh/id_rsa markers under the fake home come back "No such file or directory" from inside the sandboxed process; an unrelated control file outside any hidden dir stays readable. Also fixes six related findings from the same review round: - A same-UID sibling (a concurrently-sandboxed cron job or hook) could race the two-call launcher staging (mktemp, then a separate write+ chmod), replacing the file's content before it ever executed. Staging is now one wsl.exe round trip, writing to a Python-generated random name under `set -C` (noclobber) rather than one captured from the guest's own `mktemp`. - wsl2_distro_choices() shelled out synchronously (subprocess.run, up to 30s) directly on the dashboard's event loop from the config-schema endpoint, stalling chat, cron and the liveness heartbeat for every client on every Settings page load. Every live-enum supplier now dispatches through the existing subprocess_executor() pool. - WSL forwards a Windows env var into the guest shell only when WSLENV names it, so $KIROCREW_HOOK_EVENT/$KIROCREW_HOOK_CONTEXT and lifecycle_scripts.py's $NONINTERACTIVE/extra_env read empty under wsl2 despite the docs' claim they work. wsl2_env_passthrough() sets WSLENV for exactly the caller-supplied metadata keys, never the platform-baseline ones (PATH, HOME, ...) the guest must supply itself. - cron_script.py's command-cron shell resolver trusted the guest's /bin/sh by name instead of actually probing it, unlike every other platform's candidate -- an unusual distro whose /bin/sh is secretly bash would silently reopen the brace-expansion bypass this vet gate exists to prevent. It now calls the existing _shell_is_posix_strict probe (sandbox-routed, so it transparently exercises the guest), with a distro-qualified cache key so a verdict from one distro can never vouch for another after agent.sandbox_wsl_distro changes. - run_script_sandboxed built a native-Windows argv ([sys.executable, launcher_path]) and passed it through wrap_argv with no wsl2 awareness at all, unlike the command-cron path. Once an operator selected agent.sandbox: "wsl2", this currently-working Windows feature would have silently routed through the guest launcher and failed -- a real functional regression, not just a security gap. - **The biggest one, caught by a first-principles review counting real call sites**: wrap_argv/sandboxed_spawn_argv are shared chokepoints with dozens of callers across the codebase, most building native-Windows executable invocations (MCP server spawns, npm installs, terminal commands, git calls) rather than POSIX shell argv. The initial posix_shell_argv parameter defaulted to True (opt-out): only callers known NOT to be POSIX-shaped had to say so. With that polarity, roughly nine unexamined production spawns -- in cron_script.py, mcp_gateway/resolve_once.py, and five dashboard/handlers modules, plus git_coord.py -- would have silently started routing through the wsl2 guest launcher the moment an operator selected it, each appending a Windows-shaped argv after a Linux launcher expecting a POSIX command. The default is now False (opt-in): only the three genuinely-POSIX call sites pass True explicitly, so every other caller (all nine named, and any future one) keeps its exact pre-wsl2 behavior with zero code change of its own -- what this PR's scope description always claimed, now actually guaranteed by the default rather than merely asserted. Locked by a new test asserting the default on the real function signatures. - The keystone-masking gap this review surfaced (kirodotdev#7332) is closed upstream for the native backends by kirodotdev#7439, which gives every crew-home leaf one of three dispositions (hidden / read-only / visible) and resolves them against the data home as well as $HOME. The wsl2 backend carries the same dispositions across DrvFs: a new _wsl2_windows_side_masking() derives both sets against the WINDOWS home and Windows-side config_dir(), and _build_launcher_script gains an extra_readonly_dirs seam (default empty; the Kiro path is unchanged) so the translated ceilings are sealed rather than hidden -- an absent ceiling reads as the permissive default, so hiding one would remove it. With an identity supplied the builder skips its own config_dir()/ Path.home() resolutions, which would name Windows paths the guest cannot use, and joins the guest-home ceilings with posixpath like the hidden set already did. - Guest-staged launchers no longer accumulate: the launcher is built with unlink_self=True and removes itself once the guest interpreter has loaded it (the Windows-side spawner has no path to a guest-native file), and the staging round trip sweeps siblings older than _WSL2_STALE_LAUNCHER_MINUTES that a spawn dying before exec left behind. The native launcher keeps its spawner-deletes-it contract; the stanza is a guest-only opt-in. - The probe checks for python3 inside the distro before unshare, so a minimal distro that passes the namespace check but cannot run the launcher fails once at probe time with its own remedy (REMEDY_WSL2_NO_PYTHON3) instead of on every spawn. - The userns-refused remedy is self-contained instead of pointing at Linux-only guidance the Windows caller never sees, and every remedy says the gateway must be restarted; the dashboard hint for agent.sandbox says so too where wsl2 is on offer (detect_backend caches its verdict for the gateway's lifetime), and the distro picker's hint no longer claims the change is immediate. - The guest launcher runs under the same interpreter flags as the native one (_LAUNCHER_INTERPRETER_FLAGS, "-I -S"): without -S a same-UID workload's usercustomize.py under the guest's site-packages ran before the script reached unshare(), unconfined and with DrvFs in reach. The flags sit in the slot _launcher_script_of reads on both backends. - Script cron jobs are documented as staying on the unsandboxed-exec opt-in: they are a native-Windows Python invocation, so the wsl2 backend reports itself unavailable for them by design, and the guide no longer offers agent.sandbox "wsl2" as their alternative. The no-backend guidance says the same when a working distro refuses a non-POSIX spawn, instead of blaming a probe that passed. Both cron-path fixes and the polarity inversion verified live: the shell probe against the real distro's actual /bin/sh (dash, correctly accepted), and the script-cron guard by spying on wsl_namespace_argv -- with wsl2 selected it is never called, and the call fails closed with the identical pre-existing message. Three rounds of test-portability/mocking bugs, all caught by CI rather than locally, are fixed alongside: two in the new test/test_sandbox_wsl2.py coverage (Path.home() and _relocated_policy_cache_dirs()/_voice_runtime_sandbox_paths() reading real, environment-dependent values instead of being mocked), and one from the posix_shell_argv default flip breaking two pre-existing tests (test_cron_cancel.py, test_mcp_cron_security.py) whose wrap_argv mocks had a signature too narrow for the new keyword argument. security.md is corrected to match every fix above.
…WSL2 identity Rebasing feat/windows-wsl2-sandbox onto upstream/main's independent cancellation-aware cron rewrite left _build_launcher_script's runtime_parents assignment unconditional, so an identity-bearing (WSL2) call resolved _voice_runtime_parent_paths() against the BUILDER's own host -- exactly the leak the identity parameter exists to prevent, and caught by test_build_launcher_script_under_an_identity_uses_only_caller_supplied_host_paths. Gate it on identity like the sibling readonly_dirs resolution a few lines up; an empty carveable_parents is safe since no WSL2 caller passes extra_writable_dirs. Also restores two blank lines the same merge collapsed ahead of the WSL2 backend section (flake8 E305). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012pHruHwRtAJQGdgvKoh2j3
…to root _resolve_wsl2_identity() accepted whatever uid/gid/home the guest reported, including uid=0. The native Linux backend's "no UID 0, no UID 65534" invariant holds because unshare(CLONE_NEWUSER)'s single-uid map forces every root-owned path component to the overflow uid inside the child; wsl_namespace_argv() has no analogous confinement, since its whole premise is trusting the guest's own reported identity to build hidden_dirs/readonly_dirs. A root guest can simply unmount or chmod around whatever the launcher hides, so a root identity got no real isolation despite the backend reporting success. WSL2 defaults every distro to a non-root user, so a resolved uid=0 means an explicitly configured root default (or a misconfigured/ compromised guest) -- there is no legitimate case this backend needs to accommodate. wsl_namespace_argv() now refuses a uid=0 identity the same way it already refuses an unverifiable DrvFs mount: a loud SECURITY log line plus a fail-closed RuntimeError naming the remedy. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012pHruHwRtAJQGdgvKoh2j3
|
Rebased onto main Conflicts resolved:
Gates run locally on changed files: black, isort, flake8, Please review the resolutions. A maintainer push makes the maintainer the last pusher, so under the repo's last-push rule a second approver is needed. Reply here if anything looks wrong. |
Problem / Motivation
Windows has no OS-level sandbox backend of its own, so
sandbox.wrap_argv()fail-closes for every subprocess spawn that is not an explicitly-identified
kiro-clispawn (PR #5620's delegation). Concretely: app install/enablescripts, script hooks, and command cron jobs either refuse outright with
SandboxUnavailableError, or (command cron jobs specifically) are refusedbefore
wrap_argvis even reached, because Git-for-Windows'ssh.exeisbash and performs brace expansion that would hide a credential path from the
cron command vet gate. The only documented remedy today is
agent.sandbox_allow_unsandboxed_exec=true, which removes isolationentirely for the affected paths.
Meanwhile WSL2 — already installed on a meaningful share of Windows dev
machines — genuinely exposes working Linux user namespaces
(
sandbox.is_wsl()'s own docstring already says so), but nothing in thecodebase reaches into it.
Why it matters
This is a first-run-adjacent outage for a common Windows workflow: installing
a Kiro Crew app (the built-in App Store) runs the app's
onEnablescriptthrough exactly this fail-closed path, and fails with no better remedy than
"turn off isolation everywhere." Command cron jobs are simply unsupported on
Windows today. Both are one WSL2 distro away from working with real
isolation instead of none.
What changed (motivation → approach → change)
Adds
agent.sandbox: "wsl2"(opt-in, never auto-selected — the samepositive-identity posture PR #5620 established for Kiro CLI delegation) as a
third selectable backend on Windows. It reuses the existing Linux
namespace launcher script unmodified — same
unshare(CLONE_NEWUSER)+bind-mount logic — executed inside a WSL2 distribution's real kernel via
wsl.exe, rather than reimplementing sandbox logic for a fourth platform.Scope is deliberately narrow: only the three call sites that already build
POSIX
sh/bash -cargv benefit — app lifecycle scripts(
apps/lifecycle_scripts.py), command cron jobs (cron_script.py,previously unsupported on Windows entirely — WSL2's
/bin/shis a realPOSIX shell, unlike Git-for-Windows's bash-flavored
sh.exe, so this is agenuine capability unlock, not just a re-route), and script hooks
(
hooks.py, previously locked tocmd.exesyntax on Windows — withwsl2selected they use the same
/bin/sh -cform macOS/Linux already do, so aPOSIX-shell hook needs no rewrite). Single portable-binary invocations
(
git, the AWS CLI,tectonic, Piper) are unaffected either way: no shellis involved, so routing them through a Linux VM would buy no isolation and
would need Windows↔WSL path translation for every argument.
Complements, does not duplicate, the open #6808 (read-only WSL2 discovery in
the Electron desktop shell) — that PR's own scope note explicitly defers "a
settings UI plus gateway spawn routing into the chosen distro" as follow-up
work once real requirements are known; this is that follow-up, in the Python
gateway rather than the Electron shell, since the gateway is what actually
calls
wrap_argv.Mechanics, and two bugs found and fixed while proving them live:
WSL_UTF8=1is forced on everywsl.exeinvocation. Verified live:without it, WSL emits UTF-16LE and a plain
text=Truesubprocess captureeither raises
UnicodeEncodeErroror silently mis-decodes — corruptingevery string comparison downstream (distro listing, stderr
classification).
wsl.execall, writing to a Python-generatedrandom name (
secrets.token_hex(16)) underset -C(noclobber) ratherthan a name captured from the guest's own
mktemp. An earlier two-callform (create, then a separate write+chmod) both hit a real WSL2 quirk —
a command substitution combined with piped stdin in one
wsl.exe -- sh -cinvocation reproducibly drops the captured value, verified live — and,
caught in review, left an empty, discoverable file exposed to a same-UID
sibling for the whole gap between the two round trips. Naming the file in
Python needs no substitution at all, closing both at once;
set -Calsorefuses the write outright if the target already exists, rather than
silently overwriting a file a sibling pre-created.
Windows-side builder process (which has no
os.getuid()at all)._build_launcher_scriptgained anidentityoverride for this — everyreal Linux caller leaves it
Noneand sees no behavior change.os.path.join→posixpath.joinfor the launcher script'shome-relative path construction: identical behavior on real Linux
(
os.pathisposixpaththere), but load-bearing when the builder isWindows Python, which would otherwise embed a backslash into a script
meant to run on a Linux filesystem.
wsl.exe --cd, notthe ordinary
cwd=a caller passes toPopen— that setswsl.exe's own(Windows-side) launch directory, not the guest shell's. The Windows→WSL
path translation is verified against the guest's live mount table before
being trusted (
wslpathitself proved unreliable for this — verifiedlive, it silently drops backslashes on some multi-segment Windows paths
even invoked through a plain argv list, no shell involved), and refuses
to translate rather than guess if the guest doesn't mount drives at the
default
/mnt/<letter>location._probe_wsl2mirrors_probe_unshare's exact discipline: on-loop calls defer to a backgroundthread and return a transient (never-cached) failure immediately; off-loop
calls retry once. This matters more here than for the Linux probe — a cold
WSL2 round trip measured 7.5s live, far slower than the Linux
unshare()syscall.looser backend than configured (mirrors
_allow_unsandboxed_exec's owncontract), and a probe or staging failure raises the same
SandboxUnavailableErrorshape the existing no-backend path uses, with aremedy naming the specific fix (
wsl --install,wsl --install -d <name>, or the shared AppArmor-class guidance when the guest's own kernelrefuses
unshare).Dashboard:
agent.sandbox's Settings dropdown and thePATCH /api/config/kirocrewallowlist now read live selectable values ("wsl2"offered only when plausible — Windows, with
wsl.exepresent) instead of astatic list, following the exact pattern already established for
agent.acp_backend— the same file even names the reason: "threeindependent derivations is how the old literal list drifted."
Distribution picker. A host can have several WSL2 distributions — this
was raised in review on a real machine with two Ubuntu installs plus Docker
Desktop's own WSL2 backend.
agent.sandbox_wsl_distronow has its own livepicker (same schema pattern as above), populated from
wsl.exe -l -v(10s-cached to avoid repeat shellouts on rapid Settings loads) and
deliberately excluding Docker Desktop's internal utility instances
(
docker-desktop,docker-desktop-data) from what is offered — they areminimal VMs purpose-built for the container runtime, not general-purpose,
and not guaranteed to ship
unshareor the other tools the launcher needs.This is a picker default, not a hard restriction: the field stays a plain
pattern-matched string at the write layer, so an operator who deliberately
wants one can still set it by hand. Verified live against a real host
carrying exactly this shape (one general-purpose distro, one Docker Desktop
instance): the raw listing shows both, the picker offers only the real one.
On Windows Sandbox as an alternative backend (raised in review,
referencing Microsoft's own docs): investigated and not pursued here for
two disqualifying reasons rather than a scope call — "Windows Sandbox
currently doesn't allow multiple instances to run simultaneously" (a
machine-wide, one-at-a-time limitation that rules out sandboxing concurrent
spawns), and there is no documented headless launch, synchronous completion
signal, or stdout/exit-code capture —
LogonCommandis built for "a humanopens a window, does something, closes it," not a service dispatching a job
and reading back a result, which
wrap_argv's contract requires. It is alsoPro/Enterprise/Education-only (excludes Home) and runs native Windows
executables only, with no Linux/POSIX support. This is a real gap in the
tool's automation surface, not an implementation-effort judgment call, and a
different feature (an interactive "open this untrusted thing in a disposable
desktop" tool) would be a better fit for it than this one.
Security findings from review, and the fixes
Three independent AI reviewers on the original revision of this PR converged
on the same finding, plus two more from a fourth pass. All four are fixed in
the current revision, not worked around.
The credential/keystone leak (the serious one). The wsl2 backend's
credential-hiding only masked the WSL2 guest's own home directory — a
mostly-empty account. WSL2's default DrvFs mount makes the operator's REAL
Windows filesystem reachable inside the guest at
/mnt/<drive>/...,including
C:\Users\<user>\.aws/.sshand the governance keystone underconfig_dir()— none of it was masked, so a sandboxed app-lifecycle script,command cron, or hook could read or overwrite the operator's actual
credentials and the file
security.py's_SENSITIVE_HOME_DIRSexistsspecifically so the agent can never read or write. My own manual
verification (previous revision) had only planted markers in the guest's
own home, missing exactly this. Fixed:
wsl_namespace_argv()now computesthe same sensitive-directory set the native backend would mask, resolves it
against the REAL Windows home, translates each entry through DrvFs, and
passes it in via the existing
extra_hidden_dirsmechanism; DrvFsverification now runs unconditionally and the function fails closed if it
cannot confirm the mount. Re-verified live with a synthetic Windows home
(never my real one) carrying marker credential files — see Manual
verification below for the actual transcript.
Launcher-staging TOCTOU. A same-UID sibling (a concurrently-sandboxed
cron job or hook — exactly the untrusted workload this backend confines)
could race the two-call staging sequence, replacing the file's content
before it ever executed. Fixed by collapsing staging to one round trip with
a
set -C-protected write to an unpredictable name (see the mechanicsbullet above).
A genuinely blocking call on the dashboard's event loop.
wsl2_distro_choices()shelled out synchronously (subprocess.run, up to30s) directly from
GET /api/config/schema's handler — every Settings pageload could stall chat, cron, and the liveness heartbeat for every connected
client. This is exactly the discipline the rest of this PR was careful
about (
_probe_wsl2's own never-block-on-loop handling) but missed at thisone call site. Fixed: every live-enum supplier in that handler now
dispatches through the existing
subprocess_executor()pool.WSLENV. WSL forwards a Windows env var into the guest shell only when
WSLENVnames it — setting a var on the Windows-sideenv=a caller passesto
wsl.exedoes not make it visible inside the guest. So$KIROCREW_HOOK_EVENT/$KIROCREW_HOOK_CONTEXTand lifecycle scripts'$NONINTERACTIVE/extra_envall read empty underwsl2despite the docs'existing claim they work. Fixed with a small
wsl2_env_passthrough()helper both call sites use for exactly the metadata keys they inject — never
the platform-baseline ones (
PATH,HOME, ...), which the guest mustsupply from its own values, not the Windows host's.
A second look at review turned up two more, this time in
cron_script.py.cron_script.pyhas TWOwrap_argvcall sites, and they are notinterchangeable once
wsl2exists:/bin/shby name instead ofprobing it, unlike every other platform's candidate. The existing
_shell_is_posix_strictprobe (already sandbox-routed — it callswrap_argvon the candidate itself) would have caught an unusual distrowhose
/bin/shis secretly bash, transparently, with zero new WSL2-specificprobe code — but the resolver never called it for this branch, just
returned
/bin/shdirectly. Fixed by calling it, with a distro-qualifiedcache key (
wsl2:<distro>:/bin/sh):/bin/shnames a different binarydepending on which distro is selected, and the un-qualified cache would
have let a verdict from one distro vouch for a different one after
agent.sandbox_wsl_distrochanges.run_script_sandboxedwould have silently broken the momentwsl2wasselected. It builds a native-Windows argv (
[sys.executable, launcher_path]) and passed it throughwrap_argvwith zero wsl2awareness — unlike the command-cron path above. Once an operator selected
agent.sandbox: "wsl2", this currently-working Windows feature (scriptcrons) would have routed through the guest launcher, which appends argv
verbatim after itself expecting a POSIX command; a Windows path is neither
a valid guest path nor runnable there. This is a real functional
regression, not just a security gap. Fixed by giving
wrap_argva newposix_shell_argvparameter (defaultTrue— unchanged for the threecall sites that genuinely build POSIX argv); this call site passes
False, sowrap_argvreports wsl2 unavailable for it and falls throughto the exact fail-closed/
sandbox_allow_unsandboxed_exechandling Windowsalready had for script crons before this backend existed.
Both verified live: the shell probe against the real distro's actual
/bin/sh(dash, correctly accepted), and the script-cron guard by spying onwsl_namespace_argv— withwsl2selected andposix_shell_argv=Falseitis never called, and the call fails closed with the identical message
Windows gave for this case before
wsl2existed.A fifth thing surfaced by this review that is deliberately NOT fixed
here. Investigating the credential-masking finding above meant diffing
_build_launcher_script's own sensitive-dir list — shared by every backend,including this one — against
security.sensitive_home_dirs()'s full,authoritative set. The gap is large: most of the
.kiro/crew/*governancetree (
security_policy.json,admission_policy.json,profiles/, andmore) is not masked at its default location, on any platform — I proved
this live by planting a keystone marker in the WSL2 guest's own home (using
the identical
_build_launcher_scriptmechanism native Linux uses, noWindows/DrvFs involvement at all) and reading it straight through the
sandbox. This backend has exact parity with what native Linux/macOS already
does today, which is what this PR set out to deliver — but "parity with
native" is not the same claim as "the keystone is masked," and reconciling
two independently-maintained lists across a launcher every backend shares
is a bigger, riskier change than a platform port should carry. Filed
separately as #7332 rather than folded into this PR; this backend inherits
whatever that resolves to automatically once landed.
The biggest finding, from a first-principles review that counted real call
sites.
wrap_argv/sandboxed_spawn_argvare shared chokepoints withdozens of callers across the codebase, most of which build native-Windows
executable invocations (an MCP server spawn, an
npm install, a terminalcommand, a
gitcall) rather than POSIX shell argv. My initial revisiondefaulted the new
posix_shell_argvparameter toTrue— opt-out: onlycallers known NOT to be POSIX-shaped had to say so. Counting actual call
sites, that polarity meant roughly nine unexamined production spawns —
in
cron_script.py(an MCP server spawn),mcp_gateway/resolve_once.py,five
dashboard/handlers/*.pymodules, andgit_coord.py— would havesilently started routing through the wsl2 guest launcher the moment an
operator selected it, each appending a Windows-shaped argv after a Linux
launcher expecting a POSIX command. This directly contradicted my own "scope
is deliberately narrow" claim earlier in this description, which the
reviewer caught as a real inconsistency between what I said and what the
code did.
Fixed by inverting the default to
False(opt-in): only the threegenuinely-POSIX call sites (
apps/lifecycle_scripts.py,cron_script.py'srun_command_sandboxed,hooks.py) passTrueexplicitly. Every othercaller — all nine named above, and any future one — now keeps its exact
pre-wsl2 behavior with zero code change of its own, which is what the scope
description always claimed and what the default now actually guarantees
rather than merely asserts. Locked with a new test that asserts the default
on the real function signatures (
wrap_argv,wrap_argv_async,sandboxed_spawn_argv,sandboxed_spawn_argv_async), not just the wsl2dispatch branch, so a future edit cannot silently flip it back. This also
surfaced two pre-existing tests (
test_cron_cancel.py,test_mcp_cron_security.py) whosewrap_argvmocks had a signature toonarrow for the new keyword argument — fixed alongside.
Tests
test/test_sandbox_wsl2.py(42 tests): config-reading fail-closedbehavior,
detect_backend's cache policy (positive/permanent/transient,mirroring the existing Linux probe's own pinned tests), the
never-block-on-loop discipline, the pure path-translation function, the
wrap_argvdispatch arm (extra_hidden_dirs rejection, failure→SandboxUnavailableErrorwrapping, no-cleanup-path honesty), the_no_backend_guidanceremedy selection, and the distro-picker's filteringand caching. Added in this revision, driving
wsl_namespace_argv()directly rather than through the
wrap_argv-level mock the tests aboveuse (that mock is exactly what would hide a regression in this function):
extra_hidden_dirsreaching_build_launcher_scriptwith theDrvFs-translated real
.aws/.sshpaths (this test caught a real gapduring development —
.sshisn't in the base sensitive-dir list, it ismasked by a separate
HIDE_SSHmechanism scoped to the guest home only,which needed its own explicit translation), the fail-closed path when
DrvFs can't be verified, the single-round-trip staging with its
noclobber guard, staged-path uniqueness across calls, and
wsl2_env_passthrough's no-op/merge/set behavior. No test depends on areal WSL2 host — every
wsl.exeboundary is mocked, matching how feat(desktop): add WSL2 runtime discovery with host runtime readout #6808'sown WSL2 discovery work is tested. Two rounds of real bugs in the tests
themselves, both caught by CI rather than locally: (1) the three new
tests above called the real
Path.home()unmocked to build their expectedmasking paths — on my Windows dev machine that happens to already be
drive-rooted, so they passed there, but the Linux CI runners this matrix
also uses resolve it to
/home/runner, whichwsl_namespace_argv()thencorrectly (fail-closed) refused to translate; (2) fixing that alone still
left
_relocated_policy_cache_dirs()and_voice_runtime_sandbox_paths()reading the REAL
config_dir()/KIROCREW_HOMEthis repo's own testharness pins to an isolated Linux tmp dir per test — inconsistent with the
now-mocked Windows home, so the "is this relocated from the default?"
check always answered yes and handed back an unmasked POSIX path. Both are
now explicitly neutralized in the test fixture rather than left to the
ambient environment, the same class of host-dependence bug as the
ntpathfix below, this time in test code rather than the function under test.
test/test_spawn_audit.py: oneBENIGN_SPAWNSentry for the newsandbox.py::_wsl_runchokepoint — the centralized point every WSL2helper's
wsl.exeinvocation routes through. Sandboxing it would becircular (it constructs the boundary an agent-influenced spawn is later
confined by), the same disposition already given to
ensure_agents_slice_limits.test/test_sandbox_wsl2.py: one more test forwrap_argv's newposix_shell_argvparameter — with itFalse,wsl_namespace_argvmustnever be called even when
detect_backendsays"wsl2", and the call mustfail closed exactly like Windows' pre-existing no-backend path.
test/test_cron_script.py(TestCommandCronShellResolution, 3 newtests): the wsl2 branch of
_resolve_command_shellactually calls_shell_is_posix_strict(not trusting/bin/shby name), rejects when theprobe fails, and — driving the real (unmocked)
_POSIX_STRICT_CACHErather than stubbing the probe away — that a cached verdict for one distro
is never consulted for a different one.
upstream/mainin a freshworktree:
265 passed, 74 skipped, 0 failed(skips are the existingLinux/macOS-only tests, expected on Windows) — re-run a second time after
upstream advanced one commit mid-review (
#7053, unrelated numeric configbounds) to confirm parity with its new
test_config_load_bounds_parity.pyratchet too.
flake8,isort, this fork's harness-parity gate: clean.tsc,eslint, and — for the distro-picker's new UI text —the full
npm run i18n:checkgate (all 19 checks pass) plus thecatalogParity.test.tssuite (77/77) confirming key parity across all 12shipped languages, including the regenerated
en-XApseudolocale. Thenon-English translations are a good-faith first pass (produced without a
native reviewer for several of the 10 languages); happy to have a
maintainer or community translator refine them — the catalog already
tracks this class of debt via its own "untranslated passthrough" metric,
so it is not a novel category of imperfection.
black(pinned26.3.1, matchingpyproject.toml/setup.cfgexactly):clean. This venv's Python 3.12.5 hits a documented upstream AST safety bug
that makes
blackrefuse to run at all (.fork/preflight.shitselfdetects and skips this case) — worked around by installing the exact
pinned version under a separate Python 3.10 interpreter instead of
guessing at output from a mismatched version.
mypy: now confirmed clean against the current commit(
.fork/preflight.sh lint,mypy --cache-dir ... src/kiro_crew/PASS) —the earlier I/O-bound timeouts on this host did not recur this round.
upstream/mainatb3c3151c3(from this PR's original base,122 commits behind) after CI on the prior push caught a transient upstream
inconsistency —
.github/agent-sdk-boundary-baseline.txtbrieflyrecorded a stale count for
src/kiro_crew/subagent.py— alreadyself-corrected upstream by the time of the rebase. Full suite re-run
post-rebase:
2627 passed, 183 skipped, 0 failed.upstream/mainat7eea2bea1(97 commitsfurther) after the PR sat long enough for
mergeableto flip toCONFLICTING, which was also the likely reason CI stopped triggering onthe prior commit entirely (zero check-runs registered, even while CI's own
queue was otherwise healthy). One real conflict, in
hooks.py: anunrelated upstream commit (fix(dashboard): preserve access-control xattrs across steering and file writes #6961, xattr access-control preservation) had
landed content adjacent to this PR's own hunk; the first conflict
resolution pass accidentally duplicated a block upstream had since
refactored into an
atomic_writeimport (caught byflake8F811, fixedby deferring to the import as upstream now does). Re-verified post-rebase:
.fork/preflight.sh fast/lint/surfaceall green for this PR's owndiff —
lint's one failure (Frontend Lint & Type Check, aTabsPropstype mismatch in
tabs.tsx/DiscoverPage.tsx/McpManagement.tsx/SystemPage.tsx) andsurface's 10 failures (9WinError 1314symlink-privilege gaps plus 1 already-cataloged timing flake) all land in
files this PR does not touch, confirmed pre-existing via
git diff upstream/main HEAD --stat -- <file>returning empty for each.The directly-touched files (
test_sandbox_wsl2.pyand the other fivemodified test modules):
321 passed, 6 skipped, 0 failed.around): (1) black had reflowed two
# fmt: skip-protectedos.chmod(...)lines onto three lines each, which silently broke thetrailing
# nosemgrepsuppression's same-line anchoring and produced twofresh Semgrep findings on lines this PR never touches — restored to the
original single-line form. (2)
_translate_windows_path_to_wsl2usedos.path.splitdrive, which isposixpath.splitdriveon a POSIX host andnever recognizes a drive letter — invisible on a Windows dev machine
(where
os.pathalready isntpath), but it rejected every realWindows path on Linux CI. Fixed by importing
ntpathexplicitly, sincethis function parses a Windows-style path string and its behavior must
not depend on the host running the test.
Backend Tests (namespace sandbox)failed one test —test/test_script_hooks.py::TestRunScriptHook::test_subprocess_routes_allowlist_through_safe_spawn_funnelmocks
sandboxed_spawn_argvwith a narrow(argv, *, env)signature; thisPR's own
hooks.pychange now calls it with an addedposix_shell_argvkeyword, which the mock didn't accept. The same class of fix already
applied to two other test files during earlier review; this one file was
missed. Fixed by widening the mock to
(argv, *, env, **kwargs).Manual verification
Live-tested end to end against a real WSL2/Ubuntu-26.04 host, including the
isolation itself, not just "the command ran":
wrap_argv(["/bin/bash", "-c", …], mode="strict", cwd=<a real Windows path>)→ executed inside the guest withuid=1000(sysop), correct$HOME, andpwdlanding exactly at the--cd-translated path.apps/lifecycle_scripts.run_lifecycle_script(the exact path a CrewManager-style app install failure hits) reproduced the original failure
first, then confirmed the fix: first on-loop attempt defers transiently
(background warm thread kicked, no block), second attempt ~20s later
succeeds with
INSTALL_SCRIPT_RAN, correct translatedcwd, correct uid.applied to a fresh
upstream/mainworktree (not just the fork'sdev-betabranch it was originally developed against) — same result, confirming the
ported diff and not just the original implementation.
A prior verification pass here planted markers only in the WSL2 guest's
own home — exactly the gap review caught. This pass built an isolated,
synthetic Windows-side home (never my real one), planted
.aws/credentialsand.ssh/id_rsamarkers there, pointedPath.home()at it (
USERPROFILE), and calledwsl_namespace_argv()for real againsta live distro. Both markers, read through their real DrvFs-translated
/mnt/...path from inside the sandboxed process, come backNo such file or directory; a control file outside any hidden dir, samedirectory, stayed readable. This is the actual leak path the reviewers
described, now closed and proven closed — not merely "the mechanism
looks right."
/bin/sh -c 'echo $KIROCREW_HOOK_EVENT $KIROCREW_HOOK_CONTEXT'spawn read bothvalues correctly with
WSLENVset to name them, and empty without it —confirming the forwarding is real, not just constructed correctly by
wsl2_env_passthrough's own (also unit-tested) string logic.Screenshots / video
The Settings > Developer > Config panel, with
agent.sandboxset towsl2(the option itself is live-advertised — only offered on Windows with a
working
wsl.exe— via the same schema endpointagent.acp_backendalreadyuses, not a hardcoded list). Selecting it reveals the new WSL2
Distribution row underneath, populated from this machine's real
wsl -l -voutput:Related Issues
Complements #6808 (does not duplicate — that PR is Electron-side discovery
only, and defers this exact gateway-side follow-up in its own scope note).
Discovered and filed #7332 (pre-existing, cross-platform gap in the shared
sandbox launcher's masked-dir list — see "Security findings from review"
above) while reviewing this PR; not fixed here, out of this PR's scope.
Checklist
feat: ...)