From a3efb7277acb88691aed787fd4806be06249688c Mon Sep 17 00:00:00 2001 From: AI DevOps Date: Sun, 8 Mar 2026 12:50:05 -0600 Subject: [PATCH 01/17] fix: harden content-scanner and deploy-agents scripts (GH#3897, GH#3909) content-scanner-helper.sh (GH#3897, 6 findings): - Reorder scan_content() to normalize before pre-filter (prevents Unicode bypass) - Pre-filter uses regex matching for keywords with metacharacters - Combine $RANDOM+$$+$SECONDS for boundary ID fallback - Source shared-constants.sh with || true instead of 2>/dev/null - Propagate perl/python3 normalization stderr and exit codes - Remove od stderr suppression, use tr -d '[:space:]' deploy-agents-on-merge.sh (GH#3909, 4 findings): - Validate since_commit rejects options and verifies via rev-parse - Guard find cleanup with TARGET_DIR empty check - Add -- separator to cp, chmod, rm in deploy_changed_files - Remove 2>/dev/null from find -delete fallback --- .agents/scripts/content-scanner-helper.sh | 50 ++++++++++++++++------- .agents/scripts/deploy-agents-on-merge.sh | 22 +++++++--- 2 files changed, 51 insertions(+), 21 deletions(-) diff --git a/.agents/scripts/content-scanner-helper.sh b/.agents/scripts/content-scanner-helper.sh index bf5694870c..81a23b5825 100755 --- a/.agents/scripts/content-scanner-helper.sh +++ b/.agents/scripts/content-scanner-helper.sh @@ -33,7 +33,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" || exit 1 -source "${SCRIPT_DIR}/shared-constants.sh" 2>/dev/null || true +source "${SCRIPT_DIR}/shared-constants.sh" || true # Fallback colours if shared-constants.sh not loaded [[ -z "${RED+x}" ]] && RED='\033[0;31m' @@ -193,8 +193,16 @@ _cs_prefilter() { local keyword for keyword in "${_CS_PREFILTER_KEYWORDS[@]}"; do - if [[ "$lower_content" == *"$keyword"* ]]; then - return 0 + # Keywords containing regex metacharacters (.*+?|) use regex matching; + # plain keywords use faster literal substring matching. + if [[ "$keyword" =~ [.*+?\|] ]]; then + if [[ "$lower_content" =~ $keyword ]]; then + return 0 + fi + else + if [[ "$lower_content" == *"$keyword"* ]]; then + return 0 + fi fi done @@ -222,16 +230,24 @@ _cs_normalize_nfkc() { # Try python3 first (most reliable NFKC) if command -v python3 &>/dev/null; then - printf '%s' "$content" | python3 -c " + local py_result + if py_result=$(printf '%s' "$content" | python3 -c " import sys, unicodedata text = sys.stdin.read() sys.stdout.write(unicodedata.normalize('NFKC', text)) -" 2>/dev/null && return 0 +"); then + printf '%s' "$py_result" + return 0 + fi fi # Try perl as fallback (Unicode::Normalize is core since 5.8) if command -v perl &>/dev/null; then - printf '%s' "$content" | perl -MUnicode::Normalize -CS -pe '$_ = NFKC($_)' 2>/dev/null && return 0 + local perl_result + if perl_result=$(printf '%s' "$content" | perl -MUnicode::Normalize -CS -pe '$_ = NFKC($_)'); then + printf '%s' "$perl_result" + return 0 + fi fi # No normalizer available — pass through unchanged @@ -250,12 +266,14 @@ sys.stdout.write(unicodedata.normalize('NFKC', text)) _cs_generate_boundary_id() { # Use /dev/urandom for a short unique ID (8 hex chars) if [[ -r /dev/urandom ]]; then - od -An -tx1 -N4 /dev/urandom 2>/dev/null | tr -d ' \n' + od -An -tx1 -N4 /dev/urandom | tr -d '[:space:]' return 0 fi - # Fallback: use $RANDOM (less entropy but functional) - printf '%04x%04x' "$RANDOM" "$RANDOM" + # Fallback: combine $RANDOM with PID and epoch for less predictability. + # $RANDOM alone is a 15-bit PRNG seeded from PID — too predictable for + # boundary IDs that must resist crafted payloads. + printf '%04x%04x%04x' "$RANDOM" "$$" "$((SECONDS % 65536))" return 0 } @@ -292,18 +310,20 @@ scan_content() { byte_count=$(printf '%s' "$content" | wc -c | tr -d ' ') _cs_log_info "Scanning content ($byte_count bytes)" - # Layer 1: Keyword pre-filter - if ! _cs_prefilter "$content"; then + # Layer 2 first: NFKC normalization (must run before pre-filter to prevent + # Unicode bypass — e.g., "𝐈𝐠𝐧𝐨𝐫𝐞" normalizes to "Ignore" which the + # pre-filter can then match). + local normalized + normalized=$(_cs_normalize_nfkc "$content") + + # Layer 1: Keyword pre-filter on NORMALIZED content + if ! _cs_prefilter "$normalized"; then _cs_log_success "Pre-filter: no suspicious keywords found — skipping full scan" echo "CLEAN" return 0 fi _cs_log_info "Pre-filter: keyword match — proceeding to full scan" - # Layer 2: NFKC normalization - local normalized - normalized=$(_cs_normalize_nfkc "$content") - # Layer 3: Delegate to prompt-guard-helper.sh if [[ ! -x "$PROMPT_GUARD" ]]; then _cs_log_error "prompt-guard-helper.sh not found at: $PROMPT_GUARD" diff --git a/.agents/scripts/deploy-agents-on-merge.sh b/.agents/scripts/deploy-agents-on-merge.sh index 0f4cb3d8fa..72552ac4f3 100755 --- a/.agents/scripts/deploy-agents-on-merge.sh +++ b/.agents/scripts/deploy-agents-on-merge.sh @@ -154,7 +154,12 @@ detect_changes() { local changed_files if [[ -n "$since_commit" ]]; then - changed_files=$(git -C "$REPO_DIR" diff --name-only "$since_commit" HEAD -- '.agents/' 2>/dev/null || echo "") + # Validate since_commit is a valid revision (not an injected option) + if [[ "$since_commit" == -* ]] || ! git -C "$REPO_DIR" rev-parse --verify "$since_commit" >/dev/null 2>&1; then + log_error "Invalid commit reference: $since_commit" + return 1 + fi + changed_files=$(git -C "$REPO_DIR" diff --name-only "$since_commit" HEAD -- '.agents/' || echo "") else # Compare deployed VERSION with repo VERSION to detect staleness local repo_version deployed_version @@ -203,7 +208,7 @@ deploy_scripts_only() { else # Fallback: tar-based copy # Remove existing target contents first to match rsync --delete behavior - find "$target_scripts_dir" -mindepth 1 -delete 2>/dev/null || true + find "$target_scripts_dir" -mindepth 1 -delete || true (cd "$source_dir" && tar cf - .) | (cd "$target_scripts_dir" && tar xf -) fi @@ -261,9 +266,14 @@ deploy_all_agents() { fi # Remove existing target contents (except custom/draft) to match rsync --delete behavior + if [[ -z "$TARGET_DIR" ]]; then + log_error "TARGET_DIR is empty — refusing to run find cleanup" + rm -rf "$tmp_preserve" + return 1 + fi find "$TARGET_DIR" -mindepth 1 -maxdepth 1 \ ! -name 'custom' ! -name 'draft' ! -name 'loop-state' \ - -exec rm -rf {} + 2>/dev/null || true + -exec rm -rf {} + || true # Copy all agents (cd "$source_dir" && tar cf - --exclude='loop-state' --exclude='custom' --exclude='draft' .) | @@ -340,7 +350,7 @@ deploy_changed_files() { mkdir -p "$target_parent" # Copy file (catch errors instead of letting set -e abort) - if ! cp "$source_file" "$target_file"; then + if ! cp -- "$source_file" "$target_file"; then log_warn "Failed to copy: $rel_path" failed=$((failed + 1)) continue @@ -348,7 +358,7 @@ deploy_changed_files() { # Set executable if it's a script if [[ "$target_file" == *.sh ]]; then - if ! chmod +x "$target_file"; then + if ! chmod -- +x "$target_file"; then log_warn "Failed to set executable: $rel_path" failed=$((failed + 1)) continue @@ -359,7 +369,7 @@ deploy_changed_files() { elif [[ ! -e "$source_file" ]]; then # File was deleted in source — remove from target if [[ -f "$target_file" ]]; then - if ! rm -f "$target_file"; then + if ! rm -f -- "$target_file"; then log_warn "Failed to remove deleted file: $rel_path" failed=$((failed + 1)) continue From 9b62dd5bf82db90445fb93aa674a7d378ddb64fe Mon Sep 17 00:00:00 2001 From: AI DevOps Date: Sun, 8 Mar 2026 12:53:03 -0600 Subject: [PATCH 02/17] fix: propagate exit codes, escape osascript args, unify scorer aggregation (GH#3899, GH#3904, GH#3896) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit terminal-title-setup.sh (GH#3899, 1 finding): - Propagate check_and_fix_tabby exit code in cmd_install via || return 1 - Add || return 1 to tabby_enable_dynamic_titles call within check_and_fix_tabby - Add explicit return 0 at end of check_and_fix_tabby session-manager.md (GH#3904, 3 findings): - Escape $dir via printf %q in spawn_terminal_tab and spawn_iterm_tab to prevent osascript command injection from special characters in paths - Remove 2>/dev/null from grep, gh, VERSION read, and git describe examples response-scoring-helper.sh (GH#3896, 2 findings): - Deduplicate scores to one scorer per criterion (latest by rowid) via LEFT JOIN subquery, ensuring per-criterion MAX(CASE) and weighted avg use the same row set — fixes inconsistency when multiple scorers exist - Inline weighted avg computation instead of WEIGHTED_AVG_SQL correlated subquery so both aggregations operate on the deduplicated join --- .agents/scripts/response-scoring-helper.sh | 34 +++++++++++++++++----- .agents/scripts/terminal-title-setup.sh | 5 ++-- .agents/workflows/session-manager.md | 16 ++++++---- 3 files changed, 39 insertions(+), 16 deletions(-) diff --git a/.agents/scripts/response-scoring-helper.sh b/.agents/scripts/response-scoring-helper.sh index 9cb8862557..eb4ecf10eb 100755 --- a/.agents/scripts/response-scoring-helper.sh +++ b/.agents/scripts/response-scoring-helper.sh @@ -389,16 +389,28 @@ _sync_score_to_patterns() { # Get response metadata including per-criterion scores and token usage (t1094) # Uses LEFT JOIN + conditional aggregation instead of correlated subqueries (GH#3631) - # Note: WEIGHTED_AVG_SQL is a correlated subquery that computes a weighted sum across - # all scorers, while MAX(CASE...) picks the highest score per criterion. In practice - # the scores table has one scorer per criterion for automated scoring (UNIQUE constraint - # on response_id+criterion+scored_by). The MAX() is deterministic vs the original - # LIMIT 1 (no ORDER BY) which was arbitrary. Unifying both aggregation paths is a - # valid follow-up refactor but out of scope for this fix (see PR #3884 discussion). + # The LEFT JOIN deduplicates to one scorer per criterion (latest by rowid) so that + # both the weighted average and per-criterion scores use the same row set. With one + # scorer per criterion (the common case), the subquery is a no-op. (GH#3896) local result result=$(sqlite3 -separator '|' "$SCORING_DB" " SELECT r.model_id, p.category, p.difficulty, - ${WEIGHTED_AVG_SQL} as weighted_avg, + COALESCE(ROUND( + SUM(CASE s.criterion + WHEN 'correctness' THEN s.score * 0.30 + WHEN 'completeness' THEN s.score * 0.25 + WHEN 'code_quality' THEN s.score * 0.25 + WHEN 'clarity' THEN s.score * 0.20 + ELSE s.score * 0.25 + END) + / NULLIF(SUM(CASE s.criterion + WHEN 'correctness' THEN 0.30 + WHEN 'completeness' THEN 0.25 + WHEN 'code_quality' THEN 0.25 + WHEN 'clarity' THEN 0.20 + ELSE 0.25 + END), 0) + , 2), 0) as weighted_avg, r.token_count, r.cost_estimate, MAX(CASE WHEN s.criterion = 'correctness' THEN s.score END) as corr, MAX(CASE WHEN s.criterion = 'completeness' THEN s.score END) as comp, @@ -406,7 +418,13 @@ _sync_score_to_patterns() { MAX(CASE WHEN s.criterion = 'clarity' THEN s.score END) as clar FROM responses r JOIN prompts p ON r.prompt_id = p.prompt_id - LEFT JOIN scores s ON r.response_id = s.response_id + LEFT JOIN ( + SELECT s1.* FROM scores s1 + INNER JOIN ( + SELECT response_id, criterion, MAX(rowid) as max_rowid + FROM scores GROUP BY response_id, criterion + ) s2 ON s1.rowid = s2.max_rowid + ) s ON r.response_id = s.response_id WHERE r.response_id = ${response_id} GROUP BY r.response_id; ") || return 0 diff --git a/.agents/scripts/terminal-title-setup.sh b/.agents/scripts/terminal-title-setup.sh index 76ba0bd4b9..41622983f9 100755 --- a/.agents/scripts/terminal-title-setup.sh +++ b/.agents/scripts/terminal-title-setup.sh @@ -134,11 +134,12 @@ check_and_fix_tabby() { read -r -p "Fix Tabby config to allow dynamic titles? [Y/n]: " fix_tabby if [[ "$fix_tabby" =~ ^[Yy]?$ ]]; then - tabby_enable_dynamic_titles + tabby_enable_dynamic_titles || return 1 else log_info "Skipped Tabby config fix" log_info "You can fix manually: Settings → Profiles → Uncheck 'Disable dynamic title'" fi + return 0 } # ============================================================================= @@ -399,7 +400,7 @@ cmd_install() { install_integration "$shell_name" "$rc_file" || return 1 # Check and fix Tabby configuration if needed - check_and_fix_tabby + check_and_fix_tabby || return 1 echo "" log_success "Terminal title integration installed!" diff --git a/.agents/workflows/session-manager.md b/.agents/workflows/session-manager.md index 7a7c85afef..13a305518d 100644 --- a/.agents/workflows/session-manager.md +++ b/.agents/workflows/session-manager.md @@ -54,16 +54,16 @@ check_session_status() { echo "=== Session Status ===" # Check incomplete tasks - incomplete=$(grep -c '^\s*- \[ \]' TODO.md 2>/dev/null || echo "0") + incomplete=$(grep -c '^\s*- \[ \]' TODO.md || echo "0") echo "Incomplete tasks: ${incomplete}" # Check recent PR (requires gh CLI) - pr_state=$(gh pr view --json state --jq '.state' 2>/dev/null || echo "none") + pr_state=$(gh pr view --json state --jq '.state' || echo "none") echo "Current PR state: ${pr_state}" # Check latest release vs VERSION - version=$(/dev/null || echo "unknown") - latest_tag=$(git describe --tags --abbrev=0 2>/dev/null || echo "none") + version=$( Date: Sun, 8 Mar 2026 12:55:34 -0600 Subject: [PATCH 03/17] fix: remove error suppression, secure mysql access, harden glab install, dedup docs (GH#3898, GH#3905, GH#3912, GH#3901, GH#3913) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit credential-helper.sh (GH#3898, 7 findings): - Remove 2>/dev/null from 5 file reads where existence is already checked - Remove 2>/dev/null from 2 validate_tenant_name calls for consistency cloudron.md (GH#3905, 3 findings): - Use MYSQL_PWD env var instead of -p flag to avoid password in process list - Remove obsolete security notes about -p flag exposure opencode-gitlab-ci.yml (GH#3912, 1 finding): - Download glab archive to file before extracting (enables checksum verification) - Add comment with instructions for pinning SHA-256 checksum - Clean up archive after extraction video.md (GH#3901): already fixed in current codebase — no changes needed AGENTS.md (GH#3913, 1 finding): - Deduplicate claim-task-id.sh guidance in Self-Improvement section by referencing the cross-repo task creation workflow instead of repeating it --- .agents/AGENTS.md | 2 +- .agents/scripts/credential-helper.sh | 14 +++++++------- .agents/services/hosting/cloudron.md | 6 +++--- configs/mcp-templates/opencode-gitlab-ci.yml | 7 ++++++- 4 files changed, 17 insertions(+), 12 deletions(-) diff --git a/.agents/AGENTS.md b/.agents/AGENTS.md index 5dde455865..8f2a7d2a65 100644 --- a/.agents/AGENTS.md +++ b/.agents/AGENTS.md @@ -57,7 +57,7 @@ Every agent session — interactive, worker, or supervisor — should improve th **Route to the correct repo.** Not every improvement belongs in the current project. Before creating a self-improvement task, determine whether the problem is project-specific or framework-level: -- **Framework-level** — route to the aidevops repo. Indicators: the observation references files under `~/.aidevops/`, framework scripts (`ai-actions.sh`, `ai-lifecycle.sh`, `supervisor/`, `dispatch.sh`, `pre-edit-check.sh`, helper scripts), agent prompt behaviour, supervisor/pulse logic, or cross-repo orchestration. Use `claim-task-id.sh --repo-path --title "description"` (resolve the slug from `~/.config/aidevops/repos.json`). Only run `gh issue create --repo ` if `claim-task-id.sh` was invoked with `--no-issue` or its output did not include a `ref=GH#` (or `ref=GL#` for GitLab) token — otherwise the issue already exists and a second `gh issue create` would produce a duplicate. The fix belongs in the framework, not in the project that happened to trigger it. +- **Framework-level** — route to the aidevops repo. Indicators: the observation references files under `~/.aidevops/`, framework scripts (`ai-actions.sh`, `ai-lifecycle.sh`, `supervisor/`, `dispatch.sh`, `pre-edit-check.sh`, helper scripts), agent prompt behaviour, supervisor/pulse logic, or cross-repo orchestration. Use `claim-task-id.sh --repo-path --title "description"` and follow the cross-repo task creation workflow below. The fix belongs in the framework, not in the project that happened to trigger it. - **Project-specific** — route to the current repo. Indicators: the observation is about this project's CI, code patterns, dependencies, or domain logic. If uncertain, ask: "Would this fix apply to every repo the framework manages, or only this one?" Framework-wide problems go to aidevops; project-specific problems stay local. Never create framework tasks in a project repo — they become invisible to framework maintainers and pollute the project's task namespace. diff --git a/.agents/scripts/credential-helper.sh b/.agents/scripts/credential-helper.sh index 1e5559cfb1..26e2380f9c 100755 --- a/.agents/scripts/credential-helper.sh +++ b/.agents/scripts/credential-helper.sh @@ -45,9 +45,9 @@ get_active_tenant() { # Priority: 1) Project override, 2) Global active, 3) "default" if [[ -f "$PROJECT_TENANT_FILE" ]]; then local project_tenant - project_tenant=$(tr -d '[:space:]' <"$PROJECT_TENANT_FILE" 2>/dev/null) + project_tenant=$(tr -d '[:space:]' <"$PROJECT_TENANT_FILE") if [[ -n "$project_tenant" ]]; then - if validate_tenant_name "$project_tenant" 2>/dev/null; then + if validate_tenant_name "$project_tenant"; then echo "$project_tenant" return 0 fi @@ -57,9 +57,9 @@ get_active_tenant() { if [[ -f "$ACTIVE_TENANT_FILE" ]]; then local active - active=$(tr -d '[:space:]' <"$ACTIVE_TENANT_FILE" 2>/dev/null) + active=$(tr -d '[:space:]' <"$ACTIVE_TENANT_FILE") if [[ -n "$active" ]]; then - if validate_tenant_name "$active" 2>/dev/null; then + if validate_tenant_name "$active"; then echo "$active" return 0 fi @@ -693,7 +693,7 @@ cmd_use() { # Show current project tenant if [[ -f "$PROJECT_TENANT_FILE" ]]; then local current - current=$(tr -d '[:space:]' <"$PROJECT_TENANT_FILE" 2>/dev/null) + current=$(tr -d '[:space:]' <"$PROJECT_TENANT_FILE") print_info "Project tenant: $current" else print_info "No project-level tenant set (using global: $(get_active_tenant))" @@ -743,7 +743,7 @@ cmd_status() { local project_tenant="" if [[ -f "$PROJECT_TENANT_FILE" ]]; then - project_tenant=$(tr -d '[:space:]' <"$PROJECT_TENANT_FILE" 2>/dev/null) + project_tenant=$(tr -d '[:space:]' <"$PROJECT_TENANT_FILE") fi echo "" @@ -758,7 +758,7 @@ cmd_status() { local global_active="" if [[ -f "$ACTIVE_TENANT_FILE" ]]; then - global_active=$(tr -d '[:space:]' <"$ACTIVE_TENANT_FILE" 2>/dev/null) + global_active=$(tr -d '[:space:]' <"$ACTIVE_TENANT_FILE") fi if [[ -n "$global_active" && "$global_active" != "$active" ]]; then echo -e " Global tenant: ${DIM}$global_active${NC}" diff --git a/.agents/services/hosting/cloudron.md b/.agents/services/hosting/cloudron.md index 6f930773e0..cd997ebf19 100644 --- a/.agents/services/hosting/cloudron.md +++ b/.agents/services/hosting/cloudron.md @@ -286,11 +286,11 @@ docker inspect | grep CLOUDRON_MYSQL # Connect to MySQL via the mysql container docker exec -it mysql mysql -u -p -# Or use root access (note: -p flag exposes password in process list briefly) -docker exec -it mysql mysql -uroot -p"$(cat /home/yellowtent/platformdata/mysql/root_password)" +# Or use root access +docker exec -it -e MYSQL_PWD="$(cat /home/yellowtent/platformdata/mysql/root_password)" mysql mysql -uroot ``` -> **Security note**: The `docker inspect` command above reveals database credentials. Redact passwords before pasting output into forum posts, tickets, or chat. The `-p$(cat ...)` pattern briefly exposes the password in the process list while the command runs. +> **Security note**: The `docker inspect` command above reveals database credentials. Redact passwords before pasting output into forum posts, tickets, or chat. #### **Common Database Fixes** diff --git a/configs/mcp-templates/opencode-gitlab-ci.yml b/configs/mcp-templates/opencode-gitlab-ci.yml index 2ca534b6a8..9ed4180c58 100644 --- a/configs/mcp-templates/opencode-gitlab-ci.yml +++ b/configs/mcp-templates/opencode-gitlab-ci.yml @@ -36,10 +36,15 @@ opencode: - apt-get update && apt-get install -y git curl # Install glab CLI for GitLab operations (official gitlab-org/cli) + # To pin a checksum: download the checksums.txt from the release page + # https://gitlab.com/gitlab-org/cli/-/releases and verify with sha256sum --check - | GLAB_VERSION="v1.89.0" - curl -sL "https://gitlab.com/gitlab-org/cli/-/releases/${GLAB_VERSION}/downloads/glab_${GLAB_VERSION#v}_linux_amd64.tar.gz" | tar xz + GLAB_ARCHIVE="glab_${GLAB_VERSION#v}_linux_amd64.tar.gz" + curl -sLO "https://gitlab.com/gitlab-org/cli/-/releases/${GLAB_VERSION}/downloads/${GLAB_ARCHIVE}" + tar xzf "${GLAB_ARCHIVE}" mv bin/glab /usr/local/bin/ + rm -f "${GLAB_ARCHIVE}" # Configure git identity - git config --global user.email "opencode@gitlab.com" From 9f99ab4b5fe00b7276518d9f6dc966ff200ad79a Mon Sep 17 00:00:00 2001 From: AI DevOps Date: Sun, 8 Mar 2026 13:12:27 -0600 Subject: [PATCH 04/17] fix: jq injection, prompt-guard hardening, error suppression, security docs (GH#3225, GH#3220, GH#3319, GH#3153, GH#3297, GH#3199) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit settings-helper.sh (GH#3225, 1 critical finding): - Use --argjson for boolean and number types instead of string interpolation into jq filter, preventing potential injection if validation is relaxed - Number validation now uses jq tonumber (supports floats) instead of regex prompt-guard-helper.sh (GH#3220, 3 critical findings): - Sanitize matched_text in pipe-delimited output: replace | with fullwidth pipe and newlines with spaces to prevent format corruption and log injection - Limit stdin input to 10MB in cmd_scan_stdin to prevent DoS via memory exhaustion from untrusted content - Harden YAML pattern parser regexes: use negated character classes ([^"]+ / [^']+) instead of greedy .+ to handle trailing comments and edge cases plugins.sh (GH#3319, 1 critical finding): - Remove 2>/dev/null from uv python install to surface installation errors - Add missing failure feedback when uv installs but verification fails pulse-wrapper.sh (GH#3153, partial — 2 of 8 findings): - Remove 2>/dev/null from grep calls on sonar-project.properties where file existence is already checked local-hosting.md (GH#3297, 1 critical finding): - Replace 2>/dev/null with || true on xargs kill command google-chat.md (GH#3199, 1 critical finding): - Add WARNING about never disabling verifyGoogleTokens in production --- .agents/scripts/prompt-guard-helper.sh | 24 +++++++++++++------ .agents/scripts/pulse-wrapper.sh | 4 ++-- .agents/scripts/settings-helper.sh | 6 ++--- .../services/communications/google-chat.md | 2 ++ .agents/services/hosting/local-hosting.md | 2 +- setup-modules/plugins.sh | 3 ++- 6 files changed, 27 insertions(+), 14 deletions(-) diff --git a/.agents/scripts/prompt-guard-helper.sh b/.agents/scripts/prompt-guard-helper.sh index e3d54ba8f7..add4e996b0 100755 --- a/.agents/scripts/prompt-guard-helper.sh +++ b/.agents/scripts/prompt-guard-helper.sh @@ -196,20 +196,20 @@ _pg_load_yaml_patterns() { continue fi - # Description field - if [[ "$line" =~ ^[[:space:]]*description:[[:space:]]*\"(.+)\"$ ]]; then + # Description field (double-quoted; use [^"]+ to avoid matching trailing comments) + if [[ "$line" =~ ^[[:space:]]*description:[[:space:]]*\"([^\"]+)\"[[:space:]]*(#.*)?$ ]]; then description="${BASH_REMATCH[1]}" continue fi - # Pattern field (single-quoted — YAML standard for regex) - if [[ "$line" =~ ^[[:space:]]*pattern:[[:space:]]*\'(.+)\'$ ]]; then + # Pattern field (single-quoted — YAML standard for regex; [^']+ avoids greedy match) + if [[ "$line" =~ ^[[:space:]]*pattern:[[:space:]]*\'([^\']+)\'[[:space:]]*(#.*)?$ ]]; then pattern="${BASH_REMATCH[1]}" continue fi - # Pattern field (double-quoted) - if [[ "$line" =~ ^[[:space:]]*pattern:[[:space:]]*\"(.+)\"$ ]]; then + # Pattern field (double-quoted; [^"]+ avoids greedy match) + if [[ "$line" =~ ^[[:space:]]*pattern:[[:space:]]*\"([^\"]+)\"[[:space:]]*(#.*)?$ ]]; then pattern="${BASH_REMATCH[1]}" continue fi @@ -454,6 +454,10 @@ _pg_scan_patterns_from_stream() { if _pg_match "$pattern" "$message"; then local matched_text matched_text=$(_pg_extract_match "$pattern" "$message") || matched_text="[match]" + # Sanitize matched_text: replace pipe delimiters and newlines to prevent + # format corruption and log injection from untrusted content (GH#3220) + matched_text="${matched_text//$'\n'/ }" + matched_text="${matched_text//|/¦}" echo "${severity}|${category}|${description}|${matched_text}" _pg_scan_found=1 fi @@ -709,8 +713,10 @@ cmd_scan_stdin() { return 1 fi + # Limit stdin to 10MB to prevent DoS via memory exhaustion (GH#3220) + local max_bytes=10485760 local content - if ! content=$(cat); then + if ! content=$(head -c "$max_bytes"); then _pg_log_error "Failed to read from stdin" return 1 fi @@ -720,6 +726,10 @@ cmd_scan_stdin() { return 1 fi + if [[ ${#content} -ge $max_bytes ]]; then + _pg_log_warn "Input truncated to ${max_bytes} bytes — scanning partial content" + fi + local byte_count byte_count=$(printf '%s' "$content" | wc -c | tr -d ' ') _pg_log_info "Scanning stdin content ($byte_count bytes)" diff --git a/.agents/scripts/pulse-wrapper.sh b/.agents/scripts/pulse-wrapper.sh index 5c5f5279b9..21658aa9e1 100755 --- a/.agents/scripts/pulse-wrapper.sh +++ b/.agents/scripts/pulse-wrapper.sh @@ -2505,9 +2505,9 @@ _No smells detected or qlty analysis returned empty._ local sonar_section="" if [[ -f "${repo_path}/sonar-project.properties" ]]; then local project_key - project_key=$(grep '^sonar.projectKey=' "${repo_path}/sonar-project.properties" 2>/dev/null | cut -d= -f2) + project_key=$(grep '^sonar.projectKey=' "${repo_path}/sonar-project.properties" | cut -d= -f2 || true) local org_key - org_key=$(grep '^sonar.organization=' "${repo_path}/sonar-project.properties" 2>/dev/null | cut -d= -f2) + org_key=$(grep '^sonar.organization=' "${repo_path}/sonar-project.properties" | cut -d= -f2 || true) if [[ -n "$project_key" && -n "$org_key" ]]; then # URL-encode project_key to prevent injection via crafted sonar-project.properties diff --git a/.agents/scripts/settings-helper.sh b/.agents/scripts/settings-helper.sh index b32d7705d4..52aeb141f0 100755 --- a/.agents/scripts/settings-helper.sh +++ b/.agents/scripts/settings-helper.sh @@ -246,14 +246,14 @@ cmd_set() { return 1 ;; esac - jq "$jq_path = $value" "$SETTINGS_FILE" >"$tmp_file" + jq --argjson v "$value" "$jq_path = \$v" "$SETTINGS_FILE" >"$tmp_file" ;; number) - if ! [[ "$value" =~ ^[0-9]+$ ]]; then + if ! jq -e 'tonumber' <<<"$value" >/dev/null 2>&1; then print_error "Invalid number value: $value" return 1 fi - jq "$jq_path = $value" "$SETTINGS_FILE" >"$tmp_file" + jq --argjson v "$value" "$jq_path = \$v" "$SETTINGS_FILE" >"$tmp_file" ;; array) # Accept JSON array or comma-separated values diff --git a/.agents/services/communications/google-chat.md b/.agents/services/communications/google-chat.md index a1da5939d3..e16e65d48b 100644 --- a/.agents/services/communications/google-chat.md +++ b/.agents/services/communications/google-chat.md @@ -232,6 +232,8 @@ google-chat-helper.sh setup | `asyncResponseTimeout` | `600` | Max seconds for async runner response | | `verifyGoogleTokens` | `true` | Verify inbound request bearer tokens against Google JWKS | +> **WARNING**: Never set `verifyGoogleTokens` to `false` in production. Disabling token verification allows anyone who discovers your webhook URL to send forged events to the bot, completely bypassing authentication. This setting exists only for local testing against mock endpoints. + ## Authentication ### Inbound (Google to Bot) diff --git a/.agents/services/hosting/local-hosting.md b/.agents/services/hosting/local-hosting.md index 85aab005d0..e51765925c 100644 --- a/.agents/services/hosting/local-hosting.md +++ b/.agents/services/hosting/local-hosting.md @@ -614,7 +614,7 @@ rm -f apps/web/.next/dev/lock && pnpm dev:web "dev": "rm -f .next/dev/lock && next dev --port 3100" # Terminal profile / Tabby start command (includes lock cleanup + port kill) -rm -f apps/web/.next/dev/lock; lsof -ti:3100 | xargs kill -9 2>/dev/null; pnpm dev:web +rm -f apps/web/.next/dev/lock; lsof -ti:3100 | xargs kill -9 || true; pnpm dev:web ``` ### Docker Compose Projects diff --git a/setup-modules/plugins.sh b/setup-modules/plugins.sh index 8d6b49cf50..709ac6aef3 100644 --- a/setup-modules/plugins.sh +++ b/setup-modules/plugins.sh @@ -48,7 +48,7 @@ check_python_for_skill_scanner() { # 3. If uv is available, install Python 3.11 and retry if command -v uv &>/dev/null; then print_info "No Python >= 3.10 found. Installing Python 3.11 via uv..." - if uv python install 3.11 2>/dev/null; then + if uv python install 3.11; then # uv installs to its managed path; check if python3.11 is now available if command -v python3.11 &>/dev/null && _python_version_ok python3.11; then print_success "Python 3.11 installed via uv" @@ -61,6 +61,7 @@ check_python_for_skill_scanner() { print_success "Python 3.11 installed via uv (at $uv_py)" return 0 fi + print_warning "uv installed Python, but verification failed. Please check your uv setup." fi fi From e34ea8cb4396dd66f1464b6fcf263c6199017d12 Mon Sep 17 00:00:00 2001 From: AI DevOps Date: Sun, 8 Mar 2026 13:25:09 -0600 Subject: [PATCH 05/17] =?UTF-8?q?fix:=20HIGH=20priority=20quality-debt=20b?= =?UTF-8?q?atch=20=E2=80=94=20regex,=20injection,=20error=20suppression,?= =?UTF-8?q?=20arg=20guards=20(GH#3234,=20GH#3355,=20GH#3147,=20GH#3252,=20?= =?UTF-8?q?GH#3239,=20GH#3400,=20GH#3322,=20GH#3317,=20GH#3434,=20GH#3157,?= =?UTF-8?q?=20GH#3627,=20GH#3728,=20GH#3200)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/plugins/opencode-aidevops/tools.mjs | 2 +- .agents/scripts/circuit-breaker-helper.sh | 4 ++-- .agents/scripts/commands/patterns.md | 8 ++++---- .agents/scripts/commands/route.md | 4 ++-- .agents/scripts/document-creation-helper.sh | 4 ++-- .agents/scripts/pre-commit-hook.sh | 12 ++++++++---- .agents/scripts/session-miner-pulse.sh | 5 ++++- .agents/scripts/skills-helper.sh | 2 +- .agents/scripts/task-complete-helper.sh | 14 ++++++++++---- .agents/scripts/task-decompose-helper.sh | 6 ++++++ .agents/scripts/worktree-helper.sh | 14 +++++++++----- .agents/services/communications/msteams.md | 3 ++- .github/workflows/issue-sync.yml | 6 +++--- tests/test-smoke-help.sh | 7 +++++-- 14 files changed, 59 insertions(+), 32 deletions(-) diff --git a/.agents/plugins/opencode-aidevops/tools.mjs b/.agents/plugins/opencode-aidevops/tools.mjs index a124226843..b7bbb88314 100644 --- a/.agents/plugins/opencode-aidevops/tools.mjs +++ b/.agents/plugins/opencode-aidevops/tools.mjs @@ -283,7 +283,7 @@ export function createTools(scriptsDir, run, pipelines) { return { cmd: `echo "Error: content is required to store a memory" >&2; exit 1`, timeout: 1000 }; } return { - cmd: `bash "${helper}" store "${content}" --confidence ${args.confidence || "medium"} 2>/dev/null`, + cmd: `bash "${helper}" store "${content}" --confidence ${args.confidence || "medium"}`, timeout: 10000, }; }, diff --git a/.agents/scripts/circuit-breaker-helper.sh b/.agents/scripts/circuit-breaker-helper.sh index ed284a2a63..5987ce209b 100755 --- a/.agents/scripts/circuit-breaker-helper.sh +++ b/.agents/scripts/circuit-breaker-helper.sh @@ -178,12 +178,12 @@ cb_write_state() { # Atomic write via temp file + mv local tmp_file="${state_file}.tmp.$$" - if ! printf '%s\n' "$state_json" >"$tmp_file" 2>/dev/null; then + if ! printf '%s\n' "$state_json" >"$tmp_file"; then _cb_log_warn "failed to write temp state file: $tmp_file" rm -f "$tmp_file" 2>/dev/null || true return 1 fi - if ! mv -f "$tmp_file" "$state_file" 2>/dev/null; then + if ! mv -f "$tmp_file" "$state_file"; then _cb_log_warn "failed to move temp state to: $state_file" rm -f "$tmp_file" 2>/dev/null || true return 1 diff --git a/.agents/scripts/commands/patterns.md b/.agents/scripts/commands/patterns.md index 8a6164ac9f..b130c66712 100644 --- a/.agents/scripts/commands/patterns.md +++ b/.agents/scripts/commands/patterns.md @@ -15,16 +15,16 @@ Arguments: $ARGUMENTS ```bash # Get success patterns -~/.aidevops/agents/scripts/memory-helper.sh recall --type SUCCESS_PATTERN --limit 20 +~/.aidevops/agents/scripts/memory-helper.sh recall --recent --type SUCCESS_PATTERN --limit 20 # Get failure patterns -~/.aidevops/agents/scripts/memory-helper.sh recall --type FAILURE_PATTERN --limit 20 +~/.aidevops/agents/scripts/memory-helper.sh recall --recent --type FAILURE_PATTERN --limit 20 # Get working solutions -~/.aidevops/agents/scripts/memory-helper.sh recall --type WORKING_SOLUTION --limit 10 +~/.aidevops/agents/scripts/memory-helper.sh recall --recent --type WORKING_SOLUTION --limit 10 # Get failed approaches -~/.aidevops/agents/scripts/memory-helper.sh recall --type FAILED_APPROACH --limit 10 +~/.aidevops/agents/scripts/memory-helper.sh recall --recent --type FAILED_APPROACH --limit 10 ``` 2. If arguments are provided, filter results relevant to the task description. diff --git a/.agents/scripts/commands/route.md b/.agents/scripts/commands/route.md index ce4d9468a0..71bd6668aa 100644 --- a/.agents/scripts/commands/route.md +++ b/.agents/scripts/commands/route.md @@ -14,8 +14,8 @@ Task: $ARGUMENTS 1. First, check pattern history from cross-session memory: ```bash -~/.aidevops/agents/scripts/memory-helper.sh recall --type SUCCESS_PATTERN --limit 10 -~/.aidevops/agents/scripts/memory-helper.sh recall --type FAILURE_PATTERN --limit 10 +~/.aidevops/agents/scripts/memory-helper.sh recall --recent --type SUCCESS_PATTERN --limit 10 +~/.aidevops/agents/scripts/memory-helper.sh recall --recent --type FAILURE_PATTERN --limit 10 ``` 2. Read `tools/context/model-routing.md` for the routing rules and tier definitions. diff --git a/.agents/scripts/document-creation-helper.sh b/.agents/scripts/document-creation-helper.sh index 38240b38fe..ae686b49c8 100755 --- a/.agents/scripts/document-creation-helper.sh +++ b/.agents/scripts/document-creation-helper.sh @@ -71,9 +71,9 @@ human_filesize() { local file="$1" local bytes if [[ "$(uname)" == "Darwin" ]]; then - bytes=$(stat -f%z -- "$file" 2>/dev/null || echo "0") + bytes=$(stat -f%z -- "$file" || echo "0") else - bytes=$(stat -c%s -- "$file" 2>/dev/null || echo "0") + bytes=$(stat -c%s -- "$file" || echo "0") fi if [[ "$bytes" -ge 1073741824 ]]; then printf '%s.%sG' "$((bytes / 1073741824))" "$(((bytes % 1073741824) * 10 / 1073741824))" diff --git a/.agents/scripts/pre-commit-hook.sh b/.agents/scripts/pre-commit-hook.sh index f12e15419b..023ef2c5ec 100755 --- a/.agents/scripts/pre-commit-hook.sh +++ b/.agents/scripts/pre-commit-hook.sh @@ -79,10 +79,14 @@ validate_positional_parameters() { # Exclude currency/pricing patterns: $[1-9] followed by digit, decimal, comma, # slash (e.g. $28/mo, $1.99, $1,000), pipe (markdown table cell), or common # currency/pricing unit words (per, mo, month, flat, etc.). - if [[ -f "$file" ]] && grep -n '\$[1-9]' "$file" | grep -v 'local.*=.*\$[1-9]' | grep -vE '\$[1-9][0-9.,/]' | grep -vE '\$[1-9]\s*\|' | grep -vE '\$[1-9]\s+(per|mo(nth)?|year|yr|day|week|hr|hour|flat|each|off|fee|plan|tier|user|seat|unit|addon|setup|trial|credit|annual|quarterly|monthly)\b' >/dev/null; then - print_error "Direct positional parameter usage in $file" - grep -n '\$[1-9]' "$file" | grep -v 'local.*=.*\$[1-9]' | grep -vE '\$[1-9][0-9.,/]' | grep -vE '\$[1-9]\s*\|' | grep -vE '\$[1-9]\s+(per|mo(nth)?|year|yr|day|week|hr|hour|flat|each|off|fee|plan|tier|user|seat|unit|addon|setup|trial|credit|annual|quarterly|monthly)\b' | head -3 - ((violations++)) + if [[ -f "$file" ]]; then + local violations_output + violations_output=$(grep -n '\$[1-9]' "$file" | grep -v 'local.*=.*\$[1-9]' | grep -vE '\$[1-9][0-9.,/]' | grep -vE '\$[1-9]\s*\|' | grep -vE '\$[1-9]\s+(per|mo(nth)?|year|yr|day|week|hr|hour|flat|each|off|fee|plan|tier|user|seat|unit|addon|setup|trial|credit|annual|quarterly|monthly)\b' || true) + if [[ -n "$violations_output" ]]; then + print_error "Direct positional parameter usage in $file" + echo "$violations_output" | head -3 + ((violations++)) + fi fi done diff --git a/.agents/scripts/session-miner-pulse.sh b/.agents/scripts/session-miner-pulse.sh index 955cc6b5bf..776ebadb2d 100755 --- a/.agents/scripts/session-miner-pulse.sh +++ b/.agents/scripts/session-miner-pulse.sh @@ -19,7 +19,10 @@ set -euo pipefail # --- Configuration --- -SCRIPT_DIR="$(cd "${BASH_SOURCE[0]%/*}" && pwd)" +_sd="${BASH_SOURCE[0]%/*}" +[[ "$_sd" == "${BASH_SOURCE[0]}" ]] && _sd="." +SCRIPT_DIR="$(cd "$_sd" && pwd)" +unset _sd MINER_DIR="${HOME}/.aidevops/.agent-workspace/work/session-miner" # Shipped with aidevops; copied to workspace on first run EXTRACTOR_SRC="${SCRIPT_DIR}/session-miner/extract.py" diff --git a/.agents/scripts/skills-helper.sh b/.agents/scripts/skills-helper.sh index 91741fa203..c2e8d15a32 100755 --- a/.agents/scripts/skills-helper.sh +++ b/.agents/scripts/skills-helper.sh @@ -196,7 +196,7 @@ cmd_search_registry() { local raw_output # Strip ANSI escape codes for parsing, but capture raw for display - raw_output=$(npx --yes skills find "$query" 2>/dev/null || true) + raw_output=$(npx --yes skills find "$query" || true) if [[ -z "$raw_output" ]]; then log_warning "No results from skills.sh registry for '$query'" diff --git a/.agents/scripts/task-complete-helper.sh b/.agents/scripts/task-complete-helper.sh index a63aabd55a..cbac053d47 100755 --- a/.agents/scripts/task-complete-helper.sh +++ b/.agents/scripts/task-complete-helper.sh @@ -72,8 +72,11 @@ parse_args() { arg="$1" case "$arg" in --pr) - val="$2" - PR_NUMBER="$val" + if [[ $# -lt 2 || "${2:-}" == --* ]]; then + log_error "Missing value for --pr" + return 1 + fi + PR_NUMBER="$2" shift 2 ;; --verified) @@ -87,8 +90,11 @@ parse_args() { fi ;; --repo-path) - val="$2" - REPO_PATH="$val" + if [[ $# -lt 2 || "${2:-}" == --* ]]; then + log_error "Missing value for --repo-path" + return 1 + fi + REPO_PATH="$2" shift 2 ;; --no-push) diff --git a/.agents/scripts/task-decompose-helper.sh b/.agents/scripts/task-decompose-helper.sh index 4fbc993f09..c31d5882ec 100755 --- a/.agents/scripts/task-decompose-helper.sh +++ b/.agents/scripts/task-decompose-helper.sh @@ -71,6 +71,12 @@ check_existing_subtasks() { return 0 fi + # Validate task_id format to prevent regex injection (must be tNNN or tNNN.N) + if [[ ! "$task_id" =~ ^t[0-9]+(\.[0-9]+)*$ ]]; then + echo "false" + return 0 + fi + local parent_pattern="^- \\[[ x-]\\] ${task_id} " local child_pattern="^ - \\[[ x-]\\] ${task_id}\\." diff --git a/.agents/scripts/worktree-helper.sh b/.agents/scripts/worktree-helper.sh index 94ca7fbe04..3698ac1c25 100755 --- a/.agents/scripts/worktree-helper.sh +++ b/.agents/scripts/worktree-helper.sh @@ -704,6 +704,10 @@ cmd_clean() { local default_branch default_branch=$(get_default_branch) + # Identify the main worktree path (the one with the actual .git directory) + local main_worktree + main_worktree="$(git worktree list --porcelain | head -1 | sed 's/^worktree //')" + # Fetch to get current remote branch state (detects deleted branches) git fetch --prune origin 2>/dev/null || true @@ -720,13 +724,13 @@ cmd_clean() { elif [[ "$line" =~ ^branch\ refs/heads/(.+)$ ]]; then worktree_branch="${BASH_REMATCH[1]}" elif [[ -z "$line" ]]; then - # End of entry, check if merged (skip default branch) - if [[ -n "$worktree_branch" ]] && [[ "$worktree_branch" != "$default_branch" ]]; then + # End of entry, check if merged (skip default branch and main worktree) + if [[ -n "$worktree_branch" ]] && [[ "$worktree_branch" != "$default_branch" ]] && [[ "$worktree_path" != "$main_worktree" ]]; then local is_merged=false local merge_type="" # Check 1: Traditional merge detection - if git branch --merged "$default_branch" 2>/dev/null | grep -q "^\s*$worktree_branch$"; then + if git branch --merged "$default_branch" 2>/dev/null | grep -Fqx " $worktree_branch" || git branch --merged "$default_branch" 2>/dev/null | grep -Fqx "* $worktree_branch"; then is_merged=true merge_type="merged" # Check 2: Remote branch deleted (indicates squash merge or PR closed) @@ -806,7 +810,7 @@ cmd_clean() { elif [[ "$line" =~ ^branch\ refs/heads/(.+)$ ]]; then worktree_branch="${BASH_REMATCH[1]}" elif [[ -z "$line" ]]; then - if [[ -n "$worktree_branch" ]] && [[ "$worktree_branch" != "$default_branch" ]]; then + if [[ -n "$worktree_branch" ]] && [[ "$worktree_branch" != "$default_branch" ]] && [[ "$worktree_path" != "$main_worktree" ]]; then local should_remove=false local use_force=false @@ -819,7 +823,7 @@ cmd_clean() { echo -e "${RED}Skipping $worktree_branch - owned by active session PID $rm_owner_pid${NC}" should_remove=false # Check 1: Traditional merge - elif git branch --merged "$default_branch" 2>/dev/null | grep -q "^\s*$worktree_branch$"; then + elif git branch --merged "$default_branch" 2>/dev/null | grep -Fqx " $worktree_branch" || git branch --merged "$default_branch" 2>/dev/null | grep -Fqx "* $worktree_branch"; then should_remove=true # Check 2: Remote branch deleted - ONLY if branch was previously pushed # Check all remotes, not just origin (consistent with branch_was_pushed) diff --git a/.agents/services/communications/msteams.md b/.agents/services/communications/msteams.md index 949b6b533c..27d4b17be4 100644 --- a/.agents/services/communications/msteams.md +++ b/.agents/services/communications/msteams.md @@ -721,7 +721,8 @@ async function dispatchToRunner(prompt, context) { ); return stdout.trim(); } catch (error) { - return `Runner dispatch failed: ${error.message}`; + console.error("Runner dispatch failed", { error }); + return "Runner dispatch failed. Please try again later or contact an administrator."; } } ``` diff --git a/.github/workflows/issue-sync.yml b/.github/workflows/issue-sync.yml index e53fb2da45..15df1ea086 100644 --- a/.github/workflows/issue-sync.yml +++ b/.github/workflows/issue-sync.yml @@ -312,7 +312,7 @@ jobs: # Collect linked issue numbers from PR body (Closes #NNN, Fixes #NNN, Resolves #NNN) LINKED_ISSUES="" if [[ -n "$PR_BODY" ]]; then - LINKED_ISSUES=$(echo "$PR_BODY" | grep -oiE '(closes?|fixes?|resolves?)\s*#[0-9]+' | grep -oE '[0-9]+' | sort -u | tr '\n' ' ' || true) + LINKED_ISSUES=$(echo "$PR_BODY" | grep -oiE '(closes?|fixes?|resolves?)[[:space:]]*#[0-9]+' | grep -oE '[0-9]+' | sort -u | tr '\n' ' ' || true) fi echo "linked_issues=$LINKED_ISSUES" >> "$GITHUB_OUTPUT" @@ -364,7 +364,7 @@ jobs: run: | # Merge all issue numbers (from PR body + fallback search) ALL_ISSUES="$LINKED_ISSUES $FOUND_ISSUES" - ALL_ISSUES=$(echo "$ALL_ISSUES" | tr ' ' '\n' | sort -u | grep -v '^$' | tr '\n' ' ') + ALL_ISSUES=$(echo "$ALL_ISSUES" | tr ' ' '\n' | sort -u | { grep -v '^$' || true; } | tr '\n' ' ') if [[ -z "$ALL_ISSUES" ]]; then echo "No linked issues found — skipping closing hygiene" @@ -565,7 +565,7 @@ jobs: fi # Check for GitHub closing keywords or ref:GH# pattern - if echo "$PR_BODY" | grep -qiE '(closes|fixes|resolves)\s+#[0-9]+'; then + if echo "$PR_BODY" | grep -qiE '(closes|fixes|resolves)[[:space:]]+#[0-9]+'; then echo "PR body contains issue closing keyword" exit 0 fi diff --git a/tests/test-smoke-help.sh b/tests/test-smoke-help.sh index 2d1331068c..141acafbfa 100644 --- a/tests/test-smoke-help.sh +++ b/tests/test-smoke-help.sh @@ -180,8 +180,11 @@ while IFS= read -r script; do fi # Run help command with timeout (5s max) and capture output - help_output=$(timeout 5 bash "$abs_path" help 2>&1) || true - help_exit=$? + if help_output=$(timeout 5 bash "$abs_path" help 2>&1); then + help_exit=0 + else + help_exit=$? + fi # Some scripts exit 0 on help, some exit 1 (usage error) - both are acceptable # as long as they produce output and don't hang/crash From a3487ff0224ea0bc2aee1c412cc8b29e925d6b0d Mon Sep 17 00:00:00 2001 From: AI DevOps Date: Sun, 8 Mar 2026 13:33:02 -0600 Subject: [PATCH 06/17] =?UTF-8?q?fix:=20MEDIUM=20quality-debt=20batch=20?= =?UTF-8?q?=E2=80=94=20date=20correction,=20skip()=20detail,=20printf=20sa?= =?UTF-8?q?fety,=20busy=5Ftimeout,=20grammar=20(GH#3700,=20GH#3771,=20GH#3?= =?UTF-8?q?661,=20GH#3718,=20GH#3708,=20GH#3699)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scripts/email-thread-reconstruction.py | 252 +++++++++--------- .agents/scripts/skill-update-helper.sh | 2 +- .../scripts/test-ocr-extraction-pipeline.sh | 2 +- .../date-formats.md | 2 +- tests/test-audit-e2e.sh | 2 + tests/test-email-thread-reconstruction.sh | 2 +- tests/test-memory-mail.sh | 6 +- 7 files changed, 141 insertions(+), 127 deletions(-) diff --git a/.agents/scripts/email-thread-reconstruction.py b/.agents/scripts/email-thread-reconstruction.py index b607c01451..e4f8bbe727 100755 --- a/.agents/scripts/email-thread-reconstruction.py +++ b/.agents/scripts/email-thread-reconstruction.py @@ -24,39 +24,39 @@ def parse_frontmatter(md_file): """Extract YAML frontmatter from a markdown file. - + Returns dict of metadata, or None if no frontmatter found. """ - with open(md_file, 'r', encoding='utf-8') as f: + with open(md_file, "r", encoding="utf-8") as f: content = f.read() - + # Check for YAML frontmatter delimiters - if not content.startswith('---\n'): + if not content.startswith("---\n"): return None - + # Find the closing delimiter - end_match = re.search(r'\n---\n', content[4:]) + end_match = re.search(r"\n---\n", content[4:]) if not end_match: return None - - frontmatter_text = content[4:4 + end_match.start()] - + + frontmatter_text = content[4 : 4 + end_match.start()] + # Parse YAML-like frontmatter (simple key: value pairs) metadata = {} - for line in frontmatter_text.split('\n'): - if ':' not in line: + for line in frontmatter_text.split("\n"): + if ":" not in line: continue # Handle simple key: value (not nested structures) - if line.startswith(' '): + if line.startswith(" "): continue # Skip nested items for now - key, _, value = line.partition(':') + key, _, value = line.partition(":") key = key.strip() value = value.strip() # Remove quotes if present if value.startswith('"') and value.endswith('"'): value = value[1:-1] metadata[key] = value - + return metadata @@ -64,13 +64,13 @@ def _format_field(key, value): """Format a YAML frontmatter field as 'key: value' string.""" if isinstance(value, str): return f'{key}: "{value}"' - return f'{key}: {value}' + return f"{key}: {value}" def _find_insert_point(lines): """Find insertion point for new fields (after tokens_estimate or at end).""" for i, line in enumerate(lines): - if line.startswith('tokens_estimate:'): + if line.startswith("tokens_estimate:"): return i + 1 return len(lines) @@ -78,7 +78,7 @@ def _find_insert_point(lines): def _update_existing_field(lines, key, value): """Update an existing field in frontmatter lines. Returns True if found.""" for i, line in enumerate(lines): - if line.startswith(f'{key}:'): + if line.startswith(f"{key}:"): lines[i] = _format_field(key, value) return True return False @@ -86,181 +86,183 @@ def _update_existing_field(lines, key, value): def update_frontmatter(md_file, new_fields): """Update frontmatter in a markdown file with new fields. - + Adds or updates fields in the YAML frontmatter section. """ - with open(md_file, 'r', encoding='utf-8') as f: + with open(md_file, "r", encoding="utf-8") as f: content = f.read() - - if not content.startswith('---\n'): + + if not content.startswith("---\n"): return False - + # Find the closing delimiter - end_match = re.search(r'\n---\n', content[4:]) + end_match = re.search(r"\n---\n", content[4:]) if not end_match: return False - + frontmatter_end = 4 + end_match.start() + 5 # +5 for '\n---\n' - frontmatter_text = content[4:4 + end_match.start()] + frontmatter_text = content[4 : 4 + end_match.start()] body = content[frontmatter_end:] - - lines = frontmatter_text.split('\n') + + lines = frontmatter_text.split("\n") insert_idx = _find_insert_point(lines) - + # Update existing fields or collect new ones new_lines = [] for key, value in new_fields.items(): if not _update_existing_field(lines, key, value): new_lines.append(_format_field(key, value)) - + # Insert new lines at the insertion point if new_lines: lines = lines[:insert_idx] + new_lines + lines[insert_idx:] - + # Rebuild frontmatter - new_frontmatter = '---\n' + '\n'.join(lines) + '\n---\n' + new_frontmatter = "---\n" + "\n".join(lines) + "\n---\n" new_content = new_frontmatter + body - - with open(md_file, 'w', encoding='utf-8') as f: + + with open(md_file, "w", encoding="utf-8") as f: f.write(new_content) - + return True def build_thread_graph(emails): """Build a thread graph from email metadata. - + Args: emails: list of dicts with 'file', 'message_id', 'in_reply_to', 'date_sent' - + Returns: dict mapping thread_id (root message_id) to list of emails in thread order """ # Build lookup maps by_message_id = {} for email in emails: - msg_id = email.get('message_id', '').strip() + msg_id = email.get("message_id", "").strip() if msg_id: by_message_id[msg_id] = email - + # Build parent-child relationships children = defaultdict(list) roots = [] - + for email in emails: - msg_id = email.get('message_id', '').strip() - in_reply_to = email.get('in_reply_to', '').strip() - + msg_id = email.get("message_id", "").strip() + in_reply_to = email.get("in_reply_to", "").strip() + if not msg_id: # No message_id, treat as standalone roots.append(email) continue - + if not in_reply_to or in_reply_to not in by_message_id: # Root message (no parent or parent not in dataset) roots.append(email) else: # Reply to another message children[in_reply_to].append(email) - + # Build threads by traversing from roots threads = {} - + def traverse(email, thread_list, position=0): """Recursively traverse thread tree.""" - email['thread_position'] = position + email["thread_position"] = position thread_list.append(email) - - msg_id = email.get('message_id', '') + + msg_id = email.get("message_id", "") if msg_id in children: # Sort children by date sorted_children = sorted( - children[msg_id], - key=lambda e: e.get('date_sent', '') + children[msg_id], key=lambda e: e.get("date_sent", "") ) for child in sorted_children: traverse(child, thread_list, position + 1) - + for root in roots: thread_list = [] traverse(root, thread_list) - + # Thread ID is the root message_id (or file path if no message_id) - thread_id = root.get('message_id', '') or root['file'] - + thread_id = root.get("message_id", "") or root["file"] + # Set thread_length for all emails in thread for email in thread_list: - email['thread_length'] = len(thread_list) - email['thread_id'] = thread_id - + email["thread_length"] = len(thread_list) + email["thread_id"] = thread_id + threads[thread_id] = thread_list - + return threads def generate_thread_index(threads, output_file): """Generate a thread index file listing all emails by thread. - + Format: # Email Threads Index - + ## Thread: ( messages) Thread ID: - + 1. []() - - 2. []() - - ... """ - lines = ['# Email Threads Index', ''] - lines.append(f'Generated: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}') - lines.append(f'Total threads: {len(threads)}') - lines.append('') - + lines = ["# Email Threads Index", ""] + lines.append(f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + lines.append(f"Total threads: {len(threads)}") + lines.append("") + # Sort threads by date of first message sorted_threads = sorted( threads.items(), - key=lambda t: t[1][0].get('date_sent', '') if t[1] else '', - reverse=True # Most recent first + key=lambda t: t[1][0].get("date_sent", "") if t[1] else "", + reverse=True, # Most recent first ) - + for thread_id, emails in sorted_threads: if not emails: continue - + root = emails[0] - subject = root.get('subject', 'No Subject') - thread_length = root.get('thread_length', len(emails)) - - lines.append(f'## Thread: {subject} ({thread_length} messages)') - lines.append(f'Thread ID: `{thread_id}`') - lines.append('') - + subject = root.get("subject", "No Subject") + thread_length = root.get("thread_length", len(emails)) + + msg_word = "message" if thread_length == 1 else "messages" + lines.append(f"## Thread: {subject} ({thread_length} {msg_word})") + lines.append(f"Thread ID: `{thread_id}`") + lines.append("") + for i, email in enumerate(emails, 1): - file_path = Path(email['file']).name - email_subject = email.get('subject', 'No Subject') - from_addr = email.get('from', 'Unknown') - date_sent = email.get('date_sent', 'Unknown') - position = email.get('thread_position', i - 1) - + file_path = Path(email["file"]).name + email_subject = email.get("subject", "No Subject") + from_addr = email.get("from", "Unknown") + date_sent = email.get("date_sent", "Unknown") + position = email.get("thread_position", i - 1) + # Indent replies - indent = ' ' * position - lines.append(f'{indent}{i}. [{email_subject}]({file_path}) - {from_addr} - {date_sent}') - - lines.append('') - - with open(output_file, 'w', encoding='utf-8') as f: - f.write('\n'.join(lines)) - + indent = " " * position + lines.append( + f"{indent}{i}. [{email_subject}]({file_path}) - {from_addr} - {date_sent}" + ) + + lines.append("") + + with open(output_file, "w", encoding="utf-8") as f: + f.write("\n".join(lines)) + return output_file def reconstruct_threads(directory, output_index=None): """Reconstruct email threads from a directory of converted emails. - + Args: directory: path to directory containing .md email files output_index: path to thread index file (default: directory/thread-index.md) - + Returns: dict with 'threads', 'updated_count', 'index_file' """ @@ -268,69 +270,75 @@ def reconstruct_threads(directory, output_index=None): if not dir_path.is_dir(): print(f"ERROR: Directory not found: {directory}", file=sys.stderr) sys.exit(1) - + # Find all .md files - md_files = list(dir_path.glob('*.md')) + md_files = list(dir_path.glob("*.md")) if not md_files: print(f"WARNING: No .md files found in {directory}", file=sys.stderr) - return {'threads': {}, 'updated_count': 0, 'index_file': None} - + return {"threads": {}, "updated_count": 0, "index_file": None} + # Parse frontmatter from all files emails = [] for md_file in md_files: metadata = parse_frontmatter(md_file) if metadata: - metadata['file'] = str(md_file) + metadata["file"] = str(md_file) emails.append(metadata) - + if not emails: - print(f"WARNING: No emails with frontmatter found in {directory}", file=sys.stderr) - return {'threads': {}, 'updated_count': 0, 'index_file': None} - + print( + f"WARNING: No emails with frontmatter found in {directory}", file=sys.stderr + ) + return {"threads": {}, "updated_count": 0, "index_file": None} + # Build thread graph threads = build_thread_graph(emails) - + # Update frontmatter in all files updated_count = 0 for _tid, thread_emails in threads.items(): for email in thread_emails: new_fields = { - 'thread_id': email['thread_id'], - 'thread_position': email['thread_position'], - 'thread_length': email['thread_length'], + "thread_id": email["thread_id"], + "thread_position": email["thread_position"], + "thread_length": email["thread_length"], } - if update_frontmatter(email['file'], new_fields): + if update_frontmatter(email["file"], new_fields): updated_count += 1 - + # Generate thread index if output_index is None: - output_index = dir_path / 'thread-index.md' - + output_index = dir_path / "thread-index.md" + index_file = generate_thread_index(threads, output_index) - + return { - 'threads': threads, - 'updated_count': updated_count, - 'index_file': str(index_file) + "threads": threads, + "updated_count": updated_count, + "index_file": str(index_file), } def main(): parser = argparse.ArgumentParser( - description='Reconstruct email conversation threads from message-id chains' + description="Reconstruct email conversation threads from message-id chains" + ) + parser.add_argument("directory", help="Directory containing .md email files") + parser.add_argument( + "--output", + "-o", + help="Output thread index file (default: directory/thread-index.md)", ) - parser.add_argument('directory', help='Directory containing .md email files') - parser.add_argument('--output', '-o', help='Output thread index file (default: directory/thread-index.md)') - + args = parser.parse_args() - + result = reconstruct_threads(args.directory, args.output) - + print(f"Processed {result['updated_count']} emails") print(f"Found {len(result['threads'])} threads") - if result['index_file']: + if result["index_file"]: print(f"Thread index: {result['index_file']}") -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/.agents/scripts/skill-update-helper.sh b/.agents/scripts/skill-update-helper.sh index 0215a01537..d85d617cfa 100755 --- a/.agents/scripts/skill-update-helper.sh +++ b/.agents/scripts/skill-update-helper.sh @@ -57,7 +57,7 @@ _log_to_file() { local timestamp timestamp="$(date '+%Y-%m-%d %H:%M:%S')" mkdir -p "$(dirname "$SKILL_LOG_FILE")" 2>/dev/null || true - echo "[$timestamp] [skill-update] [$level] $*" >>"$SKILL_LOG_FILE" + printf '[%s] [skill-update] [%s] %s\n' "$timestamp" "$level" "$*" >>"$SKILL_LOG_FILE" return 0 } diff --git a/.agents/scripts/test-ocr-extraction-pipeline.sh b/.agents/scripts/test-ocr-extraction-pipeline.sh index f57bf1deb3..6d0afd1603 100755 --- a/.agents/scripts/test-ocr-extraction-pipeline.sh +++ b/.agents/scripts/test-ocr-extraction-pipeline.sh @@ -693,7 +693,7 @@ create_large_invoice() { local amount=$((i * 10)) subtotal=$((subtotal + amount)) local vat_amt - vat_amt=$(echo "$amount * 0.2" | bc 2>/dev/null || echo "$((amount / 5))") + vat_amt=$(echo "$amount * 0.2" | bc || echo "$((amount / 5))") if [[ -n "$items" ]]; then items="${items}," fi diff --git a/tests/entity-extraction-test-fixtures/date-formats.md b/tests/entity-extraction-test-fixtures/date-formats.md index 1dbcfcd8f4..5dc9636f30 100644 --- a/tests/entity-extraction-test-fixtures/date-formats.md +++ b/tests/entity-extraction-test-fixtures/date-formats.md @@ -12,6 +12,6 @@ Here are the key dates for the project: - Phase 1 deadline: 2026-03-15 - Phase 2 deadline: 28 Feb 2026 - Final delivery: March 30, 2026 -- Board review: Monday, 5 April 2026 +- Board review: Monday, 6 April 2026 Please confirm availability for all dates. diff --git a/tests/test-audit-e2e.sh b/tests/test-audit-e2e.sh index cd9e3dbedb..7eacc24328 100644 --- a/tests/test-audit-e2e.sh +++ b/tests/test-audit-e2e.sh @@ -108,6 +108,7 @@ seed_sweep_findings() { sqlite3 "$db_path" <<'SQL' >/dev/null PRAGMA journal_mode=WAL; +PRAGMA busy_timeout=5000; CREATE TABLE IF NOT EXISTS findings ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -210,6 +211,7 @@ seed_supervisor_db() { sqlite3 "$db_path" < Date: Sun, 8 Mar 2026 20:06:22 +0000 Subject: [PATCH 07/17] fix: address review bot suggestions from CodeRabbit and Gemini (GH#3916) - Expand regex metachar detection to include ()^$ in content-scanner prefilter - Fix --type filter silently ignored in --recent mode of recall.sh - Sanitize error logging in msteams.md to avoid leaking sensitive process data - Use SIGTERM before SIGKILL in local-hosting.md restart command - Align [[:space:]]* consistency between extraction and validation in issue-sync.yml - Fix truncation check to use byte count instead of char count in prompt-guard-helper.sh - Implement missing _branch_exists_on_any_remote function in worktree-helper.sh - Apply 10MB stdin limit to check-stdin and sanitize-stdin in prompt-guard-helper.sh - Add -f flag to curl for fail-fast on HTTP errors in opencode-gitlab-ci.yml - Remove 2>/dev/null from recall in tools.mjs for error visibility consistency - Fix version fallback in session-manager.md (cat VERSION || version=unknown) - Align grep pattern to use -Fqx in worktree-helper.sh cmd_list for consistency --- .agents/plugins/opencode-aidevops/tools.mjs | 2 +- .agents/scripts/content-scanner-helper.sh | 4 ++-- .agents/scripts/memory/recall.sh | 15 ++++++++++++- .agents/scripts/prompt-guard-helper.sh | 23 +++++++++++++++----- .agents/scripts/worktree-helper.sh | 13 ++++++++++- .agents/services/communications/msteams.md | 6 ++++- .agents/services/hosting/local-hosting.md | 2 +- .agents/workflows/session-manager.md | 2 +- .github/workflows/issue-sync.yml | 2 +- configs/mcp-templates/opencode-gitlab-ci.yml | 2 +- 10 files changed, 56 insertions(+), 15 deletions(-) diff --git a/.agents/plugins/opencode-aidevops/tools.mjs b/.agents/plugins/opencode-aidevops/tools.mjs index b7bbb88314..7f89ceeea4 100644 --- a/.agents/plugins/opencode-aidevops/tools.mjs +++ b/.agents/plugins/opencode-aidevops/tools.mjs @@ -266,7 +266,7 @@ export function createTools(scriptsDir, run, pipelines) { description: 'Recall memories from the aidevops cross-session memory system. Args: query (string), limit (string, default "5")', buildArgs: (args, helper) => ({ - cmd: `bash "${helper}" recall "${args.query}" --limit ${args.limit || "5"} 2>/dev/null`, + cmd: `bash "${helper}" recall "${args.query}" --limit ${args.limit || "5"}`, timeout: 10000, }), }), diff --git a/.agents/scripts/content-scanner-helper.sh b/.agents/scripts/content-scanner-helper.sh index 81a23b5825..98bd132c35 100755 --- a/.agents/scripts/content-scanner-helper.sh +++ b/.agents/scripts/content-scanner-helper.sh @@ -193,9 +193,9 @@ _cs_prefilter() { local keyword for keyword in "${_CS_PREFILTER_KEYWORDS[@]}"; do - # Keywords containing regex metacharacters (.*+?|) use regex matching; + # Keywords containing regex metacharacters (.*+?|()^$) use regex matching; # plain keywords use faster literal substring matching. - if [[ "$keyword" =~ [.*+?\|] ]]; then + if [[ "$keyword" =~ [.*+?\|()^$] ]]; then if [[ "$lower_content" =~ $keyword ]]; then return 0 fi diff --git a/.agents/scripts/memory/recall.sh b/.agents/scripts/memory/recall.sh index b7b87818b4..7bf6a95832 100755 --- a/.agents/scripts/memory/recall.sh +++ b/.agents/scripts/memory/recall.sh @@ -133,10 +133,23 @@ cmd_recall() { auto_filter="AND COALESCE(a.auto_captured, 0) = 0" fi + # Build type filter early so --recent mode can use it (GH#3916 — CodeRabbit) + local type_where="" + if [[ -n "$type_filter" ]]; then + # Validate type to prevent SQL injection (same validation as the non-recent path) + local type_pattern=" $type_filter " + if [[ ! " $VALID_TYPES " =~ $type_pattern ]]; then + log_error "Invalid type: $type_filter" + log_error "Valid types: $VALID_TYPES" + return 1 + fi + type_where="AND l.type = '$type_filter'" + fi + # Handle --recent mode (no query required) if [[ "$recent_mode" == true ]]; then local results - results=$(db -json "$MEMORY_DB" "SELECT l.id, l.content, l.type, l.tags, l.confidence, l.created_at, COALESCE(a.last_accessed_at, '') as last_accessed_at, COALESCE(a.access_count, 0) as access_count, COALESCE(a.auto_captured, 0) as auto_captured FROM learnings l LEFT JOIN learning_access a ON l.id = a.id $entity_join WHERE 1=1 $entity_where $auto_filter ORDER BY l.created_at DESC LIMIT $limit;") + results=$(db -json "$MEMORY_DB" "SELECT l.id, l.content, l.type, l.tags, l.confidence, l.created_at, COALESCE(a.last_accessed_at, '') as last_accessed_at, COALESCE(a.access_count, 0) as access_count, COALESCE(a.auto_captured, 0) as auto_captured FROM learnings l LEFT JOIN learning_access a ON l.id = a.id $entity_join WHERE 1=1 $entity_where $auto_filter $type_where ORDER BY l.created_at DESC LIMIT $limit;") if [[ "$format" == "json" ]]; then echo "$results" else diff --git a/.agents/scripts/prompt-guard-helper.sh b/.agents/scripts/prompt-guard-helper.sh index add4e996b0..5bfe7d751e 100755 --- a/.agents/scripts/prompt-guard-helper.sh +++ b/.agents/scripts/prompt-guard-helper.sh @@ -726,12 +726,13 @@ cmd_scan_stdin() { return 1 fi - if [[ ${#content} -ge $max_bytes ]]; then + local byte_count + byte_count=$(printf '%s' "$content" | wc -c | tr -d ' ') + + if [[ $byte_count -ge $max_bytes ]]; then _pg_log_warn "Input truncated to ${max_bytes} bytes — scanning partial content" fi - local byte_count - byte_count=$(printf '%s' "$content" | wc -c | tr -d ' ') _pg_log_info "Scanning stdin content ($byte_count bytes)" local results @@ -1646,21 +1647,33 @@ main() { cmd_sanitize "$content" ;; check-stdin) + local max_bytes=10485760 local content - content=$(cat) + content=$(head -c "$max_bytes") if [[ -z "$content" ]]; then _pg_log_error "No input received on stdin" return 1 fi + local byte_count + byte_count=$(printf '%s' "$content" | wc -c | tr -d ' ') + if [[ $byte_count -ge $max_bytes ]]; then + _pg_log_warn "Input truncated to ${max_bytes} bytes" + fi cmd_check "$content" ;; sanitize-stdin) + local max_bytes=10485760 local content - content=$(cat) + content=$(head -c "$max_bytes") if [[ -z "$content" ]]; then _pg_log_error "No input received on stdin" return 1 fi + local byte_count + byte_count=$(printf '%s' "$content" | wc -c | tr -d ' ') + if [[ $byte_count -ge $max_bytes ]]; then + _pg_log_warn "Input truncated to ${max_bytes} bytes" + fi cmd_sanitize "$content" ;; log) diff --git a/.agents/scripts/worktree-helper.sh b/.agents/scripts/worktree-helper.sh index 3698ac1c25..f8690d7123 100755 --- a/.agents/scripts/worktree-helper.sh +++ b/.agents/scripts/worktree-helper.sh @@ -190,6 +190,17 @@ branch_was_pushed() { return 1 } +# Check if a branch exists on any remote (GH#3916 — CodeRabbit) +# Used by cmd_clean to detect if a remote branch was deleted (squash merge indicator). +# Returns 0 if the branch exists on at least one remote, 1 otherwise. +_branch_exists_on_any_remote() { + local branch="$1" + if git for-each-ref --format='%(refname)' "refs/remotes/*/$branch" | grep -q .; then + return 0 + fi + return 1 +} + # Check if a stale remote branch exists for a branch name (t1060) # A "stale remote" means refs/remotes/origin/$branch exists but no local branch does. # This typically happens when a branch was merged via PR (remote deleted) but the @@ -490,7 +501,7 @@ cmd_list() { local merged_marker="" local default_branch default_branch=$(get_default_branch) - if [[ -n "$worktree_branch" ]] && git branch --merged "$default_branch" 2>/dev/null | grep -q "^\s*$worktree_branch$"; then + if [[ -n "$worktree_branch" ]] && (git branch --merged "$default_branch" 2>/dev/null | grep -Fqx " $worktree_branch" || git branch --merged "$default_branch" 2>/dev/null | grep -Fqx "* $worktree_branch"); then merged_marker=" ${YELLOW}(merged)${NC}" fi diff --git a/.agents/services/communications/msteams.md b/.agents/services/communications/msteams.md index 27d4b17be4..f590cadc0d 100644 --- a/.agents/services/communications/msteams.md +++ b/.agents/services/communications/msteams.md @@ -721,7 +721,11 @@ async function dispatchToRunner(prompt, context) { ); return stdout.trim(); } catch (error) { - console.error("Runner dispatch failed", { error }); + console.error("Runner dispatch failed", { + message: error?.message, + code: error?.code, + signal: error?.signal, + }); return "Runner dispatch failed. Please try again later or contact an administrator."; } } diff --git a/.agents/services/hosting/local-hosting.md b/.agents/services/hosting/local-hosting.md index e51765925c..8206fe3525 100644 --- a/.agents/services/hosting/local-hosting.md +++ b/.agents/services/hosting/local-hosting.md @@ -614,7 +614,7 @@ rm -f apps/web/.next/dev/lock && pnpm dev:web "dev": "rm -f .next/dev/lock && next dev --port 3100" # Terminal profile / Tabby start command (includes lock cleanup + port kill) -rm -f apps/web/.next/dev/lock; lsof -ti:3100 | xargs kill -9 || true; pnpm dev:web +pids="$(lsof -ti:3100)" && [ -n "$pids" ] && kill $pids || true; rm -f apps/web/.next/dev/lock; pnpm dev:web ``` ### Docker Compose Projects diff --git a/.agents/workflows/session-manager.md b/.agents/workflows/session-manager.md index 13a305518d..43b6d03b8e 100644 --- a/.agents/workflows/session-manager.md +++ b/.agents/workflows/session-manager.md @@ -62,7 +62,7 @@ check_session_status() { echo "Current PR state: ${pr_state}" # Check latest release vs VERSION - version=$( Date: Sun, 8 Mar 2026 20:15:40 +0000 Subject: [PATCH 08/17] fix: clean up regex metachar class escape in content-scanner prefilter Remove unnecessary backslash before | in character class [.*+?|()^$]. Inside a character class, | is literal and doesn't need escaping. The \| was matching both backslash and pipe, which was unintended. Per Gemini review suggestion on GH#3916. --- .agents/scripts/content-scanner-helper.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/scripts/content-scanner-helper.sh b/.agents/scripts/content-scanner-helper.sh index 98bd132c35..39ef498974 100755 --- a/.agents/scripts/content-scanner-helper.sh +++ b/.agents/scripts/content-scanner-helper.sh @@ -195,7 +195,7 @@ _cs_prefilter() { for keyword in "${_CS_PREFILTER_KEYWORDS[@]}"; do # Keywords containing regex metacharacters (.*+?|()^$) use regex matching; # plain keywords use faster literal substring matching. - if [[ "$keyword" =~ [.*+?\|()^$] ]]; then + if [[ "$keyword" =~ [.*+?|()^$] ]]; then if [[ "$lower_content" =~ $keyword ]]; then return 0 fi From abd61ec095aa2d42cc55a9ac5bce2255753f6887 Mon Sep 17 00:00:00 2001 From: AI DevOps Date: Sun, 8 Mar 2026 14:39:17 -0600 Subject: [PATCH 09/17] =?UTF-8?q?fix:=20address=20CodeRabbit=20critical/ma?= =?UTF-8?q?jor=20review=20findings=20on=20PR=20#3916=20=E2=80=94=20jq=20in?= =?UTF-8?q?jection,=20shell=20injection,=20scorer=20dedup,=20version=20fal?= =?UTF-8?q?lback,=20type=20filter=20(GH#3916=20review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/plugins/opencode-aidevops/tools.mjs | 20 +++++++++++++++---- .agents/scripts/memory/recall.sh | 17 +++------------- .agents/scripts/response-scoring-helper.sh | 8 +++++++- .agents/scripts/settings-helper.sh | 22 +++++++++++++-------- .agents/workflows/session-manager.md | 2 +- 5 files changed, 41 insertions(+), 28 deletions(-) diff --git a/.agents/plugins/opencode-aidevops/tools.mjs b/.agents/plugins/opencode-aidevops/tools.mjs index 7f89ceeea4..e0198745e7 100644 --- a/.agents/plugins/opencode-aidevops/tools.mjs +++ b/.agents/plugins/opencode-aidevops/tools.mjs @@ -2,6 +2,17 @@ import { execSync } from "child_process"; import { existsSync } from "fs"; import { join } from "path"; +/** + * Escape a string for safe interpolation into a shell command. + * Wraps in single quotes and escapes internal single quotes. + * @param {string} s - The string to escape + * @returns {string} Shell-safe quoted string + */ +function shellEscape(s) { + if (typeof s !== "string") return "''"; + return "'" + s.replace(/'/g, "'\\''") + "'"; +} + /** * Create a memory tool (recall or store) using a shared factory pattern. * Deduplicates the near-identical memory_recall and memory_store definitions. @@ -41,9 +52,10 @@ function createAidevopsTool(run) { description: 'Run aidevops CLI commands (status, repos, features, secret, etc.). Pass command as string e.g. "status", "repos", "features"', async execute(args) { - const cmd = `aidevops ${args.command || args}`; + const subcmd = String(args.command || args); + const cmd = `aidevops ${shellEscape(subcmd)}`; const result = run(cmd, 15000); - return result || `Command completed: ${cmd}`; + return result || `Command completed: aidevops ${subcmd}`; }, }; } @@ -266,7 +278,7 @@ export function createTools(scriptsDir, run, pipelines) { description: 'Recall memories from the aidevops cross-session memory system. Args: query (string), limit (string, default "5")', buildArgs: (args, helper) => ({ - cmd: `bash "${helper}" recall "${args.query}" --limit ${args.limit || "5"}`, + cmd: `bash "${helper}" recall ${shellEscape(args.query)} --limit ${shellEscape(String(args.limit || "5"))}`, timeout: 10000, }), }), @@ -283,7 +295,7 @@ export function createTools(scriptsDir, run, pipelines) { return { cmd: `echo "Error: content is required to store a memory" >&2; exit 1`, timeout: 1000 }; } return { - cmd: `bash "${helper}" store "${content}" --confidence ${args.confidence || "medium"}`, + cmd: `bash "${helper}" store ${shellEscape(content)} --confidence ${shellEscape(String(args.confidence || "medium"))}`, timeout: 10000, }; }, diff --git a/.agents/scripts/memory/recall.sh b/.agents/scripts/memory/recall.sh index 7bf6a95832..58557f9c68 100755 --- a/.agents/scripts/memory/recall.sh +++ b/.agents/scripts/memory/recall.sh @@ -133,10 +133,9 @@ cmd_recall() { auto_filter="AND COALESCE(a.auto_captured, 0) = 0" fi - # Build type filter early so --recent mode can use it (GH#3916 — CodeRabbit) + # Build type filter clause (validated before --recent and regular paths) local type_where="" if [[ -n "$type_filter" ]]; then - # Validate type to prevent SQL injection (same validation as the non-recent path) local type_pattern=" $type_filter " if [[ ! " $VALID_TYPES " =~ $type_pattern ]]; then log_error "Invalid type: $type_filter" @@ -217,18 +216,8 @@ cmd_recall() { set +f # Re-enable globbing escaped_query="$tokenised_query" - # Build filters with validation - local extra_filters="" - if [[ -n "$type_filter" ]]; then - # Validate type to prevent SQL injection - local type_pattern=" $type_filter " - if [[ ! " $VALID_TYPES " =~ $type_pattern ]]; then - log_error "Invalid type: $type_filter" - log_error "Valid types: $VALID_TYPES" - return 1 - fi - extra_filters="$extra_filters AND type = '$type_filter'" - fi + # Build filters with validation (type_where already validated above) + local extra_filters="$type_where" if [[ -n "$max_age_days" ]]; then # Validate max_age_days is a positive integer if ! [[ "$max_age_days" =~ ^[0-9]+$ ]]; then diff --git a/.agents/scripts/response-scoring-helper.sh b/.agents/scripts/response-scoring-helper.sh index eb4ecf10eb..223b8cdd15 100755 --- a/.agents/scripts/response-scoring-helper.sh +++ b/.agents/scripts/response-scoring-helper.sh @@ -359,7 +359,13 @@ readonly WEIGHTED_AVG_SQL="COALESCE(( ELSE 0.25 END), 0) , 2) - FROM scores s WHERE s.response_id = r.response_id + FROM ( + SELECT s1.* FROM scores s1 + INNER JOIN ( + SELECT response_id, criterion, MAX(rowid) as max_rowid + FROM scores GROUP BY response_id, criterion + ) s2 ON s1.rowid = s2.max_rowid + ) s WHERE s.response_id = r.response_id ), 0)" # Sync a scored response to the pattern tracker as a SUCCESS_PATTERN entry. diff --git a/.agents/scripts/settings-helper.sh b/.agents/scripts/settings-helper.sh index 52aeb141f0..464bb2b720 100755 --- a/.agents/scripts/settings-helper.sh +++ b/.agents/scripts/settings-helper.sh @@ -218,9 +218,15 @@ cmd_set() { local jq_path jq_path=$(_jq_path "$key") - # Validate the key exists in defaults + # Validate key format: only alphanumeric, underscores, and dots allowed + if [[ ! "$key" =~ ^[a-zA-Z_][a-zA-Z0-9_.]*$ ]]; then + print_error "Invalid key format: $key (only alphanumeric, underscores, dots)" + return 1 + fi + + # Validate the key exists in defaults (use getpath with key as data, not code) local default_check - default_check=$(_generate_defaults | jq -r "$jq_path // \"__MISSING__\"" 2>/dev/null || echo "__MISSING__") + default_check=$(_generate_defaults | jq -r --arg k "$key" 'getpath($k | split(".")) // "__MISSING__"' 2>/dev/null || echo "__MISSING__") if [[ "$default_check" == "__MISSING__" ]]; then print_error "Unknown setting: $key" print_info "Run 'settings-helper.sh list' to see available settings" @@ -229,7 +235,7 @@ cmd_set() { # Determine value type from defaults and coerce accordingly local default_type - default_type=$(_generate_defaults | jq -r "$jq_path | type" 2>/dev/null || echo "string") + default_type=$(_generate_defaults | jq -r --arg k "$key" 'getpath($k | split(".")) | type' 2>/dev/null || echo "string") local tmp_file tmp_file=$(mktemp) @@ -246,28 +252,28 @@ cmd_set() { return 1 ;; esac - jq --argjson v "$value" "$jq_path = \$v" "$SETTINGS_FILE" >"$tmp_file" + jq --arg k "$key" --argjson v "$value" 'setpath($k | split("."); $v)' "$SETTINGS_FILE" >"$tmp_file" ;; number) if ! jq -e 'tonumber' <<<"$value" >/dev/null 2>&1; then print_error "Invalid number value: $value" return 1 fi - jq --argjson v "$value" "$jq_path = \$v" "$SETTINGS_FILE" >"$tmp_file" + jq --arg k "$key" --argjson v "$value" 'setpath($k | split("."); $v)' "$SETTINGS_FILE" >"$tmp_file" ;; array) # Accept JSON array or comma-separated values if [[ "$value" == "["* ]]; then - jq --argjson v "$value" "$jq_path = \$v" "$SETTINGS_FILE" >"$tmp_file" + jq --arg k "$key" --argjson v "$value" 'setpath($k | split("."); $v)' "$SETTINGS_FILE" >"$tmp_file" else # Convert comma-separated to JSON array local json_array json_array=$(echo "$value" | tr ',' '\n' | jq -R . | jq -s .) - jq --argjson v "$json_array" "$jq_path = \$v" "$SETTINGS_FILE" >"$tmp_file" + jq --arg k "$key" --argjson v "$json_array" 'setpath($k | split("."); $v)' "$SETTINGS_FILE" >"$tmp_file" fi ;; *) - jq --arg v "$value" "$jq_path = \$v" "$SETTINGS_FILE" >"$tmp_file" + jq --arg k "$key" --arg v "$value" 'setpath($k | split("."); $v)' "$SETTINGS_FILE" >"$tmp_file" ;; esac diff --git a/.agents/workflows/session-manager.md b/.agents/workflows/session-manager.md index 43b6d03b8e..d1d48b07ec 100644 --- a/.agents/workflows/session-manager.md +++ b/.agents/workflows/session-manager.md @@ -62,7 +62,7 @@ check_session_status() { echo "Current PR state: ${pr_state}" # Check latest release vs VERSION - version=$(cat VERSION) || version="unknown" + version=$(/dev/null || echo "unknown") latest_tag=$(git describe --tags --abbrev=0 || echo "none") echo "VERSION: ${version}, Latest tag: ${latest_tag}" From af74605f4374cf7d445c467d2584bfeb8eb1c01e Mon Sep 17 00:00:00 2001 From: AI DevOps Date: Sun, 8 Mar 2026 15:00:22 -0600 Subject: [PATCH 10/17] =?UTF-8?q?fix:=20address=20CodeRabbit=20review=20ro?= =?UTF-8?q?und=202=20=E2=80=94=207=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - recall.sh: replace regex =~ type validation with exact loop match (prevents regex metacharacter injection in type_filter) - email-thread-reconstruction.py: use os.path.relpath for thread-index links so they work regardless of output file location - cloudron.md: replace MYSQL_PWD docker exec with interactive -p prompt to avoid password in process list - issue-sync.yml: align validation regex with extractor (closes?|fixes?|resolves?) - test-ocr-extraction-pipeline.sh: replace bc with shell arithmetic (amounts are always multiples of 5, avoids bc spam when missing) - pulse-wrapper.sh: tolerate whitespace around = and strip CR in sonar-project.properties parsing - test-audit-e2e.sh: add busy_timeout pragma to all sqlite3 sessions --- .agents/scripts/email-thread-reconstruction.py | 11 ++++++++++- .agents/scripts/memory/recall.sh | 11 +++++++++-- .agents/scripts/pulse-wrapper.sh | 4 ++-- .agents/scripts/test-ocr-extraction-pipeline.sh | 10 ++++------ .agents/services/hosting/cloudron.md | 7 ++++--- .github/workflows/issue-sync.yml | 2 +- tests/test-audit-e2e.sh | 6 +++--- 7 files changed, 33 insertions(+), 18 deletions(-) diff --git a/.agents/scripts/email-thread-reconstruction.py b/.agents/scripts/email-thread-reconstruction.py index e4f8bbe727..f850da82b1 100755 --- a/.agents/scripts/email-thread-reconstruction.py +++ b/.agents/scripts/email-thread-reconstruction.py @@ -14,6 +14,7 @@ Also generates a thread index file listing all emails in chronological order per thread. """ +import os import sys import re from pathlib import Path @@ -236,7 +237,15 @@ def generate_thread_index(threads, output_file): lines.append("") for i, email in enumerate(emails, 1): - file_path = Path(email["file"]).name + # Build link relative to the output file's directory so links + # work regardless of where the index is written + email_path = Path(email["file"]) + output_dir = Path(output_file).parent + try: + file_path = os.path.relpath(email_path, output_dir) + except ValueError: + # Different drives on Windows — fall back to basename + file_path = email_path.name email_subject = email.get("subject", "No Subject") from_addr = email.get("from", "Unknown") date_sent = email.get("date_sent", "Unknown") diff --git a/.agents/scripts/memory/recall.sh b/.agents/scripts/memory/recall.sh index 58557f9c68..301faf1a60 100755 --- a/.agents/scripts/memory/recall.sh +++ b/.agents/scripts/memory/recall.sh @@ -136,8 +136,15 @@ cmd_recall() { # Build type filter clause (validated before --recent and regular paths) local type_where="" if [[ -n "$type_filter" ]]; then - local type_pattern=" $type_filter " - if [[ ! " $VALID_TYPES " =~ $type_pattern ]]; then + local valid_type=false + local candidate + for candidate in $VALID_TYPES; do + if [[ "$candidate" == "$type_filter" ]]; then + valid_type=true + break + fi + done + if [[ "$valid_type" != "true" ]]; then log_error "Invalid type: $type_filter" log_error "Valid types: $VALID_TYPES" return 1 diff --git a/.agents/scripts/pulse-wrapper.sh b/.agents/scripts/pulse-wrapper.sh index 21658aa9e1..66ab36606f 100755 --- a/.agents/scripts/pulse-wrapper.sh +++ b/.agents/scripts/pulse-wrapper.sh @@ -2505,9 +2505,9 @@ _No smells detected or qlty analysis returned empty._ local sonar_section="" if [[ -f "${repo_path}/sonar-project.properties" ]]; then local project_key - project_key=$(grep '^sonar.projectKey=' "${repo_path}/sonar-project.properties" | cut -d= -f2 || true) + project_key=$(grep -E '^[[:space:]]*sonar\.projectKey[[:space:]]*=' "${repo_path}/sonar-project.properties" | sed 's/^[^=]*=[[:space:]]*//' | tr -d '\r' || true) local org_key - org_key=$(grep '^sonar.organization=' "${repo_path}/sonar-project.properties" | cut -d= -f2 || true) + org_key=$(grep -E '^[[:space:]]*sonar\.organization[[:space:]]*=' "${repo_path}/sonar-project.properties" | sed 's/^[^=]*=[[:space:]]*//' | tr -d '\r' || true) if [[ -n "$project_key" && -n "$org_key" ]]; then # URL-encode project_key to prevent injection via crafted sonar-project.properties diff --git a/.agents/scripts/test-ocr-extraction-pipeline.sh b/.agents/scripts/test-ocr-extraction-pipeline.sh index 6d0afd1603..52eb92bf87 100755 --- a/.agents/scripts/test-ocr-extraction-pipeline.sh +++ b/.agents/scripts/test-ocr-extraction-pipeline.sh @@ -692,8 +692,8 @@ create_large_invoice() { for i in $(seq 1 50); do local amount=$((i * 10)) subtotal=$((subtotal + amount)) - local vat_amt - vat_amt=$(echo "$amount * 0.2" | bc || echo "$((amount / 5))") + # amount is always a multiple of 5, so integer division is exact + local vat_amt=$((amount / 5)) if [[ -n "$items" ]]; then items="${items}," fi @@ -707,10 +707,8 @@ create_large_invoice() { \"vat_amount\": ${vat_amt} }" done - local vat_total - vat_total=$(echo "$subtotal * 0.2" | bc 2>/dev/null || echo "$((subtotal / 5))") - local total - total=$(echo "$subtotal + $vat_total" | bc 2>/dev/null || echo "$((subtotal + subtotal / 5))") + local vat_total=$((subtotal / 5)) + local total=$((subtotal + vat_total)) cat >"$output_file" < | grep CLOUDRON_MYSQL # Connect to MySQL via the mysql container docker exec -it mysql mysql -u -p -# Or use root access -docker exec -it -e MYSQL_PWD="$(cat /home/yellowtent/platformdata/mysql/root_password)" mysql mysql -uroot +# Or use root access (interactive prompt — avoids password in process list) +docker exec -it mysql mysql -uroot -p +# When prompted, paste the password from: cat /home/yellowtent/platformdata/mysql/root_password ``` -> **Security note**: The `docker inspect` command above reveals database credentials. Redact passwords before pasting output into forum posts, tickets, or chat. +> **Security note**: The `docker inspect` command above reveals database credentials. Redact passwords before pasting output into forum posts, tickets, or chat. Avoid passing passwords via `-p` or env vars in `docker exec` — they appear in the host process list. #### **Common Database Fixes** diff --git a/.github/workflows/issue-sync.yml b/.github/workflows/issue-sync.yml index 06b35a61ce..72c0cf4209 100644 --- a/.github/workflows/issue-sync.yml +++ b/.github/workflows/issue-sync.yml @@ -565,7 +565,7 @@ jobs: fi # Check for GitHub closing keywords or ref:GH# pattern - if echo "$PR_BODY" | grep -qiE '(closes|fixes|resolves)[[:space:]]*#[0-9]+'; then + if echo "$PR_BODY" | grep -qiE '(closes?|fixes?|resolves?)[[:space:]]*#[0-9]+'; then echo "PR body contains issue closing keyword" exit 0 fi diff --git a/tests/test-audit-e2e.sh b/tests/test-audit-e2e.sh index 7eacc24328..ae9b53d883 100644 --- a/tests/test-audit-e2e.sh +++ b/tests/test-audit-e2e.sh @@ -143,7 +143,7 @@ CREATE TABLE IF NOT EXISTS sweep_runs ( SQL # Insert test findings from multiple sources - sqlite3 "$db_path" < Date: Sun, 8 Mar 2026 21:22:55 +0000 Subject: [PATCH 11/17] fix: preserve trailing newlines in NFKC normalization (GH#3916) Use sentinel character pattern to prevent bash command substitution from stripping trailing newlines, which caused false 'changed' detection and unnecessary double-scans in content-scanner. --- .agents/scripts/content-scanner-helper.sh | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/.agents/scripts/content-scanner-helper.sh b/.agents/scripts/content-scanner-helper.sh index 39ef498974..aaeeda035a 100755 --- a/.agents/scripts/content-scanner-helper.sh +++ b/.agents/scripts/content-scanner-helper.sh @@ -229,13 +229,19 @@ _cs_normalize_nfkc() { fi # Try python3 first (most reliable NFKC) + # Use sentinel character to preserve trailing newlines through command substitution + # (bash $() strips trailing newlines, causing false "changed" detection — GH#3916) if command -v python3 &>/dev/null; then local py_result - if py_result=$(printf '%s' "$content" | python3 -c " + if py_result=$( + printf '%s' "$content" | python3 -c " import sys, unicodedata text = sys.stdin.read() sys.stdout.write(unicodedata.normalize('NFKC', text)) -"); then +" + printf x + ); then + py_result="${py_result%x}" printf '%s' "$py_result" return 0 fi @@ -244,7 +250,11 @@ sys.stdout.write(unicodedata.normalize('NFKC', text)) # Try perl as fallback (Unicode::Normalize is core since 5.8) if command -v perl &>/dev/null; then local perl_result - if perl_result=$(printf '%s' "$content" | perl -MUnicode::Normalize -CS -pe '$_ = NFKC($_)'); then + if perl_result=$( + printf '%s' "$content" | perl -MUnicode::Normalize -CS -pe '$_ = NFKC($_)' + printf x + ); then + perl_result="${perl_result%x}" printf '%s' "$perl_result" return 0 fi From 2a1d57433f58f28b6210415088866c06d757b94b Mon Sep 17 00:00:00 2001 From: marcusquinn <6428977+marcusquinn@users.noreply.github.com> Date: Sun, 8 Mar 2026 21:23:00 +0000 Subject: [PATCH 12/17] fix: guard tenant-file reads against TOCTOU race (GH#3916) Wrap file reads in explicit error handling so permission/read failures degrade gracefully to fallback paths instead of aborting under set -e. --- .agents/scripts/credential-helper.sh | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/.agents/scripts/credential-helper.sh b/.agents/scripts/credential-helper.sh index 26e2380f9c..03d2f62c17 100755 --- a/.agents/scripts/credential-helper.sh +++ b/.agents/scripts/credential-helper.sh @@ -43,10 +43,13 @@ validate_tenant_name() { # Get the active tenant name get_active_tenant() { # Priority: 1) Project override, 2) Global active, 3) "default" + # Guard reads against TOCTOU race (file may vanish/change permissions between + # -f check and read) — degrade gracefully instead of aborting under set -e (GH#3916) if [[ -f "$PROJECT_TENANT_FILE" ]]; then local project_tenant - project_tenant=$(tr -d '[:space:]' <"$PROJECT_TENANT_FILE") - if [[ -n "$project_tenant" ]]; then + if ! project_tenant=$(tr -d '[:space:]' <"$PROJECT_TENANT_FILE" 2>/dev/null); then + print_warning "Failed to read project tenant file, falling back" + elif [[ -n "$project_tenant" ]]; then if validate_tenant_name "$project_tenant"; then echo "$project_tenant" return 0 @@ -57,8 +60,9 @@ get_active_tenant() { if [[ -f "$ACTIVE_TENANT_FILE" ]]; then local active - active=$(tr -d '[:space:]' <"$ACTIVE_TENANT_FILE") - if [[ -n "$active" ]]; then + if ! active=$(tr -d '[:space:]' <"$ACTIVE_TENANT_FILE" 2>/dev/null); then + print_warning "Failed to read active tenant file, falling back" + elif [[ -n "$active" ]]; then if validate_tenant_name "$active"; then echo "$active" return 0 @@ -693,7 +697,9 @@ cmd_use() { # Show current project tenant if [[ -f "$PROJECT_TENANT_FILE" ]]; then local current - current=$(tr -d '[:space:]' <"$PROJECT_TENANT_FILE") + if ! current=$(tr -d '[:space:]' <"$PROJECT_TENANT_FILE" 2>/dev/null); then + current="" + fi print_info "Project tenant: $current" else print_info "No project-level tenant set (using global: $(get_active_tenant))" @@ -743,7 +749,7 @@ cmd_status() { local project_tenant="" if [[ -f "$PROJECT_TENANT_FILE" ]]; then - project_tenant=$(tr -d '[:space:]' <"$PROJECT_TENANT_FILE") + project_tenant=$(tr -d '[:space:]' <"$PROJECT_TENANT_FILE" 2>/dev/null) || project_tenant="" fi echo "" @@ -758,7 +764,7 @@ cmd_status() { local global_active="" if [[ -f "$ACTIVE_TENANT_FILE" ]]; then - global_active=$(tr -d '[:space:]' <"$ACTIVE_TENANT_FILE") + global_active=$(tr -d '[:space:]' <"$ACTIVE_TENANT_FILE" 2>/dev/null) || global_active="" fi if [[ -n "$global_active" && "$global_active" != "$active" ]]; then echo -e " Global tenant: ${DIM}$global_active${NC}" From 150ce95bbf15e94c9d4e20dd81a53c8b1295b11a Mon Sep 17 00:00:00 2001 From: marcusquinn <6428977+marcusquinn@users.noreply.github.com> Date: Sun, 8 Mar 2026 21:23:03 +0000 Subject: [PATCH 13/17] fix: remove redundant $? check under set -euo pipefail (GH#3916) Under set -euo pipefail, jq failures exit before reaching the check, so $? is always 0. Only the file-size check matters. --- .agents/scripts/settings-helper.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.agents/scripts/settings-helper.sh b/.agents/scripts/settings-helper.sh index 464bb2b720..0cabb704ce 100755 --- a/.agents/scripts/settings-helper.sh +++ b/.agents/scripts/settings-helper.sh @@ -277,7 +277,9 @@ cmd_set() { ;; esac - if [[ $? -eq 0 && -s "$tmp_file" ]]; then + # Under set -euo pipefail, jq failures exit before reaching here, + # so $? is always 0 — only the file-size check matters (GH#3916) + if [[ -s "$tmp_file" ]]; then mv "$tmp_file" "$SETTINGS_FILE" print_success "Set $key = $value" else From 222a55049272d854a3811c1b06364d6e9b277c12 Mon Sep 17 00:00:00 2001 From: marcusquinn <6428977+marcusquinn@users.noreply.github.com> Date: Sun, 8 Mar 2026 21:23:07 +0000 Subject: [PATCH 14/17] fix: correct version fallback syntax in session-manager docs (GH#3916) The $(/dev/null || echo "unknown") + version=$(cat VERSION 2>/dev/null) || version="unknown" latest_tag=$(git describe --tags --abbrev=0 || echo "none") echo "VERSION: ${version}, Latest tag: ${latest_tag}" From 4d031c5a40a0e652df2a2b138e59ecc3a143025a Mon Sep 17 00:00:00 2001 From: AI DevOps Date: Sun, 8 Mar 2026 15:38:24 -0600 Subject: [PATCH 15/17] fix: preserve trailing newlines in NFKC normalization (content-scanner) Command substitution $() strips trailing newlines, causing the normalized vs original comparison to always differ when content has trailing newlines. This triggered unnecessary double-scans. Fix: append sentinel character 'x' inside python3/perl output, then strip it after capture. Exit code propagation preserved since the sentinel is part of the normalizer process itself. --- .agents/scripts/content-scanner-helper.sh | 24 +++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/.agents/scripts/content-scanner-helper.sh b/.agents/scripts/content-scanner-helper.sh index aaeeda035a..78dedb7f3f 100755 --- a/.agents/scripts/content-scanner-helper.sh +++ b/.agents/scripts/content-scanner-helper.sh @@ -229,18 +229,17 @@ _cs_normalize_nfkc() { fi # Try python3 first (most reliable NFKC) - # Use sentinel character to preserve trailing newlines through command substitution - # (bash $() strips trailing newlines, causing false "changed" detection — GH#3916) + # Sentinel character preserves trailing newlines through command substitution. + # Bash $() strips trailing newlines, causing false "changed" detection. + # The sentinel is appended inside the normalizer process (not via separate + # printf) so the exit code reflects whether normalization succeeded. if command -v python3 &>/dev/null; then local py_result - if py_result=$( - printf '%s' "$content" | python3 -c " + if py_result=$(printf '%s' "$content" | python3 -c " import sys, unicodedata text = sys.stdin.read() -sys.stdout.write(unicodedata.normalize('NFKC', text)) -" - printf x - ); then +sys.stdout.write(unicodedata.normalize('NFKC', text) + 'x') +"); then py_result="${py_result%x}" printf '%s' "$py_result" return 0 @@ -250,10 +249,11 @@ sys.stdout.write(unicodedata.normalize('NFKC', text)) # Try perl as fallback (Unicode::Normalize is core since 5.8) if command -v perl &>/dev/null; then local perl_result - if perl_result=$( - printf '%s' "$content" | perl -MUnicode::Normalize -CS -pe '$_ = NFKC($_)' - printf x - ); then + if perl_result=$(printf '%s' "$content" | perl -MUnicode::Normalize -CS -e ' + local $/; + my $text = ; + print NFKC($text) . "x"; + '); then perl_result="${perl_result%x}" printf '%s' "$perl_result" return 0 From 5aafccaa21b2725847f79cea85f9c7b80aaec3de Mon Sep 17 00:00:00 2001 From: AI DevOps Date: Sun, 8 Mar 2026 16:32:18 -0600 Subject: [PATCH 16/17] fix: correct version fallback to use subshell pattern (session-manager) marcusquinn's commit 222a5504 reverted to the broken pattern: version=$(cat VERSION 2>/dev/null) || version="unknown" Under set -e, if cat fails the script exits before || runs. Correct pattern (matching latest_tag on line 66): version=$(cat VERSION 2>/dev/null || echo "unknown") --- .agents/workflows/session-manager.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/workflows/session-manager.md b/.agents/workflows/session-manager.md index e93da7a14b..cbc400e29a 100644 --- a/.agents/workflows/session-manager.md +++ b/.agents/workflows/session-manager.md @@ -62,7 +62,7 @@ check_session_status() { echo "Current PR state: ${pr_state}" # Check latest release vs VERSION - version=$(cat VERSION 2>/dev/null) || version="unknown" + version=$(cat VERSION 2>/dev/null || echo "unknown") latest_tag=$(git describe --tags --abbrev=0 || echo "none") echo "VERSION: ${version}, Latest tag: ${latest_tag}" From c2eb94df5c50cfabed2e00b98692a6ef9f1d6501 Mon Sep 17 00:00:00 2001 From: marcusquinn <6428977+marcusquinn@users.noreply.github.com> Date: Mon, 9 Mar 2026 04:15:55 +0000 Subject: [PATCH 17/17] =?UTF-8?q?fix:=20address=20CodeRabbit=20review=20ro?= =?UTF-8?q?und=203=20=E2=80=94=206=20files=20(GH#3916)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - memory-helper.sh: add MISSION_PATTERN/AGENT/SCRIPT to VALID_TYPES (prevents rejection of mission-skill-learner.sh store/recall calls) - recall.sh: --recent mode now applies --project and --max-age-days filters (previously silently ignored); refactor to share filter construction between --recent and regular query paths - store.sh: use exact-match loop for type validation (consistent with recall.sh, prevents regex metacharacter bypass) - pre-commit-hook.sh: remove pipe exclusion from positional parameter check (only runs on .sh files where $1|... is a real violation) - session-manager.md: fix grep -c fallback that produced '0\n0' on zero matches; use portable [[:space:]] class - opencode-gitlab-ci.yml: add arch detection for arm64 runners, add curl -f flag for HTTP error handling, add checksum verification guidance --- .agents/scripts/memory-helper.sh | 2 +- .agents/scripts/memory/recall.sh | 40 ++++++++++---------- .agents/scripts/memory/store.sh | 13 +++++-- .agents/scripts/pre-commit-hook.sh | 8 ++-- .agents/workflows/session-manager.md | 9 ++++- configs/mcp-templates/opencode-gitlab-ci.yml | 12 +++++- 6 files changed, 54 insertions(+), 30 deletions(-) diff --git a/.agents/scripts/memory-helper.sh b/.agents/scripts/memory-helper.sh index 8a5e19baba..d707e10aaa 100755 --- a/.agents/scripts/memory-helper.sh +++ b/.agents/scripts/memory-helper.sh @@ -61,7 +61,7 @@ readonly STALE_WARNING_DAYS=60 # Valid learning types (matches documentation and Continuous-Claude-v3) # shellcheck disable=SC2034 # Used in memory/store.sh and memory/recall.sh -readonly VALID_TYPES="WORKING_SOLUTION FAILED_APPROACH CODEBASE_PATTERN USER_PREFERENCE TOOL_CONFIG DECISION CONTEXT ARCHITECTURAL_DECISION ERROR_FIX OPEN_THREAD SUCCESS_PATTERN FAILURE_PATTERN" +readonly VALID_TYPES="WORKING_SOLUTION FAILED_APPROACH CODEBASE_PATTERN USER_PREFERENCE TOOL_CONFIG DECISION CONTEXT ARCHITECTURAL_DECISION ERROR_FIX OPEN_THREAD SUCCESS_PATTERN FAILURE_PATTERN MISSION_PATTERN MISSION_AGENT MISSION_SCRIPT" # Valid relation types (inspired by Supermemory's relational versioning) # - updates: New info supersedes old (state mutation) diff --git a/.agents/scripts/memory/recall.sh b/.agents/scripts/memory/recall.sh index 301faf1a60..a1874002b7 100755 --- a/.agents/scripts/memory/recall.sh +++ b/.agents/scripts/memory/recall.sh @@ -152,10 +152,28 @@ cmd_recall() { type_where="AND l.type = '$type_filter'" fi + # Build --max-age-days and --project filters early so --recent can use them (GH#3916) + local age_where="" + if [[ -n "$max_age_days" ]]; then + if ! [[ "$max_age_days" =~ ^[0-9]+$ ]]; then + log_error "--max-age-days must be a positive integer" + return 1 + fi + age_where="AND created_at >= datetime('now', '-$max_age_days days')" + fi + local project_where="" + if [[ -n "$project_filter" ]]; then + local escaped_project="${project_filter//"'"/"''"}" + escaped_project="${escaped_project//\\/\\\\}" + escaped_project="${escaped_project//%/\\%}" + escaped_project="${escaped_project//_/\\_}" + project_where="AND project_path LIKE '%$escaped_project%' ESCAPE '\\'" + fi + # Handle --recent mode (no query required) if [[ "$recent_mode" == true ]]; then local results - results=$(db -json "$MEMORY_DB" "SELECT l.id, l.content, l.type, l.tags, l.confidence, l.created_at, COALESCE(a.last_accessed_at, '') as last_accessed_at, COALESCE(a.access_count, 0) as access_count, COALESCE(a.auto_captured, 0) as auto_captured FROM learnings l LEFT JOIN learning_access a ON l.id = a.id $entity_join WHERE 1=1 $entity_where $auto_filter $type_where ORDER BY l.created_at DESC LIMIT $limit;") + results=$(db -json "$MEMORY_DB" "SELECT l.id, l.content, l.type, l.tags, l.confidence, l.created_at, COALESCE(a.last_accessed_at, '') as last_accessed_at, COALESCE(a.access_count, 0) as access_count, COALESCE(a.auto_captured, 0) as auto_captured FROM learnings l LEFT JOIN learning_access a ON l.id = a.id $entity_join WHERE 1=1 $entity_where $auto_filter $type_where $age_where $project_where ORDER BY l.created_at DESC LIMIT $limit;") if [[ "$format" == "json" ]]; then echo "$results" else @@ -223,24 +241,8 @@ cmd_recall() { set +f # Re-enable globbing escaped_query="$tokenised_query" - # Build filters with validation (type_where already validated above) - local extra_filters="$type_where" - if [[ -n "$max_age_days" ]]; then - # Validate max_age_days is a positive integer - if ! [[ "$max_age_days" =~ ^[0-9]+$ ]]; then - log_error "--max-age-days must be a positive integer" - return 1 - fi - extra_filters="$extra_filters AND created_at >= datetime('now', '-$max_age_days days')" - fi - if [[ -n "$project_filter" ]]; then - local escaped_project="${project_filter//"'"/"''"}" - # Escape LIKE wildcards (%, _) to prevent wildcard injection - escaped_project="${escaped_project//\\/\\\\}" - escaped_project="${escaped_project//%/\\%}" - escaped_project="${escaped_project//_/\\_}" - extra_filters="$extra_filters AND project_path LIKE '%$escaped_project%' ESCAPE '\\'" - fi + # Reuse pre-built filters (type_where, age_where, project_where validated above) + local extra_filters="${type_where}${age_where}${project_where}" # Build auto-capture filter for main query local auto_join_filter="" diff --git a/.agents/scripts/memory/store.sh b/.agents/scripts/memory/store.sh index 771255a5b0..8cf880f219 100755 --- a/.agents/scripts/memory/store.sh +++ b/.agents/scripts/memory/store.sh @@ -115,9 +115,16 @@ cmd_store() { return 0 fi - # Validate type - local type_pattern=" $type " - if [[ ! " $VALID_TYPES " =~ $type_pattern ]]; then + # Validate type (exact-match loop prevents regex metacharacter bypass — GH#3916) + local valid_type=false + local vt + for vt in $VALID_TYPES; do + if [[ "$vt" == "$type" ]]; then + valid_type=true + break + fi + done + if [[ "$valid_type" != "true" ]]; then log_error "Invalid type: $type" log_error "Valid types: $VALID_TYPES" return 1 diff --git a/.agents/scripts/pre-commit-hook.sh b/.agents/scripts/pre-commit-hook.sh index 023ef2c5ec..8b52f02620 100755 --- a/.agents/scripts/pre-commit-hook.sh +++ b/.agents/scripts/pre-commit-hook.sh @@ -77,11 +77,13 @@ validate_positional_parameters() { for file in "$@"; do # Exclude currency/pricing patterns: $[1-9] followed by digit, decimal, comma, - # slash (e.g. $28/mo, $1.99, $1,000), pipe (markdown table cell), or common - # currency/pricing unit words (per, mo, month, flat, etc.). + # slash (e.g. $28/mo, $1.99, $1,000), or common currency/pricing unit words + # (per, mo, month, flat, etc.). Pipe exclusion removed — this function only + # runs on .sh files where $1 | ... is a real positional parameter violation, + # not a markdown table cell (GH#3916). if [[ -f "$file" ]]; then local violations_output - violations_output=$(grep -n '\$[1-9]' "$file" | grep -v 'local.*=.*\$[1-9]' | grep -vE '\$[1-9][0-9.,/]' | grep -vE '\$[1-9]\s*\|' | grep -vE '\$[1-9]\s+(per|mo(nth)?|year|yr|day|week|hr|hour|flat|each|off|fee|plan|tier|user|seat|unit|addon|setup|trial|credit|annual|quarterly|monthly)\b' || true) + violations_output=$(grep -n '\$[1-9]' "$file" | grep -v 'local.*=.*\$[1-9]' | grep -vE '\$[1-9][0-9.,/]' | grep -vE '\$[1-9]\s+(per|mo(nth)?|year|yr|day|week|hr|hour|flat|each|off|fee|plan|tier|user|seat|unit|addon|setup|trial|credit|annual|quarterly|monthly)\b' || true) if [[ -n "$violations_output" ]]; then print_error "Direct positional parameter usage in $file" echo "$violations_output" | head -3 diff --git a/.agents/workflows/session-manager.md b/.agents/workflows/session-manager.md index cbc400e29a..4d334188ae 100644 --- a/.agents/workflows/session-manager.md +++ b/.agents/workflows/session-manager.md @@ -53,8 +53,13 @@ check_session_status() { echo "=== Session Status ===" - # Check incomplete tasks - incomplete=$(grep -c '^\s*- \[ \]' TODO.md || echo "0") + # Check incomplete tasks (use portable [[:space:]] and || true to avoid + # grep -c exit 1 on zero matches appending a second "0" via || echo) + if [[ -f TODO.md ]]; then + incomplete=$(grep -c '^[[:space:]]*- \[ \]' TODO.md || true) + else + incomplete="0" + fi echo "Incomplete tasks: ${incomplete}" # Check recent PR (requires gh CLI) diff --git a/configs/mcp-templates/opencode-gitlab-ci.yml b/configs/mcp-templates/opencode-gitlab-ci.yml index 1fb3f38ce1..79788c5bf1 100644 --- a/configs/mcp-templates/opencode-gitlab-ci.yml +++ b/configs/mcp-templates/opencode-gitlab-ci.yml @@ -40,8 +40,16 @@ opencode: # https://gitlab.com/gitlab-org/cli/-/releases and verify with sha256sum --check - | GLAB_VERSION="v1.89.0" - GLAB_ARCHIVE="glab_${GLAB_VERSION#v}_linux_amd64.tar.gz" - curl -fsLO "https://gitlab.com/gitlab-org/cli/-/releases/${GLAB_VERSION}/downloads/${GLAB_ARCHIVE}" + case "$(uname -m)" in + x86_64|amd64) GLAB_ARCH="amd64" ;; + aarch64|arm64) GLAB_ARCH="arm64" ;; + *) echo "Unsupported runner architecture: $(uname -m)" >&2; exit 1 ;; + esac + GLAB_ARCHIVE="glab_${GLAB_VERSION#v}_linux_${GLAB_ARCH}.tar.gz" + curl -fsSLO "https://gitlab.com/gitlab-org/cli/-/releases/${GLAB_VERSION}/downloads/${GLAB_ARCHIVE}" + # Optional: verify checksum (download checksums.txt and compare) + # curl -fsSLO "https://gitlab.com/gitlab-org/cli/-/releases/${GLAB_VERSION}/downloads/checksums.txt" + # grep "${GLAB_ARCHIVE}" checksums.txt | sha256sum --check --status tar xzf "${GLAB_ARCHIVE}" mv bin/glab /usr/local/bin/ rm -f "${GLAB_ARCHIVE}"