feat(windows): answer the provider-CLI trust question from the ACL - #4613
feat(windows): answer the provider-CLI trust question from the ACL#4613chenmingwei23 wants to merge 1 commit into
Conversation
62f6635 to
ef63caa
Compare
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS Read-only ACL facts separated from a fail-closed policy is the right shape; every unknown (unreadable descriptor, alien ACE type, remote volume, elevation) refuses rather than trusts. [DESIGN-REVIEWED] 16290a1 |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of Reading the repo around the change to verify counts before writing the review: I checked consumers of First-Principles-Verdict: CONCERNS Every item traces to the reported defect, but the strict-UTF-8 fix leaves its own named corruption alive in a sibling gh runner, and one dead constant ships. What this change shipsIntent: let a Windows user with a working, authenticated
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 16290a1 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsI've analyzed all three candidates against the diff and surrounding code. Candidate 1 ( Candidate 2 ( Candidate 3 (dropping No self-originated finding rises to the 80+ bar: the ACL parse fails closed on NULL DACL, unparsable ACE types, unreadable descriptors, and remote volumes; the No findings. [OPUS-REVIEWED] 16290a1 Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
ef63caa to
ef0b799
Compare
GPT 5.6 disposition —
|
Design Review disposition —
|
First Principles disposition —
|
Design Review disposition — correction to my previous replyI answered the CI-coverage concern as
Withdrawing the question I put to the maintainer in the previous comment — it does |
ef0b799 to
e6ae3c6
Compare
CI disposition —
|
GPT 5.6 disposition —
|
1d8f7d6 to
2ed21d0
Compare
GPT 5.6 disposition —
|
Design Review disposition —
|
2ed21d0 to
ab966cc
Compare
GPT 5.6 disposition —
|
| call | result |
|---|---|
which("gh.exe", path=requested) |
.\gh.exe — attacker CWD |
which(join(requested, "gh.exe")) |
requested dir — CWD avoided |
which(join(requested, "gh")) |
None — PATHEXT lost |
which("gh", path=requested) |
.\gh.EXE — CWD, but PATHEXT applied |
os.path.join puts a dirname on cmd, which sends which down its
early-return branch and past the PATHEXT loop, so a bare gh stops resolving
to gh.exe. Taking that fix as written would trade a security bug for the
functional regression that this PR exists to repair — the hand-rolled scan it
replaced is the thing that "found nothing at all on Windows".
So which keeps doing the PATHEXT work with path=directory, and the hit is
then required to lie inside the directory that was asked for. The check uses
abspath, deliberately not resolve: the only question here is whether the hit
came from the requested directory, while where a symlink ultimately points
belongs to the trust walk, which resolves and re-checks every component under
its own policy. Three tests pin it — an outside hit is dropped, an inside hit is
still kept (so the guard cannot pass by refusing everything), and the literal
.\gh shape a real CWD hit returns is normalised before comparing.
Those tests live in test/test_windows_acl.py, not next to the code in
test/test_github_runner.py, and that placement is the point: the latter is
skipped wholesale on Windows, so anything put there cannot be run on the only
platform whose behaviour this is. They stub which rather than reading the host
platform, so the Linux shards exercise the Windows hazard too.
CI: TestRunGh — 5 failures, AttributeError: 'str' object has no attribute 'decode'
fixed — mine, and the interesting part is why a green local run missed it.
Last round moved both spawn chokepoints to capture bytes; test_github_runner.py's
_proc helper still built CompletedProcess(stdout=""). That file carries a
module-level skipif(sys.platform == "win32"), so the five tests covering the
exact boundary I changed did not execute in my local run. The suite reported
419 passed, 70 skipped and I read the first number; the skip count had risen
from 64, which was the signal sitting in plain sight.
Verified this time rather than reasoned about: I ran that file locally with the
platform skip stripped, and all 10 TestRunGh tests pass on the fix
(10 passed). Running the wider set of gh/glab-touching suites the same way
surfaces 7 further failures, all of which are POSIX-only assertions reacting to
a Windows host — SYSTEMROOT in the safe-env allowlist, and tests that assume
no real gh is installed — not regressions; CI reported zero failures in those
files, which is the check that separates the two.
Coverage Gate was downstream of these five and needs no separate fix.
Local gates on ab966ccc8 (rebased onto 69b60c3b6): black baseline gate pass,
isort / flake8 pass on the full CI scope, mypy --platform linux src/kiro_crew/ → Success: no issues found in 1004 source files, 422 passed /
70 skipped / 0 failed, plus the skip-stripped run above.
ab966cc to
9815ba0
Compare
GPT 5.6 disposition —
|
github_runner.validate_provider_executable is the single trust gate for every gh / glab spawn. Its policy was expressed in POSIX ownership terms, so Windows was refused at four call sites and Issue Radar and Code Review Sage could not run there at all. The refusal was correct rather than lazy. On Windows os.stat reports st_uid == 0 and st_mode == 0o777 for every path, so the ownership half of the predicate always passed and the permission half always refused, both independent of the file being examined. A check that cannot see the ACL has not cleared anything, and shipping one that reads like protection is worse than refusing. Answer the two questions the policy actually asks -- is this owned by a third account, and can anything outside the trusted set replace it -- from the object's security descriptor instead. The new windows_acl module reads the owner SID and the principals holding a substitution-capable right through ctypes, and holds no policy of its own so the decision stays testable against synthetic ACLs. No new dependency: pywin32 would be one for five calls the standard library already makes. The POSIX predicate is deliberately NOT ported bit-for-bit. Mask bits 0x2 and 0x4 mean WRITE_DATA / APPEND_DATA on a file but ADD_FILE / ADD_SUBDIRECTORY on a directory, and neither directory right can touch an existing entry. The stock C:\ ACL grants Authenticated Users ADD_SUBDIRECTORY, so reading those bits as write grants would refuse every default Windows install once the walk reaches the drive root. Replacing an entry needs FILE_DELETE_CHILD on the parent, or DELETE / WRITE_DAC / WRITE_OWNER on the entry itself. Candidate discovery could never have found a Windows gh either: os.path.join(dir, "gh") does not match gh.exe. PATHEXT is now consulted, and the Program Files install dirs lead the ambient PATH so a machine-wide install still wins over a user-writable shim. Fail-closed throughout: an unreadable descriptor, a NULL DACL, and an ACE type this policy cannot parse are each a refusal rather than a silent pass. An elevated gateway is refused for the same reason a root one is, since its children would be elevated too and the walk would go vacuous. Two stale test gates are corrected in place. Neither suite is unskipped here -- both assert POSIX fixtures and messages, which is separate work -- but their stated reason no longer claims the app is POSIX-only, because it is not. Verified on Windows 11 (AMD64, non-elevated): resolve_gh resolves the stock install, both apps' gh_bin succeed, and a real gh api user call completes through the hardened runner. A synthetic Everyone:(OI)(CI)F tree is refused and names both the file and its parent. Not covered by host state and left to synthetic fixtures: winget and scoop shim layouts, GPO-altered Program Files ACLs, UNC paths, ARM64. Refs #4604
9815ba0 to
16290a1
Compare
CI disposition —
|
Closes #4604.
Problem / Motivation
github_runner.validate_provider_executableis the single trust gate for everygh/glabspawn. Its policy was written in POSIX ownership terms, so Windowswas refused at four call sites and Issue Radar could not be used at all there,
with
ghinstalled and authenticated.The refusal was the right call at the time, not laziness. Measured on Windows 11:
Both halves of the predicate degenerate, in opposite directions:
st_uidisalways
0, whichst_uid not in (0, uid)whitelists as root-owned, so theownership half always passes;
st_modeis always0o777, so the permissionhalf always refuses, flagging every file on the host as world-writable.
Neither result depends on the file examined.
Why it matters
A Windows user with a working, authenticated
ghwas told the feature does notexist on their platform. The only documented remedy was to run the whole gateway
under WSL, which means a second install with its own data home and its own
ghlogin.
What changed (motivation → approach → change)
Answer the two questions the policy actually asks — is this component owned by a
third account, and can anything outside the trusted set replace it — from the
object's security descriptor.
The new
windows_aclmodule reads the owner SID and the principals holding asubstitution-capable right, and deliberately holds no policy: keeping the
read separate from the decision is what lets the policy be tested against
synthetic ACLs on the Ubuntu runner rather than only on a Windows one. The
trusted set is
{gateway user, S-1-5-18, S-1-5-32-544, TrustedInstaller}— thedirect analog of POSIX
(0, uid).Both token reads live in
platform_compat, not in the new module, because italready owns "read this process's own access token" for the codebase:
current_user_sid(memoised, already the single source of "who is the owner" forthe gateway's pipe DACLs) and a new
is_token_elevated. Both are tri-state andboth non-
Trueanswers refuse — an unreadable token is not a not-elevatedtoken, and an unverifiable SID is not a trusted one.
windows_aclkeeps only theACL-read surface (
GetNamedSecurityInfoW,GetAce,ConvertSidToStringSidW,LookupAccountSidW), so no prototype is declared twice.No new dependency.
ctypescovers every call;pywin32would have been afirst platform-conditional dependency for nothing.
The part worth reviewing closely
The POSIX predicate is not ported bit-for-bit, on purpose:
POSIX collapses every directory mutation into one
wbit, so "the parent iswritable" genuinely implies "the entry can be swapped" — unlink and rename come
with it. Windows decomposes them, and neither directory right can touch an
existing entry. Replacing one needs
FILE_DELETE_CHILDon the parent, orDELETE/WRITE_DAC/WRITE_OWNERon the entry itself.This is load-bearing, not pedantic. The stock
C:\ACL grantsNT AUTHORITY\Authenticated UserstheADD_SUBDIRECTORYright, so a predicatethat reads that bit as a write grant refuses every default Windows install
once the walk reaches the drive root. The first draft of this change did exactly
that.
test_windows_acl.pypins the distinction from both sides: the right isignored, the principal is not (
Authenticated UsersholdingFILE_DELETE_CHILDis still refused).
Strict mode and Windows path casing
validate_provider_executable's strict mode refuses a path that differs from itsown resolution, on the grounds that it is not canonical. On Windows that test
needed to become case-insensitive, and this is a behaviour change worth stating
rather than leaving in a code comment: paths there are case-insensitive and
Path.resolve()rewrites a component to its on-disk casing, so a candidatespelled
gh.exeagainst a file namedgh.EXEdiffers from its resolution withno symlink involved. Comparing case-sensitively would refuse a plain install in
strict mode and pointlessly re-walk the same parents in relaxed mode. The
mismatch is real rather than hypothetical:
shutil.whichreturns the name casedas
PATHEXTspells it.Discovery could never have found a Windows gh either
os.path.join(entry, "gh")never matchesgh.exe, so the candidate scanreturned nothing on Windows regardless of the trust policy. Resolution inside a
directory is now delegated to
shutil.which, which applies whatever the platformdefines as runnable there —
PATHEXTon Windows,X_OKon POSIX — and theProgram Files install dirs lead the ambient
PATHso a machine-wide installstill wins over a user-writable shim.
Delegating rather than hand-rolling this also removed a bug the first draft
carried: it split
PATHEXTonos.pathsep, which is;on Windows but:elsewhere, conflating "how PATH is joined" with "how PATHEXT is joined".
Fail-closed decisions
empty writers tuple cannot be mistaken for a clean result
different offset) is a refusal, because the writers tuple would be incomplete
writer that is in fact denied — refusing a binary that would have been fine —
and never the reverse. A full allow/deny resolution would need
AccessCheck(),which answers "may this token write" rather than the policy's question ("may
anyone outside the trusted set write"), so a hand-walk is required here.
children would be elevated too and the whole walk goes vacuous
What this does NOT unblock, despite sharing the gate
The pull-request source drawer stays refused on Windows, and an earlier
revision of this body wrongly claimed otherwise. The drawer does not share Issue
Radar's spawn: it keeps its own async, sandbox-routed
_run_json, which carries aseparate refusal at
source_providers.py:713phrased around the sandbox ratherthan the trust check, and fail-closes where no OS sandbox backend exists. Fixing
the trust check therefore does not unblock it, and that guard is deliberately
left in place.
windows-install.mdnow says so, and says which of the twoblockers applies to which surface.
Code Review Sage stays refused on Windows — deliberately
Sage shares the trust gate, so that half now passes for it too, but it needs a
second thing Issue Radar does not: its review prompts and its shipped
sage-reviewskill hand the worker sessionpython3 sage_lib/…commands, andpython3is not an interpreter on Windows — the name resolves to the MicrosoftStore app-execution alias, or to nothing. Enabling Sage would trade a clear
refusal for a review that starts and produces no result record, so its Windows
refusal is kept and reworded to name the actual blocker. Tracked as #4630, whose
acceptance criterion is removing that refusal.
Two stale test gates and one baseline prune
test/test_github_runner.pyand the Sageconftest.pyboth skip on Windows;their stated reasons are corrected in place so neither claims a refusal that no
longer exists. Neither suite is unskipped here — they assert the POSIX branch's
own messages and build
#!/bin/shstubs, which is separate work..github/black-baseline.txtloses four entries. The black gate is a shrinkingbaseline and reports any baselined file that has become clean; three of the four
(
cli_commands.py,memory.py,sandbox.py) graduated through recent commits onmainrather than through this diff, and whichever PR rebases first is the onethat has to prune them.
Provider output is now decoded as UTF-8, on every platform
Removing the Windows refusals exposed a defect the refusals had been hiding, so
this is in scope as a bug in this diff rather than adjacent work — but it changes
behaviour on POSIX too, and it is named here rather than left to be found in
the diff.
run_ghand_glab_runpassedtext=Truewith noencoding=, which decodes withthe locale codec — the ANSI codepage on Windows. Reproduced on a cp936 host:
UnicodeDecodeError: 'gbk' codec can't decode byte 0xac in position 27, raisedinside subprocess's own reader thread, so
subprocess.runreturns withstdout=Noneand the caller dies on theNoneinstead of on a decode erroranyone could attribute.
Both chokepoints now capture bytes and decode strictly in their own frame.
Two rejected alternatives, since the choice is not obvious:
encoding="utf-8"alone fixes the codec but not the attribution — a strictfailure still dies on that reader thread, handing the caller
stdout=None.errors="replace", which an earlier revision of this PR used, is worse than itlooks. U+FFFD inside a JSON string value leaves the document syntactically
valid, so
json.loadssucceeds and the replacement character flows on intostored issue records. That is silent corruption, not a handled error.
Decoding in our own frame keeps both properties: strict, so nothing is silently
corrupted, and attributable, so the failure names the stream and byte offset
without echoing payload bytes.
Tests
test/test_windows_acl.py(new). Two layers, on purpose:check_provider_path_component_windows, driven by syntheticComponentSecurityvalues so it runs on every runner including the Ubuntu onethat gates CI — trusted and untrusted owners, untrusted writers, NULL DACL,
unparsable ACE types, unreadable descriptors, strict vs relaxed, and the
ADD_SUBDIRECTORYcarve-out from both sides;windows_acl.describe, Windows-only because it needs areal security descriptor: a user-owned tree, a synthetic
icacls /grant "Everyone:(OI)(CI)F"tree that must be refused, and the driveroot that must not refuse the walk.
Candidate discovery is covered by asserting the delegation (a PATH hit resolves
absolute; strict mode ignores PATH; on Windows a bare name resolves to
.exe).Manual verification
On Windows 11, AMD64, non-elevated:
resolve_gh()→C:\Program Files\GitHub CLI\gh.exe_gh_bin()succeeds; Sagegh_bin()refuses with the interpreterreason
gh api usercompletes throughrun_gh(rc 0)flake8,isortand the repo's ownscripts/check_black_formatting.pypass onthe full CI scope
mypy --platform linux src/kiro_crew/— the exact command CI's Backend Lintruns on Ubuntu — reports
Success: no issues found in 1000 source files. Baremypyon Windows cannot substitute: it defaults--platformto the host, so itanalyses the
wintypesbranch and never sees the fallback branch CI checks.test/test_source_providers.pyfails heavily on Windows before this change(baseline 20 passed / 16 failed then an asyncio hang; with this change 21 passed
/ 15 failed then the same hang). Pre-existing Windows breakage, one failure
fewer, and out of scope here.
Not covered by host state, and better served by synthetic fixtures than by
whatever the runner happens to have installed: winget and scoop shim layouts,
GPO-altered
Program FilesACLs, UNC paths and mapped drives, ARM64.Screenshots / video
Why no screenshot: backend trust-policy and docs change only; no frontend
path is touched and nothing renders differently.