Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.
### Changed
- Upgraded 23 locked Python dev-dependencies to latest compatible versions (bandit 1.9.4, mypy 1.20.1, isort 8.0.1, rich 15.0, coverage 7.13.5, …).

### Security
- The shell helpers that call Python (`config_get_auto_update`, `config_get_global_auto_upgrade`, `check_manager_outdated`, `set_auto_update.sh`) pass tool names and paths as arguments instead of placing them in the program text. Release cycles from endoflife.date are accepted only in the form of a release number (`3.13`, `24`).
- `github_release_binary.sh`, `docker_plugin.sh`, `install_docker.sh` and `install_claude.sh` download into a directory that `mktemp -d` creates for each run (mode 0700) instead of fixed or PID-derived names in `/tmp`. Installer markers for `make upgrade` default to `~/.cache/cli-audit/markers` instead of `/tmp/.cli-audit`. The Docker and Claude Code installer scripts are downloaded with curl held to HTTPS on every redirect; `install_claude.sh` no longer uses wget for that download.
- `generate_rollback_script` writes the script into a private directory with owner-only permissions and quotes tool names.

## Prior history

See [git log](https://github.com/netresearch/coding_agent_cli_toolset/commits/main) for commits prior to this changelog. Tagged releases: [Releases page](https://github.com/netresearch/coding_agent_cli_toolset/releases).
33 changes: 18 additions & 15 deletions cli_audit/bulk.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,14 @@
from __future__ import annotations

import os
import shlex
import subprocess
import tempfile
import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Callable, Sequence

from .common import vlog
Expand Down Expand Up @@ -363,44 +363,47 @@ def generate_rollback_script(results: Sequence[InstallResult], verbose: bool = F
verbose: Enable verbose logging

Returns:
Path to generated rollback script
Path to generated rollback script. It sits in a directory created for
it alone (mode 0700) and is readable, writable and executable by the
owner only.
"""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
script_path = os.path.join(tempfile.gettempdir(), f"rollback_{timestamp}.sh")
script_dir = tempfile.mkdtemp(prefix="cli-audit-rollback-")
script_path = os.path.join(script_dir, f"rollback_{timestamp}.sh")
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)

with open(script_path, "w") as f:
with os.fdopen(os.open(script_path, flags, 0o700), "w") as f:
f.write("#!/bin/bash\n")
f.write("set -euo pipefail\n\n")
f.write("# Rollback script for bulk installation\n")
f.write(f"# Generated: {datetime.now().isoformat()}\n\n")

for result in results:
if result.success and result.binary_path:
f.write(f"# Rollback: {result.tool_name}\n")
f.write(f"# Rollback: {result.tool_name!r}\n")
pm = result.package_manager_used
tool = shlex.quote(result.tool_name)

# Generate uninstall command based on package manager
if pm in ("apt", "apt-get"):
f.write(f"sudo apt-get remove -y {result.tool_name}\n")
f.write(f"sudo apt-get remove -y {tool}\n")
elif pm == "dnf":
f.write(f"sudo dnf remove -y {result.tool_name}\n")
f.write(f"sudo dnf remove -y {tool}\n")
elif pm == "pacman":
f.write(f"sudo pacman -R --noconfirm {result.tool_name}\n")
f.write(f"sudo pacman -R --noconfirm {tool}\n")
elif pm == "brew":
f.write(f"brew uninstall {result.tool_name}\n")
f.write(f"brew uninstall {tool}\n")
elif pm == "cargo":
f.write(f"cargo uninstall {result.tool_name}\n")
f.write(f"cargo uninstall {tool}\n")
elif pm in ("pip", "pipx", "uv"):
f.write(f"{pm} uninstall -y {result.tool_name}\n")
f.write(f"{pm} uninstall -y {tool}\n")
elif pm == "npm":
f.write(f"npm uninstall -g {result.tool_name}\n")
f.write(f"npm uninstall -g {tool}\n")
else:
f.write(f"# Manual removal required for {result.tool_name} ({pm})\n")
f.write(f"# Manual removal required for {result.tool_name!r} ({pm!r})\n")

f.write("\n")

# Make script executable
Path(script_path).chmod(0o755)
vlog(f"Generated rollback script: {script_path}", verbose)
return script_path

Expand Down
23 changes: 19 additions & 4 deletions cli_audit/collectors.py
Original file line number Diff line number Diff line change
Expand Up @@ -611,6 +611,17 @@
return False


# A release cycle as endoflife.date publishes it for the runtimes in the
# catalog: "3.13", "24", "1.23". The cycle becomes part of tool keys, binary
# names and environment values downstream, so anything else is dropped.
_CYCLE_RE = re.compile(r"[0-9]+(?:\.[0-9]+)*")

Check warning on line 617 in cli_audit/collectors.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use concise character class syntax '\d' instead of '[0-9]'.

See more on https://sonarcloud.io/project/issues?id=netresearch_coding_agent_cli_toolset&issues=AaEVQeYhWdv5_XvE_tNR&open=AaEVQeYhWdv5_XvE_tNR&pullRequest=173

Check warning on line 617 in cli_audit/collectors.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use concise character class syntax '\d' instead of '[0-9]'.

See more on https://sonarcloud.io/project/issues?id=netresearch_coding_agent_cli_toolset&issues=AaEVQeYhWdv5_XvE_tNS&open=AaEVQeYhWdv5_XvE_tNS&pullRequest=173


def _valid_cycle_entries(entries: list[Any]) -> list[dict[str, Any]]:
"""Keep only entries whose cycle has the shape of a release number."""
return [e for e in entries if isinstance(e, dict) and _CYCLE_RE.fullmatch(str(e.get("cycle", "")))]


def collect_endoflife(
product: str,
max_versions: int = 4,
Expand Down Expand Up @@ -660,7 +671,10 @@
logger.warning(f"endoflife.date {product}: Unexpected response format")
else:
for entry in data:
cycle = entry.get("cycle", "")
cycle = entry.get("cycle", "") if isinstance(entry, dict) else ""
if not _CYCLE_RE.fullmatch(str(cycle)):
logger.debug(f"endoflife.date {product}: skipping malformed cycle {cycle!r}")
continue
eol = entry.get("eol")
support = entry.get("support")
latest = entry.get("latest", "")
Expand Down Expand Up @@ -720,15 +734,16 @@
if isinstance(cached, dict) and isinstance(cached.get("entries"), list):
age = int(time.time()) - int(cached.get("at", 0))
logger.debug(f"endoflife.date {product}: using file cache (age {age}s)")
entries: list[dict[str, Any]] = cached["entries"]
entries: list[dict[str, Any]] = _valid_cycle_entries(cached["entries"])
_endoflife_memo[memo_key] = entries
return entries

# Legacy offline_cache argument (from write_upstream_cache dumps).
if offline_cache and product in offline_cache:
logger.debug(f"endoflife.date {product}: Using offline cache")
_endoflife_memo[memo_key] = offline_cache[product]
return offline_cache[product]
offline_entries = _valid_cycle_entries(offline_cache[product])
_endoflife_memo[memo_key] = offline_entries
return offline_entries

logger.warning(f"endoflife.date {product}: No versions found")
_endoflife_memo[memo_key] = []
Expand Down
11 changes: 6 additions & 5 deletions scripts/auto_update.sh
Original file line number Diff line number Diff line change
Expand Up @@ -698,18 +698,19 @@ check_manager_outdated() {

# Extract status for this tool from JSON snapshot
local status
status="$(python3 -c "
status="$(python3 - "$snapshot_file" "$mgr" 2>/dev/null <<'PY'
import json, sys
try:
with open('$snapshot_file') as f:
with open(sys.argv[1]) as f:
data = json.load(f)
for tool in data.get('tools', []):
if tool.get('tool') == '$mgr':
if tool.get('tool') == sys.argv[2]:
print(tool.get('status', ''))
sys.exit(0)
except:
except Exception:
pass
" 2>/dev/null)" || status=""
PY
)" || status=""

# Check if status is OUTDATED
if [ "$status" = "OUTDATED" ]; then
Expand Down
4 changes: 2 additions & 2 deletions scripts/guide.sh
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ LAST_MULTI_COUNT=0
# summary hint); de-duplicated when counted.
GUIDE_DUP_LIST=""

# Installers leave <tool>.already-current / <tool>.held-back here
MARKER_DIR="${CLI_AUDIT_MARKER_DIR:-/tmp/.cli-audit}"
# Installers leave <tool>.already-current / <tool>.held-back here (per user)
MARKER_DIR="${CLI_AUDIT_MARKER_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/cli-audit/markers}"

# Summary counters
SUMMARY_UPDATED=0
Expand Down
27 changes: 19 additions & 8 deletions scripts/install_claude.sh
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,19 @@ install_native() {

if [ "$(uname)" = "Darwin" ] || [ "$(uname)" = "Linux" ]; then
# macOS / Linux / WSL
local installer_script="/tmp/claude-install-$$.sh"

# Download installer script
# The installer is saved in a directory that mktemp creates for this run
# only (mode 0700) and removed with it.
local work_dir installer_script
work_dir="$(mktemp -d "${TMPDIR:-/tmp}/cli-audit-claude.XXXXXX")" || return 1
installer_script="$work_dir/install.sh"

# Download installer script. curl only: the URL redirects, and curl can
# hold every hop to HTTPS (--proto-redir); wget cannot.
if command -v curl >/dev/null 2>&1; then
curl -fsSL https://claude.ai/install.sh -o "$installer_script" || return 1
elif command -v wget >/dev/null 2>&1; then
wget -qO "$installer_script" https://claude.ai/install.sh || return 1
curl --proto '=https' --proto-redir '=https' -fsSL https://claude.ai/install.sh -o "$installer_script" || { rm -rf "$work_dir"; return 1; }
else
echo "[claude] curl is required for the native installer" >&2
rm -rf "$work_dir"
return 1
fi

Expand All @@ -48,13 +53,13 @@ install_native() {
fi

if bash "$installer_script" $installer_args; then
rm -f "$installer_script"
rm -rf "$work_dir"
return 0
fi

# If failed, try direct binary download as final fallback
echo "[claude] Native installer failed, trying direct download..." >&2
rm -f "$installer_script"
rm -rf "$work_dir"

if install_direct; then
return 0
Expand Down Expand Up @@ -221,6 +226,12 @@ cleanup_npm_versions() {

# Migrate from npm to native installer
migrate_npm_to_native() {
# The native installer needs curl; check before removing the npm packages,
# so a host without curl keeps the installation it has.
if ! command -v curl >/dev/null 2>&1; then
echo "[claude] curl is required for the native installer; keeping the npm installation" >&2
return 1
fi
echo "[claude] Migrating from npm to native installer..." >&2
echo "[claude] Removing npm packages..." >&2

Expand Down
19 changes: 13 additions & 6 deletions scripts/install_docker.sh
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,22 @@
echo "[docker] WSL detected - installing Docker Engine (skipping 20s wait)..."
echo "[docker] Note: Docker Desktop for Windows is also available."
echo "[docker] See: https://docs.docker.com/desktop/wsl/"
# Download script and remove the sleep to skip 20-second wait
curl -fsSL https://get.docker.com -o /tmp/get-docker.sh
sed -i 's/sleep 20/sleep 0/' /tmp/get-docker.sh
sudo sh /tmp/get-docker.sh
rm -f /tmp/get-docker.sh
# Download script and remove the sleep to skip 20-second wait. The copy
# lives in a directory that mktemp creates for this run only (mode 0700).
local work_dir
work_dir="$(mktemp -d "${TMPDIR:-/tmp}/cli-audit-docker.XXXXXX")"
if curl --proto '=https' --proto-redir '=https' -fsSL https://get.docker.com -o "$work_dir/get-docker.sh" &&
sed -i 's/sleep 20/sleep 0/' "$work_dir/get-docker.sh"; then
sudo sh "$work_dir/get-docker.sh" || { rm -rf "$work_dir"; return 1; }
else
rm -rf "$work_dir"
return 1
fi
rm -rf "$work_dir"
# Start service manually (no systemd in WSL)
sudo service docker start || echo "[docker] Run 'sudo service docker start' to start Docker"
else
curl -fsSL https://get.docker.com | sh
curl --proto '=https' --proto-redir '=https' -fsSL https://get.docker.com | sh

Check warning on line 40 in scripts/install_docker.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of using the literal '=https' 4 times.

See more on https://sonarcloud.io/project/issues?id=netresearch_coding_agent_cli_toolset&issues=AaEVXM0rzp168bsEePEk&open=AaEVXM0rzp168bsEePEk&pullRequest=173
fi

sudo usermod -aG docker "$USER" || true
Expand Down
7 changes: 4 additions & 3 deletions scripts/installers/docker_plugin.sh
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,10 @@ fi
# Docker Compose uses: docker-compose-{os}-{arch}
DOWNLOAD_URL="https://github.com/$GITHUB_REPO/releases/download/$LATEST/docker-$PLUGIN_NAME-$OS-$ARCH"

# Download
tmpfile="/tmp/docker-$PLUGIN_NAME.$$"
rm -f "$tmpfile"
# Download into a directory that mktemp creates for this run only (mode 0700)
WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/cli-audit-${TOOL}.XXXXXX")"
trap 'rm -rf "$WORK_DIR"' EXIT
tmpfile="$WORK_DIR/docker-$PLUGIN_NAME"

echo "[$TOOL] Downloading $DOWNLOAD_URL"
if ! curl -fL --retry 3 --retry-delay 1 --connect-timeout 10 -o "$tmpfile" "$DOWNLOAD_URL" 2>/dev/null; then
Expand Down
26 changes: 14 additions & 12 deletions scripts/installers/github_release_binary.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@
. "$DIR/lib/common.sh"
. "$DIR/lib/install_strategy.sh"

# Cleanup temp files on interrupt
# Download and extraction happen in a directory that mktemp creates for this
# run only (mode 0700), removed on every exit path.
WORK_DIR=""
_grb_cleanup() {
rm -f "/tmp/${BINARY_NAME:-tool}.$$" 2>/dev/null || true
rm -rf "/tmp/${BINARY_NAME:-tool}-extract.$$" 2>/dev/null || true
if [ -n "$WORK_DIR" ]; then rm -rf "$WORK_DIR" 2>/dev/null || true; fi

Check failure on line 16 in scripts/installers/github_release_binary.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[[' instead of '[' for conditional tests. The '[[' construct is safer and more feature-rich.

See more on https://sonarcloud.io/project/issues?id=netresearch_coding_agent_cli_toolset&issues=AaEVQeRtWdv5_XvE_tNN&open=AaEVQeRtWdv5_XvE_tNN&pullRequest=173
}
trap _grb_cleanup EXIT
trap '_grb_cleanup; exit 130' INT TERM

TOOL="${1:-}"
Expand Down Expand Up @@ -167,8 +169,8 @@
DOWNLOAD_URL="${DOWNLOAD_URL//\{arch_suffix\}/$ARCH}"

# Download with retry and fallback
tmpfile="/tmp/$BINARY_NAME.$$"
rm -f "$tmpfile"
WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/cli-audit-${TOOL}.XXXXXX")"
tmpfile="$WORK_DIR/download"

if ! curl --proto '=https' --proto-redir '=https' -fL --retry 3 --retry-delay 1 --connect-timeout 10 -o "$tmpfile" "$DOWNLOAD_URL" 2>/dev/null; then
if [ -n "$FALLBACK_URL_TEMPLATE" ]; then
Expand All @@ -195,8 +197,8 @@

if [[ "$DOWNLOAD_URL" == *.tar.gz ]] || [[ "$DOWNLOAD_URL" == *.tgz ]]; then
# Extract tar.gz
EXTRACT_DIR="/tmp/${BINARY_NAME}-extract.$$"
mkdir -p "$EXTRACT_DIR"
EXTRACT_DIR="$WORK_DIR/extract"
mkdir "$EXTRACT_DIR"

if ! tar -xzf "$tmpfile" -C "$EXTRACT_DIR" 2>/dev/null; then
echo "[$TOOL] Error: Failed to extract tar.gz archive" >&2
Expand All @@ -223,8 +225,8 @@
rm -f "$tmpfile"
elif [[ "$DOWNLOAD_URL" == *.tar.xz ]]; then
# Extract tar.xz
EXTRACT_DIR="/tmp/${BINARY_NAME}-extract.$$"
mkdir -p "$EXTRACT_DIR"
EXTRACT_DIR="$WORK_DIR/extract"
mkdir "$EXTRACT_DIR"

if ! tar -xJf "$tmpfile" -C "$EXTRACT_DIR" 2>/dev/null; then
echo "[$TOOL] Error: Failed to extract tar.xz archive" >&2
Expand All @@ -251,8 +253,8 @@
rm -f "$tmpfile"
elif [[ "$DOWNLOAD_URL" == *.zip ]]; then
# Extract zip
EXTRACT_DIR="/tmp/${BINARY_NAME}-extract.$$"
mkdir -p "$EXTRACT_DIR"
EXTRACT_DIR="$WORK_DIR/extract"
mkdir "$EXTRACT_DIR"

if ! unzip -q "$tmpfile" -d "$EXTRACT_DIR" 2>/dev/null; then
echo "[$TOOL] Error: Failed to extract zip archive" >&2
Expand Down Expand Up @@ -338,7 +340,7 @@
if [ "$BINARY_ALREADY_CURRENT" = "true" ]; then
printf "[%s] note: binary already matches target release %s (upstream version string may be stale)\n" "$TOOL" "$LATEST"
# Signal already-current status to callers (e.g., guide.sh)
marker_dir="${CLI_AUDIT_MARKER_DIR:-/tmp/.cli-audit}"
marker_dir="${CLI_AUDIT_MARKER_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/cli-audit/markers}"
mkdir -p "$marker_dir"
echo "$LATEST" > "$marker_dir/${TOOL}.already-current"
fi
Expand Down
2 changes: 1 addition & 1 deletion scripts/installers/package_manager.sh
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ if $pm_ok && [ -n "$before" ] && [ -n "$after" ] && [ "$before" = "$after" ]; th
printf "[%s] Note: Package manager has no newer version available\n" "$DISPLAY_NAME" >&2
# Signal held-back status to callers (guide.sh), so the run is not counted
# as an upgrade
marker_dir="${CLI_AUDIT_MARKER_DIR:-/tmp/.cli-audit}"
marker_dir="${CLI_AUDIT_MARKER_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/cli-audit/markers}"
mkdir -p "$marker_dir"
echo "$after" > "$marker_dir/${TOOL}.held-back"
fi
Expand Down
18 changes: 10 additions & 8 deletions scripts/lib/config.sh
Original file line number Diff line number Diff line change
Expand Up @@ -27,24 +27,26 @@ config_get_auto_update() {
return 1
fi

# Use Python config system to check auto_update
python3 -c "
# Use Python config system to check auto_update. The tool name and the
# root path reach Python as arguments, never as part of the program text:
# multi-version keys carry a cycle taken from endoflife.date data.
python3 - "$_CONFIG_ROOT" "$tool" 2>/dev/null <<'PY' || echo "false"
import sys
sys.path.insert(0, '$_CONFIG_ROOT')
sys.path.insert(0, sys.argv[1])
from cli_audit.config import load_config
config = load_config()
print('true' if config.is_auto_update_enabled('$tool') else 'false')
" 2>/dev/null || echo "false"
print('true' if config.is_auto_update_enabled(sys.argv[2]) else 'false')
PY
}

# Get the global auto_upgrade preference
# Returns: "true" or "false"
config_get_global_auto_upgrade() {
python3 -c "
python3 - "$_CONFIG_ROOT" 2>/dev/null <<'PY' || echo "true"
import sys
sys.path.insert(0, '$_CONFIG_ROOT')
sys.path.insert(0, sys.argv[1])
from cli_audit.config import load_config
config = load_config()
print('true' if config.preferences.auto_upgrade else 'false')
" 2>/dev/null || echo "true"
PY
}
Loading
Loading