feat: add paired review workspace flow
This commit is contained in:
@@ -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;
|
||||
|
||||
220
runners/agent-runner/src/reviewer-runtime.ts
Normal file
220
runners/agent-runner/src/reviewer-runtime.ts
Normal 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))
|
||||
);
|
||||
}
|
||||
67
runners/agent-runner/test/reviewer-runtime.test.ts
Normal file
67
runners/agent-runner/test/reviewer-runtime.test.ts
Normal 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',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
105
runners/codex-runner/src/reviewer-runtime.ts
Normal file
105
runners/codex-runner/src/reviewer-runtime.ts
Normal 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 || ''}`,
|
||||
};
|
||||
}
|
||||
54
runners/codex-runner/test/reviewer-runtime.test.ts
Normal file
54
runners/codex-runner/test/reviewer-runtime.test.ts
Normal 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',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user