Skip to content

feat(windows): sandbox Windows subprocess spawns through WSL2 - #7140

Open
GoZippy wants to merge 4 commits into
kirodotdev:mainfrom
GoZippy:feat/windows-wsl2-sandbox
Open

feat(windows): sandbox Windows subprocess spawns through WSL2#7140
GoZippy wants to merge 4 commits into
kirodotdev:mainfrom
GoZippy:feat/windows-wsl2-sandbox

Conversation

@GoZippy

@GoZippy GoZippy commented Aug 30, 2026

Copy link
Copy Markdown
Collaborator

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-cli spawn (PR #5620's delegation). Concretely: app install/enable
scripts, script hooks, and command cron jobs either refuse outright with
SandboxUnavailableError, or (command cron jobs specifically) are refused
before wrap_argv is even reached, because Git-for-Windows's sh.exe is
bash 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 isolation
entirely 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 the
codebase 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 onEnable script
through 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 same
positive-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 -c argv benefit — app lifecycle scripts
(apps/lifecycle_scripts.py), command cron jobs (cron_script.py,
previously unsupported on Windows entirely — WSL2's /bin/sh is a real
POSIX shell, unlike Git-for-Windows's bash-flavored sh.exe, so this is a
genuine capability unlock, not just a re-route), and script hooks
(hooks.py, previously locked to cmd.exe syntax on Windows — with wsl2
selected they use the same /bin/sh -c form macOS/Linux already do, so a
POSIX-shell hook needs no rewrite). Single portable-binary invocations
(git, the AWS CLI, tectonic, Piper) are unaffected either way: no shell
is 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=1 is forced on every wsl.exe invocation. Verified live:
    without it, WSL emits UTF-16LE and a plain text=True subprocess capture
    either raises UnicodeEncodeError or silently mis-decodes — corrupting
    every string comparison downstream (distro listing, stderr
    classification).
  • Launcher staging is one wsl.exe call, writing to a Python-generated
    random name (secrets.token_hex(16)) under set -C (noclobber) rather
    than a name captured from the guest's own mktemp. An earlier two-call
    form (create, then a separate write+chmod) both hit a real WSL2 quirk —
    a command substitution combined with piped stdin in one wsl.exe -- sh -c
    invocation 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 -C also
    refuses the write outright if the target already exists, rather than
    silently overwriting a file a sibling pre-created.
  • uid/gid/home are resolved inside the guest, not read from the
    Windows-side builder process (which has no os.getuid() at all).
    _build_launcher_script gained an identity override for this — every
    real Linux caller leaves it None and sees no behavior change.
  • os.path.joinposixpath.join for the launcher script's
    home-relative path construction: identical behavior on real Linux
    (os.path is posixpath there), but load-bearing when the builder is
    Windows Python, which would otherwise embed a backslash into a script
    meant to run on a Linux filesystem.
  • A caller's working directory threads through as wsl.exe --cd, not
    the ordinary cwd= a caller passes to Popen — that sets wsl.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 (wslpath itself proved unreliable for this — verified
    live, 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.
  • Probing never blocks the gateway event loop. _probe_wsl2 mirrors
    _probe_unshare's exact discipline: on-loop calls defer to a background
    thread 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.
  • Failure is fail-closed throughout: an unreadable config never grants a
    looser backend than configured (mirrors _allow_unsandboxed_exec's own
    contract), and a probe or staging failure raises the same
    SandboxUnavailableError shape the existing no-backend path uses, with a
    remedy naming the specific fix (wsl --install, wsl --install -d <name>, or the shared AppArmor-class guidance when the guest's own kernel
    refuses unshare).

Dashboard: agent.sandbox's Settings dropdown and the PATCH /api/config/kirocrew allowlist now read live selectable values ("wsl2"
offered only when plausible — Windows, with wsl.exe present) instead of a
static list, following the exact pattern already established for
agent.acp_backend — the same file even names the reason: "three
independent 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_distro now has its own live
picker (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 are
minimal VMs purpose-built for the container runtime, not general-purpose,
and not guaranteed to ship unshare or 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 — LogonCommand is built for "a human
opens 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 also
Pro/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/.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 file security.py's _SENSITIVE_HOME_DIRS exists
specifically 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 computes
the 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_dirs mechanism; DrvFs
verification 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 mechanics
bullet above).

A genuinely blocking call on the dashboard's event loop.
wsl2_distro_choices() shelled out synchronously (subprocess.run, up to
30s) directly from GET /api/config/schema's handler — every Settings page
load 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 this
one 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
WSLENV names it — setting a var on the Windows-side env= a caller passes
to wsl.exe does not make it visible inside the guest. So
$KIROCREW_HOOK_EVENT/$KIROCREW_HOOK_CONTEXT and lifecycle scripts'
$NONINTERACTIVE/extra_env all read empty under wsl2 despite 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 must
supply 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.py has TWO wrap_argv call sites, and they are not
interchangeable once wsl2 exists:

  • The command-cron shell resolver trusted /bin/sh by name instead of
    probing it
    , unlike every other platform's candidate. The existing
    _shell_is_posix_strict probe (already sandbox-routed — it calls
    wrap_argv on the candidate itself) would have caught an unusual distro
    whose /bin/sh is secretly bash, transparently, with zero new WSL2-specific
    probe code — but the resolver never called it for this branch, just
    returned /bin/sh directly. Fixed by calling it, with a distro-qualified
    cache key
    (wsl2:<distro>:/bin/sh): /bin/sh names a different binary
    depending 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_distro changes.
  • run_script_sandboxed would have silently broken the moment wsl2 was
    selected.
    It builds a native-Windows argv ([sys.executable, launcher_path]) and passed it through wrap_argv with zero wsl2
    awareness — unlike the command-cron path above. Once an operator selected
    agent.sandbox: "wsl2", this currently-working Windows feature (script
    crons) 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_argv a new
    posix_shell_argv parameter (default True — unchanged for the three
    call sites that genuinely build POSIX argv); this call site passes
    False, so wrap_argv reports wsl2 unavailable for it and falls through
    to the exact fail-closed/sandbox_allow_unsandboxed_exec handling Windows
    already 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 on
wsl_namespace_argv — with wsl2 selected and posix_shell_argv=False it
is never called, and the call fails closed with the identical message
Windows gave for this case before wsl2 existed.

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/* governance
tree (security_policy.json, admission_policy.json, profiles/, and
more) 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_script mechanism native Linux uses, no
Windows/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_argv are shared chokepoints with
dozens of callers across the codebase, most of which build native-Windows
executable invocations (an MCP server spawn, an npm install, a terminal
command, a git call) rather than POSIX shell argv. My initial revision
defaulted the new posix_shell_argv parameter to True — opt-out: only
callers 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/*.py modules, and 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. 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 three
genuinely-POSIX call sites (apps/lifecycle_scripts.py, cron_script.py's
run_command_sandboxed, hooks.py) pass True explicitly. Every other
caller — 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 wsl2
dispatch 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) whose wrap_argv mocks had a signature too
narrow for the new keyword argument — fixed alongside.

Tests

  • test/test_sandbox_wsl2.py (42 tests): config-reading fail-closed
    behavior, 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_argv dispatch arm (extra_hidden_dirs rejection, failure→
    SandboxUnavailableError wrapping, no-cleanup-path honesty), the
    _no_backend_guidance remedy selection, and the distro-picker's filtering
    and caching. Added in this revision, driving wsl_namespace_argv()
    directly rather than through the wrap_argv-level mock the tests above
    use (that mock is exactly what would hide a regression in this function):
    extra_hidden_dirs reaching _build_launcher_script with the
    DrvFs-translated real .aws/.ssh paths (this test caught a real gap
    during development — .ssh isn't in the base sensitive-dir list, it is
    masked by a separate HIDE_SSH mechanism 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 a
    real WSL2 host — every wsl.exe boundary is mocked, matching how feat(desktop): add WSL2 runtime discovery with host runtime readout #6808's
    own 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 expected
    masking 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, which wsl_namespace_argv() then
    correctly (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_HOME this repo's own test
    harness 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 ntpath
    fix below, this time in test code rather than the function under test.
  • test/test_spawn_audit.py: one BENIGN_SPAWNS entry for the new
    sandbox.py::_wsl_run chokepoint — the centralized point every WSL2
    helper's wsl.exe invocation routes through. Sandboxing it would be
    circular (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 for wrap_argv's new
    posix_shell_argv parameter — with it False, wsl_namespace_argv must
    never be called even when detect_backend says "wsl2", and the call must
    fail closed exactly like Windows' pre-existing no-backend path.
  • test/test_cron_script.py (TestCommandCronShellResolution, 3 new
    tests): the wsl2 branch of _resolve_command_shell actually calls
    _shell_is_posix_strict (not trusting /bin/sh by name), rejects when the
    probe fails, and — driving the real (unmocked) _POSIX_STRICT_CACHE
    rather than stubbing the probe away — that a cached verdict for one distro
    is never consulted for a different one.
  • Full backend suite, this branch vs. current upstream/main in a fresh
    worktree: 265 passed, 74 skipped, 0 failed (skips are the existing
    Linux/macOS-only tests, expected on Windows) — re-run a second time after
    upstream advanced one commit mid-review (#7053, unrelated numeric config
    bounds) to confirm parity with its new test_config_load_bounds_parity.py
    ratchet too.
  • flake8, isort, this fork's harness-parity gate: clean.
  • Frontend: tsc, eslint, and — for the distro-picker's new UI text —
    the full npm run i18n:check gate (all 19 checks pass) plus the
    catalogParity.test.ts suite (77/77) confirming key parity across all 12
    shipped languages, including the regenerated en-XA pseudolocale. The
    non-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 (pinned 26.3.1, matching pyproject.toml/setup.cfg exactly):
    clean. This venv's Python 3.12.5 hits a documented upstream AST safety bug
    that makes black refuse to run at all (.fork/preflight.sh itself
    detects 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.
  • Rebased onto upstream/main at b3c3151c3 (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.txt briefly
    recorded a stale count for src/kiro_crew/subagent.py — already
    self-corrected upstream by the time of the rebase. Full suite re-run
    post-rebase: 2627 passed, 183 skipped, 0 failed.
  • Rebased a second time onto upstream/main at 7eea2bea1 (97 commits
    further) after the PR sat long enough for mergeable to flip to
    CONFLICTING, which was also the likely reason CI stopped triggering on
    the prior commit entirely (zero check-runs registered, even while CI's own
    queue was otherwise healthy). One real conflict, in hooks.py: an
    unrelated 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_write import (caught by flake8 F811, fixed
    by deferring to the import as upstream now does). Re-verified post-rebase:
    .fork/preflight.sh fast/lint/surface all green for this PR's own
    diff — lint's one failure (Frontend Lint & Type Check, a TabsProps
    type mismatch in tabs.tsx/DiscoverPage.tsx/McpManagement.tsx/
    SystemPage.tsx) and surface's 10 failures (9 WinError 1314
    symlink-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.py and the other five
    modified test modules): 321 passed, 6 skipped, 0 failed.
  • Two real bugs found from this PR's own CI (both fixed, not reformatted
    around): (1) black had reflowed two # fmt: skip-protected
    os.chmod(...) lines onto three lines each, which silently broke the
    trailing # nosemgrep suppression's same-line anchoring and produced two
    fresh Semgrep findings on lines this PR never touches — restored to the
    original single-line form. (2) _translate_windows_path_to_wsl2 used
    os.path.splitdrive, which is posixpath.splitdrive on a POSIX host and
    never recognizes a drive letter — invisible on a Windows dev machine
    (where os.path already is ntpath), but it rejected every real
    Windows path on Linux CI. Fixed by importing ntpath explicitly, since
    this function parses a Windows-style path string and its behavior must
    not depend on the host running the test.
  • A third bug, from upstream CI on the post-rebase push: Backend Tests (namespace sandbox) failed one test —
    test/test_script_hooks.py::TestRunScriptHook::test_subprocess_routes_allowlist_through_safe_spawn_funnel
    mocks sandboxed_spawn_argv with a narrow (argv, *, env) signature; this
    PR's own hooks.py change now calls it with an added posix_shell_argv
    keyword, 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 with uid=1000(sysop), correct
    $HOME, and pwd landing exactly at the --cd-translated path.
  • apps/lifecycle_scripts.run_lifecycle_script (the exact path a Crew
    Manager-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 translated cwd, correct uid.
  • Re-ran the identical live verification against this PR's actual diff
    applied to a fresh upstream/main worktree (not just the fork's dev-beta
    branch it was originally developed against) — same result, confirming the
    ported diff and not just the original implementation.
  • The credential-masking fix, tested against the correct threat model.
    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/credentials and .ssh/id_rsa markers there, pointed Path.home()
    at it (USERPROFILE), and called wsl_namespace_argv() for real against
    a live distro. Both markers, read through their real DrvFs-translated
    /mnt/... path from inside the sandboxed process, come back No such file or directory; a control file outside any hidden dir, same
    directory, stayed readable. This is the actual leak path the reviewers
    described, now closed and proven closed — not merely "the mechanism
    looks right."
  • WSLENV, same live-real-distro approach: a /bin/sh -c 'echo $KIROCREW_HOOK_EVENT $KIROCREW_HOOK_CONTEXT' spawn read both
    values correctly with WSLENV set 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.sandbox set to wsl2
(the option itself is live-advertised — only offered on Windows with a
working wsl.exe — via the same schema endpoint agent.acp_backend already
uses, not a hardcoded list). Selecting it reveals the new WSL2
Distribution
row underneath, populated from this machine's real wsl -l -v output:

Sandbox set to wsl2, with the WSL2 Distribution row visible

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

  • At most two commits (one is the norm), with a Conventional Commits title (feat: ...)
  • Existing tests pass and new tests added for new functionality
  • Self-review completed; code follows project style guidelines
  • No secrets, credentials, or internal references in the diff

@GoZippy
GoZippy requested a review from a team August 30, 2026 23:46
@GoZippy
GoZippy requested a review from a team as a code owner August 30, 2026 23:46
@GoZippy
GoZippy requested a review from krishdhasmana August 30, 2026 23:46
@github-actions github-actions Bot added fork Pull request from a fork (external contributor) readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 30, 2026
@GoZippy
GoZippy force-pushed the feat/windows-wsl2-sandbox branch from 126875a to 8496482 Compare August 31, 2026 06:13
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 31, 2026
@GoZippy
GoZippy force-pushed the feat/windows-wsl2-sandbox branch from 8496482 to b6e26a8 Compare August 31, 2026 06:14
@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 31, 2026
@GoZippy
GoZippy force-pushed the feat/windows-wsl2-sandbox branch from b6e26a8 to 9a883ac Compare August 31, 2026 07:17
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 31, 2026
@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

First Principles Review (Fable 5, fork) — 🟡 CONCERNS

Premise-level review of 69f04a4573a71d7a291ec66207b08b3412260d5b via the fork AI-review pipeline — why this exists and whether the shipped surface is the smallest honest version. Updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

All facts verified. Composing the review.

First-Principles-Verdict: CONCERNS

"App install/enable scripts" is the headline harm, but the onInstall script itself (registry.py:6897) never opts in and still fails closed under wsl2.

Not justified as shipped

  1. App-install coverage — point patch, 4 counted unfixed siblings: grepping /bin/(sh|bash)", "-c" in src/ finds 6 sites plus cron's dynamic one; only 3 pass posix_shell_argv=True. Left out: apps/registry.py:6897 (the manifest onInstall script, same set -euo pipefail shape and same wrap_argv_async chokepoint as the fixed lifecycle script), apps/registry.py:3346 and :6634 (detectInstalled), apps/routes.py:1810 (app open). The description says "app install/enable scripts … benefit"; an app shipping onInstall still dies at install before onEnable runs.

What this change ships

Intent: give Windows operators real OS-level isolation for POSIX-shell subprocess spawns by routing them through an opt-in WSL2 distribution — an ADDITION.

  1. Command cron jobs run on Windows for the first time, under wsl2 — justified
  2. App enable/disable/update scripts run sandboxed on Windows under wsl2 — point patch, onInstall sibling unfixed (above)
  3. Script hooks under wsl2 use POSIX /bin/sh instead of cmd.exe — justified
  4. New config key agent.sandbox_wsl_distro + conditional Settings picker (Docker Desktop instances filtered from offers only) — justified
  5. Sandbox dropdown values now host-derived; live-enum binding became a registry — justified
  6. posix_shell_argv opt-in flag on the four spawn chokepoints, default pinned by test — justified
  7. cwdwsl.exe --cd threading on wrap_argv — justified
  8. _build_launcher_script gains identity/extra_readonly_dirs/unlink_self seams — justified
  9. Operator's real Windows credentials and governance ceilings masked across DrvFs, fail-closed on root guest or unverifiable mount — justified
  10. Settings schema load dispatches enum suppliers off-loop (wsl.exe listing, 10s cache) — justified

(Docs, i18n catalogs, and the temp-screenshots/ capture follow documented repo conventions; not itemized.)

Watch

  • The 4 sibling call sites above, onInstall first: either they take posix_shell_argv=True (with cwd=str(app_source) for 6897) or the docs stop claiming install scripts benefit. Clears when: the sites opt in, or windows-install.md names install/detect/open as excluded.

Subtractions

  • Collapse the two config accessors: cron_script.py imports the private _operator_wants_wsl2 on the line after the public wsl2_selected, whose docstring says it exists to "avoid a private (underscore-prefixed) cross-module import" — one accessor returning the distro-or-None serves both callers (2 consumers: hooks.py, cron_script.py).

[FIRST-PRINCIPLES-REVIEWED] 69f04a4

@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

UX Review (Fable 5, fork) — 🟡 CONCERNS

UX-level review of 69f04a4573a71d7a291ec66207b08b3412260d5b via the fork AI-review pipeline — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

I have the full picture: a backend WSL2 sandbox feature whose UI surface is two Settings rows in KiroCrewCfgTab.tsx (a live-populated Sandbox dropdown gaining a wsl2 option with a new tooltip, and a conditional "WSL2 Distribution" picker), new strings in 13 locales, plus new user-facing failure texts. The one screenshot the PR adds is a binary marker in the patch — not materialized in this fork lane, and no blind read ran. Base-tree checks confirm CfgSelect's labels prop and useConfigSchema already exist, hints render as InfoTip tooltips matching sibling rows, and — notably — prewarm_backend() is Linux-only and untouched by the diff, so the first wsl2 spawn after every gateway start hits the deliberately-transient "probe deferred" failure (the PR's own verification shows the first attempt failing and succeeding ~20s later).

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

  • First wsl2 spawn after every gateway start fails, then works on retry: prewarm_backend() returns early unless sys.platform == "linux" (src/kiro_crew/sandbox.py:2999) and the diff never warms the wsl2 cache at boot, so the on-loop probe defers ("probe deferred to background thread… — retry", a cold round trip the PR measured at 7.5s) and the user's app install errors before succeeding ~20s later — the PR's own verification transcript shows exactly this. Every restart × the feature's flagship path (app install) = recurring task failure with retry recovery. Smallest fix: kick _kick_wsl2_background_warm from the boot prewarm when wsl2 is selected.
  • Nothing in the Settings UI says what choosing wsl2 does: the new tooltip ("'auto' enables sandbox for untrusted tools. Switching to or from 'wsl2' takes effect after a gateway restart.") explains only auto and restart timing, so a first-time operator sees a third dropdown value with no stated outcome. Add one clause, e.g. "'wsl2' runs sandboxed commands inside a WSL2 Linux distribution (Windows)."

Evidence gaps

  • Sandbox dropdown offering wsl2 + its new tooltip — the PR's settings-sandbox-wsl2.png exists only as a binary marker in the patch; push the branch to this repository so the screenshot materializes and the blind read can run.
  • "WSL2 Distribution" row (populated picker, "(WSL default)" option) — same missing artifact, same blind read.
  • The pre-selection state (sandbox ≠ wsl2, distro row hidden) appears in no screenshot even by claim; one capture of the default state would close it.

Suggestions

  • restart_required_which_wsl2_distribution_to_sand is good copy (front-loaded, pre-answers the Docker Desktop question) — mirror that structure when extending auto_enables_sandbox_for_untrusted_tools_switchi per the Watch item above, across all 13 locale files.

[UX-REVIEWED] 69f04a4

@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review (fork) — 🔴 changes requested (blocking)

Reviewed 69f04a4573a71d7a291ec66207b08b3412260d5b via the fork AI-review pipeline; updated in place on each push.

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
extra_readonly_dirs=guest_readonly_dirs
Absent computer_use.json -> WSL launcher skips the nonexistent mount target -> sandboxed payload creates it through DrvFs and enables computer use.
Anchor: residual/security
Fix: Materialize Windows-side sealable ceilings before building the launcher, failing closed on errors.

BLOCKING -- src/kiro_crew/sandbox.py:5185 -- writable staged launcher permits pre-sandbox replacement
f"(set -C; cat > {quoted_path}) && chmod 700 {quoted_path}"
Concurrent same-UID sandboxed process -> replaces the staged file before the second WSL invocation -> attacker code executes before isolation with DrvFs credentials accessible.
Anchor: residual/security
Fix: Execute the launcher without a guest-writable pathname or stage it somewhere inaccessible to sandboxed workloads.

[BLOCK-MERGE] 69f04a4
[GPT-REVIEWED] 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.

F1wsl_namespace_argv (patch lines 1435–1457) builds windows_readonly from _wsl2_windows_side_masking and hands it to _build_launcher_script as extra_readonly_dirs, but — unlike the native namespace_argv, which calls _materialize_sealable_ceilings() first (sandbox.py:4330, comment 4321–4329) precisely so an absent ceiling gets a bind target — it materializes nothing on the Windows side. The launcher's READONLY loop is os.path.exists-guarded (sandbox.py:3783), so an absent computer_use.json (the default state on a fresh install) gets no seal and stays writable through DrvFs at /mnt/c/.... A sandboxed workload writing it turns on desktop control for itself — the exact self-elevation the seal exists to close (sandbox.py:649). Harm rung: UNBOUNDED (governance-ceiling bypass); the triggering condition is the common default, not extreme.

F2 — the launcher is staged at {guest_home}/.kirocrew-sandbox-run/ (patch 1475–1497) in a first wsl.exe call, then exec'd in a separate later call (1508–1521). On native Linux the staged file lives under config_dir()/run (sandbox.py:4342), which every running sandbox seals READONLY via _voice_runtime_parent_paths(), so a same-UID sibling cannot replace it. The guest home's .kirocrew-sandbox-run/ is in no hidden/readonly list, so a concurrent same-UID sandboxed workload can rewrite the 0700 file it owns in the inter-call window; set -C guards only pre-creation, not post-creation replacement. Attacker code then runs before the namespace is established, with DrvFs credentials in reach. Harm rung: UNBOUNDED (pre-isolation code execution / credential exposure); a concurrent sandboxed workload is the norm this backend exists to confine, not an extreme condition.

Neither condition combination is extreme enough to justify a pre-drafted acceptance argument.

[ADJUDICATION] 69f04a4573a71d7a291ec66207b08b3412260d5b total=0 uphold=0 downgrade=0
[GPT-ADJUDICATED] 69f04a4573a71d7a291ec66207b08b3412260d5b
[ADJUDICATION-FENCED] 69f04a4573a71d7a291ec66207b08b3412260d5b fenced=2 flagged=0
UPHOLD-FENCED F1 src/kiro_crew/sandbox.py:5144 -- WSL2 path never materializes Windows-side ceilings, so an absent (default) computer_use.json stays writable through DrvFs and a sandboxed workload can enable computer use for itself.
UPHOLD-FENCED F2 src/kiro_crew/sandbox.py:5185 -- launcher staged in an unsealed guest dir a concurrent same-UID sandboxed sibling can rewrite between the stage and exec calls, running attacker code pre-isolation with DrvFs credentials reachable.
[GPT-ADJUDICATED-FENCED] 69f04a4573a71d7a291ec66207b08b3417b48b10

@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5, fork) — 🔴 BLOCK (blocking)

Design-level review of 69f04a4573a71d7a291ec66207b08b3412260d5b via the fork AI-review pipeline — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

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 (src/kiro_crew/sandbox.py:337-340: <config_dir>/run is sealed READ-ONLY inside every sandbox) versus what the wsl2 path does with its guest-side staging directory.

Design-Verdict: BLOCK

The guest staging dir ~/.kirocrew-sandbox-run/ is writable from inside the sandbox, so a confined workload can hijack a concurrent spawn's launcher and run unconfined.

Blockers

Sandboxed sibling can rewrite a staged launcher before it executes, escaping to unconfined DrvFs access.
On native Linux the staging dir is sealed read-only inside every sandbox (sandbox.py:337-339: "Already sealed READ-ONLY through _voice_runtime_parent_paths()"), but wsl_namespace_argv skips that resolution when identity is set and its extra_readonly_dirs contains only Windows-translated paths — the guest's ~/.kirocrew-sandbox-run/ gets no disposition at all. A concurrently sandboxed cron/hook/app script (same guest uid, dir mode 700 = owner-writable) can poll the dir and rewrite a freshly staged kirocrew_sandbox_*.py (noclobber blocks pre-creation, not post-write modification of an existing owner-owned file) before the victim's python3 loads it; the payload then runs before unshare(), mask-free, with DrvFs write access to the real .aws/.ssh and the governance keystone — breaching the "agent can never write its own ceiling" invariant this PR's own credential-masking fix exists to protect. The description's residual-risk claim — "the same [window] the native launcher under config_dir()/run has: a same-UID sibling that can write there is already the operator" — is a false equivalence: on native, in-sandbox siblings cannot write that dir; here they can.
Fix: seal the guest staging dir read-only in every wsl2 launcher's namespace (one entry through the extra_readonly_dirs seam this PR already adds; the Windows-side spawner writes it from outside any sandbox, so staging is unaffected), and pin it with a test.
Clears when: the wsl2 launcher's readonly set demonstrably covers ~/.kirocrew-sandbox-run (test asserting an in-sandbox write to a staged sibling is refused), or evidence the in-sandbox write is already impossible.

Watch

temp-screenshots/windows-wsl2-sandbox/settings-sandbox-wsl2.png is committed into the repo — PR-description imagery, not product content, and it will live in history permanently.
Clears when: the binary is dropped from the commit and the description links it from the fork only.

[DESIGN-REVIEWED] 69f04a4

@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Opus 4.8 Review (fork) — ✅ no blocking findings

Reviewed 69f04a4573a71d7a291ec66207b08b3412260d5b via the fork AI-review pipeline; updated in place on each push.

Review details

Only Candidate 1 survives falsification. Candidate 2 requires a non-drive-rooted cwd reaching the --cd line, but the only caller passing cwd (lifecycle_scripts.py) derives app_root from the data home — if that home is UNC, _relocated_crew_targets produces UNC masking paths that raise inside the guarded translation and fail closed before --cd is reached; no practiced input reaches the unguarded line. Candidate 3 depends on a multi-drive layout plus a non-default automount root, which the candidate itself concedes is not WSL's default behavior — speculative, dropped.

Candidate 1 is confirmed: _resolve_command_shell() under wsl2 calls _shell_is_posix_strict("/bin/sh", cache_key=...), whose probe calls wrap_argv([shell, "-c", "echo x.{a,a}"], mode="strict") with posix_shell_argv left at its new default False; wrap_argv then forces backend = "none", the probe raises/ENOENTs and is caught → False, _resolve_command_shell() returns None. This is a feature-not-working defect on a path the PR adds, not a security hole/crash/data-loss, so it is advisory.

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 wrap_argv([shell, "-c", "echo x.{a,a}"], mode="strict") without posix_shell_argv=True, so under an operator's working agent.sandbox: "wsl2" the if backend == "wsl2" and not posix_shell_argv: backend = "none" gate forces the probe to the no-backend path; it returns False, _resolve_command_shell() returns None, and command cron jobs stay refused with "No POSIX shell available" despite a working distro (the two new tests miss it — one mocks _shell_is_posix_strict, the other stubs wrap_argv to a passthrough) → Fix: pass posix_shell_argv=True in the probe's wrap_argv call, since [shell, "-c", …] is genuine POSIX sh -c argv the wsl2 backend must confine.

[OPUS-REVIEWED] 69f04a4

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running and removed readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention labels Aug 31, 2026
@GoZippy
GoZippy force-pushed the feat/windows-wsl2-sandbox branch from 0d949db to d066988 Compare August 31, 2026 09:34
@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 31, 2026
@GoZippy
GoZippy force-pushed the feat/windows-wsl2-sandbox branch from d066988 to f8a2ab7 Compare August 31, 2026 10:19
@github-actions github-actions Bot removed the readiness: action required A blocking check or review needs attention label Aug 31, 2026
@github-actions github-actions Bot added the readiness: action required A blocking check or review needs attention label Sep 2, 2026
@bolichen97

Copy link
Copy Markdown
Collaborator

Audit note — part of this has already landed; the rest has not

This PR is not a duplicate and is not finished by anything on main. The audit checked it part by part against main, and some of what it does is already there. Flagging it so a reviewer does not have to rediscover the overlap, and so the PR is not mistaken for fully-covered work.

Already landed

Confirmed merged (the issue/PR reference check + the landed-commit index for main): #7439 -> PR, merged 2026-09-01T19:11:43Z, main sha c8d4c02 ("fix(sandbox): mask the crew governance tree at the OS gate"); #6808 -> PR, merged 2026-09-01T00:39:08Z, main sha 1d37b42 ("feat(desktop): add WSL2 runtime discovery with host runtime readout"); #5620 -> PR, merged 2026-08-24T16:27:51Z, main sha c7a6828 ("fix(sandbox): delegate Windows Kiro spawns internally"); #6961 (merged 2026-08-31) and #7053 (merged 2026-08-30) cited only as rebase context, no coverage claim. NONE of these three merged PRs covers any part of this PR: git show c8d4c02cb | grep -ci wsl = 0 (zero WSL2 content anywhere in #7439), and #6808 touches only website/electron/* + website/src/pages/system/* + the i18n locale files (its three keys live under pages.servicesTab, this PR's three under pages.overview.kiroCrewCfgTab, zero key collision). the initial scan's ONLY coverage claim -- #7439 at coverage=PARTIAL -- collapses on inspection: #7439 fixes the SEPARATE cross-platform keystone-masking gap this PR explicitly deferred to issue #7332 and did NOT implement, so it lands work adjacent to the PR rather than any of the PR's own parts. The remaining the initial scan relations cannot cover anything by construction and I confirmed each label: #7414 -> PR, state OPEN; #6663 -> PR, state OPEN; #7670 -> PR, state OPEN; #6307 -> PR, state OPEN; #7332 -> ISSUE, closed (an issue, no code). the initial scan did label these correctly as OPEN_PR/ISSUE and gave them coverage NONE, so no the initial scan "merged" claim turned out to be an open PR here.

Which parts main already has

Exactly one part, and it is mechanical, not behavioural: the PR's .github/black-baseline.txt removal of src/kiro_crew/hooks.py plus the ~10 black-reformat hunks in src/kiro_crew/hooks.py that accompany it. On origin/main .github/black-baseline.txt no longer lists src/kiro_crew/hooks.py (removed by earlier unrelated commits babbbcb / 5098a17), and the file is already formatted exactly as this PR reformats it -- verified line by line: hooks.py:232 _BUNDLED_AUTO_APPROVE_TOOLS: list[str] = [], hooks.py:1415 _BUILTIN_APP_MCP_SERVERS = frozenset(n.casefold() for n in names if isinstance(n, str) and n), hooks.py:3643 await asyncio.gather(stdin_task, stdout_task, stderr_task, return_exceptions=True). The sibling half of the same baseline hunk is NOT landed: src/kiro_crew/cron_script.py is still on line 196 of main's baseline and still unformatted there (cron_script.py:696 script_path[func_colon + 1:], the un-split logger.error("kill guard: ...") at 126-127).

What is still genuinely yours

Every behaviour, config, UI, test and doc part. (1) agent.sandbox enum gaining "wsl2": main's AgentConfig field lives in src/kiro_crew/config/sections.py:610 with enum=["auto", "off"]; config-baseline.json's agent.sandbox entry still enumValues ['auto','off']. (2) New config key agent.sandbox_wsl_distro: zero hits in src/test/website; absent from config-baseline.json's agent defaults and entries. (3) sandbox.py config readers _operator_wants_wsl2 / wsl2_selected / wsl2_env_passthrough: 0 hits on main. (4) The WSL2 probe (_probe_wsl2, _probe_wsl2_once, _kick_wsl2_background_warm, reset_wsl2_backend, _last_wsl2_failure, _wsl2_backend_ok): 0 hits. (5) _wsl_env / _wsl_run chokepoint and the forced WSL_UTF8=1: the only WSL_UTF8 on main is website/electron/wsl-detection.js + its test (#6808's JS side), never in Python. (6) Distro listing/picker _list_wsl2_distros / wsl2_distro_choices / _WSL2_UTILITY_DISTRO_PREFIXES: 0 hits. (7) _resolve_wsl2_identity / _verify_wsl2_drvfs_mount / _translate_windows_path_to_wsl2 (the ntpath-based translator): 0 hits. (8) wsl_namespace_argv() itself, including the extra_hidden_dirs DrvFs masking of the operator's REAL Windows .aws/.ssh and the single-round-trip set -C staging: 0 hits. (9) _sensitive_dir_names() factored out of _build_launcher_script: 0 hits -- main still has the inline if/elif at sandbox.py:2386-2390. (10) _build_launcher_script's identity parameter and the os.path->posixpath.join conversion: main's signature (sandbox.py:2356) has only sandbox_level/strip_python_env/extra_hidden_dirs/extra_visible_dirs, and posixpath does not appear in sandbox.py at all. (11) cwd + posix_shell_argv on wrap_argv / wrap_argv_async / sandboxed_spawn_argv / sandboxed_spawn_argv_async: main's def wrap_argv( ends at first_party_fixed_argv; posix_shell_argv is 0 hits repo-wide. (12) detect_backend's win32/wsl2 branch and the wsl2 arms of unavailable_remedy / unavailable_reason / unavailable_kind / _no_backend_guidance / reset_backend: main's detect_backend goes straight from the off-mode short-circuit to userns_available() / _probe_sandbox_exec() with no win32 branch. (13) apps/lifecycle_scripts.py cwd + posix_shell_argv=True + wsl2_env_passthrough: absent. (14) cron_script.py's wsl2 branch in _resolve_command_shell (main:954-955 is still a bare return None on Windows), _shell_is_posix_strict's cache_key parameter and distro-qualified _POSIX_STRICT_CACHE key, and posix_shell_argv on both wrap_argv call sites: all absent. (15) hooks.py's wsl2_env_passthrough, the IS_WINDOWS and not wsl2_selected() argv branch (main:3782 is still bare if platform_compat.IS_WINDOWS:), and posix_shell_argv=True: absent. (16) dashboard/handlers/agents.py's _LIVE_ENUM_SUPPLIERS map and async _supply_live_enum: main:1279 is still def _supply_live_enum(entry: dict) -> None: (sync, single-path), called synchronously at 1318. (17) dashboard/handlers/core.py's _selectable_sandbox_values and the values_fn + agent.sandbox_wsl_distro rows: main:1617 is still "agent.sandbox": {"type": "enum", "values": ["auto", "off"]}. (18) KiroCrewCfgTab.tsx: main:282 still hardcodes options={['auto', 'off']}, with no useConfigSchema import and no WSL2 Distribution row. (19) The three i18n keys x 13 locale files (wsl2_distribution, wsl_default_distro, immediate_which_wsl2_distribution_to_sandbox_in): 0 hits in main's en.json. (20) docs/guides/windows-install.md's whole agent.sandbox: "wsl2" section and its three rewritten per-feature table rows: 0 case-insensitive 'wsl2' hits in that file on main. (21) docs/system-specs/modules/security.md's Windows-via-WSL2 bullet block and the #5620 paragraph amendment: absent. (22) Tests: test/test_sandbox_wsl2.py does not exist on main (644 lines, ~42 tests); test_spawn_audit.py has no sandbox.py::_wsl_run BENIGN_SPAWNS entry; test_cron_script.py has none of the three TestCommandCronShellResolution wsl2 tests; test_agent_backend_editable.py's three tests are still sync (consistent with the sync _supply_live_enum); the widened mock signatures in test_cron_cancel.py / test_mcp_cron_security.py / test_script_hooks.py are absent. (23) temp-screenshots/windows-wsl2-sandbox/settings-sandbox-wsl2.png: absent (and is scratch that should not land). No land-and-revert either: git log origin/main -S<token> for wsl_namespace_argv, posix_shell_argv, sandbox_wsl_distro and wsl2_selected over src+test returns nothing, and the only WSL2-subject commit on main is #6808 (1d37b42).

Suggested action: REBASE — the remainder is real work; rebase onto the landed part rather than closing.


From a repository-wide duplicate/overlap audit of every pull request open against main (2026-09-02, 330 PRs, one reviewer per PR). Each PR was read as its full merge-base diff plus its description and every comment and review, then compared against each candidate PR's own diff and against origin/main at 1a765b88ceb7. This PR is not being closed — the note is informational. If the reading is wrong, please correct the reasoning rather than just the conclusion.

@GoZippy
GoZippy force-pushed the feat/windows-wsl2-sandbox branch from 813bc36 to 79b6993 Compare September 2, 2026 20:53
@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention merge conflict Branch has merge conflicts with its base — author must resolve before merge and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Sep 2, 2026
@bolichen97

Copy link
Copy Markdown
Collaborator

Open PR relationship audit

This 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

  • PR #6230 is OVERLAPPING relative to this PR. The goals differ or the implementations can complement each other; this is not a duplicate claim. Recommended action for PR #6230: REBASE. Both should land. Whichever merges second should carry WSLENV forwarding (or DrvFs translation) for the managed bundle variables so the Windows corporate-proxy fix survives under agent.sandbox: 'wsl2'. Files: src/kiro_crew/sandbox.py, src/kiro_crew/hooks.py.
  • PR #7414 is OVERLAPPING relative to this PR. The goals differ or the implementations can complement each other; this is not a duplicate claim. Recommended action for PR #7414: KEEP. Complementary goals, but they cannot land independently: whichever merges second must rebase, and the reconciliation is not mechanical — _BRACE_OFF_SHELLS must adopt PR #7140's cache key rather than the bare shell string, or the brace-off form recorded for a WSL2 guest shell will be applied to the native shell. Worth an explicit merge-order decision between the two authors before either lands. Files: src/kiro_crew/cron_script.py.
  • PR #7670 is OVERLAPPING relative to this PR. The goals differ or the implementations can complement each other; this is not a duplicate claim. Recommended action for PR #7670: KEEP. Large independent feature touching the same two cron spawn lines; a sequencing conflict only, already cross-referenced by its own author. Files: src/kiro_crew/cron_script.py, src/kiro_crew/sandbox.py.
  • PR #7963 is OVERLAPPING relative to this PR. The goals differ or the implementations can complement each other; this is not a duplicate claim. Recommended action for PR #7963: MERGE_DISCUSSION. Whichever lands second must extend credential_mask_applies to cover the other's new wrap_argv outcomes, or 7963's compensating control silently stops applying on the newly reachable configurations. Files: src/kiro_crew/sandbox.py. The two independent directions used different labels; the matrix conservatively retains OVERLAPPING for coordination.

No PR, Issue, label, branch, or review state was changed by the relationship-note portion of this audit.

@GoZippy

GoZippy commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator Author

@bolichen97 — both required changes landed in 79b699302, pushed about ten hours after your review, so the CHANGES_REQUESTED state predates them. Re-review would be appreciated.

1. The guest launcher now pins -I -S, sourced from the constant rather than re-spelledsrc/kiro_crew/sandbox.py:3897:

wrapped += ["--", "python3", *_LAUNCHER_INTERPRETER_FLAGS, staged_path, *argv]

The comment above it records the reason in your terms: without -S, a same-UID workload's usercustomize.py under the guest's own site-packages runs before unshare() — unconfined, with DrvFs in reach. The flags precede the script exactly as namespace_argv places them, so _launcher_script_of reads the same slot on both backends and the Description's "reused unmodified" claim now holds for the invocation too, not just the script text.

2. The Script-cron-jobs row now states the opposite of what it claimeddocs/guides/windows-install.md:356 no longer offers agent.sandbox: "wsl2" as a remedy. It reads:

agent.sandbox: "wsl2" does not cover them: a script cron is a native-Windows Python invocation, not a POSIX shell command, so the wsl2 backend reports itself unavailable for it and the job stays unsandboxed exactly as it was before wsl2 existed.

That matches cron_script.py's posix_shell_argv=False and wrap_argv's requirement, and the misleading "real isolation instead of none" reading is gone.


One further commit, unrelated to your review (1a19e9c90): the PR was red on Fork workflow-change guard for a reason that had nothing to do with wsl2. Running black over cron_script.py made it black-clean, which graduated it from .github/black-baseline.txt; check_black_formatting.py hard-fails on a graduated entry, so the line had to be pruned — but pruning touches .github/**, which the fork guard blocks outright. The two gates are individually right and jointly unsatisfiable from a fork.

Resolved the way AGENTS.md already prescribes (formatting a baselined file is optional and belongs in its own commit): the four formatting-only hunks black introduced were reverted — a logger.error reflow, an argument split, a boolean-or join, and slice spacing. The file is non-black-clean again, so it stays baselined, the baseline line is restored, and the diff against .github/ is empty. The guard now reports SKIPPED. Every functional hunk is untouched: the wsl2 imports, posix_shell_argv, and the guest /bin/sh resolution with its distro-qualified probe cache.

Verified locally before pushing: black gate passes, .github/ diff empty, test/test_cron_script.py 125 passed / 3 skipped.

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.

@GoZippy
GoZippy force-pushed the feat/windows-wsl2-sandbox branch from 1a19e9c to 18b35a6 Compare September 7, 2026 01:12
@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running and removed readiness: checking Automated validation is still running merge conflict Branch has merge conflicts with its base — author must resolve before merge readiness: action required A blocking check or review needs attention labels Sep 7, 2026
@bolichen97

Copy link
Copy Markdown
Collaborator

@GoZippy Heads-up on an overlap with the newer #7669 (@rnoack1), which rewrites the same function this PR edits.

The shared file is src/kiro_crew/hooks.py, and the shared function is run_script_hook. This PR touches its spawn region in three hunks so Windows spawns route through WSL2. #7669 rewrites that function wholesale: it pre-binds proc before the try, adds an asyncio.CancelledError arm that reaps and audits, inlines _audit_hook_invocation_now on every terminal path, and refuses the run with exit 2 when the audit singleton cannot be warmed (sel_is_warm / warm_sel_singleton). A textual conflict in the spawn body is near certain, and two of its behaviours matter beyond the text. Its TestProcIsNeverUnbound pins exactly two if proc is not None and proc.returncode is None: reap sites and leaves no unguarded reap, so a wsl2 spawn that reintroduces one fails that test rather than only conflicting. Its cold-audit refusal sits above the spawn, so a wsl2 spawn path has to sit below that refusal.

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 mergeable_state dirty, so the rebase is not extra cost.

Posted from the 2026-09-08 open-PR relationship audit (read-only, one auditor per PR); reply here if any of this is wrong.

Antigravity AI and others added 4 commits September 8, 2026 16:45
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
@bolichen97

Copy link
Copy Markdown
Collaborator

Rebased onto main acc99f21 by a maintainer as part of the 2026-09-08 open-PR audit (was 99 commits behind).

Conflicts resolved:

  • src/kiro_crew/sandbox.py (_build_launcher_script): kept your posixpath.join + identity is None gate, and kept main's new _pod_os_home_targets() extend (fix(pod): scope pod kiro-cli oauth grants to the pod home #8528) inside that same gate, since it is a builder-host resolution like its three siblings.
  • src/kiro_crew/sandbox.py: union-merged three kwarg lists where main's is_kiro_cli met your posix_shell_argv.
  • docs/system-specs/modules/security.md: took main's delegation paragraph (main reverted fix(security): sandbox verify + UI-stream timeout + mem-info gate #8117, so kiro_internal_sandbox_enabled() is gone) and re-applied only your agent.sandbox: "wsl2" clause.
  • Dropped commit 0715c0be ("keep cron_script.py baselined"): main has since removed cron_script.py from .github/black-baseline.txt itself, so its four de-formatting hunks are now a ratchet regression and the diff against .github/** is empty either way. That was the sole cause of the fork workflow-change guard block.

Gates run locally on changed files: black, isort, flake8, tsc --noEmit, and pytest over the touched test files plus the pod-mask suites (966 passed). Pre-existing, not caused by the rebase: 5 failures in test/test_sandbox_wsl2.py around wrap_argv's win32 arm fail identically on your old head be6bed4e on a Linux host.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fork Pull request from a fork (external contributor) merge conflict Branch has merge conflicts with its base — author must resolve before merge readiness: action required A blocking check or review needs attention

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants