safe-chains 0.230.4

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

# The exploring half of the fuzzing program; `fuzz-replay.yml` is the gate. Every push to `main`
# gives every target a short mutation burst, folds what it found into that target's corpus, and
# saves the corpus for the replay to read. Never a gate: nothing depends on this workflow, so a
# random find turns this run red rather than the push.
#
# This replaced a nightly (05:00 UTC, three 5h shards on `parse` plus 1h on each property target),
# retired 2026-09-26. Every real find came in a target's first days, most on the first local run;
# the nightly then ran seven weeks without another, and its later red nights were job timeouts
# (`config_load` overrunning `timeout-minutes`), not findings. A burst per push follows the code:
# new code is fuzzed the day it lands, which is when fuzzing finds things.
#
# Deeper run: `workflow_dispatch` with a larger `max_total_time` after a big change to a fuzzed
# surface, which also renders the `parse` coverage report.
on:
  push:
    branches: [main]
    # Nothing fuzzed reads Markdown or the mdbook sources (no include_str! of either, no .md
    # corpus inputs), so a docs-only push has no new code to explore.
    paths-ignore: ["**.md", "docs/**"]
  workflow_dispatch:
    inputs:
      max_total_time:
        description: "Mutation seconds per target (default 180). Raise for a deliberate deeper run; keep it under ~5h so the job completes before GitHub's 6h cap."
        default: "180"

# Queue rather than cancel: a cancelled job swallows its "Fail on crashes" step, so a real find
# would vanish behind a superseding push.
concurrency:
  group: fuzz-burst
  cancel-in-progress: false

permissions:
  contents: read

env:
  CARGO_TERM_COLOR: always
  TRIPLE: x86_64-unknown-linux-gnu
  BUDGET: ${{ github.event.inputs.max_total_time || '180' }}

jobs:
  # `cargo fuzz run` and every `-merge=1` would each rebuild, so build every target ONCE here and
  # hand the binaries on. A cargo-fuzz output is a standalone libFuzzer executable; the burst jobs
  # exec it directly with the flags cargo-fuzz would have passed.
  build:
    name: Build fuzz targets
    runs-on: ubuntu-latest
    timeout-minutes: 30
    steps:
      - uses: actions/checkout@v7

      # 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").
      # No target name: builds every [[bin]] in fuzz/Cargo.toml, so this list cannot drift.
      - name: Build
        run: |
          set -euo pipefail
          cargo +nightly fuzz build --target "$TRIPLE"
          mkdir -p bin
          for t in $(cargo +nightly fuzz list); do
            cp "fuzz/target/$TRIPLE/release/$t" bin/
          done
          ls -l bin

      - name: Upload fuzz binaries
        uses: actions/upload-artifact@v7
        with:
          name: fuzz-binaries
          path: bin
          if-no-files-found: error

      # Registry-derived seeds + dictionary for `parse`. 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 burst's merge folds
      # the seeds into the saved corpus, so the replay gate holds them too.
      - name: Generate seeds + dictionary
        run: |
          cargo +nightly 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@v7
        with:
          name: fuzz-seeds
          path: |
            fuzz/dict/parse.dict
            fuzz/corpus/parse
          if-no-files-found: error

  burst:
    name: Burst (${{ matrix.target }})
    needs: build
    runs-on: ubuntu-latest
    # Budget + corpus load + merge. The dispatch input can raise the budget; keep it under 6h, or
    # GitHub cancels the job and the cancellation hides the crash check.
    timeout-minutes: 350
    strategy:
      fail-fast: false # one target finding a crash must not cancel the others
      matrix:
        # EVERY target, kept in step with fuzz/Cargo.toml and fuzz-replay.yml by
        # `tests/fuzz_targets_wired.rs`.
        target: [parse, equivalence, hook_envelope, explain_render, suggest_roundtrip, level_monotonic, config_load, setup_merge, path_admit, gate_prefilter]
    steps:
      - uses: actions/checkout@v7

      - name: Download fuzz binaries
        uses: actions/download-artifact@v8
        with:
          name: fuzz-binaries
          path: bin

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

      # The corpus the previous burst saved. The key prefix is the one fuzz-replay.yml restores.
      - name: Restore corpus
        uses: actions/cache/restore@v6
        with:
          path: fuzz/corpus/${{ matrix.target }}
          key: fuzz-corpus-${{ matrix.target }}-
          restore-keys: fuzz-corpus-${{ matrix.target }}-

      # Downloading into fuzz/ unions the gen-* seeds with the restored corpus and adds the dict.
      - name: Download seeds + dictionary
        if: matrix.target == 'parse'
        uses: actions/download-artifact@v8
        with:
          name: fuzz-seeds
          path: fuzz

      # fuzz/burst.sh: one process, not fork mode (fork mode reloads the whole corpus in every child;
      # a nominal 60s fork-mode burst measured ~275s against a 12.6k corpus). The budget clock starts
      # once the corpus has loaded, since libFuzzer's -max_total_time counts the load and a large
      # corpus on a runner can take longer to load than the whole budget. New units go to
      # fuzz/new/<target>, then `-merge=1` folds [restored corpus + seeds, new] into a minimized
      # union. The merge runs after a crash too; a successful one sets `merged=true`. The script
      # picks up fuzz/dict/<target>.dict itself, so `parse` gets the dictionary downloaded above.
      #
      # `continue-on-error` so the save and upload still run after a find; "Fail on crashes" turns
      # the job red.
      - name: Fuzz and merge
        id: fuzz
        continue-on-error: true
        run: bash fuzz/burst.sh "./bin/${{ matrix.target }}" "${{ matrix.target }}" "$BUDGET"

      # Unique (always-miss) key, so the next restore's prefix match pulls the newest. Only a
      # completed merge is saved, so a half-merged corpus never becomes canonical.
      - name: Save corpus
        if: ${{ !cancelled() && steps.fuzz.outputs.merged == 'true' }}
        uses: actions/cache/save@v6
        with:
          path: fuzz/corpus/${{ matrix.target }}
          key: fuzz-corpus-${{ matrix.target }}-${{ github.run_id }}-${{ github.run_attempt }}

      - name: Upload findings
        if: always()
        uses: actions/upload-artifact@v7
        with:
          name: fuzz-findings-${{ matrix.target }}
          path: fuzz/artifacts/
          if-no-files-found: ignore

      # The Fuzz and merge step is continue-on-error, so surface findings explicitly.
      # crash/timeout/oom are fatal; slow-unit is uploaded above for triage but is contention-sensitive, so not fatal.
      # A burst that failed with NO artifact (missing binary, leak report, bad flag) is red too,
      # or continue-on-error would turn a broken burst green.
      - name: Fail on crashes
        if: always()
        env:
          FUZZ_OUTCOME: ${{ steps.fuzz.outcome }}
        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/oom artifacts (see fuzz-findings-${{ matrix.target }})"
            printf '%s\n' "$hits"
            exit 1
          fi
          if [ "$FUZZ_OUTCOME" != success ]; then
            echo "::error::the Fuzz and merge step ended '$FUZZ_OUTCOME' without a crash/timeout/oom artifact; read its log"
            exit 1
          fi
          echo "no crash/timeout/oom artifacts"

  # What the crash/timeout signal CANNOT tell you: which parts of the classifier the corpus never
  # reaches. A green burst 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.
  #
  # Dispatch only: its own instrumented build plus a full replay would triple a per-push burst, and
  # coverage moves slowly. Run it with the deeper dispatch run, after a large change.
  coverage:
    name: Coverage report
    needs: burst
    if: ${{ !cancelled() && github.event_name == 'workflow_dispatch' }}
    runs-on: ubuntu-latest
    timeout-minutes: 60
    steps:
      - uses: actions/checkout@v7

      # 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 parse burst saved the merged corpus under this run's id, so the prefix match pulls it.
      - name: Restore merged corpus
        uses: actions/cache/restore@v6
        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@v7
        with:
          name: fuzz-coverage-html
          path: coverage-html
          if-no-files-found: warn