safe-chains 0.215.0

Auto-allow safe bash commands in agentic coding tools
Documentation
name: Fuzz

# Overnight coverage-guided fuzzing of the command classifier. Runs on nightly Rust (cargo-fuzz
# needs -Zsanitizer); the fuzz crate is a standalone workspace, so this never touches the stable CI.
#
# Shape: build the target ONCE, then fan out to N parallel shards that each fuzz independently from
# the same restored corpus, then a single merge job unions + minimizes all shard corpora back into
# one canonical corpus for the next run to build on. Parallel shards multiply crash-finding; the
# merge is what makes their coverage compound instead of being dropped (only one cache entry is ever
# restored). A final coverage job measures which parts of the classifier the corpus actually reaches.
on:
  schedule:
    - cron: "0 7 * * *" # ~07:00 UTC nightly
  workflow_dispatch:
    inputs:
      max_total_time:
        description: "Fuzz duration in seconds, per shard (default 5h). Use a small value to smoke-test the workflow."
        default: "18000"

env:
  CARGO_TERM_COLOR: always
  TRIPLE: x86_64-unknown-linux-gnu

jobs:
  # `cargo fuzz run` and `cargo fuzz cmin` each rebuild the target, so a 3-shard nightly used to
  # compile it four times. Build once here and hand the binary to every downstream job: the
  # cargo-fuzz output is a standalone libFuzzer executable, so shards and merge can exec it directly
  # with the flags cargo-fuzz would have passed. (Coverage still builds its own — that
  # instrumentation differs.)
  build:
    name: Build fuzz target
    runs-on: ubuntu-latest
    timeout-minutes: 30
    steps:
      - uses: actions/checkout@v5

      # nightly + rust-src: cargo-fuzz builds std with the sanitizer instrumented.
      - uses: dtolnay/rust-toolchain@nightly
        with:
          components: rust-src

      - uses: Swatinem/rust-cache@v2
        with:
          workspaces: fuzz

      - uses: taiki-e/install-action@v2
        with:
          tool: cargo-fuzz

      # --target is pinned to the gnu host triple: cargo-fuzz is installed as a prebuilt musl static
      # binary and otherwise defaults the fuzz target to its own musl triple, whose static libc can't
      # carry AddressSanitizer ("sanitizer is incompatible with statically linked libc").
      - name: Build
        run: cargo +nightly fuzz build parse --target "$TRIPLE"

      - name: Upload fuzz binary
        uses: actions/upload-artifact@v4
        with:
          name: fuzz-binary
          path: fuzz/target/${{ env.TRIPLE }}/release/parse
          if-no-files-found: error

      # Registry-derived seeds + dictionary. Byte mutation barely reaches the per-command grammars
      # (~26% region); the examples_safe/denied invocations as seeds plus the command/flag vocabulary
      # as a dictionary more than DOUBLE it (measured ~61% combined). Generated fresh each run so new
      # commands are covered automatically; the seeds are absorbed into the canonical corpus by the
      # merge job, so this compounds rather than repeating work.
      - name: Generate seeds + dictionary
        run: |
          cargo run --bin gen-fuzz-corpus --features fuzz-gen
          test -s fuzz/dict/parse.dict
          test "$(find fuzz/corpus/parse -name 'gen-*' | wc -l)" -gt 0

      - name: Upload seeds + dictionary
        uses: actions/upload-artifact@v4
        with:
          name: fuzz-seeds
          path: |
            fuzz/dict/parse.dict
            fuzz/corpus/parse
          if-no-files-found: error

  fuzz:
    name: Fuzz (parse) shard ${{ matrix.shard }}
    needs: build
    runs-on: ubuntu-latest
    # GitHub-hosted jobs are force-cancelled at a 6h hard cap — which shows as "cancelled" and
    # SWALLOWS the "Fail on crashes" red signal, so an 8h budget silently hid crashes every night.
    # Keep the fuzz budget (5h) + setup under 6h so the job COMPLETES: findings then surface as a
    # red run, and the merge sees clean shard outcomes. This timeout is a backstop below the cap.
    timeout-minutes: 350
    strategy:
      fail-fast: false # one shard finding a crash must not cancel the others (or the merge)
      matrix:
        shard: [1, 2, 3]
    steps:
      - uses: actions/checkout@v5

      - name: Download fuzz binary
        uses: actions/download-artifact@v4
        with:
          name: fuzz-binary
          path: bin

      # Artifact upload does not preserve the executable bit.
      - name: Make executable
        run: chmod +x bin/parse

      # Every shard restores the same latest canonical corpus, then diverges (libFuzzer seeds its
      # RNG randomly, so shards explore different regions). Restore-only: shards hand their findings
      # to the merge job as artifacts, they do not write the cache themselves.
      - name: Restore corpus
        uses: actions/cache/restore@v4
        with:
          path: fuzz/corpus/parse
          key: fuzz-corpus-parse-
          restore-keys: fuzz-corpus-parse-

      # Registry seeds (gen-*) land alongside the restored corpus; the dictionary feeds -dict below.
      # Downloading into the same corpus dir unions with the cache (both are present when fuzzing).
      - name: Download seeds + dictionary
        uses: actions/download-artifact@v4
        with:
          name: fuzz-seeds
          path: fuzz

      # Fork mode (-fork=1) + -ignore_crashes: a crash/timeout kills only the child; the parent keeps
      # fuzzing to -max_total_time and SAVES every finding to fuzz/artifacts/ instead of libFuzzer's
      # default of aborting on the first. That is what lets a run enumerate the whole crash set (and
      # use its full time budget) rather than dying minutes in on the nearest shallow bug — every shard
      # otherwise trips the same shallow crash from the shared corpus and exits, wasting the parallelism.
      # -dict feeds the registry vocabulary so the mutator splices real command/flag tokens.
      #
      # Two corpus dirs: libFuzzer WRITES new units only to the FIRST (`new/`) and reads the rest as
      # read-only input, so the shard uploads only what it DISCOVERED — not a copy of the ~14k it
      # restored. That keeps the merge's incoming set (and its file I/O) from scaling with shard count.
      # The step stays green even with findings; "Fail on crashes" below turns any into a red run.
      - name: Fuzz
        run: |
          mkdir -p fuzz/corpus/parse fuzz/corpus/new fuzz/artifacts/parse
          ./bin/parse \
            -fork=1 -ignore_crashes=1 \
            -dict=fuzz/dict/parse.dict \
            -max_total_time=${{ github.event.inputs.max_total_time || '18000' }} \
            -timeout=25 \
            -rss_limit_mb=4096 \
            -artifact_prefix=fuzz/artifacts/parse/ \
            fuzz/corpus/new fuzz/corpus/parse

      # Only this shard's NEW finds. The prior corpus + registry seeds reach the merge via the cache
      # and the fuzz-seeds artifact, not re-uploaded per shard. Content-hash named, so no collisions.
      - name: Upload shard finds
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: corpus-shard-${{ matrix.shard }}
          path: fuzz/corpus/new
          if-no-files-found: ignore

      # Reproducing inputs land in fuzz/artifacts/parse/ (crash-*, timeout-*, oom-*, slow-unit-*).
      # Upload always — in fork mode the fuzz step is green, so failure() would never fire.
      - name: Upload crash artifacts
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: fuzz-crashes-shard-${{ matrix.shard }}
          path: fuzz/artifacts/
          if-no-files-found: ignore

      # Fork mode swallows the non-zero exit, so surface findings explicitly: any crash/timeout/oom
      # makes the shard (and the run) red, so a regression can't hide behind a green fork-mode run.
      # slow-unit is uploaded above for triage but is not fatal on its own.
      - name: Fail on crashes
        if: always()
        run: |
          hits=$(find fuzz/artifacts -type f \( -name 'crash-*' -o -name 'timeout-*' -o -name 'oom-*' \) 2>/dev/null || true)
          if [ -n "$hits" ]; then
            echo "::error::fuzzing saved crash/timeout artifacts (see fuzz-crashes-shard-${{ matrix.shard }})"
            printf '%s\n' "$hits"
            exit 1
          fi
          echo "no crash/timeout/oom artifacts"

  merge:
    name: Merge corpus
    needs: [build, fuzz]
    # Merge even when a shard went RED on a crash (its corpus is still worth keeping) — but not when
    # the build itself failed, since there'd be no binary to merge with.
    if: ${{ !cancelled() && needs.build.result == 'success' }}
    runs-on: ubuntu-latest
    timeout-minutes: 60
    steps:
      - uses: actions/checkout@v5

      - name: Download fuzz binary
        uses: actions/download-artifact@v4
        with:
          name: fuzz-binary
          path: bin

      - name: Make executable
        run: chmod +x bin/parse

      # Seed the merge with the prior canonical corpus, then fold the registry seeds and every
      # shard's NEW finds on top of it.
      - name: Restore prior corpus
        uses: actions/cache/restore@v4
        with:
          path: fuzz/corpus/parse
          key: fuzz-corpus-parse-
          restore-keys: fuzz-corpus-parse-

      # Shards no longer re-upload the seeds, so fold them in here (into the prior corpus dir) — this
      # is what bakes the registry-example coverage into the canonical corpus the per-push replay reads.
      - name: Download seeds
        uses: actions/download-artifact@v4
        with:
          name: fuzz-seeds
          path: fuzz

      - name: Download shard finds
        uses: actions/download-artifact@v4
        with:
          pattern: corpus-shard-*
          path: incoming

      # `-merge=1 DST SRC...` unions and minimizes in ONE step: libFuzzer copies into DST only the
      # inputs that add coverage, so starting from an empty DST over [prior corpus + seeds, every
      # shard's finds] yields exactly the minimized union — no separate copy-then-cmin pass, no rebuild.
      # Incoming is now just the shards' NEW finds, so the file count read scales with discovery, not
      # with 3x the whole corpus.
      - name: Union and minimize
        run: |
          set -euo pipefail
          mkdir -p fuzz/corpus/parse minimized
          echo "Prior corpus + seeds: $(find fuzz/corpus/parse -type f | wc -l) inputs"
          echo "Incoming shard finds: $(find incoming -type f 2>/dev/null | wc -l)"
          # Unquoted on purpose: each shard dir is a separate SRC argument to -merge=1.
          shards=$(find incoming -mindepth 1 -maxdepth 1 -type d 2>/dev/null || true)
          ./bin/parse -merge=1 minimized fuzz/corpus/parse $shards
          rm -rf fuzz/corpus/parse
          mv minimized fuzz/corpus/parse
          echo "Minimized corpus size: $(find fuzz/corpus/parse -type f | wc -l) inputs"

      # Save under a unique (always-miss) key so the merged corpus becomes the newest entry the next
      # run's restore-keys prefix match will pull.
      - name: Save merged corpus
        uses: actions/cache/save@v4
        with:
          path: fuzz/corpus/parse
          key: fuzz-corpus-parse-${{ github.run_id }}

  # What the crash/timeout signal CANNOT tell you: which parts of the classifier the corpus never
  # reaches. A green night only means "no panic in the region explored" — this job measures that
  # region. Replays the freshly-merged corpus under instrumentation and reports per-file coverage, so
  # an unreached module is visible as a coverage hole (either dead code, or a grammar the byte-level
  # mutator cannot stumble into and that wants a dictionary / structure-aware target). Informational:
  # it gates nothing, but it is the input to deciding where fuzzing effort should go next.
  coverage:
    name: Coverage report
    needs: merge
    if: ${{ !cancelled() }}
    runs-on: ubuntu-latest
    timeout-minutes: 60
    steps:
      - uses: actions/checkout@v5

      # llvm-tools-preview is REQUIRED: without it `cargo fuzz coverage` runs the corpus fine and then
      # dies at "Merging raw coverage data" because llvm-profdata is absent from the nightly sysroot.
      - uses: dtolnay/rust-toolchain@nightly
        with:
          components: rust-src, llvm-tools-preview

      - uses: Swatinem/rust-cache@v2
        with:
          workspaces: fuzz

      - uses: taiki-e/install-action@v2
        with:
          tool: cargo-fuzz

      # The merge job saved the canonical corpus under this run's id, so the prefix match pulls it.
      - name: Restore merged corpus
        uses: actions/cache/restore@v4
        with:
          path: fuzz/corpus/parse
          key: fuzz-corpus-parse-
          restore-keys: fuzz-corpus-parse-

      # A separate build: coverage instrumentation differs from the sanitizer/fuzzing build, so this
      # one cannot reuse the shared binary.
      - name: Generate coverage data
        run: cargo +nightly fuzz coverage parse --target "$TRIPLE"

      # Don't hardcode cargo-fuzz's layout — it differs with/without --target and has moved between
      # releases. The `coverage` build lands under the WORKSPACE-ROOT `target/`, NOT `fuzz/target/`,
      # in a nested `<triple>/coverage/<triple>/release/` path (verified on this runner). Search both
      # roots for the coverage build, then PROBE each candidate and keep the first llvm-cov accepts —
      # the probe, not the path, is the real check (guards against a stale non-instrumented binary).
      - name: Render report
        run: |
          set -euo pipefail
          LLVM_COV="$(rustc +nightly --print sysroot)/lib/rustlib/$TRIPLE/bin/llvm-cov"
          PROF=fuzz/coverage/parse/coverage.profdata
          BIN=""
          for cand in $(find target fuzz/target -path '*coverage*release*' -name parse -type f 2>/dev/null); do
            if "$LLVM_COV" report "$cand" -instr-profile="$PROF" >/dev/null 2>&1; then
              BIN="$cand"; break
            fi
          done
          if [ -z "$BIN" ]; then
            echo "::error::no INSTRUMENTED parse binary matched $PROF (cargo-fuzz layout changed?)"
            find target fuzz/target -name parse -type f 2>/dev/null || true
            exit 1
          fi
          echo "llvm-cov: $LLVM_COV"
          echo "instrumented binary: $BIN"
          # Report safe-chains' OWN authored source only — not deps, std, the fuzz shim, or
          # build-script-GENERATED files (build/*/out/*, e.g. the compiled command registry).
          IGNORE='(/\.cargo/|/rustc/|/fuzz/|/out/)'
          {
            echo '## Fuzz coverage (`parse` target)'
            echo
            echo "Corpus: $(find fuzz/corpus/parse -type f | wc -l) inputs"
            echo
            echo '```'
            "$LLVM_COV" report "$BIN" -instr-profile="$PROF" -ignore-filename-regex="$IGNORE"
            echo '```'
          } >> "$GITHUB_STEP_SUMMARY"
          "$LLVM_COV" show "$BIN" -instr-profile="$PROF" -ignore-filename-regex="$IGNORE" \
            -format=html -output-dir=coverage-html

      - name: Upload HTML coverage
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: fuzz-coverage-html
          path: coverage-html
          if-no-files-found: warn