fix(gateguard): extract the arm tree without handing tar a drive letter

materializeTree passed the archive as an absolute path. GNU tar, which Git for
Windows bundles and puts first on PATH in Git Bash, reads an -f argument
containing a colon as a host:path remote spec and fails with "Cannot connect to
C: resolve failed". bsdtar, the tar in system32 that PowerShell resolves, has no
remote-spec parsing, so the fault only appeared under a bash-based shell - and
there it failed a committed test, not just a dry run.

Name the archive relatively and run tar with dest as its cwd, which extracts
identically under both tars and makes -C unnecessary. git archive keeps the
absolute -o path, which git handles on Windows.

Two tests, because the environment-dependent kind of fault needs both: one
injects a spawn and asserts no tar argument is absolute or drive-lettered, which
holds the invariant on any platform; the other prepends Git's bundled GNU tar to
PATH and extracts into a drive-letter destination, so the original failure is
reproducible from PowerShell too, where it was previously invisible.
This commit is contained in:
montjeffrey
2026-10-04 22:40:42 -04:00
parent e716f7ea65
commit bfd4f8255a
2 changed files with 67 additions and 7 deletions
+13 -7
View File
@@ -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);
@@ -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;
// <git>/mingw64/libexec/git-core -> <git>/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-');