feat(exit): detect permission denials and halt loop (Issue #101) (#142)

When Claude Code is denied permission to execute commands (e.g., npm install),
Ralph now detects this from the permission_denials array in the JSON output
and halts the loop immediately with clear guidance for the user.

Changes:
- Add permission denial detection to parse_json_response() in response_analyzer.sh
  - Extract permission_denials array from Claude Code JSON output
  - Track has_permission_denials, permission_denial_count, denied_commands
- Add analyze_response() support for permission denial fields
- Add permission denial exit condition to should_exit_gracefully() in ralph_loop.sh
  - Permission denial takes highest priority among exit conditions
  - Display helpful guidance for updating ALLOWED_TOOLS in .ralphrc
- Update circuit breaker with CB_PERMISSION_DENIAL_THRESHOLD=2
  - Track consecutive_permission_denials in state file
  - Open circuit after 2 consecutive loops with permission denials
- Add 11 new TDD tests (6 in test_json_parsing.bats, 5 in test_exit_detection.bats)
- Update documentation in CLAUDE.md and README.md

Test count: 452 (up from 452 - added 11 new tests)

Fixes #101

Co-authored-by: Test User <test@example.com>
This commit is contained in:
Frank Bria 2026-01-29 23:17:16 -07:00 committed by GitHub
parent 5cad271eac
commit 328294847d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 519 additions and 6 deletions

View file

@ -699,3 +699,182 @@ EOF
local indicator_count=$(jq '.completion_indicators | length' "$EXIT_SIGNALS_FILE")
assert_equal "$indicator_count" "0"
}
# =============================================================================
# PERMISSION DENIAL EXIT TESTS (Issue #101)
# =============================================================================
# When Claude Code is denied permission to run commands, Ralph should detect
# this from the permission_denials field and halt the loop to allow user intervention.
# Helper function with permission denial support
should_exit_gracefully_with_denials() {
if [[ ! -f "$EXIT_SIGNALS_FILE" ]]; then
echo ""
return 1
fi
local signals=$(cat "$EXIT_SIGNALS_FILE")
local recent_test_loops
local recent_done_signals
local recent_completion_indicators
recent_test_loops=$(echo "$signals" | jq '.test_only_loops | length' 2>/dev/null || echo "0")
recent_done_signals=$(echo "$signals" | jq '.done_signals | length' 2>/dev/null || echo "0")
recent_completion_indicators=$(echo "$signals" | jq '.completion_indicators | length' 2>/dev/null || echo "0")
# Check for permission denials first (highest priority - Issue #101)
if [[ -f "$RESPONSE_ANALYSIS_FILE" ]]; then
local has_permission_denials=$(jq -r '.analysis.has_permission_denials // false' "$RESPONSE_ANALYSIS_FILE" 2>/dev/null || echo "false")
if [[ "$has_permission_denials" == "true" ]]; then
echo "permission_denied"
return 0
fi
fi
# 1. Too many consecutive test-only loops
if [[ $recent_test_loops -ge $MAX_CONSECUTIVE_TEST_LOOPS ]]; then
echo "test_saturation"
return 0
fi
# 2. Multiple "done" signals
if [[ $recent_done_signals -ge $MAX_CONSECUTIVE_DONE_SIGNALS ]]; then
echo "completion_signals"
return 0
fi
# 3. Strong completion indicators (only if Claude's EXIT_SIGNAL is true)
local claude_exit_signal="false"
if [[ -f "$RESPONSE_ANALYSIS_FILE" ]]; then
claude_exit_signal=$(jq -r '.analysis.exit_signal // false' "$RESPONSE_ANALYSIS_FILE" 2>/dev/null || echo "false")
fi
if [[ $recent_completion_indicators -ge 2 ]] && [[ "$claude_exit_signal" == "true" ]]; then
echo "project_complete"
return 0
fi
echo ""
return 1
}
# Test 36: Exit on permission denial detected
@test "should_exit_gracefully exits on permission_denied" {
echo '{"test_only_loops": [], "done_signals": [], "completion_indicators": []}' > "$EXIT_SIGNALS_FILE"
# Create response analysis with permission denials
cat > "$RESPONSE_ANALYSIS_FILE" << 'EOF'
{
"loop_number": 1,
"output_format": "json",
"analysis": {
"has_completion_signal": false,
"is_test_only": false,
"is_stuck": false,
"has_progress": false,
"files_modified": 0,
"confidence_score": 70,
"exit_signal": false,
"work_summary": "Tried to run npm install but permission denied",
"has_permission_denials": true,
"permission_denial_count": 1,
"denied_commands": ["npm install"]
}
}
EOF
result=$(should_exit_gracefully_with_denials)
assert_equal "$result" "permission_denied"
}
# Test 37: No exit when no permission denials
@test "should_exit_gracefully continues when no permission denials" {
echo '{"test_only_loops": [], "done_signals": [], "completion_indicators": []}' > "$EXIT_SIGNALS_FILE"
# Create response analysis without permission denials
cat > "$RESPONSE_ANALYSIS_FILE" << 'EOF'
{
"loop_number": 1,
"output_format": "json",
"analysis": {
"has_completion_signal": false,
"is_test_only": false,
"is_stuck": false,
"has_progress": true,
"files_modified": 3,
"confidence_score": 70,
"exit_signal": false,
"work_summary": "Implementing feature",
"has_permission_denials": false,
"permission_denial_count": 0,
"denied_commands": []
}
}
EOF
result=$(should_exit_gracefully_with_denials || true)
assert_equal "$result" ""
}
# Test 38: Permission denial takes priority over other signals
@test "permission_denied takes priority over test_saturation" {
# Set up test saturation condition
echo '{"test_only_loops": [1,2,3], "done_signals": [], "completion_indicators": []}' > "$EXIT_SIGNALS_FILE"
# Create response analysis with permission denials
cat > "$RESPONSE_ANALYSIS_FILE" << 'EOF'
{
"loop_number": 3,
"analysis": {
"is_test_only": true,
"has_permission_denials": true,
"permission_denial_count": 1,
"denied_commands": ["npm install"]
}
}
EOF
# Permission denied should take priority
result=$(should_exit_gracefully_with_denials)
assert_equal "$result" "permission_denied"
}
# Test 39: Multiple permission denials detected
@test "should_exit_gracefully detects multiple permission denials" {
echo '{"test_only_loops": [], "done_signals": [], "completion_indicators": []}' > "$EXIT_SIGNALS_FILE"
cat > "$RESPONSE_ANALYSIS_FILE" << 'EOF'
{
"loop_number": 1,
"analysis": {
"has_permission_denials": true,
"permission_denial_count": 3,
"denied_commands": ["npm install", "pnpm install", "yarn add lodash"]
}
}
EOF
result=$(should_exit_gracefully_with_denials)
assert_equal "$result" "permission_denied"
}
# Test 40: Missing has_permission_denials field defaults to false (backward compat)
@test "should_exit_gracefully handles missing permission denial fields" {
echo '{"test_only_loops": [], "done_signals": [], "completion_indicators": []}' > "$EXIT_SIGNALS_FILE"
# Old format response analysis without permission denial fields
cat > "$RESPONSE_ANALYSIS_FILE" << 'EOF'
{
"loop_number": 1,
"analysis": {
"has_completion_signal": false,
"is_test_only": false,
"exit_signal": false
}
}
EOF
result=$(should_exit_gracefully_with_denials || true)
assert_equal "$result" ""
}

View file

@ -919,3 +919,193 @@ EOF
local session_id=$(jq -r '.session_id' "$result_file")
assert_equal "$session_id" "session-in-result-only"
}
# =============================================================================
# PERMISSION DENIAL DETECTION TESTS (Issue #101)
# =============================================================================
# Tests for detecting permission_denials from Claude Code JSON output.
# When Claude Code is denied permission to execute commands (e.g., npm install),
# the JSON output contains a permission_denials array that Ralph should detect.
@test "parse_json_response detects permission_denials array" {
local output_file="$LOG_DIR/test_output.log"
# Create JSON output with permission denials (as Claude Code outputs)
cat > "$output_file" << 'EOF'
{
"result": "I tried to run npm install but was denied permission.",
"sessionId": "session-denied-123",
"is_error": false,
"permission_denials": [
{"tool": "Bash", "command": "npm install", "reason": "Tool not in allowed list"}
]
}
EOF
run parse_json_response "$output_file"
assert_equal "$status" "0"
local result_file="$RALPH_DIR/.json_parse_result"
[[ -f "$result_file" ]]
# Should extract has_permission_denials flag
local has_denials=$(jq -r '.has_permission_denials' "$result_file")
assert_equal "$has_denials" "true"
}
@test "parse_json_response extracts permission_denial_count" {
local output_file="$LOG_DIR/test_output.log"
cat > "$output_file" << 'EOF'
{
"result": "Multiple commands were denied.",
"sessionId": "session-multi-deny",
"permission_denials": [
{"tool": "Bash", "command": "npm install", "reason": "Not allowed"},
{"tool": "Bash", "command": "pnpm install", "reason": "Not allowed"},
{"tool": "Bash", "command": "yarn add lodash", "reason": "Not allowed"}
]
}
EOF
run parse_json_response "$output_file"
assert_equal "$status" "0"
local result_file="$RALPH_DIR/.json_parse_result"
[[ -f "$result_file" ]]
# Should count denials correctly
local denial_count=$(jq -r '.permission_denial_count' "$result_file")
assert_equal "$denial_count" "3"
}
@test "parse_json_response extracts denied_commands list" {
local output_file="$LOG_DIR/test_output.log"
cat > "$output_file" << 'EOF'
{
"result": "Permission denied for npm install",
"sessionId": "session-extract-cmds",
"permission_denials": [
{"tool": "Bash", "command": "npm install express", "reason": "Not allowed"}
]
}
EOF
run parse_json_response "$output_file"
assert_equal "$status" "0"
local result_file="$RALPH_DIR/.json_parse_result"
[[ -f "$result_file" ]]
# Should extract the denied commands
local denied_cmds=$(jq -r '.denied_commands[0]' "$result_file")
[[ "$denied_cmds" == *"npm install"* ]]
}
@test "parse_json_response handles empty permission_denials array" {
local output_file="$LOG_DIR/test_output.log"
cat > "$output_file" << 'EOF'
{
"result": "All commands executed successfully.",
"sessionId": "session-no-denials",
"permission_denials": []
}
EOF
run parse_json_response "$output_file"
assert_equal "$status" "0"
local result_file="$RALPH_DIR/.json_parse_result"
[[ -f "$result_file" ]]
# Should set has_permission_denials to false
local has_denials=$(jq -r '.has_permission_denials' "$result_file")
assert_equal "$has_denials" "false"
local denial_count=$(jq -r '.permission_denial_count' "$result_file")
assert_equal "$denial_count" "0"
}
@test "parse_json_response handles missing permission_denials field (backward compat)" {
local output_file="$LOG_DIR/test_output.log"
# Old format without permission_denials field
cat > "$output_file" << 'EOF'
{
"status": "COMPLETE",
"exit_signal": true,
"work_type": "IMPLEMENTATION",
"files_modified": 5
}
EOF
run parse_json_response "$output_file"
assert_equal "$status" "0"
local result_file="$RALPH_DIR/.json_parse_result"
[[ -f "$result_file" ]]
# Should default to no denials
local has_denials=$(jq -r '.has_permission_denials' "$result_file")
assert_equal "$has_denials" "false"
local denial_count=$(jq -r '.permission_denial_count' "$result_file")
assert_equal "$denial_count" "0"
}
@test "analyze_response includes permission denial info in analysis result" {
local output_file="$LOG_DIR/test_output.log"
cat > "$output_file" << 'EOF'
{
"result": "Tried npm install but permission was denied.",
"sessionId": "session-analyze-denial",
"permission_denials": [
{"tool": "Bash", "command": "npm install", "reason": "Tool not allowed"}
]
}
EOF
analyze_response "$output_file" 1
assert_file_exists "$RALPH_DIR/.response_analysis"
# Should include permission denial in analysis
local has_denials=$(jq -r '.analysis.has_permission_denials' "$RALPH_DIR/.response_analysis")
assert_equal "$has_denials" "true"
local denial_count=$(jq -r '.analysis.permission_denial_count' "$RALPH_DIR/.response_analysis")
assert_equal "$denial_count" "1"
}
@test "parse_json_response handles Claude CLI array format with permission denials" {
local output_file="$LOG_DIR/test_output.log"
# Claude CLI array format with permission denials in result
cat > "$output_file" << 'EOF'
[
{"type": "system", "subtype": "init", "session_id": "session-array-deny"},
{"type": "assistant", "message": {"content": [{"type": "text", "text": "Trying to install..."}]}},
{
"type": "result",
"subtype": "success",
"result": "Could not run npm install - permission denied",
"session_id": "session-array-deny",
"permission_denials": [
{"tool": "Bash", "command": "npm install", "reason": "Not in allowed tools"}
]
}
]
EOF
run parse_json_response "$output_file"
assert_equal "$status" "0"
local result_file="$RALPH_DIR/.json_parse_result"
[[ -f "$result_file" ]]
local has_denials=$(jq -r '.has_permission_denials' "$result_file")
assert_equal "$has_denials" "true"
}