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