Skip to content

Commit 9bc7cc1

Browse files
committed
fix(security): validate RALPH_DIR, parse .ralphrc safely, check PLATFORM_DRIVER (#76, #77, #79)
Replace `source .ralphrc` with a safe key=value parser that rejects command substitution ($(), backticks) and only sets allowlisted config keys. Validate RALPH_DIR to reject path traversal (..) and non-default relative paths. Add path traversal checks to PLATFORM_DRIVER in both ralph_loop.sh and ralph_import.sh. Guard RALPH_DIR in library files to prevent silent fallback to unvalidated defaults. Closes #76, closes #77, closes #79
1 parent 186c3ff commit 9bc7cc1

9 files changed

Lines changed: 297 additions & 14 deletions

File tree

ralph/lib/circuit_breaker.sh

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,11 @@ CB_STATE_HALF_OPEN="HALF_OPEN" # Monitoring mode, checking for recovery
1212
CB_STATE_OPEN="OPEN" # Failure detected, execution halted
1313

1414
# Circuit Breaker Configuration
15-
# Use RALPH_DIR if set by main script, otherwise default to .ralph
16-
RALPH_DIR="${RALPH_DIR:-.ralph}"
15+
# RALPH_DIR must be set by the caller (ralph_loop.sh validates it)
16+
if [[ -z "${RALPH_DIR:-}" ]]; then
17+
echo "Error: RALPH_DIR is not set. Source ralph_loop.sh first." >&2
18+
return 1
19+
fi
1720
CB_STATE_FILE="$RALPH_DIR/.circuit_breaker_state"
1821
CB_HISTORY_FILE="$RALPH_DIR/.circuit_breaker_history"
1922
# Configurable thresholds - override via environment variables:

ralph/lib/response_analyzer.sh

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,11 @@ YELLOW='\033[1;33m'
1515
BLUE='\033[0;34m'
1616
NC='\033[0m'
1717

18-
# Use RALPH_DIR if set by main script, otherwise default to .ralph
19-
RALPH_DIR="${RALPH_DIR:-.ralph}"
18+
# RALPH_DIR must be set by the caller (ralph_loop.sh validates it)
19+
if [[ -z "${RALPH_DIR:-}" ]]; then
20+
echo "Error: RALPH_DIR is not set. Source ralph_loop.sh first." >&2
21+
return 1
22+
fi
2023

2124
# Analysis configuration
2225
COMPLETION_KEYWORDS=("done" "complete" "finished" "all tasks complete" "project complete" "ready for review")

ralph/lib/write_heartbeat.sh

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,11 @@ start_write_heartbeat() {
8282
# Stop the heartbeat monitor and clean up marker files.
8383
# Safe to call even if no monitor is running.
8484
stop_write_heartbeat() {
85-
local ralph_dir="${RALPH_DIR:-.ralph}"
85+
if [[ -z "${RALPH_DIR:-}" ]]; then
86+
echo "Error: RALPH_DIR is not set" >&2
87+
return 1
88+
fi
89+
local ralph_dir="$RALPH_DIR"
8690
local pid_file="$ralph_dir/.write_heartbeat_pid"
8791

8892
if [[ -f "$pid_file" ]]; then
@@ -102,6 +106,10 @@ stop_write_heartbeat() {
102106
# Returns 0 (true) if the timeout marker exists.
103107
# Does NOT delete the marker — the caller is responsible for cleanup.
104108
was_write_heartbeat_timeout() {
105-
local ralph_dir="${RALPH_DIR:-.ralph}"
109+
if [[ -z "${RALPH_DIR:-}" ]]; then
110+
echo "Error: RALPH_DIR is not set" >&2
111+
return 1
112+
fi
113+
local ralph_dir="$RALPH_DIR"
106114
[[ -f "$ralph_dir/.write_heartbeat_triggered" ]]
107115
}

ralph/ralph_import.sh

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ CLAUDE_CODE_CMD="claude"
1515
SCRIPT_DIR="$(dirname "${BASH_SOURCE[0]}")"
1616
PLATFORM_DRIVER="${PLATFORM_DRIVER:-claude-code}"
1717

18+
# Reject path traversal in PLATFORM_DRIVER (#77)
19+
if [[ "$PLATFORM_DRIVER" =~ [/] ]] || [[ "$PLATFORM_DRIVER" == *".."* ]]; then
20+
echo "Error: Invalid PLATFORM_DRIVER: $PLATFORM_DRIVER" >&2
21+
exit 1
22+
fi
23+
1824
# Source platform driver if available
1925
if [[ -f "$SCRIPT_DIR/drivers/${PLATFORM_DRIVER}.sh" ]]; then
2026
# shellcheck source=/dev/null

ralph/ralph_loop.sh

Lines changed: 76 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,23 @@ set -e # Exit on any error
1010
# via --allowedTools flag in CLAUDE_CMD_ARGS, which is the proper approach.
1111
# Exporting sandbox variables without a verified sandbox would be misleading.
1212

13-
# Source library components
13+
# validate_ralph_dir - Reject dangerous RALPH_DIR values (#79)
14+
# Accepts: ".ralph" (default), empty (becomes .ralph), absolute paths (tests)
15+
# Rejects: paths with ".." (traversal), non-default relative paths
16+
validate_ralph_dir() {
17+
local dir="$1"
18+
[[ "$dir" == ".ralph" || -z "$dir" ]] && return 0
19+
[[ "$dir" == *".."* ]] && { echo "Error: RALPH_DIR must not contain '..': '$dir'" >&2; return 1; }
20+
[[ "$dir" != /* ]] && { echo "Error: RALPH_DIR must be '.ralph' or an absolute path, got: '$dir'" >&2; return 1; }
21+
return 0
22+
}
23+
24+
# Validate and default RALPH_DIR BEFORE sourcing libraries that depend on it
25+
validate_ralph_dir "${RALPH_DIR:-}" || exit 1
26+
RALPH_DIR="${RALPH_DIR:-.ralph}"
27+
export RALPH_DIR
28+
29+
# Source library components (RALPH_DIR must be set before this point)
1430
SCRIPT_DIR="$(dirname "${BASH_SOURCE[0]}")"
1531
source "$SCRIPT_DIR/lib/date_utils.sh"
1632
source "$SCRIPT_DIR/lib/timeout_utils.sh"
@@ -20,7 +36,57 @@ source "$SCRIPT_DIR/lib/write_heartbeat.sh"
2036

2137
# Configuration
2238
# Ralph-specific files live in .ralph/ subfolder
23-
RALPH_DIR="${RALPH_DIR:-.ralph}"
39+
40+
# Allowlist of known .ralphrc config keys (#76)
41+
# Space-delimited string (avoids declare -A scoping issues when sourced)
42+
RALPHRC_ALLOWED_KEYS=" PLATFORM_DRIVER PROJECT_NAME PROJECT_TYPE MAX_CALLS_PER_HOUR CLAUDE_TIMEOUT_MINUTES CLAUDE_OUTPUT_FORMAT WRITE_TIMEOUT_MINUTES ALLOWED_TOOLS CLAUDE_PERMISSION_MODE PERMISSION_DENIAL_MODE SESSION_CONTINUITY SESSION_EXPIRY_HOURS TASK_SOURCES GITHUB_TASK_LABEL BEADS_FILTER CB_NO_PROGRESS_THRESHOLD CB_SAME_ERROR_THRESHOLD CB_OUTPUT_DECLINE_THRESHOLD CB_READ_ONLY_TIMEOUT_THRESHOLD CB_COOLDOWN_MINUTES CB_AUTO_RESET TEST_COMMAND QUALITY_GATES QUALITY_GATE_MODE QUALITY_GATE_TIMEOUT QUALITY_GATE_ON_COMPLETION_ONLY REVIEW_MODE REVIEW_ENABLED REVIEW_INTERVAL CLAUDE_MIN_VERSION RALPH_VERBOSE PROMPT_FILE FIX_PLAN_FILE AGENT_FILE "
43+
44+
# parse_ralphrc - Safely parse .ralphrc as key=value config (#76)
45+
# Rejects command substitution ($(), backticks). Only sets allowlisted keys.
46+
parse_ralphrc() {
47+
local config_file="$1"
48+
local line_num=0
49+
while IFS= read -r line || [[ -n "$line" ]]; do
50+
line_num=$((line_num + 1))
51+
# Strip leading/trailing whitespace
52+
line="${line#"${line%%[![:space:]]*}"}"
53+
line="${line%"${line##*[![:space:]]}"}"
54+
# Skip empty lines and comments
55+
[[ -z "$line" || "$line" == \#* ]] && continue
56+
# Reject command substitution ($() and backticks)
57+
if [[ "$line" == *'$('* ]] || [[ "$line" == *'`'* ]]; then
58+
log_status "WARN" ".ralphrc:$line_num: rejected (command substitution): $line"
59+
continue
60+
fi
61+
# Match KEY=VALUE
62+
if [[ "$line" =~ ^([A-Z_][A-Z0-9_]*)=(.*) ]]; then
63+
local key="${BASH_REMATCH[1]}"
64+
local raw_value="${BASH_REMATCH[2]}"
65+
# Check allowlist
66+
if [[ "$RALPHRC_ALLOWED_KEYS" != *" $key "* ]]; then
67+
log_status "WARN" ".ralphrc:$line_num: unknown key ignored: $key"
68+
continue
69+
fi
70+
# Strip outer quotes
71+
local value="$raw_value"
72+
if [[ "$value" =~ ^\"(.*)\"$ ]]; then
73+
value="${BASH_REMATCH[1]}"
74+
elif [[ "$value" =~ ^\'(.*)\'$ ]]; then
75+
value="${BASH_REMATCH[1]}"
76+
fi
77+
# Handle ${VAR:-default} and ${VAR:-} (empty default)
78+
if [[ "$value" =~ ^\$\{([A-Z_][A-Z0-9_]*):-([^}]*)\}$ ]]; then
79+
local ref_var="${BASH_REMATCH[1]}"
80+
local default_val="${BASH_REMATCH[2]}"
81+
local current="${!ref_var:-}"
82+
value="${current:-$default_val}"
83+
fi
84+
printf -v "$key" '%s' "$value"
85+
else
86+
log_status "WARN" ".ralphrc:$line_num: skipped (not KEY=VALUE): $line"
87+
fi
88+
done < "$config_file"
89+
}
2490
PROMPT_FILE="$RALPH_DIR/PROMPT.md"
2591
LOG_DIR="$RALPH_DIR/logs"
2692
DOCS_DIR="$RALPH_DIR/docs/generated"
@@ -164,7 +230,7 @@ TEST_PERCENTAGE_THRESHOLD=30 # If more than 30% of recent loops are test-only,
164230
# Ralph configuration file
165231
# bmalph installs .ralph/.ralphrc. Fall back to a project-root .ralphrc for
166232
# older standalone Ralph layouts.
167-
RALPHRC_FILE="${RALPHRC_FILE:-$RALPH_DIR/.ralphrc}"
233+
RALPHRC_FILE="$RALPH_DIR/.ralphrc"
168234
RALPHRC_LOADED=false
169235

170236
# Platform driver (set from .ralphrc or environment)
@@ -214,9 +280,8 @@ load_ralphrc() {
214280
return 0
215281
fi
216282

217-
# Source config (this may override default values)
218-
# shellcheck source=/dev/null
219-
source "$config_file"
283+
# Parse config as safe key=value pairs (#76 — no longer sourced as bash)
284+
parse_ralphrc "$config_file"
220285

221286
# Map config variable names to internal names
222287
if [[ -n "${ALLOWED_TOOLS:-}" ]]; then
@@ -300,6 +365,11 @@ driver_permission_denial_help() {
300365

301366
# Source platform driver
302367
load_platform_driver() {
368+
# Reject path traversal in PLATFORM_DRIVER (#77)
369+
if [[ "$PLATFORM_DRIVER" =~ [/] ]] || [[ "$PLATFORM_DRIVER" == *".."* ]]; then
370+
log_status "ERROR" "Invalid PLATFORM_DRIVER (path traversal): $PLATFORM_DRIVER"
371+
return 1
372+
fi
303373
local driver_file="$SCRIPT_DIR/drivers/${PLATFORM_DRIVER}.sh"
304374
if [[ ! -f "$driver_file" ]]; then
305375
log_status "ERROR" "Platform driver not found: $driver_file"

ralph/templates/ralphrc.template

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
# .ralphrc - Ralph project configuration
2-
# Generated by: ralph enable
3-
# Documentation: https://github.com/frankbria/ralph-claude-code
2+
# Generated by: bmalph init
3+
# Documentation: https://github.com/LarsCowe/bmalph
4+
#
5+
# SECURITY: This file is parsed as KEY=VALUE pairs, NOT sourced as bash.
6+
# Only recognized config keys are accepted. Shell constructs ($(), backticks)
7+
# are rejected. Supported value forms: VALUE, "VALUE", 'VALUE', "${VAR:-default}"
48
#
59
# This file configures Ralph's behavior for this specific project.
610
# Values here override global Ralph defaults.

src/installer/template-files.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,18 @@ const CLAUDE_ALLOWED_TOOLS_TEMPLATE_LINE =
1414
'ALLOWED_TOOLS="Write,Read,Edit,MultiEdit,Glob,Grep,Task,TodoWrite,WebFetch,WebSearch,EnterPlanMode,ExitPlanMode,NotebookEdit,Bash"';
1515
const PREVIOUS_CLAUDE_ALLOWED_TOOLS_TEMPLATE_LINE =
1616
'ALLOWED_TOOLS="Write,Read,Edit,MultiEdit,Glob,Grep,Task,TodoWrite,WebFetch,WebSearch,NotebookEdit,Bash"';
17+
const RALPHRC_HEADER_BLOCK = `# .ralphrc - Ralph project configuration
18+
# Generated by: bmalph init
19+
# Documentation: https://github.com/LarsCowe/bmalph
20+
#
21+
# SECURITY: This file is parsed as KEY=VALUE pairs, NOT sourced as bash.
22+
# Only recognized config keys are accepted. Shell constructs ($(), backticks)
23+
# are rejected. Supported value forms: VALUE, "VALUE", 'VALUE', "\${VAR:-default}"
24+
#`;
25+
const PREVIOUS_RALPHRC_HEADER_BLOCK = `# .ralphrc - Ralph project configuration
26+
# Generated by: ralph enable
27+
# Documentation: https://github.com/frankbria/ralph-claude-code
28+
#`;
1729
const RALPHRC_DRIVER_COMMENT_LINE =
1830
"# Platform driver for Ralph loop (claude-code, codex, opencode, copilot, or cursor)";
1931
const PREVIOUS_RALPHRC_DRIVER_COMMENT_LINE =
@@ -210,6 +222,7 @@ function renderLegacyRalphrcTemplate(platformId: string): string {
210222

211223
function normalizeManagedRalphrcContent(content: string): string {
212224
return content
225+
.replace(PREVIOUS_RALPHRC_HEADER_BLOCK, RALPHRC_HEADER_BLOCK)
213226
.replace(PREVIOUS_RALPHRC_DRIVER_COMMENT_LINE, RALPHRC_DRIVER_COMMENT_LINE)
214227
.replace(PREVIOUS_RALPHRC_IGNORED_DRIVERS_COMMENT_LINE, RALPHRC_IGNORED_DRIVERS_COMMENT_LINE);
215228
}

tests/bash/ralph_loop.bats

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,164 @@ _enable_assertions() {
123123
eval "${_bats_err_trap:-}"
124124
}
125125

126+
# ===========================================================================
127+
# validate_ralph_dir
128+
# ===========================================================================
129+
130+
@test "validate_ralph_dir accepts .ralph" {
131+
_enable_assertions
132+
run validate_ralph_dir ".ralph"
133+
assert_success
134+
}
135+
136+
@test "validate_ralph_dir accepts empty string (will default to .ralph)" {
137+
_enable_assertions
138+
run validate_ralph_dir ""
139+
assert_success
140+
}
141+
142+
@test "validate_ralph_dir accepts absolute path" {
143+
_enable_assertions
144+
run validate_ralph_dir "/tmp/bats-test-ralph"
145+
assert_success
146+
}
147+
148+
@test "validate_ralph_dir rejects path traversal" {
149+
_enable_assertions
150+
run validate_ralph_dir "../../etc"
151+
assert_failure
152+
assert_output --partial "must not contain '..'"
153+
}
154+
155+
@test "validate_ralph_dir rejects relative non-default path" {
156+
_enable_assertions
157+
run validate_ralph_dir "foo/bar"
158+
assert_failure
159+
assert_output --partial "must be '.ralph' or an absolute path"
160+
}
161+
162+
# ===========================================================================
163+
# load_platform_driver
164+
# ===========================================================================
165+
166+
@test "load_platform_driver rejects path traversal" {
167+
_enable_assertions
168+
PLATFORM_DRIVER="../../evil"
169+
run load_platform_driver
170+
assert_failure
171+
assert_output --partial "Invalid PLATFORM_DRIVER"
172+
}
173+
174+
@test "load_platform_driver rejects slash in name" {
175+
_enable_assertions
176+
PLATFORM_DRIVER="foo/bar"
177+
run load_platform_driver
178+
assert_failure
179+
assert_output --partial "Invalid PLATFORM_DRIVER"
180+
}
181+
182+
@test "load_platform_driver rejects nonexistent driver" {
183+
_enable_assertions
184+
PLATFORM_DRIVER="nonexistent"
185+
run load_platform_driver
186+
assert_failure
187+
assert_output --partial "not found"
188+
}
189+
190+
# ===========================================================================
191+
# parse_ralphrc
192+
# ===========================================================================
193+
194+
@test "parse_ralphrc sets known key" {
195+
_enable_assertions
196+
echo 'MAX_CALLS_PER_HOUR=50' > "$RALPH_DIR/test.conf"
197+
parse_ralphrc "$RALPH_DIR/test.conf"
198+
[[ "$MAX_CALLS_PER_HOUR" == "50" ]]
199+
}
200+
201+
@test "parse_ralphrc handles double-quoted value" {
202+
_enable_assertions
203+
echo 'CLAUDE_OUTPUT_FORMAT="json"' > "$RALPH_DIR/test.conf"
204+
parse_ralphrc "$RALPH_DIR/test.conf"
205+
[[ "$CLAUDE_OUTPUT_FORMAT" == "json" ]]
206+
}
207+
208+
@test "parse_ralphrc handles single-quoted value" {
209+
_enable_assertions
210+
echo "CLAUDE_OUTPUT_FORMAT='text'" > "$RALPH_DIR/test.conf"
211+
parse_ralphrc "$RALPH_DIR/test.conf"
212+
[[ "$CLAUDE_OUTPUT_FORMAT" == "text" ]]
213+
}
214+
215+
@test "parse_ralphrc handles \${VAR:-default} with env unset" {
216+
_enable_assertions
217+
unset PROJECT_NAME
218+
echo 'PROJECT_NAME="${PROJECT_NAME:-my-project}"' > "$RALPH_DIR/test.conf"
219+
parse_ralphrc "$RALPH_DIR/test.conf"
220+
[[ "$PROJECT_NAME" == "my-project" ]]
221+
}
222+
223+
@test "parse_ralphrc handles \${VAR:-default} with env set" {
224+
_enable_assertions
225+
export PROJECT_NAME="real-project"
226+
echo 'PROJECT_NAME="${PROJECT_NAME:-my-project}"' > "$RALPH_DIR/test.conf"
227+
parse_ralphrc "$RALPH_DIR/test.conf"
228+
[[ "$PROJECT_NAME" == "real-project" ]]
229+
}
230+
231+
@test "parse_ralphrc handles \${VAR:-} empty default" {
232+
_enable_assertions
233+
unset TEST_COMMAND
234+
echo 'TEST_COMMAND="${TEST_COMMAND:-}"' > "$RALPH_DIR/test.conf"
235+
parse_ralphrc "$RALPH_DIR/test.conf"
236+
[[ "$TEST_COMMAND" == "" ]]
237+
}
238+
239+
@test "parse_ralphrc allows semicolons in value" {
240+
_enable_assertions
241+
echo 'QUALITY_GATES="npm run lint;npm test"' > "$RALPH_DIR/test.conf"
242+
parse_ralphrc "$RALPH_DIR/test.conf"
243+
[[ "$QUALITY_GATES" == "npm run lint;npm test" ]]
244+
}
245+
246+
@test "parse_ralphrc rejects command substitution" {
247+
_enable_assertions
248+
echo 'PROJECT_NAME=$(echo pwned)' > "$RALPH_DIR/test.conf"
249+
MAX_CALLS_PER_HOUR=100
250+
parse_ralphrc "$RALPH_DIR/test.conf"
251+
# PROJECT_NAME should not be set to the result of command substitution
252+
[[ "${PROJECT_NAME:-}" != "pwned" ]]
253+
}
254+
255+
@test "parse_ralphrc rejects backtick execution" {
256+
_enable_assertions
257+
echo 'PROJECT_NAME=`echo pwned`' > "$RALPH_DIR/test.conf"
258+
parse_ralphrc "$RALPH_DIR/test.conf"
259+
[[ "${PROJECT_NAME:-}" != "pwned" ]]
260+
}
261+
262+
@test "parse_ralphrc ignores unknown key with warning" {
263+
_enable_assertions
264+
echo 'UNKNOWN_KEY=foobar' > "$RALPH_DIR/test.conf"
265+
run parse_ralphrc "$RALPH_DIR/test.conf"
266+
assert_success
267+
assert_output --partial "unknown key ignored"
268+
}
269+
270+
@test "parse_ralphrc skips comments and blank lines" {
271+
_enable_assertions
272+
printf '# comment\n\nMAX_CALLS_PER_HOUR=42\n' > "$RALPH_DIR/test.conf"
273+
parse_ralphrc "$RALPH_DIR/test.conf"
274+
[[ "$MAX_CALLS_PER_HOUR" == "42" ]]
275+
}
276+
277+
@test "RALPHRC_FILE env override is ignored" {
278+
_enable_assertions
279+
# After sourcing ralph_loop.sh, RALPHRC_FILE should be derived from RALPH_DIR
280+
# not from an env var
281+
[[ "$RALPHRC_FILE" == "$RALPH_DIR/.ralphrc" ]]
282+
}
283+
126284
# ===========================================================================
127285
# load_ralphrc
128286
# ===========================================================================

0 commit comments

Comments
 (0)