diff --git a/.agents/scripts/supervisor-archived/sanity-check.sh b/.agents/scripts/supervisor-archived/sanity-check.sh index 9b0f557a0e..3d5cd176c2 100755 --- a/.agents/scripts/supervisor-archived/sanity-check.sh +++ b/.agents/scripts/supervisor-archived/sanity-check.sh @@ -36,6 +36,22 @@ SUPERVISOR_SANITY_AI_TIMEOUT="${SUPERVISOR_SANITY_AI_TIMEOUT:-120}" # Log directory for sanity check AI reasoning SANITY_AI_LOG_DIR="${SANITY_AI_LOG_DIR:-$HOME/.aidevops/logs/ai-supervisor}" +####################################### +# Escape a string for safe use in grep/sed regex patterns. +# Escapes all BRE/ERE metacharacters, not just dots. +# Prevents regex injection when task IDs or blocker IDs contain +# special characters (., /, [, ], *, (, ), &, ^, $, +, ?, {, }, |, \). +# Args: +# $1 - raw string to escape +# Returns: +# Escaped string on stdout +####################################### +_escape_regex() { + # shellcheck disable=SC2016 # Single quotes intentional — sed needs literal \& + printf '%s' "$1" | sed 's/[].\/[*^$()+?{}|\\]/\\&/g' + return 0 +} + ####################################### # Phase 0.9: Sanity check — AI-powered stall diagnosis # @@ -241,7 +257,11 @@ _build_sanity_state_snapshot() { # 1. Open tasks from TODO.md with metadata snapshot+="### Open Tasks (TODO.md)\n" local open_tasks - open_tasks=$(grep -E '^\s*- \[ \] t[0-9]+' "$todo_file" 2>/dev/null || echo "") + if [[ -f "$todo_file" ]]; then + open_tasks=$(grep -E '^\s*- \[ \] t[0-9]+' "$todo_file" || echo "") + else + open_tasks="" + fi if [[ -n "$open_tasks" ]]; then snapshot+="$open_tasks\n" else @@ -322,8 +342,8 @@ _build_sanity_state_snapshot() { while IFS='|' read -r oid ostatus; do [[ -z "$oid" ]] && continue local escaped_oid - escaped_oid=$(printf '%s' "$oid" | sed 's/\./\\./g') - if ! grep -qE "^[[:space:]]*- \[.\] ${escaped_oid}( |$)" "$todo_file" 2>/dev/null; then + escaped_oid=$(_escape_regex "$oid") + if [[ ! -f "$todo_file" ]] || ! grep -qE "^[[:space:]]*- \[.\] ${escaped_oid}( |$)" "$todo_file"; then snapshot+="$oid ($ostatus): IN DB but NOT in TODO.md\n" fi done <<<"$orphans" @@ -484,14 +504,14 @@ _build_sanity_state_snapshot() { WHERE repo = '$(sql_escape "$repo_path")' AND status IN ('complete', 'verified', 'deployed', 'merged') ORDER BY id; - " 2>/dev/null || echo "") + " || echo "") if [[ -n "$completed_stale" ]]; then local stale_found=false while IFS='|' read -r csid csstatus cspr; do [[ -z "$csid" ]] && continue local escaped_csid - escaped_csid=$(printf '%s' "$csid" | sed 's/\./\\./g') - if grep -qE "^[[:space:]]*- \[ \] ${escaped_csid}( |$)" "$todo_file" 2>/dev/null; then + escaped_csid=$(_escape_regex "$csid") + if [[ -f "$todo_file" ]] && grep -qE "^[[:space:]]*- \[ \] ${escaped_csid}( |$)" "$todo_file"; then if [[ "$stale_found" == false ]]; then snapshot+="id|db_status|pr_url (these need trigger_update_todo, NOT reset)\n" stale_found=true @@ -528,12 +548,25 @@ _build_sanity_state_snapshot() { _build_sanity_prompt() { local state_snapshot="$1" + # Sanitize state snapshot to mitigate prompt injection from DB data (GH#2866). + # Strip control characters and null bytes that could break prompt parsing. + # The snapshot is wrapped in DATA boundary markers so the AI treats it as + # structured data, not as instructions to follow. + local sanitized_snapshot + sanitized_snapshot=$(printf '%s' "$state_snapshot" | tr -d '\000-\010\016-\037') + cat < +$sanitized_snapshot + ## CRITICAL: Directional Authority Rule (t2838) @@ -690,6 +723,14 @@ _execute_sanity_action() { return 1 fi + # Validate task_id format to prevent regex/sed injection (GH#2866). + # Task IDs must match t[0-9]+ with optional dot-separated sub-IDs (e.g., t025.3), + # or special values like "pipeline" for log_only actions. + if [[ "$action_type" != "log_only" ]] && ! [[ "$task_id" =~ ^t[0-9]+(\.[0-9]+)*$ ]]; then + echo "invalid task_id format: $task_id (expected t[.])" + return 1 + fi + log_verbose " Sanity AI action: $action_type on $task_id — $reasoning" # Guard: NEVER downgrade tasks in advanced DB states (t2838) @@ -697,7 +738,7 @@ _execute_sanity_action() { # than TODO.md. Completed tasks showing [ ] in TODO.md need their TODO.md # updated (via trigger_update_todo), NOT their DB state reset to queued. local _current_db_status="" - _current_db_status=$(db "$SUPERVISOR_DB" "SELECT status FROM tasks WHERE id = '$(sql_escape "$task_id")';" 2>/dev/null || echo "") + _current_db_status=$(db "$SUPERVISOR_DB" "SELECT status FROM tasks WHERE id = '$(sql_escape "$task_id")';" || echo "") case "$action_type" in unclaim | reset) case "$_current_db_status" in @@ -717,8 +758,8 @@ _execute_sanity_action() { if cmd_unclaim "$task_id" "$repo_path" --force >>"${SUPERVISOR_LOG:-/dev/null}" 2>&1; then # If task has retries available, reset to queued local retries max_retries - retries=$(db "$SUPERVISOR_DB" "SELECT COALESCE(retries, 0) FROM tasks WHERE id = '$(sql_escape "$task_id")';" 2>/dev/null || echo "0") - max_retries=$(db "$SUPERVISOR_DB" "SELECT COALESCE(max_retries, 3) FROM tasks WHERE id = '$(sql_escape "$task_id")';" 2>/dev/null || echo "3") + retries=$(db "$SUPERVISOR_DB" "SELECT COALESCE(retries, 0) FROM tasks WHERE id = '$(sql_escape "$task_id")';" || echo "0") + max_retries=$(db "$SUPERVISOR_DB" "SELECT COALESCE(max_retries, 3) FROM tasks WHERE id = '$(sql_escape "$task_id")';" || echo "3") retries="${retries:-0}" max_retries="${max_retries:-3}" if [[ "$retries" -lt "$max_retries" ]]; then @@ -763,7 +804,7 @@ _execute_sanity_action() { fi local escaped_task_id - escaped_task_id=$(printf '%s' "$task_id" | sed 's/\./\\./g') + escaped_task_id=$(_escape_regex "$task_id") local line_num line_num=$(grep -nE "^[[:space:]]*- \[ \] ${escaped_task_id}( |$)" "$todo_file" | head -1 | cut -d: -f1 || echo "") @@ -784,7 +825,7 @@ _execute_sanity_action() { fi local escaped_blocker - escaped_blocker=$(printf '%s' "$blocker_id" | sed 's/\./\\./g') + escaped_blocker=$(_escape_regex "$blocker_id") if [[ "$blocked_by" == "$blocker_id" ]]; then # Only blocker — remove the whole field @@ -794,7 +835,7 @@ _execute_sanity_action() { local new_blockers new_blockers=$(printf '%s' ",$blocked_by," | sed "s/,${escaped_blocker},/,/" | sed 's/^,//;s/,$//') local escaped_blocked_by - escaped_blocked_by=$(printf '%s' "$blocked_by" | sed 's/\./\\./g') + escaped_blocked_by=$(_escape_regex "$blocked_by") if [[ -n "$new_blockers" ]]; then sed_inplace "${line_num}s/blocked-by:${escaped_blocked_by}/blocked-by:${new_blockers}/" "$todo_file" else @@ -811,7 +852,7 @@ _execute_sanity_action() { add_auto_dispatch) # Add #auto-dispatch tag to an eligible task local escaped_task_id - escaped_task_id=$(printf '%s' "$task_id" | sed 's/\./\\./g') + escaped_task_id=$(_escape_regex "$task_id") local line_num line_num=$(grep -nE "^[[:space:]]*- \[ \] ${escaped_task_id}( |$)" "$todo_file" | head -1 | cut -d: -f1 || echo "") @@ -862,7 +903,7 @@ _execute_sanity_action() { # This is the correct fix when DB shows complete/verified/deployed but TODO.md shows [ ]. # The DB is authoritative — we update TODO.md to match, NOT reset DB to queued. if [[ -z "$_current_db_status" ]]; then - _current_db_status=$(db "$SUPERVISOR_DB" "SELECT status FROM tasks WHERE id = '$(sql_escape "$task_id")';" 2>/dev/null || echo "") + _current_db_status=$(db "$SUPERVISOR_DB" "SELECT status FROM tasks WHERE id = '$(sql_escape "$task_id")';" || echo "") fi case "$_current_db_status" in complete | verified | deployed | merged) @@ -877,10 +918,10 @@ _execute_sanity_action() { local today today=$(date +%Y-%m-%d) local escaped_task_id - escaped_task_id=$(printf '%s' "$task_id" | sed 's/\./\\./g') - if grep -qE "^[[:space:]]*- \[ \] ${escaped_task_id}( |$)" "$todo_file" 2>/dev/null; then + escaped_task_id=$(_escape_regex "$task_id") + if [[ -f "$todo_file" ]] && grep -qE "^[[:space:]]*- \[ \] ${escaped_task_id}( |$)" "$todo_file"; then sed_inplace -E "s/^([[:space:]]*- )\[ \] (${escaped_task_id} .*)$/\1[x] \2 verified:${today} completed:${today}/" "$todo_file" - if grep -qE "^[[:space:]]*- \[x\] ${escaped_task_id} " "$todo_file" 2>/dev/null; then + if grep -qE "^[[:space:]]*- \[x\] ${escaped_task_id} " "$todo_file"; then commit_and_push_todo "$repo_path" "chore: force-mark $task_id complete in TODO.md (DB=$_current_db_status, t2838)" >>"${SUPERVISOR_LOG:-/dev/null}" 2>&1 || true echo "TODO.md force-marked [x] for $_current_db_status task (deliverable verification bypassed)" return 0 @@ -956,10 +997,17 @@ _log_queue_stall_reasons() { local todo_file="$repo_path/TODO.md" local open_count claimed_count blocked_count no_tag_count db_failed_count - open_count=$(grep -cE '^\s*- \[ \] t[0-9]+' "$todo_file" 2>/dev/null || echo "0") - claimed_count=$(grep -cE '^\s*- \[ \] t[0-9]+.*(assignee:|started:)' "$todo_file" 2>/dev/null || echo "0") - blocked_count=$(grep -cE '^\s*- \[ \] t[0-9]+.*blocked-by:' "$todo_file" 2>/dev/null || echo "0") - no_tag_count=$(grep -E '^\s*- \[ \] t[0-9]+' "$todo_file" 2>/dev/null | grep -cv '#auto-dispatch' || echo "0") + if [[ -f "$todo_file" ]]; then + open_count=$(grep -cE '^\s*- \[ \] t[0-9]+' "$todo_file" || echo "0") + claimed_count=$(grep -cE '^\s*- \[ \] t[0-9]+.*(assignee:|started:)' "$todo_file" || echo "0") + blocked_count=$(grep -cE '^\s*- \[ \] t[0-9]+.*blocked-by:' "$todo_file" || echo "0") + no_tag_count=$(grep -E '^\s*- \[ \] t[0-9]+' "$todo_file" | grep -cv '#auto-dispatch' || echo "0") + else + open_count="0" + claimed_count="0" + blocked_count="0" + no_tag_count="0" + fi db_failed_count=$(db "$SUPERVISOR_DB" " SELECT COUNT(*) FROM tasks WHERE repo = '$(sql_escape "$repo_path")'