Full port of llm-security plugin for internal use on Windows with GitHub Copilot CLI. Protocol translation layer (copilot-hook-runner.mjs) normalizes Copilot camelCase I/O to Claude Code snake_case format — all original hook scripts run unmodified. - 8 hooks with protocol translation (stdin/stdout/exit code) - 18 SKILL.md skills (Agent Skills Open Standard) - 6 .agent.md agent definitions - 20 scanners + 14 scanner lib modules (unchanged) - 14 knowledge files (unchanged) - 39 test files including copilot-port-verify.mjs (17 tests) - Windows-ready: node:path, os.tmpdir(), process.execPath, no bash Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
283 lines
11 KiB
JavaScript
283 lines
11 KiB
JavaScript
// git-clone-sandbox.test.mjs — Tests for sandboxed git clone + fs-utils tmppath
|
|
// Zero external dependencies: node:test + node:assert only.
|
|
|
|
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { existsSync, rmSync, readFileSync, realpathSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
|
const LIB_DIR = join(__dirname, '..', '..', 'scanners', 'lib');
|
|
const GIT_CLONE = join(LIB_DIR, 'git-clone.mjs');
|
|
const FS_UTILS = join(LIB_DIR, 'fs-utils.mjs');
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Import sandbox exports for unit testing
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const {
|
|
GIT_SANDBOX_CONFIG, GIT_SANDBOX_ENV, buildSandboxProfile, buildBwrapArgs,
|
|
buildSandboxedClone, MAX_CLONE_SIZE_MB,
|
|
} = await import('../../scanners/lib/git-clone.mjs');
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GIT_SANDBOX_CONFIG
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('GIT_SANDBOX_CONFIG', () => {
|
|
it('disables hooks', () => {
|
|
const idx = GIT_SANDBOX_CONFIG.indexOf('core.hooksPath=/dev/null');
|
|
assert.ok(idx > 0, 'core.hooksPath=/dev/null must be in config flags');
|
|
});
|
|
|
|
it('disables symlinks', () => {
|
|
assert.ok(GIT_SANDBOX_CONFIG.includes('core.symlinks=false'));
|
|
});
|
|
|
|
it('disables fsmonitor', () => {
|
|
assert.ok(GIT_SANDBOX_CONFIG.includes('core.fsmonitor=false'));
|
|
});
|
|
|
|
it('disables LFS filter drivers', () => {
|
|
assert.ok(GIT_SANDBOX_CONFIG.includes('filter.lfs.process='));
|
|
assert.ok(GIT_SANDBOX_CONFIG.includes('filter.lfs.smudge='));
|
|
assert.ok(GIT_SANDBOX_CONFIG.includes('filter.lfs.clean='));
|
|
});
|
|
|
|
it('blocks local file protocol', () => {
|
|
assert.ok(GIT_SANDBOX_CONFIG.includes('protocol.file.allow=never'));
|
|
});
|
|
|
|
it('enables fsck on transfer', () => {
|
|
assert.ok(GIT_SANDBOX_CONFIG.includes('transfer.fsckObjects=true'));
|
|
});
|
|
|
|
it('has 8 -c flag pairs (16 elements)', () => {
|
|
const cCount = GIT_SANDBOX_CONFIG.filter(f => f === '-c').length;
|
|
assert.equal(cCount, 8, 'Should have exactly 8 -c flags');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GIT_SANDBOX_ENV
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('GIT_SANDBOX_ENV', () => {
|
|
it('sets GIT_CONFIG_NOSYSTEM', () => {
|
|
assert.equal(GIT_SANDBOX_ENV.GIT_CONFIG_NOSYSTEM, '1');
|
|
});
|
|
|
|
it('sets GIT_CONFIG_GLOBAL to /dev/null', () => {
|
|
assert.equal(GIT_SANDBOX_ENV.GIT_CONFIG_GLOBAL, '/dev/null');
|
|
});
|
|
|
|
it('sets GIT_ATTR_NOSYSTEM', () => {
|
|
assert.equal(GIT_SANDBOX_ENV.GIT_ATTR_NOSYSTEM, '1');
|
|
});
|
|
|
|
it('sets GIT_TERMINAL_PROMPT to 0', () => {
|
|
assert.equal(GIT_SANDBOX_ENV.GIT_TERMINAL_PROMPT, '0');
|
|
});
|
|
|
|
it('preserves existing PATH', () => {
|
|
assert.ok(GIT_SANDBOX_ENV.PATH, 'PATH must be preserved from process.env');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// buildSandboxProfile
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('buildSandboxProfile', () => {
|
|
it('returns a profile string on macOS', () => {
|
|
if (process.platform !== 'darwin') return;
|
|
// Use tmpdir() which always exists — realpathSync needs an existing path
|
|
const profile = buildSandboxProfile(tmpdir());
|
|
assert.ok(profile !== null, 'Should return a profile on macOS');
|
|
assert.ok(profile.includes('(version 1)'), 'Profile must start with version');
|
|
assert.ok(profile.includes('(deny file-write*)'), 'Must deny writes by default');
|
|
});
|
|
|
|
it('includes the resolved real path in the profile', () => {
|
|
if (process.platform !== 'darwin') return;
|
|
const realPath = realpathSync(tmpdir());
|
|
const profile = buildSandboxProfile(tmpdir());
|
|
assert.ok(profile.includes(realPath), `Profile must contain resolved path: ${realPath}`);
|
|
});
|
|
|
|
it('allows /dev/null and /dev/tty writes', () => {
|
|
if (process.platform !== 'darwin') return;
|
|
const profile = buildSandboxProfile(tmpdir());
|
|
assert.ok(profile.includes('/dev/null'), 'Must allow /dev/null');
|
|
assert.ok(profile.includes('/dev/tty'), 'Must allow /dev/tty');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// buildBwrapArgs
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('buildBwrapArgs', () => {
|
|
it('returns null on non-Linux platforms', () => {
|
|
if (process.platform === 'linux') return;
|
|
const result = buildBwrapArgs('/tmp/test', ['git', 'clone']);
|
|
assert.equal(result, null, 'Should return null on non-Linux');
|
|
});
|
|
|
|
it('on Linux: returns args array if bwrap is available', () => {
|
|
if (process.platform !== 'linux') return;
|
|
const check = spawnSync('which', ['bwrap'], { encoding: 'utf8' });
|
|
if (check.status !== 0) return; // bwrap not installed, skip
|
|
const result = buildBwrapArgs('/tmp/test-bwrap', ['git', 'clone']);
|
|
if (result === null) return; // bwrap installed but fails (Ubuntu 24.04+)
|
|
assert.ok(Array.isArray(result), 'Should return an array');
|
|
assert.ok(result.includes('--ro-bind'), 'Should include --ro-bind');
|
|
assert.ok(result.includes('--unshare-all'), 'Should include --unshare-all');
|
|
assert.ok(result.includes('/tmp/test-bwrap'), 'Should include the allowed write path');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// buildSandboxedClone
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('buildSandboxedClone', () => {
|
|
it('returns cmd, args, and sandbox properties', () => {
|
|
const result = buildSandboxedClone(tmpdir(), ['clone', '--depth', '1', 'url', tmpdir()]);
|
|
assert.ok(result.cmd, 'Must have cmd');
|
|
assert.ok(Array.isArray(result.args), 'args must be an array');
|
|
assert.ok('sandbox' in result, 'Must have sandbox property');
|
|
});
|
|
|
|
it('uses sandbox-exec on macOS', () => {
|
|
if (process.platform !== 'darwin') return;
|
|
const result = buildSandboxedClone(tmpdir(), ['clone', '--depth', '1', 'url', tmpdir()]);
|
|
assert.equal(result.sandbox, 'sandbox-exec');
|
|
assert.equal(result.cmd, 'sandbox-exec');
|
|
});
|
|
|
|
it('includes git config flags in args regardless of platform', () => {
|
|
const result = buildSandboxedClone(tmpdir(), ['clone', '--depth', '1', 'url', tmpdir()]);
|
|
const argsStr = result.args.join(' ');
|
|
assert.ok(argsStr.includes('core.hooksPath=/dev/null'), 'Must include hooksPath');
|
|
assert.ok(argsStr.includes('core.symlinks=false'), 'Must include symlinks=false');
|
|
});
|
|
|
|
it('falls back gracefully with sandbox=null when no OS sandbox', () => {
|
|
// This test verifies the structure — on macOS/Linux with sandbox available,
|
|
// it will have a sandbox. The key assertion is structural.
|
|
const result = buildSandboxedClone(tmpdir(), ['clone', 'url', tmpdir()]);
|
|
if (result.sandbox === null) {
|
|
assert.equal(result.cmd, 'git', 'Fallback must use git directly');
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// MAX_CLONE_SIZE_MB
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('MAX_CLONE_SIZE_MB', () => {
|
|
it('is 100', () => {
|
|
assert.equal(MAX_CLONE_SIZE_MB, 100);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// fs-utils tmppath uniqueness
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('fs-utils tmppath', () => {
|
|
it('generates unique paths for the same base name', () => {
|
|
const paths = new Set();
|
|
for (let i = 0; i < 5; i++) {
|
|
const result = spawnSync('node', [FS_UTILS, 'tmppath', 'content-extract.json'], {
|
|
encoding: 'utf8',
|
|
});
|
|
assert.equal(result.status, 0, `tmppath should exit 0, got: ${result.stderr}`);
|
|
paths.add(result.stdout.trim());
|
|
}
|
|
assert.equal(paths.size, 5, 'All 5 paths should be unique');
|
|
});
|
|
|
|
it('preserves file extension', () => {
|
|
const result = spawnSync('node', [FS_UTILS, 'tmppath', 'test-file.json'], {
|
|
encoding: 'utf8',
|
|
});
|
|
assert.ok(result.stdout.trim().endsWith('.json'), 'Should preserve .json extension');
|
|
});
|
|
|
|
it('preserves base name prefix', () => {
|
|
const result = spawnSync('node', [FS_UTILS, 'tmppath', 'my-evidence.json'], {
|
|
encoding: 'utf8',
|
|
});
|
|
assert.ok(result.stdout.trim().includes('my-evidence-'), 'Should contain base name prefix');
|
|
});
|
|
|
|
it('paths are under tmpdir', () => {
|
|
const result = spawnSync('node', [FS_UTILS, 'tmppath', 'test.json'], {
|
|
encoding: 'utf8',
|
|
});
|
|
const path = result.stdout.trim();
|
|
assert.ok(path.startsWith(tmpdir()), `Path should be under tmpdir: ${path}`);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// git-clone CLI: validate
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('git-clone validate', () => {
|
|
it('accepts valid HTTPS GitHub URL', () => {
|
|
const result = spawnSync('node', [GIT_CLONE, 'validate', 'https://github.com/org/repo'], {
|
|
encoding: 'utf8',
|
|
});
|
|
assert.equal(result.status, 0);
|
|
});
|
|
|
|
it('accepts valid SSH GitHub URL', () => {
|
|
const result = spawnSync('node', [GIT_CLONE, 'validate', 'git@github.com:org/repo.git'], {
|
|
encoding: 'utf8',
|
|
});
|
|
assert.equal(result.status, 0);
|
|
});
|
|
|
|
it('rejects non-GitHub URL', () => {
|
|
const result = spawnSync('node', [GIT_CLONE, 'validate', 'https://evil.com/repo'], {
|
|
encoding: 'utf8',
|
|
});
|
|
assert.equal(result.status, 1);
|
|
});
|
|
|
|
it('rejects URL with tree path', () => {
|
|
const result = spawnSync('node', [GIT_CLONE, 'validate', 'https://github.com/org/repo/tree/main/dir'], {
|
|
encoding: 'utf8',
|
|
});
|
|
assert.equal(result.status, 1);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// git-clone CLI: cleanup safety
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('git-clone cleanup', () => {
|
|
it('refuses to remove paths outside tmpdir', () => {
|
|
const result = spawnSync('node', [GIT_CLONE, 'cleanup', '/home/user/important'], {
|
|
encoding: 'utf8',
|
|
});
|
|
assert.equal(result.status, 1);
|
|
assert.ok(result.stderr.includes('refusing to remove'));
|
|
});
|
|
|
|
it('handles non-existent tmpdir path gracefully', () => {
|
|
const fakePath = join(tmpdir(), 'llm-sec-nonexistent-test-' + Date.now());
|
|
const result = spawnSync('node', [GIT_CLONE, 'cleanup', fakePath], {
|
|
encoding: 'utf8',
|
|
});
|
|
assert.equal(result.status, 0, 'Should exit 0 for non-existent path in tmpdir');
|
|
});
|
|
});
|