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/plugins/opencode-aidevops/tools.mjs b/.agents/plugins/opencode-aidevops/tools.mjs index a124226843..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"} 2>/dev/null`, + 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"} 2>/dev/null`, + cmd: `bash "${helper}" store ${shellEscape(content)} --confidence ${shellEscape(String(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/content-scanner-helper.sh b/.agents/scripts/content-scanner-helper.sh index bf5694870c..78dedb7f3f 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 @@ -221,17 +229,35 @@ _cs_normalize_nfkc() { fi # Try python3 first (most reliable NFKC) + # 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 - 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 +sys.stdout.write(unicodedata.normalize('NFKC', text) + 'x') +"); then + py_result="${py_result%x}" + 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 -e ' + local $/; + my $text = ; + print NFKC($text) . "x"; + '); then + perl_result="${perl_result%x}" + printf '%s' "$perl_result" + return 0 + fi fi # No normalizer available — pass through unchanged @@ -250,12 +276,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 +320,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/credential-helper.sh b/.agents/scripts/credential-helper.sh index 1e5559cfb1..03d2f62c17 100755 --- a/.agents/scripts/credential-helper.sh +++ b/.agents/scripts/credential-helper.sh @@ -43,11 +43,14 @@ 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" 2>/dev/null) - if [[ -n "$project_tenant" ]]; then - if validate_tenant_name "$project_tenant" 2>/dev/null; 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 fi @@ -57,9 +60,10 @@ get_active_tenant() { if [[ -f "$ACTIVE_TENANT_FILE" ]]; then local active - active=$(tr -d '[:space:]' <"$ACTIVE_TENANT_FILE" 2>/dev/null) - if [[ -n "$active" ]]; then - if validate_tenant_name "$active" 2>/dev/null; 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 fi @@ -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" 2>/dev/null) + 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" 2>/dev/null) + 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" 2>/dev/null) + 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}" 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 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/email-thread-reconstruction.py b/.agents/scripts/email-thread-reconstruction.py index b607c01451..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 @@ -24,39 +25,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 +65,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 +79,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 +87,191 @@ 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) - + # 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") + 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 +279,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/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 b7b87818b4..a1874002b7 100755 --- a/.agents/scripts/memory/recall.sh +++ b/.agents/scripts/memory/recall.sh @@ -133,10 +133,47 @@ cmd_recall() { auto_filter="AND COALESCE(a.auto_captured, 0) = 0" fi + # Build type filter clause (validated before --recent and regular paths) + local type_where="" + if [[ -n "$type_filter" ]]; 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 + fi + 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 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 @@ -204,34 +241,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 - 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 f12e15419b..8b52f02620 100755 --- a/.agents/scripts/pre-commit-hook.sh +++ b/.agents/scripts/pre-commit-hook.sh @@ -77,12 +77,18 @@ 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.). - 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++)) + # 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+(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/prompt-guard-helper.sh b/.agents/scripts/prompt-guard-helper.sh index e3d54ba8f7..5bfe7d751e 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 @@ -722,6 +728,11 @@ cmd_scan_stdin() { 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 + _pg_log_info "Scanning stdin content ($byte_count bytes)" local results @@ -1636,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/pulse-wrapper.sh b/.agents/scripts/pulse-wrapper.sh index 5c5f5279b9..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" 2>/dev/null | cut -d= -f2) + 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" 2>/dev/null | cut -d= -f2) + 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/response-scoring-helper.sh b/.agents/scripts/response-scoring-helper.sh index 9cb8862557..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. @@ -389,16 +395,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 +424,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/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/settings-helper.sh b/.agents/scripts/settings-helper.sh index b32d7705d4..0cabb704ce 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,32 +252,34 @@ cmd_set() { return 1 ;; esac - jq "$jq_path = $value" "$SETTINGS_FILE" >"$tmp_file" + jq --arg k "$key" --argjson v "$value" 'setpath($k | split("."); $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 --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 - 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 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/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/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/scripts/test-ocr-extraction-pipeline.sh b/.agents/scripts/test-ocr-extraction-pipeline.sh index f57bf1deb3..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 2>/dev/null || 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" </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 @@ -704,6 +715,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 +735,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 +821,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 +834,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/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/communications/msteams.md b/.agents/services/communications/msteams.md index 949b6b533c..f590cadc0d 100644 --- a/.agents/services/communications/msteams.md +++ b/.agents/services/communications/msteams.md @@ -721,7 +721,12 @@ async function dispatchToRunner(prompt, context) { ); return stdout.trim(); } catch (error) { - return `Runner dispatch failed: ${error.message}`; + 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/cloudron.md b/.agents/services/hosting/cloudron.md index 6f930773e0..4c98e53360 100644 --- a/.agents/services/hosting/cloudron.md +++ b/.agents/services/hosting/cloudron.md @@ -286,11 +286,12 @@ 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 (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. 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. Avoid passing passwords via `-p` or env vars in `docker exec` — they appear in the host process list. #### **Common Database Fixes** diff --git a/.agents/services/hosting/local-hosting.md b/.agents/services/hosting/local-hosting.md index 85aab005d0..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 2>/dev/null; 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 7a7c85afef..4d334188ae 100644 --- a/.agents/workflows/session-manager.md +++ b/.agents/workflows/session-manager.md @@ -53,17 +53,22 @@ check_session_status() { echo "=== Session Status ===" - # Check incomplete tasks - incomplete=$(grep -c '^\s*- \[ \]' TODO.md 2>/dev/null || 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) - 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=$(cat VERSION 2>/dev/null || echo "unknown") + latest_tag=$(git describe --tags --abbrev=0 || echo "none") echo "VERSION: ${version}, Latest tag: ${latest_tag}" # Suggest if complete @@ -135,7 +140,9 @@ Suggestions: spawn_terminal_tab() { local dir="${1:-$(pwd)}" local cmd="${2:-opencode}" - osascript -e "tell application \"Terminal\" to do script \"cd '${dir}' && ${cmd}\"" + local safe_dir + safe_dir=$(printf %q "$dir") + osascript -e "tell application \"Terminal\" to do script \"cd ${safe_dir} && ${cmd}\"" return 0 } @@ -143,7 +150,9 @@ spawn_terminal_tab() { spawn_iterm_tab() { local dir="${1:-$(pwd)}" local cmd="${2:-opencode}" - osascript -e "tell application \"iTerm\" to tell current window to create tab with default profile command \"cd '${dir}' && ${cmd}\"" + local safe_dir + safe_dir=$(printf %q "$dir") + osascript -e "tell application \"iTerm\" to tell current window to create tab with default profile command \"cd ${safe_dir} && ${cmd}\"" return 0 } diff --git a/.github/workflows/issue-sync.yml b/.github/workflows/issue-sync.yml index e53fb2da45..72c0cf4209 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/configs/mcp-templates/opencode-gitlab-ci.yml b/configs/mcp-templates/opencode-gitlab-ci.yml index 2ca534b6a8..79788c5bf1 100644 --- a/configs/mcp-templates/opencode-gitlab-ci.yml +++ b/configs/mcp-templates/opencode-gitlab-ci.yml @@ -36,10 +36,23 @@ 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 + 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}" # Configure git identity - git config --global user.email "opencode@gitlab.com" 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 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..ae9b53d883 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, @@ -142,7 +143,7 @@ CREATE TABLE IF NOT EXISTS sweep_runs ( SQL # Insert test findings from multiple sources - sqlite3 "$db_path" <&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