From 305a0e61f839de12f5aebaee6e1469e138c86fd7 Mon Sep 17 00:00:00 2001 From: "Simen A. W. Olsen" Date: Tue, 27 Oct 2026 11:50:21 +0100 Subject: feat: gate updates behind vet supply-chain checks update now holds inputs and skills back by a cooldown, has Claude review third-party diffs, and reports CVE changes before switching. Homebrew taps are pinned through nix-homebrew, and npm/pnpm get install policies. --- pkgs/vet/vet.sh | 460 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 460 insertions(+) create mode 100644 pkgs/vet/vet.sh (limited to 'pkgs/vet/vet.sh') diff --git a/pkgs/vet/vet.sh b/pkgs/vet/vet.sh new file mode 100644 index 0000000..04d9345 --- /dev/null +++ b/pkgs/vet/vet.sh @@ -0,0 +1,460 @@ +usage() { + cat <<'EOF' +vet holds flake inputs and agent skills back by a cooldown, and has Claude +review what changed in third-party sources before they are installed. + +Usage: + vet lock + Move flake inputs and skills to the newest revisions that are past the + cooldown. Own sources move to their newest revision. + vet check [--base REV] [--report FILE] + Compare flake.lock and skills.lock with REV (default HEAD). Changes + inside the cooldown are blocked; third-party changes are reviewed by + Claude. Exits 1 when blocked, 2 when a review needs a human and stdin + is not a terminal. + vet cves OLD NEW [--report FILE] + List the CVEs that switching from system OLD to NEW adds and fixes, + matched by vulnix against the NVD. Known false positives go in + vulnix.toml at the root of the repository. Only reports, never blocks. + +Sources are sorted into own, trusted and third-party in vet.json at the root +of the repository. VET_COOLDOWN_DAYS and VET_MODEL override the defaults. +EOF +} + +log() { echo "vet: $*" >&2; } +die() { + log "$*" + exit 1 +} + +# Lower-cases a URL and strips scheme, user and .git, so that +# ssh://git@host/repo, git@host:repo and https://host/repo.git all compare equal. +norm() { + local u=${1,,} + u=${u#*://} + u=${u#*@} + u=${u%.git} + u=${u/://} + printf '%s\n' "$u" +} + +tier() { + local p + for p in "${own[@]}"; do + if [[ $1 == "$p"* ]]; then + echo own + return + fi + done + for p in "${trusted[@]}"; do + if [[ $1 == "$p" ]]; then + echo trusted + return + fi + done + echo third-party +} + +# Prints {name, node} for each direct input of a flake.lock. +inputs() { + jq -c '.nodes as $n | .nodes.root.inputs | to_entries[] + | select(.value | type == "string") | {name: .key, node: $n[.value]}' "$1" +} + +base_node() { + jq -c --arg n "$1" '.nodes.root.inputs[$n] as $k + | if ($k | type) == "string" then .nodes[$k] else null end' "$tmp/base-flake.lock" +} + +node_source() { + norm "$(jq -r '.original | if .type == "github" then "github.com/\(.owner)/\(.repo)" else .url // "" end' <<<"$1")" +} + +gh_api() { + local auth=() + if [[ -n $token ]]; then auth=(-H "Authorization: Bearer $token"); fi + curl -fsSL "${auth[@]}" -H 'Accept: application/vnd.github+json' "https://api.github.com/$1" +} + +short() { + if [[ -n $1 ]]; then echo "${1:0:12}"; else echo new; fi +} + +iso() { date -u -d "@$1" +%Y-%m-%dT%H:%M:%SZ; } + +# Prints " " for the newest commit on ref that is older than +# the cutoff. +github_rev() { + local q until + until=$(iso "$cutoff") + q="repos/$1/$2/commits?per_page=1&until=$until" + if [[ -n $3 ]]; then q+="&sha=$3"; fi + gh_api "$q" | jq -r '.[0] // empty | "\(.sha) \(.commit.committer.date)"' +} + +# The newest nixpkgs-unstable channel release older than the cutoff. Channel +# releases are commits Hydra has built, so they are in the binary cache; an +# arbitrary older commit on the branch might not be. +nixpkgs_channel_rev() { + local current key + current=$(curl -fsSI https://channels.nixos.org/nixpkgs-unstable | tr -d '\r' | sed -n 's|^location: .*/||Ip') + [[ -n $current ]] || return 1 + key=$(curl -fsSL "https://nix-releases.s3.amazonaws.com/?delimiter=/&prefix=nixpkgs/${current%%pre*}pre" | + grep -o '[^<]*[^<]*' | + sed 's|||; s|| |' | + awk -v c="$(iso "$cutoff")" '$2 < c { print $2, $1 }' | sort | tail -n 1 | cut -d ' ' -f 2) + [[ -n $key ]] || return 1 + curl -fsSL "https://releases.nixos.org/$key/git-revision" +} + +# Fetches revs from url into a cached bare repository and prints its path. +fetch() { + local url=$1 d rev + shift + d=$cache/repos/$(printf '%s' "$url" | sha256sum | cut -c 1-16) + [[ -d $d ]] || git init -q --bare "$d" + for rev; do + git -C "$d" cat-file -e "$rev^{commit}" 2>/dev/null && continue + git -C "$d" fetch -q "$url" "$rev" 2>/dev/null || + git -C "$d" fetch -q "$url" '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' + git -C "$d" cat-file -e "$rev^{commit}" 2>/dev/null || die "$url has no commit $rev" + done + printf '%s\n' "$d" +} + +load_base() { + git show "$1:flake.lock" >"$tmp/base-flake.lock" 2>/dev/null || + echo '{"nodes":{"root":{"inputs":{}}}}' >"$tmp/base-flake.lock" + git show "$1:skills.lock" >"$tmp/base-skills.lock" 2>/dev/null || + echo '{"sources":{}}' >"$tmp/base-skills.lock" +} + +cmd_lock() { + local overrides=() updates=() line name node src owner repo ref rev date base base_mod + load_base HEAD + + while IFS= read -r line; do + name=$(jq -r .name <<<"$line") + node=$(jq -c .node <<<"$line") + if [[ -n $(jq -r '.original.rev // empty' <<<"$node") ]]; then + log "$name: pinned to a commit in flake.nix, left alone" + continue + fi + src=$(node_source "$node") + if [[ $(tier "$src") == own ]]; then + updates+=("$name") + continue + fi + if [[ $(jq -r .original.type <<<"$node") != github ]]; then + log "$name: only GitHub inputs can be held back, left alone" + continue + fi + owner=$(jq -r .original.owner <<<"$node") + repo=$(jq -r .original.repo <<<"$node") + ref=$(jq -r '.original.ref // empty' <<<"$node") + + if [[ $src == github.com/nixos/nixpkgs && $ref == nixpkgs-unstable ]]; then + if ! rev=$(nixpkgs_channel_rev); then + log "$name: no channel release is $days days old yet, left alone" + continue + fi + date=$(gh_api "repos/$owner/$repo/commits/$rev" | jq -r .commit.committer.date) + else + read -r rev date < <(github_rev "$owner" "$repo" "$ref") || true + if [[ -z $rev ]]; then + log "$name: no commit is $days days old yet, left alone" + continue + fi + fi + + # Never move behind what HEAD already locks. + base=$(base_node "$name") + base_mod=$(jq -r '.locked.lastModified // 0' <<<"$base") + if (($(date -d "$date" +%s) < base_mod)); then + rev=$(jq -r .locked.rev <<<"$base") + date=$(iso "$base_mod") + fi + overrides+=(--override-input "$name" "github:$owner/$repo/$rev") + log "$name: ${rev:0:12} ($date)" + done < <(inputs flake.lock) + + if ((${#updates[@]})); then + log "own inputs to newest: ${updates[*]}" + nix flake update "${updates[@]}" + fi + if ((${#overrides[@]})); then nix flake lock "${overrides[@]}"; fi + + [[ -f skills.lock ]] || return 0 + SKILLS_MANAGED_BY=home-manager SKILLS_MANIFEST=$repo/skills.json skills update -q + + local url d committed entry rows row + mapfile -t rows < <(jq -r '.sources | to_entries[] | [.key, .value.url, .value.rev] | @tsv' skills.lock) + for row in "${rows[@]}"; do + IFS=$'\t' read -r name url rev <<<"$row" + [[ $(tier "$(norm "$url")") == own ]] && continue + entry=$(jq -c --arg n "$name" '.sources[$n] // empty' "$tmp/base-skills.lock") + [[ -n $entry && $(jq -r .rev <<<"$entry") == "$rev" ]] && continue + d=$(fetch "$url" "$rev") + committed=$(git -C "$d" show -s --format=%ct "$rev") + ((committed <= cutoff)) && continue + if [[ -z $entry ]]; then + log "skill source $name is new and inside the cooldown; vet check will block it" + continue + fi + log "skill source $name: ${rev:0:12} is inside the cooldown, kept at $(jq -r '.rev[:12]' <<<"$entry")" + jq --arg n "$name" --argjson e "$entry" '.sources[$n] = $e' skills.lock >"$tmp/skills.lock" + cat "$tmp/skills.lock" >skills.lock + done +} + +worst=pass +report=() + +record() { + case $1 in + block) worst=block ;; + review) [[ $worst == block ]] || worst=review ;; + esac + report+=("$2") +} + +schema='{ + "type": "object", + "properties": { + "verdict": {"type": "string", "enum": ["pass", "review", "block"]}, + "summary": {"type": "string", "description": "One sentence on what changed and why it is or is not a concern."}, + "findings": { + "type": "array", + "items": { + "type": "object", + "properties": { + "severity": {"type": "string", "enum": ["low", "medium", "high"]}, + "file": {"type": "string"}, + "description": {"type": "string"} + }, + "required": ["severity", "description"] + } + } + }, + "required": ["verdict", "summary", "findings"] +}' + +# review [paths...] +review() { + local name=$1 kind=$2 url=$3 old=$4 new=$5 d from in size truncated=false out result v + shift 5 + if [[ -n $old ]]; then d=$(fetch "$url" "$old" "$new"); else d=$(fetch "$url" "$new"); fi + from=${old:-$(git -C "$d" hash-object -t tree /dev/null)} + in=$tmp/review-$name.txt + + { + echo "Dependency: $name ($kind)" + echo "Source: $url" + if [[ -n $old ]]; then echo "Change: $old..$new"; else echo "Change: new dependency, full contents at $new"; fi + if (($#)); then echo "Paths that get installed: $*"; fi + echo + echo "## Commits" + if [[ -n $old ]]; then + git -C "$d" log -n 200 --format='%h %an <%ae> %cI %s' "$old..$new" -- "$@" + else + git -C "$d" log -n 20 --format='%h %an <%ae> %cI %s' "$new" -- "$@" + fi + echo + echo "## Diffstat" + git -C "$d" diff --stat "$from" "$new" -- "$@" + echo + echo "## Diff" + } >"$in" + git -C "$d" diff --no-color --no-ext-diff "$from" "$new" -- "$@" >"$tmp/diff" + size=$(wc -c <"$tmp/diff") + if ((size > max_bytes)); then + truncated=true + head -c "$max_bytes" "$tmp/diff" >>"$in" + echo "[diff truncated at $max_bytes of $size bytes]" >>"$in" + else + cat "$tmp/diff" >>"$in" + fi + + log "reviewing $name with Claude ($((size / 1024)) KiB of diff)" + # Run from an empty directory without tools, MCP servers, settings or + # slash commands: the diff is attacker-controlled input. + if ! out=$(cd "$tmp/empty" && claude -p --model "$model" \ + --system-prompt "$(cat "$VET_PROMPT")" \ + --tools "" --strict-mcp-config --setting-sources "" --disable-slash-commands \ + --no-session-persistence --output-format json --json-schema "$schema" \ + "Review the dependency change on stdin." <"$in"); then + record review "- $name: Claude review failed, review by hand" + return + fi + result=$(jq -c '.structured_output // empty' <<<"$out") + if [[ -z $result ]]; then + record review "- $name: Claude gave no verdict, review by hand" + return + fi + v=$(jq -r .verdict <<<"$result") + if [[ $truncated == true && $v == pass ]]; then v=review; fi + record "$v" "$(jq -r --arg n "$name" --arg v "$v" --argjson t "$truncated" \ + '"- \($n): \($v)\(if $t then " (diff truncated)" else "" end): \(.summary)" + + ([.findings[] | "\n - [\(.severity)] \(if .file then .file + ": " else "" end)\(.description)"] | join(""))' \ + <<<"$result")" +} + +cooldown() { # name lastModified + if (($2 > cutoff)); then + record block "- $1: blocked, committed $(((now - $2) / 3600)) hours ago (cooldown is $days days)" + return 1 + fi +} + +cmd_check() { + local base=HEAD out="" line name node old_node src t new_id old_id old new kind url + while (($#)); do + case $1 in + --base) base=$2; shift 2 ;; + --report) out=$2; shift 2 ;; + *) usage >&2; exit 64 ;; + esac + done + load_base "$base" + + while IFS= read -r line; do + name=$(jq -r .name <<<"$line") + node=$(jq -c .node <<<"$line") + old_node=$(base_node "$name") + new_id=$(jq -r '.locked | .rev // .narHash' <<<"$node") + old_id=$(jq -r '.locked | .rev // .narHash // empty' <<<"$old_node") + [[ $new_id == "$old_id" ]] && continue + + src=$(node_source "$node") + t=$(tier "$src") + new=$(jq -r '.locked.rev // empty' <<<"$node") + old=$(jq -r '.locked.rev // empty' <<<"$old_node") + if [[ $t == own ]]; then + report+=("- $name: own source, $(short "$old") -> $(short "$new")") + continue + fi + cooldown "$name" "$(jq -r '.locked.lastModified // 0' <<<"$node")" || continue + if [[ $t == trusted ]]; then + report+=("- $name: trusted, $(short "$old") -> $(short "$new")") + continue + fi + [[ -n $new ]] || { + record review "- $name: no git revision to review, review by hand" + continue + } + case $src in + github.com/*/homebrew-*) kind="homebrew tap" ;; + *) kind="nix flake input" ;; + esac + url=$(jq -r '.locked | if .type == "github" then "https://github.com/\(.owner)/\(.repo)" else .url end' <<<"$node") + review "$name" "$kind" "$url" "$old" "$new" + done < <(inputs flake.lock) + + if [[ -f skills.lock ]]; then + local paths=() d + while IFS= read -r line; do + name=$(jq -r .key <<<"$line") + url=$(jq -r .value.url <<<"$line") + new=$(jq -r .value.rev <<<"$line") + old=$(jq -r --arg n "$name" '.sources[$n].rev // empty' "$tmp/base-skills.lock") + [[ $new == "$old" ]] && continue + if [[ $(tier "$(norm "$url")") == own ]]; then + report+=("- skills/$name: own source, $(short "$old") -> $(short "$new")") + continue + fi + d=$(fetch "$url" "$new") + cooldown "skills/$name" "$(git -C "$d" show -s --format=%ct "$new")" || continue + mapfile -t paths < <(jq -r '.value.skills[]' <<<"$line") + review "skills/$name" "agent skill" "$url" "$old" "$new" "${paths[@]}" + done < <(jq -c '.sources | to_entries[]' skills.lock) + fi + + if ((${#report[@]} == 0)); then + log "nothing changed since $base" + : >"${out:-/dev/null}" + return 0 + fi + printf '%s\n' "${report[@]}" | tee "${out:-/dev/null}" + + case $worst in + block) + log "blocked" + exit 1 + ;; + review) + [[ -t 0 ]] || exit 2 + local answer + read -r -p "vet: accept these changes? [y/N] " answer + [[ $answer == [yY]* ]] || exit 1 + ;; + esac +} + +# vulnix exits 2 when it finds vulnerabilities, which is not a failure here. +vulnix_scan() { + local args=(-C --json) + if [[ -f $repo/vulnix.toml ]]; then args+=(-w "$repo/vulnix.toml"); fi + vulnix "${args[@]}" "$1" || (($? == 2)) +} + +cmd_cves() { + local old=$1 new=$2 out=/dev/null + shift 2 + if [[ ${1:-} == --report ]]; then out=$2; fi + log "scanning $old and $new with vulnix" + vulnix_scan "$old" >"$tmp/old.json" + vulnix_scan "$new" >"$tmp/new.json" + jq -nr --slurpfile old "$tmp/old.json" --slurpfile new "$tmp/new.json" ' + def cves: [.[] | .pname as $p | .version as $v | .cvssv3_basescore as $s + | .affected_by[] | {key: "\($p) \(.)", pkg: "\($p) \($v)", cve: ., score: ($s[.] // null)}]; + ($old[0] | cves | map(.key)) as $before + | ($new[0] | cves) as $after + | ($after | map(.key)) as $now + | ($after | map(select(.key as $k | $before | index($k) | not)) + | sort_by(-(.score // 0))) as $added + | ($before | map(select(. as $k | $now | index($k) | not))) as $fixed + | "CVEs added: \($added | length), fixed: \($fixed | length), open: \($now | length)", + ($added[] | "- \(.pkg): \(.cve)\(if .score then " (CVSS \(.score))" else "" end)") + ' | tee "$out" +} + +repo=$(git rev-parse --show-toplevel) +cd "$repo" +[[ -f vet.json ]] || die "no vet.json in $repo" + +cache=${XDG_CACHE_HOME:-$HOME/.cache}/vet +mkdir -p "$cache/repos" +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT +mkdir "$tmp/empty" + +days=${VET_COOLDOWN_DAYS:-$(jq -r .cooldownDays vet.json)} +model=${VET_MODEL:-opus} +max_bytes=400000 +now=$(date +%s) +cutoff=$((now - days * 86400)) +mapfile -t own < <(jq -r '.own[] | ascii_downcase' vet.json) +mapfile -t trusted < <(jq -r '.trusted[] | ascii_downcase' vet.json) +token=${GITHUB_TOKEN:-$(gh auth token 2>/dev/null || true)} + +case ${1:-} in + lock) cmd_lock ;; + cves) + shift + (($# >= 2)) || { + usage >&2 + exit 64 + } + cmd_cves "$@" + ;; + check) + shift + cmd_check "$@" + ;; + -h | --help | help) usage ;; + *) + usage >&2 + exit 64 + ;; +esac -- cgit v1.3.1