security: kill the child process group when a command times out (#23)#79
Conversation
|
Warning Review limit reached
Next review available in: 16 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Repository UI (base), Organization UI (inherited) Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (2)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Why
Timeout+capture3failed to kill the childRunCommand.executepreviously wrappedOpen3.capture3inTimeout.timeout(max_time). When the deadline fired,Timeout::Errorwas raised in the Ruby thread, butOpen3.capture3's ownensurecallswait_thr.value, which blocks waiting for the child to exit. The spawned process was therefore never signalled: a hung or runaway allowed command (e.g. an infinite test loop, a fork bomb via an allowed binary) kept running on the host while the caller got a "timed out" message. The command was also not placed in its own process group, so any children it forked were unreachable for cleanup.The fix: process-group spawn + watchdog + TERM/KILL the group
executenow resolves the invocation and hands it to a newcaptureexecutor:Open3.popen3(*command, pgroup: true)(host addschdir:), making the child its own process-group leader (pgid == pid).wait_thr.join(max_time)waits up to the configuredmax_execution_time. If it returnsnilthe command overran.terminate_process_groupsendsSIGTERMto the negated pgid (Process.kill('TERM', -pgid)), waits a short grace period (TERM_GRACE_PERIOD = 2s), then escalates toSIGKILLif it has not exited. Signalling the negated group id reaps the command and any children it forked.signal_groupignoresErrno::ESRCH(group already gone).popen3's block form performs the final reap on exit.No-deadlock stdout/stderr reading
STDOUT and STDERR are drained on two separate threads (
Thread.new { stdout.read }), so a chatty or hung child can never deadlock by filling a pipe buffer while the reader waits on the other stream. The reader threads are joined (readers.map(&:value)) before the block returns — after the child dies its write ends close, the reads hit EOF, and the threads finish cleanly.Preserved behavior (fail-closed security + return shape)
This change only replaces the execution + timeout mechanism. All recently-merged guards are untouched:
DANGEROUS_COMMANDSblocklist,allowed_commandsallowlist, and the fail-closedHOST_EXECUTION_REFUSED(refuses host execution unlessallow_host_executionis true or a container is active), plus the one-time host-execution warning.shellsplit+ argv invocation (no shell interpretation).Exit Status:/STDOUT:/STDERR:) and the identical timeout stringError: Command execution timed out after N seconds.so callers/tests keying on it still work.docker exec -w /sandbox <id> ...invocation (now also timeout-protected and run in its own group).Tests
test_call_returns_timeout_result_without_waiting_for_full_runtime— a 1s timeout againstsleep 30returns the exact timeout string and the call comes back well before the full 30s sleep (proves the watchdog no longer blocks onwait_thr.value).test_call_kills_runaway_child_process_on_timeout— proof the child is actually terminated: after timeout,pgrep -P <pid> sleepfinds no lingering child (the process group was killed and reaped). Verified meaningful: while alive the child is detected; after the group TERM + reap it is gone.echosuccess, fail-closed refusal (now assertsOpen3.popen3is never invoked), host warning, and the containerdocker execinvocation.Gates (all green from repo root)
bundle exec rubocop— 272 files, no offensesbundle exec reek— cleanbundle exec rake test— 762 runs, 0 failures, 0 errors (4 pre-existing skips)bundle exec rake yard:coverage— 0 undocumented public objectsCloses #23