diff --git a/docker/gateguard-effectiveness/arms.js b/docker/gateguard-effectiveness/arms.js index 9fb59a5bf..294968b45 100644 --- a/docker/gateguard-effectiveness/arms.js +++ b/docker/gateguard-effectiveness/arms.js @@ -45,8 +45,8 @@ function run(rawInput) { module.exports = { run }; `; -function git(repoRoot, args) { - const result = spawnSync('git', ['-C', repoRoot, ...args], { encoding: 'utf8', shell: false, timeout: 60000 }); +function git(repoRoot, args, spawn = spawnSync) { + const result = spawn('git', ['-C', repoRoot, ...args], { encoding: 'utf8', shell: false, timeout: 60000 }); if (result.status !== 0 || result.error) throw new Error(`git ${args[0]} failed: ${String(result.stderr || result.error).trim()}`); return result.stdout; } @@ -57,12 +57,18 @@ function resolveRef(repoRoot, ref) { } /** Extracts scripts/ at a commit into dest and adds the arm wrapper; returns dest. */ -function materializeTree(repoRoot, sha, dest) { +function materializeTree(repoRoot, sha, dest, spawn = spawnSync) { fs.mkdirSync(dest, { recursive: true }); - const tar = path.join(dest, 'scripts.tar'); - git(repoRoot, ['archive', '--format=tar', '-o', tar, sha, 'scripts']); - const extract = spawnSync('tar', ['-xf', tar, '-C', dest], { encoding: 'utf8', shell: false, timeout: 60000 }); - fs.rmSync(tar, { force: true }); + const tarName = 'scripts.tar'; + git(repoRoot, ['archive', '--format=tar', '-o', path.join(dest, tarName), sha, 'scripts'], spawn); + // Name the archive relatively and run tar with dest as cwd, so no argument carries a + // "C:\..." drive letter. GNU tar (Git Bash / MSYS on Windows) reads an archive argument + // containing a colon as a "host:path" remote spec and dies with "Cannot connect to C: + // resolve failed"; bsdtar (C:\Windows\system32\tar.exe) does not, which is why the + // absolute path only broke under GNU tar. A relative -f plus cwd extracts identically + // under both and makes -C unnecessary. + const extract = spawn('tar', ['-xf', tarName], { cwd: dest, encoding: 'utf8', shell: false, timeout: 60000 }); + fs.rmSync(path.join(dest, tarName), { force: true }); if (extract.status !== 0 || extract.error) throw new Error(`tar failed: ${String(extract.stderr || extract.error).trim()}`); fs.copyFileSync(path.join(__dirname, 'arm-patch.js'), path.join(dest, 'arm-patch.js')); fs.writeFileSync(path.join(dest, ...ARM_HOOK.split('/')), WRAPPER); diff --git a/tests/docker/gateguard-effectiveness.test.js b/tests/docker/gateguard-effectiveness.test.js index 4cdec76b7..50228a3ed 100644 --- a/tests/docker/gateguard-effectiveness.test.js +++ b/tests/docker/gateguard-effectiveness.test.js @@ -52,6 +52,17 @@ function gitAvailable() { return result.status === 0 && !result.error; } +/** Directory of the GNU tar that Git for Windows bundles, or null. GNU tar reads `C:\...` as a remote host:path. */ +function gitBundledGnuTarDir() { + if (process.platform !== 'win32') return null; + const execPath = spawnSync('git', ['--exec-path'], { encoding: 'utf8' }); + if (execPath.status !== 0 || execPath.error) return null; + // /mingw64/libexec/git-core -> /usr/bin + const dir = path.resolve(execPath.stdout.trim(), '..', '..', '..', 'usr', 'bin'); + const version = spawnSync(path.join(dir, 'tar.exe'), ['--version'], { encoding: 'utf8' }); + return version.status === 0 && /GNU tar/.test(version.stdout) ? dir : null; +} + console.log('\n=== Testing gateguard-effectiveness ===\n'); const scenarios = lib.loadScenarios(); @@ -227,6 +238,49 @@ test('arguments require an output folder, a model and the real-provider opt-in', assert.deepStrictEqual(options.arms, arms.DEFAULT_ARMS); }); +test('materializeTree hands tar no host path, only a relative archive name run inside dest', () => { + const work = tempDir('gg-eff-tarargs-'); + try { + const dest = path.join(work, 'tree'); + const calls = []; + const spawn = (command, args, options) => { + calls.push({ command, args, cwd: options.cwd }); + if (command === 'git') fs.writeFileSync(args[args.indexOf('-o') + 1], ''); + if (command === 'tar') fs.mkdirSync(path.join(dest, 'scripts', 'hooks'), { recursive: true }); + return { status: 0, stdout: '', stderr: '' }; + }; + arms.materializeTree(ROOT, 'deadbeef', dest, spawn); + const tar = calls.find(call => call.command === 'tar'); + assert.strictEqual(tar.cwd, dest); + for (const arg of tar.args) { + assert.ok(!path.isAbsolute(arg) && !/^[A-Za-z]:/.test(arg), `tar argument looks like a host path: ${arg}`); + } + } finally { + fs.rmSync(work, { recursive: true, force: true }); + } +}); + +const gnuTarDir = gitAvailable() ? gitBundledGnuTarDir() : null; +if (gnuTarDir) { + test('materializeTree extracts into a drive-letter destination when GNU tar is first on PATH', () => { + const work = tempDir('gg-eff-tar-'); + const savedPath = process.env.PATH; + try { + const dest = path.join(work, 'tree'); + assert.match(dest, /^[A-Za-z]:\\/); + process.env.PATH = `${gnuTarDir}${path.delimiter}${savedPath}`; + const tree = arms.materializeTree(ROOT, arms.resolveRef(ROOT, 'HEAD'), dest); + assert.strictEqual(tree, dest); + assert.ok(fs.existsSync(path.join(dest, 'scripts', 'hooks', 'gateguard-fact-force.js'))); + assert.ok(fs.existsSync(path.join(dest, ...arms.ARM_HOOK.split('/')))); + assert.ok(!fs.existsSync(path.join(dest, 'scripts.tar')), 'the archive is removed after extraction'); + } finally { + process.env.PATH = savedPath; + fs.rmSync(work, { recursive: true, force: true }); + } + }); +} + if (gitAvailable()) { test('a trial wires the arm hook, counts the denial and grades the result (test double for Claude)', () => { const work = tempDir('gg-eff-trial-');