Fix audit tool bootstrap and add per-run preflight

audit-code install-tools.sh:
- Buffer the opengrep release JSON before grep -m1; curl died with (23)
  under pipefail when grep quit early.
- Use ${m}: in the PowerShell block; $m: parsed as a scope-qualified var.
- On Arch, skip paru/yay when pacman -Q shows every package installed,
  since --needed still invokes sudo.
- Add --check-only (fast, installs nothing, non-zero naming missing tools)
  and --user-only (no system package managers, no sudo).

log-run.py (both skills): put the skill dir on sys.path so running it as
a script from any cwd no longer raises ModuleNotFoundError.

audit-terraform: move deps from requirements.txt into pyproject
dependency groups and add scripts/install-tools.sh (uv sync --group tools,
then check trivy, tflint, tofu, terragrunt, gh).

Both SKILL.md files gain a 0.5 Preflight step and call scripts through
uv run --project ${SKILL_DIR}. tools_unavailable is now a map of tool to
exact install command; audit-terraform skips trivy when absent and stops
with an install hint instead of crashing when tofu/terragrunt is missing.
This commit is contained in:
2026-09-22 15:21:28 -05:00
parent d3258b224a
commit 37fc3fb291
30 changed files with 1045 additions and 84 deletions
+4 -2
View File
@@ -129,7 +129,9 @@ clean result.
### Installing them
```
scripts/install-tools.sh
scripts/install-tools.sh # everything, including system packages
scripts/install-tools.sh --user-only # skip brew/pacman/apt (no sudo)
scripts/install-tools.sh --check-only # install nothing; non-zero if anything is missing
```
What it does:
@@ -220,7 +222,7 @@ Runs are logged by a single call after the fan-out completes:
```
echo '{"walkthrough-reviewer": {"model":"sonnet","input_tokens":N,"output_tokens":N,"duration_ms":N}, ...}' \
| python ${SKILL_DIR}/scripts/log-run.py \
| uv run --project ${SKILL_DIR} python ${SKILL_DIR}/scripts/log-run.py \
--output-dir <OUTPUT> --run-id <hex> --repo <REPO> \
--mode <local|ref> --usage-json -
```
+25 -3
View File
@@ -43,6 +43,27 @@ other command so the bundled scripts resolve wherever the plugin is installed:
export SKILL_DIR=<absolute path to the directory holding this SKILL.md>
```
### 0.5 Preflight — every run
```
bash ${SKILL_DIR}/scripts/install-tools.sh --check-only
```
It installs nothing and returns in milliseconds when everything is present,
so run it every time. Each plugin version runs from its own directory with a
fresh, empty `.venv`, so expect it to fail on the first run after an update.
- **Exit 0:** continue.
- **Non-zero:** it lists what is missing. Run
`bash ${SKILL_DIR}/scripts/install-tools.sh --user-only` (Python tools into
`${SKILL_DIR}/.venv`, opengrep, user-scope PowerShell modules; never sudo),
then re-run `--check-only`.
- **Still missing** (`gitleaks`, `osv-scanner`, `gh` are system packages):
ask the user before running `bash ${SKILL_DIR}/scripts/install-tools.sh`
without `--user-only` — it installs through brew/paru/yay/pacman and may
call sudo. If they decline, continue: the collection script records each
missing tool in `tools_unavailable` with its install command.
### 1. Resolve mode, repo identity, and worktree
First resolve `NWO` (owner/repo) so every `gh` call works regardless of
@@ -103,7 +124,7 @@ Create the directory.
### 3. Run the collection script
```
python ${SKILL_DIR}/scripts/collect-findings.py \
uv run --project ${SKILL_DIR} python ${SKILL_DIR}/scripts/collect-findings.py \
--repo <REPO> --head <head-or-HEAD> \
--output-dir <OUTPUT> --mode <local|ref>
```
@@ -170,7 +191,7 @@ echo '{
"secrets-reviewer": {"model":"sonnet","input_tokens":N,"output_tokens":N,"duration_ms":N},
"maintainability-reviewer": {"model":"haiku","input_tokens":N,"output_tokens":N,"duration_ms":N},
"gha-reviewer": {"model":"sonnet","input_tokens":N,"output_tokens":N,"duration_ms":N}
}' | python ${SKILL_DIR}/scripts/log-run.py \
}' | uv run --project ${SKILL_DIR} python ${SKILL_DIR}/scripts/log-run.py \
--output-dir <OUTPUT> --run-id $RUN_ID --repo <REPO> \
--mode <local|ref> --usage-json -
```
@@ -287,7 +308,8 @@ In ref mode, end with: "Worktree left at `<REPO>` for follow-up review."
coverage was partial. Example: `⚠️ Go file changed but not reviewed:
cmd/server.go (Go support not in this skill yet).`
- **No `gh`:** see step 1.
- **Tools missing on PATH:** non-fatal; `tools_unavailable` lists them.
- **Tools missing on PATH:** non-fatal; `tools_unavailable` maps each to
its install command.
Agents acknowledge degraded coverage.
- **PowerShell files changed but no `pwsh`:** `psscriptanalyzer` and
`injectionhunter` report `not on PATH` / `<module> not installed`. Both
@@ -22,6 +22,7 @@ dev = [
[tool.ruff.lint.per-file-ignores]
"scripts/collect-findings.py" = ["E402"]
"scripts/log-run.py" = ["E402"]
[tool.pytest.ini_options]
markers = [
@@ -54,6 +54,23 @@ from scripts.runner import run_tool, tool_available
from scripts.slicing import slice_for_agent
# Anything not listed here is installed by the skill's own bootstrap script.
_INSTALL_TOOLS = f"bash {_HERE / 'install-tools.sh'} --user-only"
_INSTALL_COMMANDS = {
"eslint": "npm i -D eslint eslint-plugin-security",
"tsc": "npm i -D typescript",
"knip": "npm i -D knip",
"jscpd": "npm i -D jscpd",
"dotnet": "curl -fsSL https://dot.net/v1/dotnet-install.sh | bash",
"selene": "cargo install selene",
"luac": "install lua (ships luac) with your OS package manager",
"actionlint": "go install github.com/rhysd/actionlint/cmd/actionlint@latest",
"zizmor": "uv tool install zizmor",
"gitleaks": "go install github.com/zricethezav/gitleaks/v8@latest",
"osv-scanner": "go install github.com/google/osv-scanner/v2/cmd/osv-scanner@latest",
}
def _git_show(repo: str, ref: str, path: str) -> str:
"""Return file content at `ref`, or empty string if not present (e.g. new file)."""
r = subprocess.run(
@@ -350,7 +367,7 @@ def main(argv: list[str] | None = None) -> int:
default_branch=default_branch,
language_breakdown=LanguageBreakdown(),
changed_files=[], findings=[],
package_diffs={}, tool_stats={}, tools_unavailable=[],
package_diffs={}, tool_stats={}, tools_unavailable={},
errors=[str(e)],
)
(out_dir / "manifest.json").write_text(manifest.to_json())
@@ -396,7 +413,7 @@ def main(argv: list[str] | None = None) -> int:
mode=args.mode, base_ref=base, head_ref=args.head,
default_branch=default_branch, language_breakdown=breakdown,
changed_files=[], findings=[],
package_diffs={}, tool_stats={}, tools_unavailable=[],
package_diffs={}, tool_stats={}, tools_unavailable={},
errors=errors,
)
(out_dir / "manifest.json").write_text(manifest.to_json())
@@ -433,7 +450,10 @@ def main(argv: list[str] | None = None) -> int:
if stat.ran:
stat.post_filter = sum(1 for f in filtered if f.tool == tool_name)
tools_unavailable = [name for name, s in tool_stats.items() if not s.ran]
tools_unavailable = {
name: _INSTALL_COMMANDS.get(name, _INSTALL_TOOLS)
for name, s in tool_stats.items() if not s.ran
}
package_diffs = _build_package_diffs(repo, base, dep_manifest_paths)
@@ -3,24 +3,25 @@
set -euo pipefail
SKILL_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$SKILL_DIR"
say() { printf '\n\033[1m▶ %s\033[0m\n' "$*"; }
warn() { printf '\033[33m! %s\033[0m\n' "$*"; }
if ! command -v uv >/dev/null 2>&1; then
warn "uv not installed. Install: https://docs.astral.sh/uv/getting-started/installation/"
warn "Falling back to plain pip — tools will install into the active environment."
if ! command -v pip >/dev/null 2>&1; then
echo "Neither uv nor pip available. Aborting Python-tools install."
exit 1
install_python_tools() {
if ! command -v uv >/dev/null 2>&1; then
warn "uv not installed. Install: https://docs.astral.sh/uv/getting-started/installation/"
warn "Falling back to plain pip — tools will install into the active environment."
if ! command -v pip >/dev/null 2>&1; then
echo "Neither uv nor pip available. Aborting Python-tools install."
exit 1
fi
pip install bandit ruff mypy pip-audit
else
say "Installing Python tools into $SKILL_DIR/.venv/ via uv"
uv sync --group tools
fi
pip install bandit ruff mypy pip-audit
else
say "Installing Python tools into $SKILL_DIR/.venv/ via uv"
uv sync --group tools
fi
}
install_opengrep() {
@@ -42,8 +43,10 @@ install_opengrep() {
return
;;
esac
local tag url
tag="$(curl -fsSL https://api.github.com/repos/opengrep/opengrep/releases/latest | grep -m1 '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/')"
local release tag url
# Buffer the response: piping curl into an early-exiting `grep -m1` makes curl die with (23), which pipefail turns fatal.
release="$(curl -fsSL https://api.github.com/repos/opengrep/opengrep/releases/latest)"
tag="$(grep -m1 '"tag_name"' <<<"$release" | sed -E 's/.*"([^"]+)".*/\1/')"
if [[ -z "$tag" ]]; then
warn "opengrep: could not resolve latest release tag, install manually"
return
@@ -54,8 +57,6 @@ install_opengrep() {
chmod +x "$SKILL_DIR/.venv/bin/opengrep"
}
install_opengrep
install_powershell_modules() {
if ! command -v pwsh >/dev/null 2>&1; then
@@ -67,16 +68,14 @@ install_powershell_modules() {
pwsh -NoProfile -NonInteractive -Command '
foreach ($m in "PSScriptAnalyzer", "InjectionHunter") {
if (Get-Module -ListAvailable -Name $m) {
Write-Host " $m: already installed"
Write-Host " ${m}: already installed"
} else {
Install-Module -Name $m -Scope CurrentUser -Force -AcceptLicense -Repository PSGallery
Write-Host " $m: installed"
Write-Host " ${m}: installed"
}
}'
}
install_powershell_modules
install_native_brew() {
say "Installing native tools via Homebrew"
@@ -113,7 +112,15 @@ install_native_arch() {
fi
say "Installing native tools via $helper"
local pkgs=(gitleaks github-cli osv-scanner)
# --needed still invokes sudo, so skip the helper entirely when nothing is missing.
local pkgs=() pkg
for pkg in gitleaks github-cli osv-scanner; do
pacman -Q "$pkg" >/dev/null 2>&1 || pkgs+=("$pkg")
done
if [[ ${#pkgs[@]} -eq 0 ]]; then
echo " gitleaks, github-cli, osv-scanner: already installed"
return
fi
if [[ "$helper" == "pacman" ]]; then
sudo pacman -S --needed --noconfirm gitleaks github-cli || true
if ! command -v osv-scanner >/dev/null 2>&1; then
@@ -125,29 +132,66 @@ install_native_arch() {
fi
}
if command -v brew >/dev/null 2>&1; then
install_native_brew
elif command -v pacman >/dev/null 2>&1; then
install_native_arch
elif command -v apt-get >/dev/null 2>&1; then
install_native_apt
else
warn "No supported native package manager found (brew/pacman/apt). Install gitleaks, osv-scanner, gh manually."
fi
say "Verifying tool availability"
for tool in bandit ruff mypy pip-audit opengrep vulture radon interrogate lizard gitleaks osv-scanner gh pwsh; do
if [[ -x "$SKILL_DIR/.venv/bin/$tool" ]]; then
printf ' %-15s %s\n' "$tool" "(.venv/bin)"
elif command -v "$tool" >/dev/null 2>&1; then
printf ' %-15s %s\n' "$tool" "$(command -v "$tool")"
install_native() {
if command -v brew >/dev/null 2>&1; then
install_native_brew
elif command -v pacman >/dev/null 2>&1; then
install_native_arch
elif command -v apt-get >/dev/null 2>&1; then
install_native_apt
else
printf ' %-15s \033[31mmissing\033[0m\n' "$tool"
warn "No supported native package manager found (brew/pacman/apt). Install gitleaks, osv-scanner, gh manually."
fi
done
}
cat <<EOF
# Checked in .venv/bin only: each plugin version gets its own venv, and a copy on PATH says nothing about this one.
VENV_TOOLS=(bandit ruff mypy pip-audit vulture radon interrogate lizard opengrep)
NATIVE_TOOLS=(gitleaks osv-scanner gh)
verify_tools() {
say "Verifying tool availability"
local tool missing=()
for tool in "${VENV_TOOLS[@]}"; do
if [[ -x "$SKILL_DIR/.venv/bin/$tool" ]]; then
printf ' %-15s %s\n' "$tool" "(.venv/bin)"
else
missing+=("$tool")
fi
done
for tool in "${NATIVE_TOOLS[@]}" pwsh; do
if command -v "$tool" >/dev/null 2>&1; then
printf ' %-15s %s\n' "$tool" "$(command -v "$tool")"
elif [[ "$tool" == pwsh ]]; then
printf ' %-15s %s\n' "$tool" "missing (optional: PowerShell review only)"
else
missing+=("$tool")
fi
done
[[ ${#missing[@]} -eq 0 ]] && return
printf ' %-15s \033[31mmissing\033[0m\n' "${missing[@]}"
return 1
}
main() {
local user_only=0
case "${1:-}" in
"") ;;
--check-only) verify_tools; return ;;
--user-only) user_only=1 ;;
*) echo "usage: install-tools.sh [--check-only | --user-only]" >&2; return 2 ;;
esac
cd "$SKILL_DIR"
install_python_tools
install_opengrep
install_powershell_modules
if [[ $user_only -eq 1 ]]; then
warn "--user-only: skipped system packages (gitleaks, osv-scanner, gh). Re-run without it to install them."
else
install_native
fi
cat <<EOF
Per-project tools (not installed here — must live in the target repo):
- eslint + eslint-plugin-security (npm i -D)
@@ -158,3 +202,9 @@ Per-project tools (not installed here — must live in the target repo):
Run /audit-code to use the skill.
EOF
verify_tools
}
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
main "$@"
fi
@@ -25,6 +25,8 @@ import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from scripts.telemetry import append_subagent_run
_DEFAULT_LOG = Path.home() / ".claude/cache/audit-code/runs.jsonl"
@@ -124,7 +124,7 @@ class Manifest:
findings: list[Finding]
package_diffs: dict[str, PackageDiff]
tool_stats: dict[str, ToolStat]
tools_unavailable: list[str]
tools_unavailable: dict[str, str]
errors: list[str]
def to_dict(self) -> dict:
@@ -138,7 +138,7 @@ class Manifest:
"findings": [f.to_dict() for f in self.findings],
"package_diffs": {k: v.to_dict() for k, v in self.package_diffs.items()},
"tool_stats": {k: v.to_dict() for k, v in self.tool_stats.items()},
"tools_unavailable": list(self.tools_unavailable),
"tools_unavailable": dict(self.tools_unavailable),
"errors": list(self.errors),
}
@@ -29,7 +29,7 @@ def slice_for_agent(manifest: dict, agent: str) -> dict:
"language_breakdown": manifest["language_breakdown"],
"changed_files": list(manifest["changed_files"]),
"tool_stats": dict(manifest["tool_stats"]),
"tools_unavailable": list(manifest["tools_unavailable"]),
"tools_unavailable": dict(manifest["tools_unavailable"]),
"errors": list(manifest["errors"]),
}
findings = manifest["findings"]
@@ -123,6 +123,7 @@ def test_cli_records_tool_unavailable(tmp_path):
assert rc == 0
manifest = json.loads((out_dir / "manifest.json").read_text())
assert "bandit" in manifest["tools_unavailable"]
assert manifest["tools_unavailable"]["bandit"].endswith("scripts/install-tools.sh --user-only")
def test_cli_alerts_on_unsupported_languages(tmp_path):
@@ -0,0 +1,127 @@
"""Tests for scripts/install-tools.sh, run against a copy with stubbed binaries."""
from __future__ import annotations
import os
import re
import shutil
import subprocess
from pathlib import Path
import pytest
_SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "install-tools.sh"
_VENV_TOOLS = ("bandit", "ruff", "mypy", "pip-audit", "vulture", "radon", "interrogate", "lizard", "opengrep")
_NATIVE_TOOLS = ("gitleaks", "osv-scanner", "gh")
def _skill_copy(tmp_path: Path) -> Path:
skill = tmp_path / "skill"
(skill / "scripts").mkdir(parents=True)
shutil.copy(_SCRIPT, skill / "scripts" / "install-tools.sh")
return skill / "scripts" / "install-tools.sh"
def _stub(bin_dir: Path, name: str, body: str) -> None:
bin_dir.mkdir(parents=True, exist_ok=True)
path = bin_dir / name
path.write_text("#!/usr/bin/env bash\n" + body)
path.chmod(0o755)
def _run(cmd: str, bin_dir: Path, **env: str) -> subprocess.CompletedProcess:
return subprocess.run(
["bash", "-c", cmd],
capture_output=True, text=True, timeout=60, check=False,
env={**os.environ, "PATH": f"{bin_dir}:/usr/bin:/bin", **env},
)
def test_opengrep_tag_lookup_survives_large_release_body(tmp_path: Path) -> None:
script = _skill_copy(tmp_path)
bin_dir = tmp_path / "bin"
log = tmp_path / "curl.log"
_stub(bin_dir, "curl", r'''
for ((i = 1; i <= $#; i++)); do
if [[ "${!i}" == "-o" ]]; then
j=$((i + 1)); echo "$*" >> "$CURL_LOG"; echo bin > "${!j}"; exit 0
fi
done
printf '{\n "tag_name": "v9.9.9",\n "body": "'
head -c 2000000 /dev/zero | tr '\0' x
printf '"\n}\n'
''')
r = _run(f"source {script} && install_opengrep", bin_dir, CURL_LOG=str(log))
assert r.returncode == 0, r.stderr
assert (script.parent.parent / ".venv" / "bin" / "opengrep").is_file()
assert "/releases/download/v9.9.9/" in log.read_text()
@pytest.mark.skipif(shutil.which("pwsh") is None, reason="pwsh not installed")
def test_powershell_module_block_parses() -> None:
block = re.search(r"pwsh -NoProfile -NonInteractive -Command '(.*?)'", _SCRIPT.read_text(), re.DOTALL)
assert block, "embedded pwsh -Command block not found"
check = (
"$errs = $null; "
"[System.Management.Automation.Language.Parser]::ParseInput($env:PS_BLOCK, [ref]$null, [ref]$errs) | Out-Null; "
"$errs | ForEach-Object { $_.Message }; exit $errs.Count"
)
r = subprocess.run(
["pwsh", "-NoProfile", "-NonInteractive", "-Command", check],
capture_output=True, text=True, timeout=60, check=False,
env={**os.environ, "PS_BLOCK": block.group(1)},
)
assert r.returncode == 0, r.stdout + r.stderr
def _arch_stubs(bin_dir: Path, installed: set[str]) -> None:
_stub(bin_dir, "pacman", f'''
[[ "$1" == "-Q" ]] || {{ echo "pacman $*" >> "$CALLS"; exit 1; }}
case "$2" in {"|".join(installed) or "__none__"}) exit 0 ;; *) exit 1 ;; esac
''')
_stub(bin_dir, "paru", 'echo "paru $*" >> "$CALLS"\n')
_stub(bin_dir, "sudo", 'echo "sudo $*" >> "$CALLS"; exit 1\n')
def test_arch_skips_helper_when_everything_installed(tmp_path: Path) -> None:
script = _skill_copy(tmp_path)
bin_dir = tmp_path / "bin"
calls = tmp_path / "calls.log"
_arch_stubs(bin_dir, {"gitleaks", "github-cli", "osv-scanner"})
r = _run(f"source {script} && install_native_arch", bin_dir, CALLS=str(calls))
assert r.returncode == 0, r.stderr
assert not calls.exists(), calls.read_text()
def test_arch_helper_installs_only_missing_packages(tmp_path: Path) -> None:
script = _skill_copy(tmp_path)
bin_dir = tmp_path / "bin"
calls = tmp_path / "calls.log"
_arch_stubs(bin_dir, {"gitleaks", "github-cli"})
r = _run(f"source {script} && install_native_arch", bin_dir, CALLS=str(calls))
assert r.returncode == 0, r.stderr
assert calls.read_text().splitlines() == ["paru -S --needed --noconfirm osv-scanner"]
def test_check_only_on_empty_venv_fails_and_names_missing_tools(tmp_path: Path) -> None:
script = _skill_copy(tmp_path)
bin_dir = tmp_path / "bin"
for tool in _NATIVE_TOOLS:
_stub(bin_dir, tool, "")
r = _run(f"bash {script} --check-only", bin_dir)
assert r.returncode != 0
assert "lizard" in r.stdout
assert "opengrep" in r.stdout
assert not (script.parent.parent / ".venv").exists(), "--check-only must not install anything"
def test_check_only_passes_when_everything_present(tmp_path: Path) -> None:
script = _skill_copy(tmp_path)
venv_bin = script.parent.parent / ".venv" / "bin"
for tool in _VENV_TOOLS:
_stub(venv_bin, tool, "")
bin_dir = tmp_path / "bin"
for tool in _NATIVE_TOOLS:
_stub(bin_dir, tool, "")
r = _run(f"bash {script} --check-only", bin_dir)
assert r.returncode == 0, r.stdout + r.stderr
@@ -3,6 +3,9 @@ from __future__ import annotations
import importlib.util
import json
import os
import subprocess
import sys
from pathlib import Path
_SPEC = importlib.util.spec_from_file_location(
@@ -136,3 +139,17 @@ def test_log_path_parent_is_created(tmp_path: Path) -> None:
])
assert rc == 0
assert log.exists()
def test_runs_as_a_script_from_another_cwd(tmp_path: Path) -> None:
log = tmp_path / "runs.jsonl"
env = {k: v for k, v in os.environ.items() if k != "PYTHONPATH"}
r = subprocess.run(
[sys.executable, str(_SPEC.origin),
"--output-dir", str(tmp_path), "--run-id", "x", "--repo", "/r",
"--mode", "local", "--log-path", str(log), "--usage-json", "-"],
input=json.dumps({"x-reviewer": {"model": "sonnet"}}),
capture_output=True, text=True, cwd=tmp_path, env=env, check=False,
)
assert r.returncode == 0, r.stderr
assert _read_log(log)[0]["agent"] == "x-reviewer"
@@ -46,7 +46,7 @@ def test_manifest_roundtrip():
"csharp": PackageDiff(),
},
tool_stats={},
tools_unavailable=[],
tools_unavailable={},
errors=[],
)
payload = json.loads(m.to_json())
@@ -35,7 +35,7 @@ def _manifest():
],
"package_diffs": {"python": {"added": [], "removed": [], "upgraded": []}},
"tool_stats": {},
"tools_unavailable": [],
"tools_unavailable": {},
"errors": [],
}
@@ -20,7 +20,7 @@ def _manifest_dict():
],
"package_diffs": {"python": {"added": [], "removed": [], "upgraded": []}},
"tool_stats": {},
"tools_unavailable": [],
"tools_unavailable": {},
"errors": [],
}
@@ -96,7 +96,7 @@ def _manifest_with_lua_gha():
],
"package_diffs": {},
"tool_stats": {},
"tools_unavailable": [],
"tools_unavailable": {},
"errors": [],
}
@@ -160,7 +160,7 @@ def _manifest_with_powershell():
],
"package_diffs": {},
"tool_stats": {},
"tools_unavailable": [],
"tools_unavailable": {},
"errors": [],
}