#!/usr/bin/env bash # # gh-issue-decrypt — Encrypt, submit, scan, and decrypt age-encrypted GitHub issues. # # SENDER (encrypt + submit): # gh-issue-decrypt --encrypt PUBKEY < message.txt # gh-issue-decrypt --encrypt PUBKEY --submit OWNER/REPO --title "Security report" # # RECEIVER (scan + decrypt): # gh-issue-decrypt OWNER/REPO # scan all open issues # gh-issue-decrypt OWNER/REPO NUMBER # decrypt a specific issue # # SETUP: # gh-issue-decrypt --install # install dependencies (age, gh) # gh-issue-decrypt --keygen # generate a new age keypair # # One-liner install (with cache buster): # curl -fsSL "https://raw.githubusercontent.com/Dicklesworthstone/misc_coding_agent_tips_and_scripts/main/gh-issue-decrypt?$(date +%s)" | bash -s -- --install # # Options: # --quiet Suppress non-error output # --no-gum Disable gum formatting even if available # --json Output decrypted content as JSON # --key FILE Use specific age key file # --force Reinstall even if already present # # Environment: # AGE_KEY Path to age private key (default: ~/.config/age/issuebot.key) # HTTPS_PROXY HTTPS proxy URL # HTTP_PROXY HTTP proxy URL # set -euo pipefail umask 022 shopt -s lastpipe 2>/dev/null || true # ═══════════════════════════════════════════════════════════════════════════════ # Configuration # ═══════════════════════════════════════════════════════════════════════════════ AGE_KEY="${AGE_KEY:-$HOME/.config/age/issuebot.key}" QUIET=0 NO_GUM=0 JSON_OUTPUT=0 FORCE_INSTALL=0 MODE="" # scan | install | keygen | help | encrypt | quickstart REPO_ARG="" ISSUE_NUMBER="" ENCRYPT_PUBKEY="" SUBMIT_REPO="" SUBMIT_TITLE="" # ═══════════════════════════════════════════════════════════════════════════════ # Argument Parsing # ═══════════════════════════════════════════════════════════════════════════════ while [[ $# -gt 0 ]]; do case "$1" in --install) MODE="install"; shift ;; --keygen) MODE="keygen"; shift ;; --help|-h) MODE="help"; shift ;; --quiet) QUIET=1; shift ;; --no-gum) NO_GUM=1; shift ;; --json) JSON_OUTPUT=1; shift ;; --force) FORCE_INSTALL=1; shift ;; --encrypt) MODE="encrypt" if [[ $# -lt 2 ]]; then echo "Error: --encrypt requires a PUBKEY argument" >&2; exit 1 fi ENCRYPT_PUBKEY="$2"; shift 2 ;; --key) if [[ $# -lt 2 ]]; then echo "Error: --key requires a FILE argument" >&2; exit 1 fi AGE_KEY="$2"; shift 2 ;; --submit) if [[ $# -lt 2 ]]; then echo "Error: --submit requires an OWNER/REPO argument" >&2; exit 1 fi SUBMIT_REPO="$2"; shift 2 ;; --title) if [[ $# -lt 2 ]]; then echo "Error: --title requires a string argument" >&2; exit 1 fi SUBMIT_TITLE="$2"; shift 2 ;; -*) echo "Unknown option: $1" >&2; exit 1 ;; *) if [[ -z "$REPO_ARG" ]]; then REPO_ARG="$1" elif [[ -z "$ISSUE_NUMBER" ]]; then ISSUE_NUMBER="$1" fi shift ;; esac done # Validate: --submit and --title require --encrypt if [[ -n "$SUBMIT_REPO" || -n "$SUBMIT_TITLE" ]] && [[ "$MODE" != "encrypt" ]]; then echo "Error: --submit and --title require --encrypt PUBKEY" >&2 echo "Usage: echo 'msg' | gh-issue-decrypt --encrypt PUBKEY --submit OWNER/REPO" >&2 exit 1 fi # Default mode: quickstart if no args at all, scan if repo given if [[ -z "$MODE" ]]; then if [[ -z "$REPO_ARG" ]]; then MODE="quickstart" else MODE="scan" fi fi # ═══════════════════════════════════════════════════════════════════════════════ # UI: Gum detection + ANSI fallback # ═══════════════════════════════════════════════════════════════════════════════ HAS_GUM=0 if command -v gum &>/dev/null && [ -t 1 ]; then HAS_GUM=1 fi info() { [ "$QUIET" -eq 1 ] && return 0 if [ "$HAS_GUM" -eq 1 ] && [ "$NO_GUM" -eq 0 ]; then gum style --foreground 39 "-> $*" else echo -e "\033[0;34m->\033[0m $*" fi } ok() { [ "$QUIET" -eq 1 ] && return 0 if [ "$HAS_GUM" -eq 1 ] && [ "$NO_GUM" -eq 0 ]; then gum style --foreground 42 "ok $*" else echo -e "\033[0;32mok\033[0m $*" fi } warn() { if [ "$HAS_GUM" -eq 1 ] && [ "$NO_GUM" -eq 0 ]; then gum style --foreground 214 "!! $*" else echo -e "\033[1;33m!!\033[0m $*" fi } err() { if [ "$HAS_GUM" -eq 1 ] && [ "$NO_GUM" -eq 0 ]; then gum style --foreground 196 "xx $*" >&2 else echo -e "\033[0;31mxx\033[0m $*" >&2 fi } run_with_spinner() { local title="$1"; shift if [ "$HAS_GUM" -eq 1 ] && [ "$NO_GUM" -eq 0 ] && [ "$QUIET" -eq 0 ]; then gum spin --spinner dot --title "$title" -- "$@" else info "$title" "$@" fi } draw_box() { local color="$1"; shift local lines=("$@") local max_width=0 local esc esc=$(printf '\033') local strip_ansi_sed="s/${esc}\\[[0-9;]*m//g" for line in "${lines[@]}"; do local stripped stripped=$(printf '%b' "$line" | LC_ALL=C sed "$strip_ansi_sed") local len=${#stripped} [ "$len" -gt "$max_width" ] && max_width=$len done local inner_width=$((max_width + 4)) local border="" for ((i=0; i/dev/null || printf '%s' "$input" | base64 -D 2>/dev/null } # Escape a string for JSON output without python3 json_escape() { local s="$1" s="${s//\\/\\\\}" s="${s//\"/\\\"}" s="${s//$'\n'/\\n}" s="${s//$'\r'/\\r}" s="${s//$'\t'/\\t}" printf '"%s"' "$s" } # ═══════════════════════════════════════════════════════════════════════════════ # Proxy Support # ═══════════════════════════════════════════════════════════════════════════════ PROXY_ARGS=() if [[ -n "${HTTPS_PROXY:-}" ]]; then PROXY_ARGS=(--proxy "$HTTPS_PROXY") elif [[ -n "${HTTP_PROXY:-}" ]]; then PROXY_ARGS=(--proxy "$HTTP_PROXY") fi # ═══════════════════════════════════════════════════════════════════════════════ # Platform Detection # ═══════════════════════════════════════════════════════════════════════════════ detect_platform() { OS=$(uname -s | tr 'A-Z' 'a-z') ARCH=$(uname -m) case "$ARCH" in x86_64|amd64) ARCH="x86_64" ;; arm64|aarch64) ARCH="aarch64" ;; esac IS_WSL=0 if [[ "$OS" == "linux" ]] && grep -qi microsoft /proc/version 2>/dev/null; then IS_WSL=1 fi } # ═══════════════════════════════════════════════════════════════════════════════ # Dependency Installation # ═══════════════════════════════════════════════════════════════════════════════ install_age() { if command -v age &>/dev/null && [[ "$FORCE_INSTALL" -eq 0 ]]; then ok "age already installed ($(age --version 2>/dev/null || echo 'unknown version'))" return 0 fi info "Installing age (X25519 encryption)..." case "$OS" in linux) if command -v apt-get &>/dev/null; then run_with_spinner "Installing age via apt..." \ sudo apt-get update -qq sudo apt-get install -y -qq age 2>&1 | tail -3 elif command -v dnf &>/dev/null; then run_with_spinner "Installing age via dnf..." \ sudo dnf install -y -q age elif command -v pacman &>/dev/null; then run_with_spinner "Installing age via pacman..." \ sudo pacman -S --noconfirm age elif command -v apk &>/dev/null; then run_with_spinner "Installing age via apk..." \ sudo apk add --quiet age elif command -v zypper &>/dev/null; then run_with_spinner "Installing age via zypper..." \ sudo zypper install -y age elif command -v nix-env &>/dev/null; then run_with_spinner "Installing age via nix..." \ nix-env -iA nixpkgs.age else install_age_from_github return $? fi ;; darwin) if command -v brew &>/dev/null; then run_with_spinner "Installing age via Homebrew..." \ brew install age elif command -v port &>/dev/null; then run_with_spinner "Installing age via MacPorts..." \ sudo port install age elif command -v nix-env &>/dev/null; then run_with_spinner "Installing age via nix..." \ nix-env -iA nixpkgs.age else install_age_from_github return $? fi ;; *) err "Unsupported OS: $OS" err "Install age manually: https://github.com/FiloSottile/age/releases" return 1 ;; esac if command -v age &>/dev/null; then ok "age installed ($(age --version 2>/dev/null || echo 'unknown'))" else err "age installation failed" return 1 fi } install_age_from_github() { info "No package manager found; downloading age binary from GitHub..." local age_version age_version=$(curl -fsSL "${PROXY_ARGS[@]}" \ "https://api.github.com/repos/FiloSottile/age/releases/latest" 2>/dev/null \ | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/') || true if [[ -z "$age_version" ]]; then age_version=$(curl -fsSL -o /dev/null -w '%{url_effective}' "${PROXY_ARGS[@]}" \ "https://github.com/FiloSottile/age/releases/latest" 2>/dev/null \ | sed -E 's|.*/tag/||') || true fi if [[ -z "$age_version" ]]; then err "Could not resolve age version from GitHub" err "Install manually: https://github.com/FiloSottile/age/releases" return 1 fi local age_os="$OS" local age_arch="$ARCH" case "$age_arch" in x86_64) age_arch="amd64" ;; aarch64) age_arch="arm64" ;; esac local tarball="age-${age_version}-${age_os}-${age_arch}.tar.gz" local url="https://github.com/FiloSottile/age/releases/download/${age_version}/${tarball}" local tmp_dir tmp_dir=$(mktemp -d) trap "rm -rf '$tmp_dir'" RETURN if ! curl -fsSL "${PROXY_ARGS[@]}" "$url" -o "$tmp_dir/$tarball" 2>/dev/null; then err "Failed to download age from: $url" err "Install manually: https://github.com/FiloSottile/age/releases" return 1 fi tar -xzf "$tmp_dir/$tarball" -C "$tmp_dir" local dest="${HOME}/.local/bin" mkdir -p "$dest" local age_bin age_bin=$(find "$tmp_dir" -name "age" -type f -perm -u+x 2>/dev/null | head -1) local age_keygen_bin age_keygen_bin=$(find "$tmp_dir" -name "age-keygen" -type f -perm -u+x 2>/dev/null | head -1) if [[ -z "$age_bin" ]]; then age_bin=$(find "$tmp_dir" -name "age" -type f | head -1) age_keygen_bin=$(find "$tmp_dir" -name "age-keygen" -type f | head -1) fi if [[ -z "$age_bin" ]]; then err "Could not find age binary in downloaded archive" return 1 fi install -m 0755 "$age_bin" "$dest/age" [[ -n "$age_keygen_bin" ]] && install -m 0755 "$age_keygen_bin" "$dest/age-keygen" case ":$PATH:" in *":$dest:"*) ;; *) export PATH="$dest:$PATH" ;; esac ok "age $age_version installed to $dest" } install_gh() { if command -v gh &>/dev/null && [[ "$FORCE_INSTALL" -eq 0 ]]; then ok "gh CLI already installed ($(gh --version 2>/dev/null | head -1 || echo 'unknown'))" return 0 fi info "Installing GitHub CLI (gh)..." case "$OS" in linux) if command -v apt-get &>/dev/null; then if [[ ! -f /usr/share/keyrings/githubcli-archive-keyring.gpg ]]; then info "Adding GitHub CLI apt repository..." curl -fsSL "${PROXY_ARGS[@]}" https://cli.github.com/packages/githubcli-archive-keyring.gpg \ | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg 2>/dev/null sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ | sudo tee /etc/apt/sources.list.d/github-cli.list >/dev/null fi run_with_spinner "Installing gh via apt..." \ sudo apt-get update -qq sudo apt-get install -y -qq gh 2>&1 | tail -3 elif command -v dnf &>/dev/null; then sudo dnf install -y -q 'dnf-command(config-manager)' 2>/dev/null || true sudo dnf config-manager --add-repo https://cli.github.com/packages/rpm/gh-cli.repo 2>/dev/null || true run_with_spinner "Installing gh via dnf..." \ sudo dnf install -y -q gh elif command -v pacman &>/dev/null; then run_with_spinner "Installing gh via pacman..." \ sudo pacman -S --noconfirm github-cli elif command -v apk &>/dev/null; then run_with_spinner "Installing gh via apk..." \ sudo apk add --quiet github-cli else install_gh_from_github return $? fi ;; darwin) if command -v brew &>/dev/null; then run_with_spinner "Installing gh via Homebrew..." \ brew install gh else install_gh_from_github return $? fi ;; *) err "Unsupported OS: $OS" err "Install gh manually: https://cli.github.com" return 1 ;; esac if command -v gh &>/dev/null; then ok "gh CLI installed" else err "gh CLI installation failed" return 1 fi } install_gh_from_github() { info "Downloading gh CLI binary from GitHub..." local gh_version gh_version=$(curl -fsSL "${PROXY_ARGS[@]}" \ "https://api.github.com/repos/cli/cli/releases/latest" 2>/dev/null \ | grep '"tag_name":' | sed -E 's/.*"v?([^"]+)".*/\1/') || true if [[ -z "$gh_version" ]]; then err "Could not resolve gh version" err "Install manually: https://cli.github.com" return 1 fi local gh_os="$OS" local gh_arch="$ARCH" case "$gh_arch" in x86_64) gh_arch="amd64" ;; aarch64) gh_arch="arm64" ;; esac local ext="tar.gz" [[ "$gh_os" == "darwin" ]] && ext="zip" local tarball="gh_${gh_version}_${gh_os}_${gh_arch}.${ext}" local url="https://github.com/cli/cli/releases/download/v${gh_version}/${tarball}" local tmp_dir tmp_dir=$(mktemp -d) trap "rm -rf '$tmp_dir'" RETURN if ! curl -fsSL "${PROXY_ARGS[@]}" "$url" -o "$tmp_dir/$tarball" 2>/dev/null; then err "Failed to download gh from: $url" return 1 fi case "$ext" in tar.gz) tar -xzf "$tmp_dir/$tarball" -C "$tmp_dir" ;; zip) unzip -q "$tmp_dir/$tarball" -d "$tmp_dir" ;; esac local dest="${HOME}/.local/bin" mkdir -p "$dest" local gh_bin gh_bin=$(find "$tmp_dir" -name "gh" -type f -perm -u+x 2>/dev/null | head -1) [[ -z "$gh_bin" ]] && gh_bin=$(find "$tmp_dir" -name "gh" -type f | head -1) if [[ -z "$gh_bin" ]]; then err "Could not find gh binary in downloaded archive" return 1 fi install -m 0755 "$gh_bin" "$dest/gh" case ":$PATH:" in *":$dest:"*) ;; *) export PATH="$dest:$PATH" ;; esac ok "gh $gh_version installed to $dest" } # ═══════════════════════════════════════════════════════════════════════════════ # Self-Install (save the script to ~/.local/bin) # ═══════════════════════════════════════════════════════════════════════════════ SCRIPT_URL="https://raw.githubusercontent.com/Dicklesworthstone/misc_coding_agent_tips_and_scripts/main/gh-issue-decrypt" install_self() { local dest="${HOME}/.local/bin/gh-issue-decrypt" # If already installed and not forcing, skip if [[ -x "$dest" ]] && [[ "$FORCE_INSTALL" -eq 0 ]]; then ok "gh-issue-decrypt already installed at $dest" return 0 fi mkdir -p "${HOME}/.local/bin" # Try downloading from GitHub (gets the latest version) if curl -fsSL "${PROXY_ARGS[@]}" "$SCRIPT_URL" -o "$dest" 2>/dev/null; then chmod +x "$dest" ok "gh-issue-decrypt installed to $dest" elif [[ -f "$0" ]] && [[ "$0" != "bash" ]] && [[ "$0" != "-bash" ]] && [[ "$0" != "/dev/stdin" ]]; then # Fallback: copy ourselves if running from a file (not a pipe) cp "$0" "$dest" chmod +x "$dest" ok "gh-issue-decrypt installed to $dest (from local copy)" else # Last resort: we're running from a pipe, download failed warn "Could not save gh-issue-decrypt to $dest" warn "Download manually: curl -fsSL $SCRIPT_URL -o $dest && chmod +x $dest" return 1 fi # Ensure ~/.local/bin is in PATH case ":$PATH:" in *":${HOME}/.local/bin:"*) ;; *) export PATH="${HOME}/.local/bin:$PATH" info "Added ~/.local/bin to PATH for this session" ;; esac } # ═══════════════════════════════════════════════════════════════════════════════ # Skill Installation (Claude Code + Codex) # ═══════════════════════════════════════════════════════════════════════════════ SKILL_NAME="reporting-sensitive-encrypted-gh-issues" SKILL_URL="https://raw.githubusercontent.com/Dicklesworthstone/misc_coding_agent_tips_and_scripts/main/skills/${SKILL_NAME}/SKILL.md" install_skill_to() { local dest="$1" local agent_name="$2" mkdir -p "$dest" if curl -fsSL "${PROXY_ARGS[@]}" "$SKILL_URL" -o "$dest/SKILL.md" 2>/dev/null; then ok "Skill installed for ${agent_name}: ${dest}" return 0 else # Inline fallback: create minimal skill if download fails cat > "$dest/SKILL.md" << 'SKILL_FALLBACK' --- name: reporting-sensitive-encrypted-gh-issues description: >- Encrypt, submit, scan, and decrypt age-encrypted GitHub Issues (X25519). Use when reporting vulnerabilities, scanning for encrypted issues, or decrypting security reports. --- # Encrypted GitHub Issues — gh-issue-decrypt | Goal | Command | |------|---------| | Submit encrypted report | `echo "details" \| gh-issue-decrypt --encrypt PUBKEY --submit OWNER/REPO` | | Scan repo for encrypted issues | `gh-issue-decrypt OWNER/REPO` | | Decrypt a specific issue | `gh-issue-decrypt OWNER/REPO 42` | | Install + keygen | `gh-issue-decrypt --install` | | Full guide | `gh-issue-decrypt` (no args) | Private key: `~/.config/age/issuebot.key`. Public keys start with `age1...` (62 chars, Bech32). SKILL_FALLBACK ok "Skill installed for ${agent_name} (inline fallback): ${dest}" return 0 fi } install_skill() { local claude_dir="$HOME/.claude/skills/${SKILL_NAME}" local codex_dir="$HOME/.codex/skills/${SKILL_NAME}" local claude_exists=0 local codex_exists=0 [[ -f "$claude_dir/SKILL.md" ]] && claude_exists=1 [[ -f "$codex_dir/SKILL.md" ]] && codex_exists=1 # Skip if both already installed and not forcing if [[ "$claude_exists" -eq 1 && "$codex_exists" -eq 1 ]] && [[ "$FORCE_INSTALL" -eq 0 ]]; then ok "Skills already installed for Claude Code and Codex" return 0 fi info "Installing coding agent skill: ${SKILL_NAME}" # Claude Code if [[ -d "$HOME/.claude" ]]; then if [[ "$claude_exists" -eq 1 ]] && [[ "$FORCE_INSTALL" -eq 0 ]]; then ok "Claude Code skill already installed" else install_skill_to "$claude_dir" "Claude Code" fi else info "~/.claude not found; skipping Claude Code skill" fi # Codex if [[ -d "$HOME/.codex" ]]; then if [[ "$codex_exists" -eq 1 ]] && [[ "$FORCE_INSTALL" -eq 0 ]]; then ok "Codex skill already installed" else install_skill_to "$codex_dir" "Codex" fi else info "~/.codex not found; skipping Codex skill" fi } # ═══════════════════════════════════════════════════════════════════════════════ # Key Generation # ═══════════════════════════════════════════════════════════════════════════════ do_keygen() { detect_platform install_age || exit 1 local key_dir key_dir=$(dirname "$AGE_KEY") mkdir -p "$key_dir" if [[ -f "$AGE_KEY" ]]; then warn "Key already exists at $AGE_KEY" local pubkey pubkey=$(grep '^# public key:' "$AGE_KEY" | sed 's/^# public key: //') info "Public key: $pubkey" info "Use --key /other/path to generate a different keypair" return 0 fi info "Generating age X25519 keypair..." local keygen_err if ! keygen_err=$(age-keygen -o "$AGE_KEY" 2>&1); then err "age-keygen failed: $keygen_err" exit 1 fi chmod 600 "$AGE_KEY" local pubkey pubkey=$(grep '^# public key:' "$AGE_KEY" | sed 's/^# public key: //') echo "" if [ "$HAS_GUM" -eq 1 ] && [ "$NO_GUM" -eq 0 ]; then gum style \ --border normal \ --border-foreground 42 \ --padding "0 1" \ --margin "1 0" \ "$(gum style --foreground 42 --bold 'age keypair generated')" \ "" \ "$(gum style --foreground 245 "Private key: $AGE_KEY (chmod 600)")" \ "$(gum style --foreground 39 "Public key: $pubkey")" \ "" \ "$(gum style --foreground 245 'Publish the public key in your repo README.')" \ "$(gum style --foreground 245 'Back up the private key -- if lost, encrypted messages are unrecoverable.')" else draw_box "0;32" \ "age keypair generated" \ "" \ "Private key: $AGE_KEY (chmod 600)" \ "Public key: $pubkey" \ "" \ "Publish the public key in your repo README." \ "Back up the private key -- if lost, encrypted messages are unrecoverable." fi } # ═══════════════════════════════════════════════════════════════════════════════ # Install Mode # ═══════════════════════════════════════════════════════════════════════════════ do_install() { echo "" if [ "$HAS_GUM" -eq 1 ] && [ "$NO_GUM" -eq 0 ]; then gum style \ --border normal \ --border-foreground 39 \ --padding "0 1" \ --margin "0 0 1 0" \ "$(gum style --foreground 42 --bold 'gh-issue-decrypt installer')" \ "$(gum style --foreground 245 'Encrypted GitHub issues via age (X25519)')" else draw_box "0;34" \ "\033[1;32mgh-issue-decrypt installer\033[0m" \ "Encrypted GitHub issues via age (X25519)" fi detect_platform if [[ "$IS_WSL" -eq 1 ]]; then warn "WSL detected. Installation will proceed as Linux." fi info "Platform: $OS / $ARCH" # Preflight: check network if curl -fsSL --connect-timeout 3 "${PROXY_ARGS[@]}" "https://github.com" -o /dev/null 2>/dev/null; then ok "Network connectivity confirmed" else warn "Cannot reach github.com -- package manager installs may still work" fi local failed=0 # Install the script itself to ~/.local/bin if not already there install_self install_age || failed=$((failed + 1)) install_gh || failed=$((failed + 1)) # Generate keypair if none exists (only if age-keygen is available) if [[ ! -f "$AGE_KEY" ]] && command -v age-keygen &>/dev/null; then info "No age keypair found; generating one..." local key_dir key_dir=$(dirname "$AGE_KEY") mkdir -p "$key_dir" if age-keygen -o "$AGE_KEY" 2>/dev/null && [[ -f "$AGE_KEY" ]]; then chmod 600 "$AGE_KEY" ok "Keypair generated at $AGE_KEY" else warn "Keypair generation failed (check disk space and permissions)" failed=$((failed + 1)) fi fi local pubkey="" if [[ -f "$AGE_KEY" ]]; then pubkey=$(grep '^# public key:' "$AGE_KEY" | sed 's/^# public key: //' || true) fi # Install skill for coding agents (Claude Code + Codex) install_skill # Self-test (only if we have both age and a key with a valid pubkey) if [[ -n "$pubkey" ]] && command -v age &>/dev/null; then info "Running self-test..." local test_msg="gh-issue-decrypt self-test $(date +%s)" if echo "$test_msg" | age -a -r "$pubkey" 2>/dev/null | age -d -i "$AGE_KEY" 2>/dev/null | grep -q "gh-issue-decrypt self-test"; then ok "Encrypt/decrypt round-trip verified" else err "Self-test FAILED: encrypt/decrypt round-trip broken" failed=$((failed + 1)) fi elif [[ -z "$pubkey" ]]; then warn "No keypair available; skipping self-test" fi # Summary echo "" local summary_lines=() summary_lines+=("Script:") if [[ -x "${HOME}/.local/bin/gh-issue-decrypt" ]]; then summary_lines+=(" gh-issue-decrypt: ${HOME}/.local/bin/gh-issue-decrypt") else summary_lines+=(" gh-issue-decrypt: NOT INSTALLED") fi summary_lines+=("Dependencies:") if command -v age &>/dev/null; then summary_lines+=(" age: $(age --version 2>/dev/null || echo 'installed')") else summary_lines+=(" age: MISSING") fi if command -v gh &>/dev/null; then summary_lines+=(" gh: $(gh --version 2>/dev/null | head -1 || echo 'installed')") else summary_lines+=(" gh: MISSING") fi summary_lines+=("Skills:") [[ -f "$HOME/.claude/skills/${SKILL_NAME}/SKILL.md" ]] \ && summary_lines+=(" Claude Code: installed") \ || summary_lines+=(" Claude Code: not installed") [[ -f "$HOME/.codex/skills/${SKILL_NAME}/SKILL.md" ]] \ && summary_lines+=(" Codex: installed") \ || summary_lines+=(" Codex: not installed") summary_lines+=("") summary_lines+=("Key: $AGE_KEY") [[ -n "$pubkey" ]] && summary_lines+=("Pub: $pubkey") summary_lines+=("") summary_lines+=("Usage:") summary_lines+=(" gh-issue-decrypt OWNER/REPO # scan all open issues") summary_lines+=(" gh-issue-decrypt OWNER/REPO 42 # decrypt specific issue") summary_lines+=("") if [[ "$failed" -gt 0 ]]; then summary_lines+=("$failed component(s) had errors -- check output above") else summary_lines+=("All components installed and verified.") fi if [ "$HAS_GUM" -eq 1 ] && [ "$NO_GUM" -eq 0 ]; then { gum style --foreground 42 --bold "gh-issue-decrypt is ready" echo "" for line in "${summary_lines[@]}"; do if [[ "$line" == *"MISSING"* ]] || [[ "$line" == *"errors"* ]]; then gum style --foreground 196 "$line" elif [[ "$line" == *"Pub:"* ]]; then gum style --foreground 39 "$line" else gum style --foreground 245 "$line" fi done } | gum style --border normal --border-foreground 42 --padding "1 2" else if [[ "$failed" -gt 0 ]]; then draw_box "0;33" "${summary_lines[@]}" else draw_box "0;32" "${summary_lines[@]}" fi fi echo "" if [ "$HAS_GUM" -eq 1 ] && [ "$NO_GUM" -eq 0 ]; then gum style --foreground 245 --italic "To uninstall: rm ~/.local/bin/gh-issue-decrypt ~/.config/age/issuebot.key ~/.claude/skills/${SKILL_NAME}/SKILL.md ~/.codex/skills/${SKILL_NAME}/SKILL.md" else echo -e "\033[0;90mTo uninstall: rm ~/.local/bin/gh-issue-decrypt ~/.config/age/issuebot.key ~/.claude/skills/${SKILL_NAME}/SKILL.md ~/.codex/skills/${SKILL_NAME}/SKILL.md\033[0m" fi } # ═══════════════════════════════════════════════════════════════════════════════ # Encrypt Mode (for senders) # ═══════════════════════════════════════════════════════════════════════════════ do_encrypt() { # Ensure age is available if ! command -v age &>/dev/null; then detect_platform install_age || { err "Cannot encrypt without age"; exit 1; } fi # Validate pubkey format if [[ ! "$ENCRYPT_PUBKEY" =~ ^age1[a-z0-9]{58}$ ]]; then err "Invalid age public key format: $ENCRYPT_PUBKEY" err "Expected format: age1<58 lowercase alphanumeric chars>" exit 1 fi local plaintext [ -t 0 ] && info "Enter your message (Ctrl+D when done):" plaintext=$(cat) if [[ -z "$plaintext" ]]; then err "No input provided" exit 1 fi local ciphertext ciphertext=$(echo "$plaintext" | age -a -r "$ENCRYPT_PUBKEY" 2>/dev/null) || { err "Encryption failed" exit 1 } if [[ -n "$SUBMIT_REPO" ]]; then # Submit directly as a GitHub issue if ! command -v gh &>/dev/null; then err "gh CLI required for --submit. Install with: gh-issue-decrypt --install" exit 1 fi local title="${SUBMIT_TITLE:-[Encrypted] Security Report}" local body body=$(printf '[enc:age]\n%s\n[/enc:age]' "$ciphertext") local issue_url local gh_err gh_err=$(mktemp) issue_url=$(gh issue create --repo "$SUBMIT_REPO" --title "$title" --body "$body" 2>"$gh_err") || { err "Failed to create issue in $SUBMIT_REPO" [[ -s "$gh_err" ]] && err "$(cat "$gh_err")" err "Make sure you're authenticated: gh auth login" rm -f "$gh_err" exit 1 } rm -f "$gh_err" ok "Issue created: $issue_url" else # Output formatted block for manual pasting echo "" echo "[enc:age]" echo "$ciphertext" echo "[/enc:age]" echo "" if [ -t 1 ]; then info "Copy the block above and paste it into a GitHub issue." fi fi } # ═══════════════════════════════════════════════════════════════════════════════ # Quickstart / Agent Guide (no-args mode) # ═══════════════════════════════════════════════════════════════════════════════ do_quickstart() { # Detect local key for dynamic examples local local_pubkey="" if [[ -f "$AGE_KEY" ]]; then local_pubkey=$(grep '^# public key:' "$AGE_KEY" | sed 's/^# public key: //' 2>/dev/null || true) fi local example_pubkey="${local_pubkey:-age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx}" local key_note="" if [[ -n "$local_pubkey" ]]; then key_note=" (detected from $AGE_KEY)" else key_note=" (replace with the actual key from the project's README)" fi cat < Fetching issue #42 from OWNER/REPO... === Issue #42 -- encrypted block(s) found === ok Block #1 decrypted: SQL injection in /api/users — the WHERE clause interpolates user input directly FOR PROJECT OWNERS — Receiving encrypted reports: ------------------------------------------------- 1. Install and generate your keypair: gh-issue-decrypt --install 2. The installer outputs your public key. Publish it in your README. Your private key is stored at: ~/.config/age/issuebot.key 3. Your coding agents scan and decrypt: gh-issue-decrypt YourOrg/YourRepo ALL COMMANDS: gh-issue-decrypt This guide gh-issue-decrypt OWNER/REPO Scan all open issues gh-issue-decrypt OWNER/REPO NUMBER Decrypt specific issue gh-issue-decrypt --encrypt PUBKEY Encrypt stdin for submission gh-issue-decrypt --encrypt PUBKEY --submit OWNER/REPO Encrypt + create issue gh-issue-decrypt --install Install age + gh CLI gh-issue-decrypt --keygen Generate age keypair gh-issue-decrypt --help Short help gh-issue-decrypt --json Machine-readable output gh-issue-decrypt --key FILE Use alternate private key GUIDE } # ═══════════════════════════════════════════════════════════════════════════════ # Help # ═══════════════════════════════════════════════════════════════════════════════ do_help() { if [ "$HAS_GUM" -eq 1 ] && [ "$NO_GUM" -eq 0 ]; then gum style \ --border normal \ --border-foreground 39 \ --padding "0 1" \ "$(gum style --foreground 42 --bold 'gh-issue-decrypt')" \ "$(gum style --foreground 245 'Encrypt, submit, scan & decrypt age-encrypted GitHub issues')" echo "" fi cat <<'USAGE' Usage: gh-issue-decrypt Agent quickstart guide gh-issue-decrypt OWNER/REPO Scan all open issues gh-issue-decrypt OWNER/REPO NUMBER Decrypt specific issue (body + comments) gh-issue-decrypt --encrypt PUBKEY Encrypt stdin, output armored block gh-issue-decrypt --encrypt PUBKEY --submit OWNER/REPO Encrypt + create issue gh-issue-decrypt --install Install dependencies (age, gh CLI) gh-issue-decrypt --keygen Generate a new age X25519 keypair gh-issue-decrypt --help Show this help Options: --quiet Suppress non-error output --no-gum Disable gum formatting --json Output decrypted content as JSON --key FILE Use specific age private key file --title TEXT Issue title (with --submit; default: "[Encrypted] Security Report") --force Reinstall even if already present Environment: AGE_KEY Path to private key (default: ~/.config/age/issuebot.key) HTTPS_PROXY HTTPS proxy URL (passed to curl) HTTP_PROXY HTTP proxy URL (fallback) USAGE } # ═══════════════════════════════════════════════════════════════════════════════ # Scan & Decrypt (core logic) # ═══════════════════════════════════════════════════════════════════════════════ check_scan_deps() { local missing=0 if ! command -v age &>/dev/null; then err "age not found" missing=$((missing + 1)) fi if ! command -v gh &>/dev/null; then err "gh CLI not found" missing=$((missing + 1)) fi if [[ "$missing" -gt 0 ]]; then echo "" info "Run 'gh-issue-decrypt --install' to install missing dependencies" exit 1 fi if [[ ! -f "$AGE_KEY" ]]; then err "age private key not found at $AGE_KEY" info "Run 'gh-issue-decrypt --keygen' to generate one" info "Or set AGE_KEY=/path/to/key" exit 1 fi } decrypt_body() { local issue_num="$1" local body="$2" if ! echo "$body" | grep -q "BEGIN AGE ENCRYPTED FILE"; then return 1 fi if [[ "$JSON_OUTPUT" -eq 0 ]]; then echo "" if [ "$HAS_GUM" -eq 1 ] && [ "$NO_GUM" -eq 0 ]; then gum style --foreground 39 --bold "Issue #${issue_num} -- encrypted block(s) found" else echo -e "\033[1;34m=== Issue #${issue_num} -- encrypted block(s) found ===\033[0m" fi fi local block_num=0 local block="" local in_block=0 while IFS= read -r line; do if [[ "$line" == *"-----BEGIN AGE ENCRYPTED FILE-----"* ]]; then in_block=1 block="$line" elif [[ "$in_block" -eq 1 ]]; then block="${block}"$'\n'"${line}" if [[ "$line" == *"-----END AGE ENCRYPTED FILE-----"* ]]; then in_block=0 block_num=$((block_num + 1)) local decrypted if decrypted=$(echo "$block" | age -d -i "$AGE_KEY" 2>/dev/null); then if [[ "$JSON_OUTPUT" -eq 1 ]]; then printf '{"issue":%s,"block":%d,"status":"ok","plaintext":%s}\n' \ "$(json_escape "$issue_num")" "$block_num" "$(json_escape "$decrypted")" else ok "Block #${block_num} decrypted:" echo "$decrypted" fi else if [[ "$JSON_OUTPUT" -eq 1 ]]; then printf '{"issue":%s,"block":%d,"status":"failed"}\n' \ "$(json_escape "$issue_num")" "$block_num" else err "Block #${block_num}: decryption failed (wrong key or corrupted)" fi fi block="" fi fi done <<< "$body" return 0 } do_scan() { check_scan_deps if [[ -n "$ISSUE_NUMBER" ]]; then # Single issue mode [[ "$JSON_OUTPUT" -eq 0 ]] && info "Fetching issue #${ISSUE_NUMBER} from ${REPO_ARG}..." local body body=$(gh api "repos/${REPO_ARG}/issues/${ISSUE_NUMBER}" --jq '.body // ""' 2>/dev/null) || { err "Failed to fetch issue #${ISSUE_NUMBER} from ${REPO_ARG}" exit 1 } if ! decrypt_body "$ISSUE_NUMBER" "$body"; then [[ "$JSON_OUTPUT" -eq 0 ]] && info "Issue #${ISSUE_NUMBER}: no encrypted content in body" fi # Check comments — use @base64 to handle multi-line bodies safely local found_comment=0 while IFS=$'\t' read -r cid body_b64; do [[ -z "$cid" ]] && continue if [[ "$found_comment" -eq 0 ]]; then [[ "$JSON_OUTPUT" -eq 0 ]] && info "Encrypted comment(s) found on #${ISSUE_NUMBER}..." found_comment=1 fi local cbody cbody=$(printf '%s' "$body_b64" | b64_decode) decrypt_body "${ISSUE_NUMBER}/comment-${cid}" "$cbody" || true done < <(gh api "repos/${REPO_ARG}/issues/${ISSUE_NUMBER}/comments" --jq \ '.[] | select(.body != null) | select(.body | contains("BEGIN AGE ENCRYPTED FILE")) | [.id, (.body | @base64)] | @tsv' 2>/dev/null || true) else # Scan all open issues — use @base64 to handle multi-line bodies safely [[ "$JSON_OUTPUT" -eq 0 ]] && info "Scanning open issues in ${REPO_ARG}..." local found=0 local scanned=0 while IFS=$'\t' read -r num title body_b64; do [[ -z "$num" ]] && continue scanned=$((scanned + 1)) local body body=$(printf '%s' "$body_b64" | b64_decode) if echo "$body" | grep -q "BEGIN AGE ENCRYPTED FILE"; then if [[ "$JSON_OUTPUT" -eq 0 ]]; then if [ "$HAS_GUM" -eq 1 ] && [ "$NO_GUM" -eq 0 ]; then gum style --foreground 214 "### Issue #${num}: ${title}" else echo -e "\n\033[1m### Issue #${num}: ${title}\033[0m" fi fi decrypt_body "$num" "$body" found=$((found + 1)) fi done < <(gh api "repos/${REPO_ARG}/issues?state=open&per_page=100" --paginate --jq \ '.[] | select(.pull_request == null) | [.number, .title, ((.body // "") | @base64)] | @tsv' 2>/dev/null || true) if [[ "$JSON_OUTPUT" -eq 0 ]]; then if [[ "$found" -eq 0 ]]; then info "No encrypted issues found in ${REPO_ARG} (scanned ${scanned} issues)" elif [[ "$scanned" -gt 1 ]]; then echo "" ok "Found ${found} encrypted issue(s) out of ${scanned} scanned" fi fi fi } # ═══════════════════════════════════════════════════════════════════════════════ # Main # ═══════════════════════════════════════════════════════════════════════════════ case "$MODE" in install) do_install ;; keygen) do_keygen ;; help) do_help ;; encrypt) do_encrypt ;; quickstart) do_quickstart ;; scan) do_scan ;; esac