feat: add paired review workspace flow

This commit is contained in:
Eyejoker
2026-03-28 21:14:25 +09:00
parent 79a8a2639e
commit e7f49d77da
19 changed files with 2719 additions and 20 deletions

View File

@@ -25,6 +25,11 @@ import {
prependRoomRoleHeader,
type RoomRoleContext,
} from './room-role-context.js';
import {
buildReviewerGitGuardEnv,
isReviewerMutatingShellCommand,
isReviewerRuntime,
} from './reviewer-runtime.js';
interface ContainerInput {
prompt: string;
@@ -466,6 +471,25 @@ function createSanitizeBashHook(): HookCallback {
};
}
function createReviewerBashGuardHook(): HookCallback {
return async (input, _toolUseId, _context) => {
const preInput = input as PreToolUseHookInput;
const command = (preInput.tool_input as { command?: string })?.command;
if (!command || !isReviewerMutatingShellCommand(command)) {
return {};
}
return {
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason:
'EJClaw reviewer runtime blocks mutating shell commands in paired review mode.',
},
};
};
}
function sanitizeFilename(summary: string): string {
return summary
.toLowerCase()
@@ -595,6 +619,7 @@ async function runQuery(
mcpServerPath: string,
containerInput: ContainerInput,
sdkEnv: Record<string, string | undefined>,
reviewerRuntime: boolean,
): Promise<{
newSessionId?: string;
closedDuringQuery: boolean;
@@ -658,6 +683,28 @@ async function runQuery(
if (model) log(`Using model: ${model}`);
if (thinking) log(`Thinking config: ${JSON.stringify(thinking)}`);
if (effort) log(`Effort: ${effort}`);
if (reviewerRuntime) log('Reviewer runtime restrictions enabled');
const allowedTools = reviewerRuntime
? [
'Bash',
'Read', 'Glob', 'Grep',
'WebSearch', 'WebFetch',
'Task', 'TaskOutput', 'TaskStop',
'TeamCreate', 'TeamDelete', 'SendMessage',
'TodoWrite', 'ToolSearch', 'Skill',
'mcp__ejclaw__*',
]
: [
'Bash',
'Read', 'Write', 'Edit', 'Glob', 'Grep',
'WebSearch', 'WebFetch',
'Task', 'TaskOutput', 'TaskStop',
'TeamCreate', 'TeamDelete', 'SendMessage',
'TodoWrite', 'ToolSearch', 'Skill',
'NotebookEdit',
'mcp__ejclaw__*'
];
for await (const message of query({
prompt: stream,
@@ -668,16 +715,7 @@ async function runQuery(
effort,
additionalDirectories: extraDirs.length > 0 ? extraDirs : undefined,
resume: sessionId,
allowedTools: [
'Bash',
'Read', 'Write', 'Edit', 'Glob', 'Grep',
'WebSearch', 'WebFetch',
'Task', 'TaskOutput', 'TaskStop',
'TeamCreate', 'TeamDelete', 'SendMessage',
'TodoWrite', 'ToolSearch', 'Skill',
'NotebookEdit',
'mcp__ejclaw__*'
],
allowedTools,
env: sdkEnv,
permissionMode: 'bypassPermissions',
allowDangerouslySkipPermissions: true,
@@ -715,7 +753,14 @@ async function runQuery(
},
hooks: {
PreCompact: [{ hooks: [createPreCompactHook(containerInput.assistantName)] }],
PreToolUse: [{ matcher: 'Bash', hooks: [createSanitizeBashHook()] }],
PreToolUse: [
{
matcher: 'Bash',
hooks: reviewerRuntime
? [createReviewerBashGuardHook(), createSanitizeBashHook()]
: [createSanitizeBashHook()],
},
],
},
agentProgressSummaries: true,
}
@@ -954,6 +999,10 @@ async function main(): Promise<void> {
for (const [key, value] of Object.entries(containerInput.secrets || {})) {
sdkEnv[key] = value;
}
const reviewerRuntime =
process.env.EJCLAW_REVIEWER_RUNTIME === '1' ||
isReviewerRuntime(containerInput.roomRoleContext);
const guardedSdkEnv = buildReviewerGitGuardEnv(sdkEnv, reviewerRuntime);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const mcpServerPath = path.join(__dirname, 'ipc-mcp-stdio.js');
@@ -1001,7 +1050,7 @@ async function main(): Promise<void> {
resume: sessionId,
systemPrompt: undefined,
allowedTools: [],
env: sdkEnv,
env: guardedSdkEnv,
permissionMode: 'bypassPermissions' as const,
allowDangerouslySkipPermissions: true,
settingSources: ['project', 'user'] as const,
@@ -1088,7 +1137,8 @@ async function main(): Promise<void> {
sessionId,
mcpServerPath,
containerInput,
sdkEnv,
guardedSdkEnv,
reviewerRuntime,
);
if (queryResult.newSessionId) {
sessionId = queryResult.newSessionId;

View File

@@ -0,0 +1,220 @@
import { execFileSync } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import type { RoomRoleContext } from './room-role-context.js';
const BLOCKED_GIT_SUBCOMMANDS = new Set([
'add',
'am',
'apply',
'branch',
'checkout',
'cherry-pick',
'clean',
'commit',
'merge',
'push',
'rebase',
'reset',
'restore',
'stash',
'switch',
'tag',
'worktree',
]);
const MUTATING_SHELL_PATTERNS = [
/\bsed\s+-i\b/i,
/\bperl\s+-i\b/i,
/(^|[;&|])\s*(cat|echo|printf)\b[^#\n]*>>?/i,
];
export function isReviewerRuntime(
roomRoleContext?: RoomRoleContext,
): boolean {
return roomRoleContext?.role === 'reviewer';
}
function findGitSubcommand(args: string[]): string {
let skipNext = false;
for (const arg of args) {
if (skipNext) {
skipNext = false;
continue;
}
switch (arg) {
case '-c':
case '-C':
case '--git-dir':
case '--work-tree':
case '--namespace':
case '--exec-path':
case '--config-env':
skipNext = true;
continue;
default:
break;
}
if (
arg.startsWith('-c') ||
arg.startsWith('-C') ||
arg.startsWith('--git-dir=') ||
arg.startsWith('--work-tree=') ||
arg.startsWith('--namespace=') ||
arg.startsWith('--exec-path=') ||
arg.startsWith('--config-env=')
) {
continue;
}
if (arg.startsWith('-')) {
continue;
}
return arg;
}
return '';
}
function tokenizeShellWords(segment: string): string[] {
const tokens: string[] = [];
let current = '';
let quote: '"' | "'" | null = null;
let escaped = false;
for (const char of segment) {
if (escaped) {
current += char;
escaped = false;
continue;
}
if (char === '\\' && quote !== "'") {
escaped = true;
continue;
}
if (quote) {
if (char === quote) {
quote = null;
} else {
current += char;
}
continue;
}
if (char === '"' || char === "'") {
quote = char;
continue;
}
if (/\s/.test(char)) {
if (current) {
tokens.push(current);
current = '';
}
continue;
}
current += char;
}
if (current) {
tokens.push(current);
}
return tokens;
}
function isBlockedGitCommand(command: string): boolean {
const segments = command
.split(/&&|\|\||[;\n]/)
.map((segment) => segment.trim())
.filter(Boolean);
for (const segment of segments) {
const tokens = tokenizeShellWords(segment);
if (tokens[0] !== 'git') {
continue;
}
const subcommand = findGitSubcommand(tokens.slice(1));
if (BLOCKED_GIT_SUBCOMMANDS.has(subcommand)) {
return true;
}
}
return false;
}
function resolveGitBinary(baseEnv: NodeJS.ProcessEnv): string {
return execFileSync('bash', ['-lc', 'command -v git'], {
encoding: 'utf-8',
env: baseEnv,
stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
}
export function buildReviewerGitGuardEnv(
baseEnv: NodeJS.ProcessEnv,
reviewerRuntime: boolean,
): NodeJS.ProcessEnv {
if (!reviewerRuntime) {
return baseEnv;
}
const realGitPath = resolveGitBinary(baseEnv);
const wrapperDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'ejclaw-reviewer-git-'),
);
const wrapperPath = path.join(wrapperDir, 'git');
const blocked = [...BLOCKED_GIT_SUBCOMMANDS]
.map((value) => `'${value}'`)
.join(' ');
const script = `#!/usr/bin/env bash
set -euo pipefail
real_git=${JSON.stringify(realGitPath)}
blocked_subcommands=(${blocked})
subcmd=""
skip_next=0
for arg in "$@"; do
if [[ "$skip_next" == "1" ]]; then
skip_next=0
continue
fi
case "$arg" in
-c|-C|--git-dir|--work-tree|--namespace|--exec-path|--config-env)
skip_next=1
continue
;;
-c*|-C*|--git-dir=*|--work-tree=*|--namespace=*|--exec-path=*|--config-env=*)
continue
;;
--*)
continue
;;
-*)
continue
;;
*)
subcmd="$arg"
break
;;
esac
done
for blocked in "\${blocked_subcommands[@]}"; do
if [[ "$subcmd" == "$blocked" ]]; then
echo "EJClaw reviewer runtime blocks mutating git subcommands: $subcmd" >&2
exit 1
fi
done
exec "$real_git" "$@"
`;
fs.writeFileSync(wrapperPath, script, { mode: 0o755 });
return {
...baseEnv,
EJCLAW_REAL_GIT: realGitPath,
PATH: `${wrapperDir}:${baseEnv.PATH || ''}`,
};
}
export function isReviewerMutatingShellCommand(command: string): boolean {
const normalized = command.trim();
return (
isBlockedGitCommand(normalized) ||
MUTATING_SHELL_PATTERNS.some((pattern) => pattern.test(normalized))
);
}

View File

@@ -0,0 +1,67 @@
import { execFileSync } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { describe, expect, it } from 'vitest';
import {
buildReviewerGitGuardEnv,
isReviewerMutatingShellCommand,
isReviewerRuntime,
} from '../src/reviewer-runtime.js';
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('flags mutating shell commands', () => {
expect(isReviewerMutatingShellCommand('git commit -m "x"')).toBe(true);
expect(
isReviewerMutatingShellCommand('git -c color.ui=false commit -m "x"'),
).toBe(true);
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();
});
it('blocks mutating git subcommands even when git options come first', () => {
const env = buildReviewerGitGuardEnv({ PATH: process.env.PATH }, true);
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-reviewer-test-'));
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',
);
}
});
});

View File

@@ -23,6 +23,10 @@ import {
prependRoomRoleHeader,
type RoomRoleContext,
} from './room-role-context.js';
import {
buildReviewerGitGuardEnv,
isReviewerRuntime,
} from './reviewer-runtime.js';
// ── Types ──────────────────────────────────────────────────────────
@@ -358,9 +362,13 @@ async function runAppServerSession(
containerInput: ContainerInput,
prompt: string,
): Promise<void> {
const reviewerRuntime =
process.env.EJCLAW_REVIEWER_RUNTIME === '1' ||
isReviewerRuntime(containerInput.roomRoleContext);
const clientEnv = buildReviewerGitGuardEnv(process.env, reviewerRuntime);
const client = new CodexAppServerClient({
cwd: EFFECTIVE_CWD,
env: process.env,
env: clientEnv,
log,
});

View File

@@ -0,0 +1,105 @@
import { execFileSync } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import type { RoomRoleContext } from './room-role-context.js';
const BLOCKED_GIT_SUBCOMMANDS = new Set([
'add',
'am',
'apply',
'branch',
'checkout',
'cherry-pick',
'clean',
'commit',
'merge',
'push',
'rebase',
'reset',
'restore',
'stash',
'switch',
'tag',
'worktree',
]);
export function isReviewerRuntime(
roomRoleContext?: RoomRoleContext,
): boolean {
return roomRoleContext?.role === 'reviewer';
}
function resolveGitBinary(baseEnv: NodeJS.ProcessEnv): string {
return execFileSync('bash', ['-lc', 'command -v git'], {
encoding: 'utf-8',
env: baseEnv,
stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
}
export function buildReviewerGitGuardEnv(
baseEnv: NodeJS.ProcessEnv,
reviewerRuntime: boolean,
): NodeJS.ProcessEnv {
if (!reviewerRuntime) {
return baseEnv;
}
const realGitPath = resolveGitBinary(baseEnv);
const wrapperDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'ejclaw-reviewer-git-'),
);
const wrapperPath = path.join(wrapperDir, 'git');
const blocked = [...BLOCKED_GIT_SUBCOMMANDS]
.map((value) => `'${value}'`)
.join(' ');
const script = `#!/usr/bin/env bash
set -euo pipefail
real_git=${JSON.stringify(realGitPath)}
blocked_subcommands=(${blocked})
subcmd=""
skip_next=0
for arg in "$@"; do
if [[ "$skip_next" == "1" ]]; then
skip_next=0
continue
fi
case "$arg" in
-c|-C|--git-dir|--work-tree|--namespace|--exec-path|--config-env)
skip_next=1
continue
;;
-c*|-C*|--git-dir=*|--work-tree=*|--namespace=*|--exec-path=*|--config-env=*)
continue
;;
--*)
continue
;;
-*)
continue
;;
*)
subcmd="$arg"
break
;;
esac
done
for blocked in "\${blocked_subcommands[@]}"; do
if [[ "$subcmd" == "$blocked" ]]; then
echo "EJClaw reviewer runtime blocks mutating git subcommands: $subcmd" >&2
exit 1
fi
done
exec "$real_git" "$@"
`;
fs.writeFileSync(wrapperPath, script, { mode: 0o755 });
return {
...baseEnv,
EJCLAW_REAL_GIT: realGitPath,
PATH: `${wrapperDir}:${baseEnv.PATH || ''}`,
};
}

View File

@@ -0,0 +1,54 @@
import { execFileSync } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { describe, expect, it } from 'vitest';
import {
buildReviewerGitGuardEnv,
isReviewerRuntime,
} from '../src/reviewer-runtime.js';
describe('codex 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('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();
});
it('blocks mutating git subcommands even when git options come first', () => {
const env = buildReviewerGitGuardEnv({ PATH: process.env.PATH }, true);
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-reviewer-test-'));
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',
);
}
});
});