diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index ef94181b..9980ed57 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "adept", - "version": "4.7.0", + "version": "4.7.1", "description": "Development-workflow skills: design, TDD, adversarial review, shipping, and campaign orchestration for Claude Code and Codex.", "author": { "name": "David Christensen" diff --git a/scripts/check-public-safety-test.sh b/scripts/check-public-safety-test.sh index 42fe8cec..4e1c0d2d 100755 --- a/scripts/check-public-safety-test.sh +++ b/scripts/check-public-safety-test.sh @@ -70,6 +70,18 @@ if "$CHECKER" "$SCRATCH/repo" >"$SCRATCH/output" 2>&1; then fi rm -f "$SCRATCH/repo/linux.txt" +# Windows profile paths can appear in a publication composed on any host. +for private_path in 'C:\Us''ers\example-user\project' 'd:/us''ers/example-user/project'; do + printf '%s\n' "$private_path" >"$SCRATCH/repo/windows.txt" + status=0 + "$CHECKER" "$SCRATCH/repo/windows.txt" >"$SCRATCH/output" 2>&1 || status=$? + if [ "$status" -ne 1 ]; then + printf 'public-safety-test: Windows profile leak must return 1, got %s\n' "$status" >&2 + exit 1 + fi +done +rm -f "$SCRATCH/repo/windows.txt" + # /home/runner and /home/linuxbrew are published constants of GitHub's runner # images, not anyone's home directory. The ubuntu image documents the shellenv # line below as the way to reach Homebrew, so a workflow that carries it must diff --git a/scripts/check-public-safety.sh b/scripts/check-public-safety.sh index 7a41aacc..2f5e01ef 100755 --- a/scripts/check-public-safety.sh +++ b/scripts/check-public-safety.sh @@ -2,370 +2,8 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" - -# ripgrep applies the contents of RIPGREP_CONFIG_PATH as arguments ahead of the -# ones below, so without this every flag this scan does not set is chosen by -# whoever set that variable -- a personal ripgreprc, a shell profile, a CI -# environment. --fixed-strings alone turns these patterns into literals that -# match nothing and this gate exits 0 on a leaking tree. Unsetting once covers -# every ripgrep this file runs, including one added later. -unset RIPGREP_CONFIG_PATH - -if ! command -v rg >/dev/null 2>&1; then - echo "public-safety: rg is required" >&2 - exit 2 +# Keep the repository gate's default scope while sharing the skill's scanner. +if [ "$#" -eq 0 ]; then + set -- "$ROOT" fi -if ! command -v jq >/dev/null 2>&1; then - echo "public-safety: jq is required" >&2 - exit 2 -fi - -if (($# > 0)); then - scan_paths=("$@") -else - scan_paths=("$ROOT") -fi - -denied_patterns=( - '/Users/[[:alnum:]_][[:alnum:]_.-]+' - '/home/[[:alnum:]_][[:alnum:]_.-]+' - '/Volumes/[[:alnum:]_][^`)]*' - 'pdx\.drc' - 'ts\.drc' - '192\.168\.' - '(^|[^[:alnum:]])10\.[0-9]{1,3}\.' - '172\.(1[6-9]|2[0-9]|3[0-1])\.' - '[[:alnum:]-]+\.atlassian\.net' - 'Basic[[:space:]]+[A-Za-z0-9+/=]{12,}' - 'ATATT[A-Za-z0-9_=.-]{20,}' - "ATLASSIAN[A-Z0-9_]*['\"]?[[:space:]]*[=:][[:space:]]*['\"]?[A-Za-z0-9+/]{20,}" - 'gh[pousr]_[A-Za-z0-9_]{20,}' - 'sk-[A-Za-z0-9]{20,}' - 'AKIA[0-9A-Z]{16}' - 'xox[baprs]-[A-Za-z0-9-]{20,}' -) - -# ripgrep applies .gitignore, .ignore and .rgignore while walking, and it applies -# them to tracked files too. `git add -f` on an ignored path produces a file that -# is in the index -- it ships to everyone who clones this public repo -- and that -# the walk below never opens, so the gate prints nothing and exits 0. That is a -# false green on exactly the content this gate exists to catch. -# -# --no-ignore would close it by disabling ignore handling wholesale, but it also -# pulls in ignored files that are *untracked* and therefore never ship — the -# agent scratch trees (`.agent/`, `CLAUDE.local.md`) whose whole purpose is to -# hold host-specific identity out of the tracked tree. Turning those red would -# fail every local `just verify` and every commit through the pre-commit hook. -# -# Naming the tracked files instead scans what ships and nothing else: ripgrep -# searches a path given explicitly on the command line whatever the ignore rules -# say, so this covers every ignore mechanism rather than .gitignore alone. On a -# tree with nothing hidden it is the same set the walk already covers. -# -# What ships is the union of two sets, and the gate enumerates both. `git -# ls-files` reports the *index*, which is a local cache: removing it is the -# documented recovery for a stuck index.lock and the residue of an interrupted -# operation, and with it gone that listing exits 0 with no output while HEAD -# still carries every tracked-and-ignored path. The enumeration then degrades -# silently to the ignore-respecting walk it was added to supplement and the gate -# greens over a `git add -f`'d secret that ships to everyone who clones. `git -# ls-tree -r HEAD` reports the commit and answers that case directly. -# -# Neither set replaces the other. A path staged but not yet committed -- `git -# add -f secret` in the tree the pre-commit hook is about to gate -- is in the -# index and not in HEAD, so dropping the index half would open a hole at the one -# moment this gate runs on every commit, and a repository before its first -# commit has an index and no HEAD at all. Scanning both and letting the finding -# de-duplication below absorb the overlap is the only reading that covers each -# (issue #150). The overlap is the ordinary case, so this roughly doubles the -# path count handed to ripgrep. The cost is a longer argument list, and an -# argument list that will not fit reaches the scan below as a status that is -# neither 0 nor 1 -- a fault that stops the run, never a quiet under-scan. -# -# HEAD is resolved before it is listed rather than after. `git ls-tree HEAD` in -# a repository with no commits is a fatal at exit 128, which is the status a -# repository git could not read returns too, and its diagnostic would reach -# every caller working in a tree before its first commit. `git rev-parse -# --verify --quiet HEAD` separates them without printing anything in the -# legitimate case: 0 names a commit, 1 is an unborn HEAD and nothing is -# committed yet, anything else is a fault. -# -# Both listings name paths, not content: a tracked file deleted from the -# worktree and not yet staged is still in the index, and a committed file whose -# deletion is staged is still in HEAD. Either names a path with nothing behind -# it. There is no content to scan there, and the status check below would -# otherwise turn every such tree into a fault, so those paths are dropped before -# the scan rather than after. -# -# The test is -f, not -e: the walk only ever opened regular files, and naming a -# path explicitly makes ripgrep open whatever is there. A tracked path replaced -# by a FIFO blocks the scan forever with no writer, which is a burned CI timeout -# rather than a wrong answer, but the walk-only shape never had it. -f drops a -# dangling symlink and a directory substitution in the same breath. -# -# The listing is captured to a file rather than read from a process -# substitution. `while ... done < <(git ls-files -z)` reports the loop's status -# and never git's, so a listing that emitted some paths and then died left this -# gate scanning a short set and reporting a pass -- and the `2>/dev/null` that -# call carried removed the one thing on stderr that would have said why. Both -# halves are what ADR 0005 rules out; list-shell-sources.sh captures its own -# walk for the same reason. -# -# Any non-zero status is a fault. `git ls-files` exits 128 both for a directory -# that is no repository and for a repository it could not read, so no branch -# can tell them apart, and stopping is the only reading that cannot go green -# over content it never listed. That makes "a directory handed to this gate is -# inside a git worktree" part of the contract, which is what the gate is for: -# the tracked set is the set that ships. The same rule governs the HEAD half, -# with the one exception the rev-parse probe above carves out by name. -# -# `-C` chooses a directory to run in; it does not choose a repository. Git's -# repository-local environment selectors -- the set `git rev-parse -# --local-env-vars` names, GIT_DIR and GIT_WORK_TREE and GIT_INDEX_FILE among -# them -- are read ahead of directory discovery, so with any of them exported -# this listing answers about the *ambient* repository at exit 0 and names paths -# that are not under the scan path. The `-f` filter below then drops every one, -# scan_targets collapses to the walk alone, and the gate reports a pass over -# exactly the tracked-and-ignored content the enumeration was added to catch. -# Reproduced on macOS with git 2.50.1: GIT_DIR alone and GIT_INDEX_FILE alone -# each suffice, on a force-added ignored token the clean-environment run -# reports (issue #147). Clearing the set is the same fix at the same call as -# clear_git_env in scripts/test-fixture-helpers.sh and the block in -# scripts/verify-push.sh, which says why it is there: "Hooks export selectors -# for their source worktree." The copy is deliberate; a production gate -# sourcing a file named test-fixture-helpers.sh is worse layering than the -# duplication it removes, which is the call that file already records for -# cleanup. -# -# Clearing GIT_INDEX_FILE decides a question the other two do not raise, so it -# is pinned here rather than inherited: the gate enumerates the repository's own -# index, never the caller's. Under `git commit --only ` a hook's -# GIT_INDEX_FILE names a temporary index built from HEAD plus the named paths, -# and `--only` refuses a path git does not already know, so that index can name -# nothing the repository's own index and HEAD do not already carry between them -# -- honouring it can only narrow the scan, which is the wrong direction for a -# gate whose subject is content someone may be trying to get past it. The -# scanned bytes come from the worktree either way; the index is consulted only -# for which paths ship. And a gate whose verdict depends on who invoked it is -# the defect class this clearing closes, so the same tree has to answer the -# same under `just verify`, under prek, and in CI. -# -# A scan path that is not a directory is not asked. A regular file -- the shape -# skills/quest/scripts/publish-forge-review passes -- already names itself on -# the command line, so ripgrep opens it whatever the ignore rules say and there -# is nothing to enumerate; `git -C` on it could only ever fail. A path that is -# not there at all reaches ripgrep, which faults on it below. -# -# An empty enumeration under a zero status is a legitimate answer, not a fault. -# Unlike `git rev-parse --local-env-vars`, which always names at least GIT_DIR, -# this one has a real empty case: a checkout subdirectory with nothing tracked -# or committed under it, and a repository before its first `git add`. The walk -# still covers the tree in both. There is no count floor to fall back on either -# -- the gate takes arbitrary scan paths, so unlike list-shell-sources.sh, which -# runs only at this repository's root and can therefore treat an empty subset -# as broken discovery, this site has no scope over which a floor would hold. -# -# That reading is only safe because both sources are asked. A single-source -# empty answer could mean the source disagreed with what ships rather than that -# nothing is there, and neither disagreement is an ADR 0005 shape any status -# check reaches: git ran and answered truthfully both times. Asking the index -# and HEAD together is what makes empty mean empty here. - -# The scratch file is created at the first directory scan path rather than up -# front, for the reason the git guard below gives: a regular-file scan needs no -# listing, so a temp directory it never writes to must not be able to fail it. -# The distinction is invisible here -- bare mktemp on macOS resolves through the -# per-user temp directory and ignores TMPDIR -- and live on Linux, where mktemp -# honours TMPDIR and fails when it is missing or read-only. That is the CI leg. -tracked_listing='' -# Modelled on check-skill-shape.sh's cleanup, and for its reason. Under the -# `set -e` above, an EXIT trap's non-zero return becomes the shell's exit -# status, and this gate reports 0 clean, 1 a finding, anything else a fault -- -# so a trap that just returned rm's status would turn a failed removal into a -# phantom finding: exit 1, nothing printed, on the gate whose findings name a -# file, a line and a pattern. Capture the run's status first; let a cleanup -# failure take the fault status only when the run was otherwise clean, so a -# real finding keeps its own. Issue #77 tracks this shape at two other gates. -# -# -f so a file already gone is not reported as left behind. An empty -# tracked_listing means no directory scan path ever needed one. -# -# shellcheck disable=SC2329 # run by the EXIT trap, not called directly -cleanup() { - local exit_status=$? - if [ -n "$tracked_listing" ] && ! rm -f -- "$tracked_listing"; then - printf 'public-safety: retained scratch path: %s\n' "$tracked_listing" >&2 - if [ "$exit_status" -eq 0 ]; then - exit 2 - fi - fi - exit "$exit_status" -} -trap cleanup EXIT - -# Captured rather than read from a process substitution, which reports the -# loop's status and never rev-parse's: a rev-parse that could not answer leaves -# the loop reading nothing, nothing unset, and the enumeration below still -# addressing the ambient repository -- a scan that could not run read as one -# that found nothing (ADR 0005). -# -# Empty output is the same failure wearing a zero exit status: git has always -# named at least GIT_DIR here, so nothing to clear means the answer did not -# arrive rather than that there was nothing to do. -clear_local_git_env() { - local variable variables - variables=$(git rev-parse --local-env-vars) || { - echo "public-safety: cannot read git local env vars" >&2 - exit 2 - } - [ -n "$variables" ] || { - echo "public-safety: git reported no local env vars" >&2 - exit 2 - } - while IFS= read -r variable; do - [ -n "$variable" ] || continue - unset "$variable" - done <<<"$variables" -} - -git_env_cleared=0 -scan_targets=("${scan_paths[@]}") -for scan_path in "${scan_paths[@]}"; do - [[ -d "$scan_path" ]] || continue - # git is required to enumerate a directory, and only a directory. The test - # sits here rather than beside the rg and jq preflights because a - # regular-file target is never enumerated and genuinely does not need git: - # skills/quest/scripts/publish-forge-review scans one, discards stderr and - # reports every non-zero status as a leaking body, so an unconditional - # preflight would tell that operator their publication leaked content when - # the real answer is a host missing a tool that scan never used. - if ! command -v git >/dev/null 2>&1; then - echo "public-safety: git is required to scan a directory" >&2 - exit 2 - fi - # Cleared here rather than at the top of the file, and once rather than per - # path, for the reason the git preflight above gives: reading the variable - # list needs git, and a regular-file scan target never runs git at all, so a - # host without it must still scan a file. - if [ "$git_env_cleared" -eq 0 ]; then - clear_local_git_env - git_env_cleared=1 - fi - if [ -z "$tracked_listing" ]; then - tracked_listing=$(mktemp) || { - echo "public-safety: could not create a scratch file for the tracked listing" >&2 - exit 2 - } - fi - listing_status=0 - git -C "$scan_path" ls-files -z >"$tracked_listing" || listing_status=$? - if [ "$listing_status" -ne 0 ]; then - printf 'public-safety: could not list the tracked files under %s (git ls-files exit %s)\n' \ - "$scan_path" "$listing_status" >&2 - exit 2 - fi - while IFS= read -r -d '' tracked; do - [[ -f "$scan_path/$tracked" ]] || continue - scan_targets+=("$scan_path/$tracked") - done <"$tracked_listing" - # The scratch file is reused rather than doubled: the index listing above has - # been read to its end by the time the commit listing overwrites it. - head_status=0 - git -C "$scan_path" rev-parse --verify --quiet HEAD >/dev/null || head_status=$? - case $head_status in - 0) - committed_status=0 - git -C "$scan_path" ls-tree -r -z --name-only HEAD >"$tracked_listing" || - committed_status=$? - if [ "$committed_status" -ne 0 ]; then - printf 'public-safety: could not list the committed files under %s (git ls-tree exit %s)\n' \ - "$scan_path" "$committed_status" >&2 - exit 2 - fi - while IFS= read -r -d '' committed; do - [[ -f "$scan_path/$committed" ]] || continue - scan_targets+=("$scan_path/$committed") - done <"$tracked_listing" - ;; - # An unborn HEAD: nothing is committed, so the index half is the whole - # answer, and it already covers a `git add -f` made before the first commit. - 1) ;; - *) - printf 'public-safety: could not resolve HEAD under %s (git rev-parse exit %s)\n' \ - "$scan_path" "$head_status" >&2 - exit 2 - ;; - esac -done - -# Two names under /home are not people. GitHub's runner images publish -# /home/runner as the workspace root and install Homebrew to /home/linuxbrew, -# and the ubuntu image's own documentation tells you to run -# `eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)"` because Homebrew is -# not on PATH there. A workflow or design doc that quotes either published -# constant is not leaking anyone's home directory, and a gate that reddens on it -# teaches people to route around the gate. -# -# The exception applies only to exact submatches. A line that names -# /home/runner and a real home directory or a token still carries a non-exempt -# submatch and is reported, while a person named `runnerbee` is never exempt. -public_home_match='^/home/(runner|linuxbrew)$' - -status=0 -# --text: ripgrep judges a file binary on one NUL byte and skips it while -# walking a directory, so a single NUL anywhere in a file hides every secret in -# it. --encoding none: a leading \xFF\xFE makes ripgrep transcode the file as -# UTF-16, garbling ASCII so these ASCII patterns cannot match. Both are one line -# of file content to trigger, and this gate's subject is content someone may be -# trying to get past it. The trade in --encoding none -- a file genuinely stored -# as UTF-16 stops being scanned -- is accepted: no tracked file here is one, and -# a documented five-byte bypass is the worse half. -# -# ripgrep exits 2 for a scan it could not complete -- an unreadable file, a path -# it was handed that it cannot open, an argument list too long for exec -- and it -# does so even when it also found matches. Read as a boolean, that is "no match": -# the gate would print a secret to stdout and still exit 0. A bare `if` here is -# what made a plain `rm` of any tracked file turn this gate green. Every gate in -# this repo branches on the status for the same reason: 0 is a finding, 1 is -# clean, anything else is a fault that stops the run. -for pattern in "${denied_patterns[@]}"; do - rg_status=0 - matches=$(rg --json --hidden --text --encoding none \ - --glob '!.git' --glob '!.git/**' "$pattern" "${scan_targets[@]}") || - rg_status=$? - case $rg_status in - 0) - # The walk and the explicit paths overlap on every tracked file no - # ignore rule hides, so each match arrives twice. Structured records keep - # arbitrary filename bytes out of the content decision and preserve the - # exact path, line and content in the finding. - filter_status=0 - matches=$(printf '%s\n' "$matches" | - jq -c --arg allowed "$public_home_match" ' - select(.type == "match") - | select(any(.data.submatches[]; - ((.match.text // (.match.bytes | @base64d)) - | test($allowed) | not))) - | .data - | {path, line_number, lines}' | - awk '!seen[$0]++') || filter_status=$? - if [ "$filter_status" -ne 0 ]; then - printf 'public-safety: jq could not filter ripgrep output (exit %s)\n' \ - "$filter_status" >&2 - exit 2 - fi - [ -n "$matches" ] || continue - printf '%s\n' "$matches" - printf 'public-safety: denied pattern matched: %s\n' "$pattern" >&2 - status=1 - ;; - 1) ;; - *) - printf 'public-safety: ripgrep could not complete the scan (exit %s); pattern: %s\n' \ - "$rg_status" "$pattern" >&2 - exit 2 - ;; - esac -done - -exit "$status" +exec "$ROOT/skills/quest/scripts/check-public-safety" "$@" diff --git a/skills/quest/SKILL.md b/skills/quest/SKILL.md index 110457bc..d1d62356 100644 --- a/skills/quest/SKILL.md +++ b/skills/quest/SKILL.md @@ -810,6 +810,17 @@ specifies into a `mktemp` file beside the ledger — no headings; each destinati its own — atomically rename it only after the write, reject carriage return, NUL, and outer annotation markers, and keep the temporary and installed payload in mode 0600. A run with nothing to carry creates no payload file and skips every payload step below. + +The publication helper and its `scripts/check-public-safety` scanner ship together in this +skill. Resolve both from the installed skill directory; never search the target repository for +Adept maintenance scripts. The scanner requires `rg` and `jq`; the helper checks these alongside +its other commands before composing content. Missing commands name an installation/PATH remedy. +A missing bundled scanner requires repairing the installed skill, not substituting a repository gate. +The scanner checks generic private paths (including Windows profile paths), private addresses, +and credential patterns. It is a backstop to the public-safety review, not exhaustive PII detection. +Unsafe content and scan failures both stop publication and retain evidence, with distinct messages +that do not echo matched content. + Before any PR-body write, invoke the helper in validation-only mode with the exact publication arguments: diff --git a/skills/quest/scripts/check-public-safety b/skills/quest/scripts/check-public-safety new file mode 100755 index 00000000..0f9efb07 --- /dev/null +++ b/skills/quest/scripts/check-public-safety @@ -0,0 +1,362 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ripgrep applies the contents of RIPGREP_CONFIG_PATH as arguments ahead of the +# ones below, so without this every flag this scan does not set is chosen by +# whoever set that variable -- a personal ripgreprc, a shell profile, a CI +# environment. --fixed-strings alone turns these patterns into literals that +# match nothing and this gate exits 0 on a leaking tree. Unsetting once covers +# every ripgrep this file runs, including one added later. +unset RIPGREP_CONFIG_PATH + +if ! command -v rg >/dev/null 2>&1; then + echo "public-safety: rg is required" >&2 + exit 2 +fi +if ! command -v jq >/dev/null 2>&1; then + echo "public-safety: jq is required" >&2 + exit 2 +fi + +if (($# > 0)); then + scan_paths=("$@") +else + scan_paths=(.) +fi + +denied_patterns=( + '/Users/[[:alnum:]_][[:alnum:]_.-]+' + '/home/[[:alnum:]_][[:alnum:]_.-]+' + '/Volumes/[[:alnum:]_][^`)]*' + '(?i)[a-z]:[\\/]Users[\\/][[:alnum:]_][[:alnum:]_.-]+' + '192\.168\.' + '(^|[^[:alnum:]])10\.[0-9]{1,3}\.' + '172\.(1[6-9]|2[0-9]|3[0-1])\.' + '[[:alnum:]-]+\.atlassian\.net' + 'Basic[[:space:]]+[A-Za-z0-9+/=]{12,}' + 'ATATT[A-Za-z0-9_=.-]{20,}' + "ATLASSIAN[A-Z0-9_]*['\"]?[[:space:]]*[=:][[:space:]]*['\"]?[A-Za-z0-9+/]{20,}" + 'gh[pousr]_[A-Za-z0-9_]{20,}' + 'sk-[A-Za-z0-9]{20,}' + 'AKIA[0-9A-Z]{16}' + 'xox[baprs]-[A-Za-z0-9-]{20,}' +) + +# ripgrep applies .gitignore, .ignore and .rgignore while walking, and it applies +# them to tracked files too. `git add -f` on an ignored path produces a file that +# is in the index -- it ships to everyone who clones this public repo -- and that +# the walk below never opens, so the gate prints nothing and exits 0. That is a +# false green on exactly the content this gate exists to catch. +# +# --no-ignore would close it by disabling ignore handling wholesale, but it also +# pulls in ignored files that are *untracked* and therefore never ship — the +# agent scratch trees (`.agent/`, `CLAUDE.local.md`) whose whole purpose is to +# hold host-specific identity out of the tracked tree. Turning those red would +# fail every local `just verify` and every commit through the pre-commit hook. +# +# Naming the tracked files instead scans what ships and nothing else: ripgrep +# searches a path given explicitly on the command line whatever the ignore rules +# say, so this covers every ignore mechanism rather than .gitignore alone. On a +# tree with nothing hidden it is the same set the walk already covers. +# +# What ships is the union of two sets, and the gate enumerates both. `git +# ls-files` reports the *index*, which is a local cache: removing it is the +# documented recovery for a stuck index.lock and the residue of an interrupted +# operation, and with it gone that listing exits 0 with no output while HEAD +# still carries every tracked-and-ignored path. The enumeration then degrades +# silently to the ignore-respecting walk it was added to supplement and the gate +# greens over a `git add -f`'d secret that ships to everyone who clones. `git +# ls-tree -r HEAD` reports the commit and answers that case directly. +# +# Neither set replaces the other. A path staged but not yet committed -- `git +# add -f secret` in the tree the pre-commit hook is about to gate -- is in the +# index and not in HEAD, so dropping the index half would open a hole at the one +# moment this gate runs on every commit, and a repository before its first +# commit has an index and no HEAD at all. Scanning both and letting the finding +# de-duplication below absorb the overlap is the only reading that covers each +# (issue #150). The overlap is the ordinary case, so this roughly doubles the +# path count handed to ripgrep. The cost is a longer argument list, and an +# argument list that will not fit reaches the scan below as a status that is +# neither 0 nor 1 -- a fault that stops the run, never a quiet under-scan. +# +# HEAD is resolved before it is listed rather than after. `git ls-tree HEAD` in +# a repository with no commits is a fatal at exit 128, which is the status a +# repository git could not read returns too, and its diagnostic would reach +# every caller working in a tree before its first commit. `git rev-parse +# --verify --quiet HEAD` separates them without printing anything in the +# legitimate case: 0 names a commit, 1 is an unborn HEAD and nothing is +# committed yet, anything else is a fault. +# +# Both listings name paths, not content: a tracked file deleted from the +# worktree and not yet staged is still in the index, and a committed file whose +# deletion is staged is still in HEAD. Either names a path with nothing behind +# it. There is no content to scan there, and the status check below would +# otherwise turn every such tree into a fault, so those paths are dropped before +# the scan rather than after. +# +# The test is -f, not -e: the walk only ever opened regular files, and naming a +# path explicitly makes ripgrep open whatever is there. A tracked path replaced +# by a FIFO blocks the scan forever with no writer, which is a burned CI timeout +# rather than a wrong answer, but the walk-only shape never had it. -f drops a +# dangling symlink and a directory substitution in the same breath. +# +# The listing is captured to a file rather than read from a process +# substitution. `while ... done < <(git ls-files -z)` reports the loop's status +# and never git's, so a listing that emitted some paths and then died left this +# gate scanning a short set and reporting a pass -- and the `2>/dev/null` that +# call carried removed the one thing on stderr that would have said why. Both +# halves are what ADR 0005 rules out; list-shell-sources.sh captures its own +# walk for the same reason. +# +# Any non-zero status is a fault. `git ls-files` exits 128 both for a directory +# that is no repository and for a repository it could not read, so no branch +# can tell them apart, and stopping is the only reading that cannot go green +# over content it never listed. That makes "a directory handed to this gate is +# inside a git worktree" part of the contract, which is what the gate is for: +# the tracked set is the set that ships. The same rule governs the HEAD half, +# with the one exception the rev-parse probe above carves out by name. +# +# `-C` chooses a directory to run in; it does not choose a repository. Git's +# repository-local environment selectors -- the set `git rev-parse +# --local-env-vars` names, GIT_DIR and GIT_WORK_TREE and GIT_INDEX_FILE among +# them -- are read ahead of directory discovery, so with any of them exported +# this listing answers about the *ambient* repository at exit 0 and names paths +# that are not under the scan path. The `-f` filter below then drops every one, +# scan_targets collapses to the walk alone, and the gate reports a pass over +# exactly the tracked-and-ignored content the enumeration was added to catch. +# Reproduced on macOS with git 2.50.1: GIT_DIR alone and GIT_INDEX_FILE alone +# each suffice, on a force-added ignored token the clean-environment run +# reports (issue #147). Clearing the set is the same fix at the same call as +# clear_git_env in scripts/test-fixture-helpers.sh and the block in +# scripts/verify-push.sh, which says why it is there: "Hooks export selectors +# for their source worktree." The copy is deliberate; a production gate +# sourcing a file named test-fixture-helpers.sh is worse layering than the +# duplication it removes, which is the call that file already records for +# cleanup. +# +# Clearing GIT_INDEX_FILE decides a question the other two do not raise, so it +# is pinned here rather than inherited: the gate enumerates the repository's own +# index, never the caller's. Under `git commit --only ` a hook's +# GIT_INDEX_FILE names a temporary index built from HEAD plus the named paths, +# and `--only` refuses a path git does not already know, so that index can name +# nothing the repository's own index and HEAD do not already carry between them +# -- honouring it can only narrow the scan, which is the wrong direction for a +# gate whose subject is content someone may be trying to get past it. The +# scanned bytes come from the worktree either way; the index is consulted only +# for which paths ship. And a gate whose verdict depends on who invoked it is +# the defect class this clearing closes, so the same tree has to answer the +# same under `just verify`, under prek, and in CI. +# +# A scan path that is not a directory is not asked. A regular file -- the shape +# skills/quest/scripts/publish-forge-review passes -- already names itself on +# the command line, so ripgrep opens it whatever the ignore rules say and there +# is nothing to enumerate; `git -C` on it could only ever fail. A path that is +# not there at all reaches ripgrep, which faults on it below. +# +# An empty enumeration under a zero status is a legitimate answer, not a fault. +# Unlike `git rev-parse --local-env-vars`, which always names at least GIT_DIR, +# this one has a real empty case: a checkout subdirectory with nothing tracked +# or committed under it, and a repository before its first `git add`. The walk +# still covers the tree in both. There is no count floor to fall back on either +# -- the gate takes arbitrary scan paths, so unlike list-shell-sources.sh, which +# runs only at this repository's root and can therefore treat an empty subset +# as broken discovery, this site has no scope over which a floor would hold. +# +# That reading is only safe because both sources are asked. A single-source +# empty answer could mean the source disagreed with what ships rather than that +# nothing is there, and neither disagreement is an ADR 0005 shape any status +# check reaches: git ran and answered truthfully both times. Asking the index +# and HEAD together is what makes empty mean empty here. + +# The scratch file is created at the first directory scan path rather than up +# front, for the reason the git guard below gives: a regular-file scan needs no +# listing, so a temp directory it never writes to must not be able to fail it. +# The distinction is invisible here -- bare mktemp on macOS resolves through the +# per-user temp directory and ignores TMPDIR -- and live on Linux, where mktemp +# honours TMPDIR and fails when it is missing or read-only. That is the CI leg. +tracked_listing='' +# Modelled on check-skill-shape.sh's cleanup, and for its reason. Under the +# `set -e` above, an EXIT trap's non-zero return becomes the shell's exit +# status, and this gate reports 0 clean, 1 a finding, anything else a fault -- +# so a trap that just returned rm's status would turn a failed removal into a +# phantom finding: exit 1, nothing printed, on the gate whose findings name a +# file, a line and a pattern. Capture the run's status first; let a cleanup +# failure take the fault status only when the run was otherwise clean, so a +# real finding keeps its own. Issue #77 tracks this shape at two other gates. +# +# -f so a file already gone is not reported as left behind. An empty +# tracked_listing means no directory scan path ever needed one. +# +# shellcheck disable=SC2329 # run by the EXIT trap, not called directly +cleanup() { + local exit_status=$? + if [ -n "$tracked_listing" ] && ! rm -f -- "$tracked_listing"; then + printf 'public-safety: retained scratch path: %s\n' "$tracked_listing" >&2 + if [ "$exit_status" -eq 0 ]; then + exit 2 + fi + fi + exit "$exit_status" +} +trap cleanup EXIT + +# Captured rather than read from a process substitution, which reports the +# loop's status and never rev-parse's: a rev-parse that could not answer leaves +# the loop reading nothing, nothing unset, and the enumeration below still +# addressing the ambient repository -- a scan that could not run read as one +# that found nothing (ADR 0005). +# +# Empty output is the same failure wearing a zero exit status: git has always +# named at least GIT_DIR here, so nothing to clear means the answer did not +# arrive rather than that there was nothing to do. +clear_local_git_env() { + local variable variables + variables=$(git rev-parse --local-env-vars) || { + echo "public-safety: cannot read git local env vars" >&2 + exit 2 + } + [ -n "$variables" ] || { + echo "public-safety: git reported no local env vars" >&2 + exit 2 + } + while IFS= read -r variable; do + [ -n "$variable" ] || continue + unset "$variable" + done <<<"$variables" +} + +git_env_cleared=0 +scan_targets=("${scan_paths[@]}") +for scan_path in "${scan_paths[@]}"; do + [[ -d "$scan_path" ]] || continue + # Regular-file scans need no Git metadata; only directories need enumeration. + if ! command -v git >/dev/null 2>&1; then + echo "public-safety: git is required to scan a directory" >&2 + exit 2 + fi + # Cleared here rather than at the top of the file, and once rather than per + # path, for the reason the git preflight above gives: reading the variable + # list needs git, and a regular-file scan target never runs git at all, so a + # host without it must still scan a file. + if [ "$git_env_cleared" -eq 0 ]; then + clear_local_git_env + git_env_cleared=1 + fi + if [ -z "$tracked_listing" ]; then + tracked_listing=$(mktemp) || { + echo "public-safety: could not create a scratch file for the tracked listing" >&2 + exit 2 + } + fi + listing_status=0 + git -C "$scan_path" ls-files -z >"$tracked_listing" || listing_status=$? + if [ "$listing_status" -ne 0 ]; then + printf 'public-safety: could not list the tracked files under %s (git ls-files exit %s)\n' \ + "$scan_path" "$listing_status" >&2 + exit 2 + fi + while IFS= read -r -d '' tracked; do + [[ -f "$scan_path/$tracked" ]] || continue + scan_targets+=("$scan_path/$tracked") + done <"$tracked_listing" + # The scratch file is reused rather than doubled: the index listing above has + # been read to its end by the time the commit listing overwrites it. + head_status=0 + git -C "$scan_path" rev-parse --verify --quiet HEAD >/dev/null || head_status=$? + case $head_status in + 0) + committed_status=0 + git -C "$scan_path" ls-tree -r -z --name-only HEAD >"$tracked_listing" || + committed_status=$? + if [ "$committed_status" -ne 0 ]; then + printf 'public-safety: could not list the committed files under %s (git ls-tree exit %s)\n' \ + "$scan_path" "$committed_status" >&2 + exit 2 + fi + while IFS= read -r -d '' committed; do + [[ -f "$scan_path/$committed" ]] || continue + scan_targets+=("$scan_path/$committed") + done <"$tracked_listing" + ;; + # An unborn HEAD: nothing is committed, so the index half is the whole + # answer, and it already covers a `git add -f` made before the first commit. + 1) ;; + *) + printf 'public-safety: could not resolve HEAD under %s (git rev-parse exit %s)\n' \ + "$scan_path" "$head_status" >&2 + exit 2 + ;; + esac +done + +# Two names under /home are not people. GitHub's runner images publish +# /home/runner as the workspace root and install Homebrew to /home/linuxbrew, +# and the ubuntu image's own documentation tells you to run +# `eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)"` because Homebrew is +# not on PATH there. A workflow or design doc that quotes either published +# constant is not leaking anyone's home directory, and a gate that reddens on it +# teaches people to route around the gate. +# +# The exception applies only to exact submatches. A line that names +# /home/runner and a real home directory or a token still carries a non-exempt +# submatch and is reported, while a person named `runnerbee` is never exempt. +public_home_match='^/home/(runner|linuxbrew)$' + +status=0 +# --text: ripgrep judges a file binary on one NUL byte and skips it while +# walking a directory, so a single NUL anywhere in a file hides every secret in +# it. --encoding none: a leading \xFF\xFE makes ripgrep transcode the file as +# UTF-16, garbling ASCII so these ASCII patterns cannot match. Both are one line +# of file content to trigger, and this gate's subject is content someone may be +# trying to get past it. The trade in --encoding none -- a file genuinely stored +# as UTF-16 stops being scanned -- is accepted: no tracked file here is one, and +# a documented five-byte bypass is the worse half. +# +# ripgrep exits 2 for a scan it could not complete -- an unreadable file, a path +# it was handed that it cannot open, an argument list too long for exec -- and it +# does so even when it also found matches. Read as a boolean, that is "no match": +# the gate would print a secret to stdout and still exit 0. A bare `if` here is +# what made a plain `rm` of any tracked file turn this gate green. Every gate in +# this repo branches on the status for the same reason: 0 is a finding, 1 is +# clean, anything else is a fault that stops the run. +for pattern in "${denied_patterns[@]}"; do + rg_status=0 + matches=$(rg --no-config --json --hidden --text --encoding none \ + --glob '!.git' --glob '!.git/**' "$pattern" "${scan_targets[@]}") || + rg_status=$? + case $rg_status in + 0) + # The walk and the explicit paths overlap on every tracked file no + # ignore rule hides, so each match arrives twice. Structured records keep + # arbitrary filename bytes out of the content decision and preserve the + # exact path, line and content in the finding. + filter_status=0 + matches=$(printf '%s\n' "$matches" | + jq -c --arg allowed "$public_home_match" ' + select(.type == "match") + | select(any(.data.submatches[]; + ((.match.text // (.match.bytes | @base64d)) + | test($allowed) | not))) + | .data + | {path, line_number, lines}' | + awk '!seen[$0]++') || filter_status=$? + if [ "$filter_status" -ne 0 ]; then + printf 'public-safety: jq could not filter ripgrep output (exit %s)\n' \ + "$filter_status" >&2 + exit 2 + fi + [ -n "$matches" ] || continue + printf '%s\n' "$matches" + printf 'public-safety: denied pattern matched: %s\n' "$pattern" >&2 + status=1 + ;; + 1) ;; + *) + printf 'public-safety: ripgrep could not complete the scan (exit %s); pattern: %s\n' \ + "$rg_status" "$pattern" >&2 + exit 2 + ;; + esac +done + +exit "$status" diff --git a/skills/quest/scripts/publish-forge-review b/skills/quest/scripts/publish-forge-review index 829df933..5501ea19 100755 --- a/skills/quest/scripts/publish-forge-review +++ b/skills/quest/scripts/publish-forge-review @@ -10,7 +10,6 @@ MAX_BODY_BYTES=32768 SCRIPT_DIR=${BASH_SOURCE[0]%/*} [ "$SCRIPT_DIR" = "${BASH_SOURCE[0]}" ] && SCRIPT_DIR=. SCRIPT_DIR=$(cd "$SCRIPT_DIR" && pwd) -ROOT=$(cd "$SCRIPT_DIR/../../.." && pwd) body='' disposer='' repo='' @@ -46,8 +45,9 @@ fail() { preflight() { local command - for command in gh jq mktemp awk git iconv od grep sed dirname tail uname cat chmod stat wc rm; do - command -v "$command" >/dev/null 2>&1 || fail "required command is unavailable" + for command in gh jq rg mktemp awk git iconv od grep sed dirname tail uname cat chmod stat wc rm; do + command -v "$command" >/dev/null 2>&1 || + fail "required command is unavailable: $command; install it or expose it in PATH" done platform=$(uname -s) case $platform in @@ -55,8 +55,10 @@ preflight() { Linux) disposer=gio ;; *) fail 'no supported recoverable-delete command on this platform' ;; esac - command -v "$disposer" >/dev/null 2>&1 || fail 'recoverable-delete command is unavailable' - [ -x "$ROOT/scripts/check-public-safety.sh" ] || fail 'public-safety check is unavailable' + command -v "$disposer" >/dev/null 2>&1 || + fail "recoverable-delete command is unavailable: $disposer; install it or expose it in PATH" + [ -x "$SCRIPT_DIR/check-public-safety" ] || + fail 'bundled public-safety check is unavailable; repair the installed quest skill' } validate_arguments() { @@ -225,8 +227,14 @@ compose_body() { fi printf '%s\n' "$OUTER_SENTINEL" >>"$body" || fail 'cannot compose publication body' check_size 'publication body' "$body" "$MAX_BODY_BYTES" - "$ROOT/scripts/check-public-safety.sh" "$body" >/dev/null 2>&1 || - fail 'publication body did not pass public-safety validation' + local scan_status=0 + # Scanner diagnostics contain matched private content; expose only the outcome. + "$SCRIPT_DIR/check-public-safety" "$body" >/dev/null 2>&1 || scan_status=$? + case $scan_status in + 0) ;; + 1) fail 'publication body contains unsafe content; redact the retained body and source inputs' ;; + *) fail "public-safety scan could not complete (exit $scan_status); check scanner dependencies and input access" ;; + esac } post_comment() { diff --git a/tests/fixtures/quest/publish-forge-review-test.sh b/tests/fixtures/quest/publish-forge-review-test.sh index 97a1c528..1523bed6 100755 --- a/tests/fixtures/quest/publish-forge-review-test.sh +++ b/tests/fixtures/quest/publish-forge-review-test.sh @@ -7,7 +7,7 @@ SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) . "$SCRIPT_DIR/../../../scripts/test-fixture-helpers.sh" clear_git_env -SCRIPT="$SCRIPT_DIR/../../../skills/quest/scripts/publish-forge-review" +SKILL_SOURCE="$SCRIPT_DIR/../../../skills/quest" ORIGINAL_PATH=$PATH SYSTEM_CAT=$(command -v cat) SYSTEM_TAIL=$(command -v tail) @@ -16,6 +16,9 @@ SYSTEM_UNAME=$(command -v uname) passed=0 failed=0 fixture_init publish-forge-review-test +# Run every publication case from the installed skill alone. +cp -R "$SKILL_SOURCE" "$SCRATCH/quest" +SCRIPT="$SCRATCH/quest/scripts/publish-forge-review" ok() { passed=$((passed + 1)) @@ -351,6 +354,10 @@ case_public_safety_stops_publication() { if [ "$STATUS" -eq 0 ] || ! assert_no_post "$name" || ! assert_retained "$name"; then return fi + if ! grep -q 'unsafe content' "$REPO/error"; then + fail "$name" 'unsafe content was not distinguished from a scanner fault' + return + fi new_case mkdir -p "$REPO/scan-fault" printf '%s\n' '#!/usr/bin/env bash' 'exit 2' >"$REPO/scan-fault/rg" @@ -359,6 +366,35 @@ case_public_safety_stops_publication() { if [ "$STATUS" -eq 0 ] || ! assert_no_post "$name" || ! assert_retained "$name"; then return fi + if ! grep -q 'scan could not complete (exit 2)' "$REPO/error"; then + fail "$name" 'scanner fault was reported as unsafe content' + return + fi + ok "$name" +} + +case_missing_scan_dependencies() { + local name='PFR missing scan dependencies fail before composition' missing tool + for missing in rg jq; do + new_case + mkdir "$REPO/bin" + for tool in bash gh jq rg mktemp awk git iconv od grep sed dirname tail uname cat chmod stat wc rm; do + [ "$tool" != "$missing" ] || continue + ln -s "$(command -v "$tool")" "$REPO/bin/$tool" + done + cp "$LEDGER" "$REPO/ledger-before" + run_preflight required "$REVIEW" /usr/bin/env PATH="$FAKES:$REPO/bin" + if [ "$STATUS" -eq 0 ] || [ ! -f "$REVIEW" ] || [ ! -f "$SUMMARY" ] || + ! cmp -s "$LEDGER" "$REPO/ledger-before" || body_file >/dev/null; then + fail "$name" 'missing dependency did not stop before composition with sources retained' + return + fi + assert_no_post "$name" || return + if ! grep -q "required command is unavailable: $missing" "$REPO/error"; then + fail "$name" 'missing tool was not named' + return + fi + done ok "$name" } @@ -985,6 +1021,7 @@ case_argument_arity_is_bounded() { printf 'publish-forge-review\n\n' case_required_safe_review case_public_safety_stops_publication +case_missing_scan_dependencies case_compose_source_failure_stops_publication case_publication_modes case_comment_failures_never_retry