352 lines
10 KiB
TypeScript
352 lines
10 KiB
TypeScript
import { execFileSync } from 'child_process';
|
|
import fs from 'fs';
|
|
import os from 'os';
|
|
import path from 'path';
|
|
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
|
|
import {
|
|
assertReadonlyWorkspaceRepoConnectivity,
|
|
buildClaudeReadonlySandboxSettings,
|
|
buildReviewerGitGuardEnv,
|
|
getClaudeReadonlySandboxMode,
|
|
isClaudeReadonlyReviewerRuntime,
|
|
isReviewerMutatingShellCommand,
|
|
isReviewerRuntime,
|
|
} from '../src/reviewer-runtime.js';
|
|
|
|
const ORIGINAL_CLAUDE_REVIEWER_READONLY =
|
|
process.env.EJCLAW_CLAUDE_REVIEWER_READONLY;
|
|
const ORIGINAL_UNSAFE_HOST_PAIRED_MODE =
|
|
process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE;
|
|
|
|
afterEach(() => {
|
|
if (ORIGINAL_CLAUDE_REVIEWER_READONLY == null) {
|
|
delete process.env.EJCLAW_CLAUDE_REVIEWER_READONLY;
|
|
} else {
|
|
process.env.EJCLAW_CLAUDE_REVIEWER_READONLY =
|
|
ORIGINAL_CLAUDE_REVIEWER_READONLY;
|
|
}
|
|
|
|
if (ORIGINAL_UNSAFE_HOST_PAIRED_MODE == null) {
|
|
delete process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE;
|
|
} else {
|
|
process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE =
|
|
ORIGINAL_UNSAFE_HOST_PAIRED_MODE;
|
|
}
|
|
});
|
|
|
|
beforeEach(() => {
|
|
delete process.env.EJCLAW_CLAUDE_REVIEWER_READONLY;
|
|
delete process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE;
|
|
});
|
|
|
|
function createTempRepo(prefix: string): string {
|
|
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
execFileSync('git', ['init'], {
|
|
cwd,
|
|
encoding: 'utf-8',
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
execFileSync('git', ['config', 'user.email', 'test@example.com'], {
|
|
cwd,
|
|
encoding: 'utf-8',
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
execFileSync('git', ['config', 'user.name', 'EJClaw Test'], {
|
|
cwd,
|
|
encoding: 'utf-8',
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
fs.writeFileSync(path.join(cwd, 'README.md'), 'seed\n');
|
|
execFileSync('git', ['add', 'README.md'], {
|
|
cwd,
|
|
encoding: 'utf-8',
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
execFileSync('git', ['commit', '-m', 'seed'], {
|
|
cwd,
|
|
encoding: 'utf-8',
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
return cwd;
|
|
}
|
|
|
|
describe('claude reviewer runtime guard', () => {
|
|
it('detects reviewer room metadata', () => {
|
|
expect(
|
|
isReviewerRuntime({
|
|
serviceId: 'codex-review',
|
|
role: 'reviewer',
|
|
ownerServiceId: 'codex-main',
|
|
reviewerServiceId: 'codex-review',
|
|
failoverOwner: false,
|
|
}),
|
|
).toBe(true);
|
|
});
|
|
|
|
it('detects claude host reviewer read-only mode from env + role', () => {
|
|
process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE = '1';
|
|
process.env.EJCLAW_CLAUDE_REVIEWER_READONLY = '1';
|
|
|
|
expect(
|
|
isClaudeReadonlyReviewerRuntime({
|
|
serviceId: 'claude',
|
|
role: 'reviewer',
|
|
ownerServiceId: 'codex-main',
|
|
reviewerServiceId: 'claude',
|
|
failoverOwner: false,
|
|
}),
|
|
).toBe(true);
|
|
expect(
|
|
isClaudeReadonlyReviewerRuntime({
|
|
serviceId: 'claude',
|
|
role: 'owner',
|
|
ownerServiceId: 'codex-main',
|
|
reviewerServiceId: 'claude',
|
|
failoverOwner: false,
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('builds a read-only sandbox with normalized protected paths', () => {
|
|
const sandbox = buildClaudeReadonlySandboxSettings(
|
|
['/repo/work', '/repo/work', '/repo/owner/../owner'],
|
|
'linux',
|
|
'strict',
|
|
);
|
|
|
|
expect(sandbox).toMatchObject({
|
|
enabled: true,
|
|
failIfUnavailable: true,
|
|
autoAllowBashIfSandboxed: true,
|
|
allowUnsandboxedCommands: false,
|
|
filesystem: {
|
|
denyWrite: ['/repo/work', '/repo/owner'],
|
|
},
|
|
});
|
|
});
|
|
|
|
it('uses best-effort sandbox mode when linux capability probe fails', () => {
|
|
expect(getClaudeReadonlySandboxMode('linux', () => false)).toBe(
|
|
'best-effort',
|
|
);
|
|
|
|
const sandbox = buildClaudeReadonlySandboxSettings(
|
|
['/repo/work'],
|
|
'linux',
|
|
'best-effort',
|
|
);
|
|
|
|
if (!sandbox) {
|
|
throw new Error('expected sandbox settings');
|
|
}
|
|
expect(sandbox.failIfUnavailable).toBe(false);
|
|
});
|
|
|
|
it('keeps non-linux reviewers in best-effort sandbox mode', () => {
|
|
expect(getClaudeReadonlySandboxMode('darwin', () => true)).toBe(
|
|
'best-effort',
|
|
);
|
|
|
|
const sandbox = buildClaudeReadonlySandboxSettings(
|
|
['/repo/work'],
|
|
'darwin',
|
|
'best-effort',
|
|
);
|
|
|
|
if (!sandbox) {
|
|
throw new Error('expected sandbox settings');
|
|
}
|
|
expect(sandbox.failIfUnavailable).toBe(false);
|
|
});
|
|
|
|
it('flags mutating shell commands', () => {
|
|
expect(isReviewerMutatingShellCommand('git commit -m "x"')).toBe(false);
|
|
expect(
|
|
isReviewerMutatingShellCommand('git -c color.ui=false commit -m "x"'),
|
|
).toBe(false);
|
|
expect(isReviewerMutatingShellCommand('sed -i s/a/b/ file.ts')).toBe(true);
|
|
expect(isReviewerMutatingShellCommand('git status')).toBe(false);
|
|
expect(isReviewerMutatingShellCommand('npm test')).toBe(false);
|
|
});
|
|
|
|
it('prepends a git wrapper to PATH for reviewer runtimes', () => {
|
|
const env = buildReviewerGitGuardEnv({ PATH: process.env.PATH }, true);
|
|
expect(env.PATH).toContain('ejclaw-reviewer-git-');
|
|
expect(env.EJCLAW_REAL_GIT).toBeTruthy();
|
|
expect(env.GIT_CONFIG_GLOBAL).toContain('global.gitconfig');
|
|
expect(env.GIT_CONFIG_NOSYSTEM).toBe('1');
|
|
expect(fs.existsSync(env.GIT_CONFIG_GLOBAL!)).toBe(true);
|
|
});
|
|
|
|
it('prefers an executable HOME-scoped wrapper dir before tmp', () => {
|
|
const homeDir = fs.mkdtempSync(
|
|
path.join(os.tmpdir(), 'ejclaw-reviewer-home-'),
|
|
);
|
|
try {
|
|
const env = buildReviewerGitGuardEnv(
|
|
{
|
|
PATH: process.env.PATH,
|
|
HOME: homeDir,
|
|
},
|
|
true,
|
|
);
|
|
const wrapperDir = env.PATH?.split(path.delimiter)[0] ?? '';
|
|
expect(wrapperDir).toContain(
|
|
path.join(homeDir, '.ejclaw-reviewer-runtime'),
|
|
);
|
|
} finally {
|
|
fs.rmSync(homeDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('allows mutating git commands in temp repos outside the protected workspace', () => {
|
|
const protectedDir = fs.mkdtempSync(
|
|
path.join(os.tmpdir(), 'ejclaw-protected-workspace-'),
|
|
);
|
|
const env = buildReviewerGitGuardEnv(
|
|
{
|
|
PATH: process.env.PATH,
|
|
EJCLAW_WORK_DIR: protectedDir,
|
|
},
|
|
true,
|
|
);
|
|
const cwd = createTempRepo('ejclaw-reviewer-temp-repo-');
|
|
fs.writeFileSync(path.join(cwd, 'note.txt'), 'ok\n');
|
|
|
|
expect(() =>
|
|
execFileSync('git', ['add', 'note.txt'], {
|
|
cwd,
|
|
env,
|
|
encoding: 'utf-8',
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
}),
|
|
).not.toThrow();
|
|
});
|
|
|
|
it('blocks mutating git subcommands inside the protected reviewer workspace', () => {
|
|
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-reviewer-test-'));
|
|
const env = buildReviewerGitGuardEnv(
|
|
{
|
|
PATH: process.env.PATH,
|
|
EJCLAW_WORK_DIR: cwd,
|
|
},
|
|
true,
|
|
);
|
|
|
|
try {
|
|
execFileSync('git', ['-c', 'color.ui=false', 'commit', '-m', 'x'], {
|
|
cwd,
|
|
env,
|
|
encoding: 'utf-8',
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
throw new Error('expected git wrapper to block commit');
|
|
} catch (error) {
|
|
const stderr =
|
|
error instanceof Error && 'stderr' in error
|
|
? String((error as Error & { stderr?: string | Buffer }).stderr ?? '')
|
|
: '';
|
|
expect(stderr).toContain(
|
|
'EJClaw reviewer runtime blocks mutating git subcommands: commit',
|
|
);
|
|
}
|
|
});
|
|
|
|
it('overrides problematic git config paths so read-only git queries still work', () => {
|
|
const cwd = createTempRepo('ejclaw-reviewer-readonly-git-');
|
|
const env = buildReviewerGitGuardEnv(
|
|
{
|
|
PATH: process.env.PATH,
|
|
HOME: cwd,
|
|
EJCLAW_WORK_DIR: cwd,
|
|
GIT_CONFIG_GLOBAL: path.join(cwd, '.gitconfig'),
|
|
},
|
|
true,
|
|
);
|
|
|
|
expect(env.GIT_CONFIG_GLOBAL).not.toBe(path.join(cwd, '.gitconfig'));
|
|
expect(() =>
|
|
execFileSync('git', ['log', '--oneline', '-1'], {
|
|
cwd,
|
|
env,
|
|
encoding: 'utf-8',
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
}),
|
|
).not.toThrow();
|
|
expect(fs.existsSync(path.join(cwd, '.gitconfig'))).toBe(false);
|
|
});
|
|
|
|
it('accepts a mounted local origin path that resolves as a git repo', () => {
|
|
const originDir = createTempRepo('ejclaw-reviewer-origin-');
|
|
const cwd = createTempRepo('ejclaw-reviewer-workspace-');
|
|
execFileSync('git', ['remote', 'add', 'origin', originDir], {
|
|
cwd,
|
|
encoding: 'utf-8',
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
const env = buildReviewerGitGuardEnv(
|
|
{
|
|
PATH: process.env.PATH,
|
|
EJCLAW_WORK_DIR: cwd,
|
|
},
|
|
true,
|
|
);
|
|
|
|
expect(() =>
|
|
assertReadonlyWorkspaceRepoConnectivity(env, true),
|
|
).not.toThrow();
|
|
});
|
|
|
|
it.each([
|
|
'https://github.com/EyeJoker-Internal/eyejoker-db.git',
|
|
'git@github.com:EyeJoker-Internal/eyejoker-db.git',
|
|
])(
|
|
'accepts remote origin %s without requiring a mounted local canonical path',
|
|
(originUrl) => {
|
|
const cwd = createTempRepo('ejclaw-reviewer-remote-origin-');
|
|
execFileSync('git', ['remote', 'add', 'origin', originUrl], {
|
|
cwd,
|
|
encoding: 'utf-8',
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
const env = buildReviewerGitGuardEnv(
|
|
{
|
|
PATH: process.env.PATH,
|
|
EJCLAW_WORK_DIR: cwd,
|
|
},
|
|
true,
|
|
);
|
|
|
|
expect(() =>
|
|
assertReadonlyWorkspaceRepoConnectivity(env, true),
|
|
).not.toThrow();
|
|
},
|
|
);
|
|
|
|
it('fails fast when the local origin path is not mounted as a git repo', () => {
|
|
const cwd = createTempRepo('ejclaw-reviewer-workspace-');
|
|
const missingOriginDir = path.join(
|
|
os.tmpdir(),
|
|
`ejclaw-reviewer-missing-origin-${Date.now()}`,
|
|
);
|
|
execFileSync('git', ['remote', 'add', 'origin', missingOriginDir], {
|
|
cwd,
|
|
encoding: 'utf-8',
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
const env = buildReviewerGitGuardEnv(
|
|
{
|
|
PATH: process.env.PATH,
|
|
EJCLAW_WORK_DIR: cwd,
|
|
},
|
|
true,
|
|
);
|
|
|
|
expect(() => assertReadonlyWorkspaceRepoConnectivity(env, true)).toThrow(
|
|
`EJClaw readonly runtime cannot access local git origin path: ${missingOriginDir}`,
|
|
);
|
|
});
|
|
});
|