From ad6f0aa21280ca003397a7d337c7c84a6d5c5b3e Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Fri, 11 Sep 2026 19:05:10 +0000 Subject: [PATCH] fix(cso): support hardened Git on Windows --- lib/cso/process.ts | 8 ++++-- test/cso-windows-launcher.test.ts | 48 +++---------------------------- 2 files changed, 10 insertions(+), 46 deletions(-) diff --git a/lib/cso/process.ts b/lib/cso/process.ts index 134484e23..789f7e97b 100644 --- a/lib/cso/process.ts +++ b/lib/cso/process.ts @@ -147,13 +147,17 @@ function hardenGit(file:string,args:string[]):{args:string[];configs?:GitConfigI if(!isAbsolute(requested))throw new CsoError('INVALID_ARGUMENT','CSO Git operations require an absolute audited working directory'); const repo=realpathSync(requested),stat=statSync(repo); if(!stat.isDirectory())throw new CsoError('MISSING_INPUT','Audited Git working directory is not a directory'); - const configs=gitConfigIdentities(repo),nullPath=process.platform==='win32'?'NUL':'/dev/null'; + const configs=gitConfigIdentities(repo),nullPath=process.platform==='win32'?'NUL':'/dev/null', + // Git for Windows accepts NUL for ordinary file-valued settings, but its + // config include machinery treats NUL as a failing include. Its MSYS path + // layer maps /dev/null correctly for this one directive. + includeNullPath=process.platform==='win32'?'/dev/null':nullPath; const prefix=args.slice(0,position),command=args.slice(position+2); return{configs,args:[...prefix, '--no-replace-objects', '-c','core.fsmonitor=false','-c',`core.hooksPath=${nullPath}`,'-c',`core.attributesFile=${nullPath}`, '-c',`core.excludesFile=${nullPath}`,'-c','core.ignoreCase=false','-c','core.precomposeUnicode=false', - '-c','core.untrackedCache=false','-c',`include.path=${nullPath}`,'-c','core.pager=cat', + '-c','core.untrackedCache=false','-c',`include.path=${includeNullPath}`,'-c','core.pager=cat', '-C',repo,`--work-tree=${repo}`,...command]}; } export async function runProcess(file: string, args: string[], opts: { diff --git a/test/cso-windows-launcher.test.ts b/test/cso-windows-launcher.test.ts index 8d2e062ac..8ad447527 100644 --- a/test/cso-windows-launcher.test.ts +++ b/test/cso-windows-launcher.test.ts @@ -91,46 +91,6 @@ function expectSuccessfulProcess(result: ReturnType, label: st })}`); } -function gitProbeMatrix(git: string, repository: string, profile: string): Record { - const asciiRoot=fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()),'gstack-cso-git-probe-')), - asciiHome=path.join(asciiRoot,'home'),unicodeHome=path.join(profile,'.gstack','security','cso','000000000000000000000000','0000000000000-0000000000000000','home'); - fs.mkdirSync(asciiHome);fs.mkdirSync(unicodeHome,{recursive:true}); - const repo=fs.realpathSync(repository),windowsRoot=process.env.SystemRoot||'C:\\Windows',system=path.join(windowsRoot,'System32'),trustedPath=[path.dirname(git),system].join(path.delimiter),nul='NUL'; - const configs:Record={ - fsmonitor:'core.fsmonitor=false',hooks:`core.hooksPath=${nul}`,attributes:`core.attributesFile=${nul}`,excludes:`core.excludesFile=${nul}`, - ignoreCase:'core.ignoreCase=false',precompose:'core.precomposeUnicode=false',untracked:'core.untrackedCache=false',include:`include.path=${nul}`,pager:'core.pager=cat', - }; - const environment=(home:string,extra:Record={})=>{ - const env:Record={PATH:trustedPath,HOME:home,LANG:'C.UTF-8',LC_ALL:'C.UTF-8',TZ:'UTC',GIT_CONFIG_NOSYSTEM:'1',GIT_CONFIG_GLOBAL:'NUL',GIT_TERMINAL_PROMPT:'0',GIT_OPTIONAL_LOCKS:'0',GIT_ATTR_NOSYSTEM:'1'}; - for(const [key,value] of Object.entries(extra)){if(value===undefined)delete env[key];else env[key]=value;}return env; - }; - const args=(omit=new Set(),replace:Record={})=>[ - '--no-optional-locks',...(!omit.has('replaceObjects')?['--no-replace-objects']:[]), - ...Object.entries(configs).flatMap(([key,value])=>omit.has(key)?[]:['-c',replace[key]??value]), - '-C',repo,...(!omit.has('workTree')?[`--work-tree=${repo}`]:[]),'rev-parse','--show-object-format', - ]; - const result:Record={}; - const probe=(label:string,command:string[],home=unicodeHome,cwd=unicodeHome,extra:Record={})=>{ - const child=spawnSync(git,command,{cwd,env:environment(home,extra),encoding:'utf8',timeout:10_000}); - result[label]=child.status??(child.error?'spawn-error':child.signal??'no-status'); - }; - try{ - probe('baseline',['--no-optional-locks','-C',repo,'rev-parse','--show-object-format']); - probe('exact',args()); - probe('ascii-home-cwd',args(),asciiHome,asciiHome); - probe('ascii-home',args(),asciiHome,unicodeHome); - probe('ascii-cwd',args(),unicodeHome,asciiHome); - probe('with-systemroot',args(),unicodeHome,unicodeHome,{SystemRoot:windowsRoot}); - probe('with-temp',args(),unicodeHome,unicodeHome,{TEMP:asciiRoot,TMP:asciiRoot}); - probe('without-global-config-env',args(),unicodeHome,unicodeHome,{GIT_CONFIG_GLOBAL:undefined}); - probe('global-dev-null',args(),unicodeHome,unicodeHome,{GIT_CONFIG_GLOBAL:'/dev/null'}); - for(const key of ['replaceObjects','fsmonitor','hooks','attributes','excludes','ignoreCase','precompose','untracked','include','pager','workTree'])probe(`without-${key}`,args(new Set([key]))); - for(const key of ['hooks','attributes','excludes','include'])probe(`${key}-dev-null`,args(new Set(),{[key]:configs[key].replace('NUL','/dev/null')})); - probe('without-attr-nosystem',args(),unicodeHome,unicodeHome,{GIT_ATTR_NOSYSTEM:undefined}); - return result; - }finally{fs.rmSync(asciiRoot,{recursive:true,force:true});} -} - describe('CSO native Windows build contract', () => { test('Windows builds use MSVC with a static CRT and no Bun-hosted public launcher', () => { const build = fs.readFileSync(path.join(ROOT, 'scripts/build-cso.sh'), 'utf8'); @@ -158,6 +118,8 @@ describe('CSO native Windows build contract', () => { expect(msvc).toContain('GSTACK_CSO_GIT_PATH'); expect(msvc).toContain('/FI$binding'); expect(msvc).toContain('if ($LASTEXITCODE -ne 0)'); + const processSource=fs.readFileSync(path.join(ROOT,'lib','cso','process.ts'),'utf8'); + expect(processSource).toContain("includeNullPath=process.platform==='win32'?'/dev/null':nullPath"); const launcherSource = fs.readFileSync(path.join(ROOT, 'lib/cso/launcher-windows.c'), 'utf8'); expect(launcherSource).toContain('.gstack-cso-generation.lock'); expect(launcherSource).toContain('.gstack-cso-generation'); @@ -320,9 +282,7 @@ describe('CSO native Windows build contract', () => { fs.writeFileSync(path.join(repository,'app.js'),'console.log("safe")\n'); for(const args of [['add','app.js'],['commit','-qm','fixture']] as string[][]){const result=spawnSync(git,args,{cwd:repository,encoding:'utf8',env:gitEnv,timeout:10_000});expect(result.status).toBe(0);} const actual=path.join(ROOT,'bin','gstack-cso-launcher.exe'),env={...process.env,HOME:'',GSTACK_HOME:'',CLAUDE_PLUGIN_ROOT:'',CLAUDE_PLUGIN_DATA:'',USERPROFILE:profile,PATH:temporary,NODE_OPTIONS:'--require=hostile'}; - const doctor=spawnSync(actual,['doctor','--repo',repository],{cwd:repository,encoding:'utf8',env,timeout:30_000});expect(doctor.status).toBe(0);const diagnosis=JSON.parse(doctor.stdout);expect(diagnosis.downloads).toBe(false); - const started=spawnSync(actual,['start','--repo',repository,'--offline'],{cwd:repository,encoding:'utf8',env,timeout:30_000}); - if(started.status!==0)throw new Error(`gstack-cso start failed; fixed Git probe statuses: ${JSON.stringify(gitProbeMatrix(git,repository,profile))}`); - expectSuccessfulProcess(started,'gstack-cso start');expect(JSON.parse(started.stdout).schemaVersion).toBe(3);expect(fs.existsSync(path.join(profile,'.gstack','security','cso'))).toBe(true); + const doctor=spawnSync(actual,['doctor','--repo',repository],{cwd:repository,encoding:'utf8',env,timeout:30_000});expect(doctor.status).toBe(0);expect(JSON.parse(doctor.stdout).downloads).toBe(false); + const started=spawnSync(actual,['start','--repo',repository,'--offline'],{cwd:repository,encoding:'utf8',env,timeout:30_000});expectSuccessfulProcess(started,'gstack-cso start');expect(JSON.parse(started.stdout).schemaVersion).toBe(3);expect(fs.existsSync(path.join(profile,'.gstack','security','cso'))).toBe(true); }); });