From 0d989f73f786d69f3ff2c23953eb1d4bb00af60d Mon Sep 17 00:00:00 2001 From: Sander Striker Date: Tue, 27 Oct 2026 12:23:57 +0100 Subject: [PATCH 1/4] fix: make an image push fail, and remove the pages workflow The harness image holds harnesses that the project may not redistribute, and it is built from unreleased source. Thus nothing may publish it. A repository check in make check, a guard rule and the workflow permission now each stop a push. The pages workflow could publish unreleased pages, so it is removed. The old repository name is also removed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Bhj4Ck4y5iwZUsjpgQx8A2 --- .claude/hooks/guard-bash.sh | 55 ++++++++++++++++ .claude/hooks/guard-bash.test.sh | 32 +++++++++- .github/workflows/image.yml | 7 ++- .github/workflows/pages.yml | 34 ---------- AGENTS.md | 7 ++- CONTRIBUTING.md | 4 +- Cargo.toml | 2 +- Makefile | 6 +- crates/memory/src/open.rs | 4 +- images/harness/Dockerfile | 2 +- images/harness/README.md | 19 ++++++ local/README.md | 15 +++-- scripts/no-image-push.sh | 79 +++++++++++++++++++++++ scripts/no-image-push.test.sh | 104 +++++++++++++++++++++++++++++++ 14 files changed, 316 insertions(+), 54 deletions(-) delete mode 100644 .github/workflows/pages.yml create mode 100755 scripts/no-image-push.sh create mode 100755 scripts/no-image-push.test.sh diff --git a/.claude/hooks/guard-bash.sh b/.claude/hooks/guard-bash.sh index a4c320d..c2b5e4c 100755 --- a/.claude/hooks/guard-bash.sh +++ b/.claude/hooks/guard-bash.sh @@ -263,6 +263,58 @@ check_git_stash() { esac } +# Nothing in this repository pushes an image, or logs in to a registry to push one +# (images/harness/README.md, "Never published"). The harness image carries harnesses +# under their vendors' own licences, which the project may not redistribute, so a push +# from here is never right, whatever the tag. scripts/no-image-push.sh holds the files to +# the same rule; this holds what a session types. +# +# The subcommand is the first word that is not an option, or the second where an option +# takes a value (`docker -H host push`); `image push` and `manifest push` are pushes too. +check_image_push() { + _tool=${1##*/} + shift + _first='' _second='' + for _w in "$@"; do + case $_w in + -*) continue ;; + *) : ;; + esac + if [ -z "$_first" ]; then + _first=$_w + else + _second=$_w + break + fi + done + _verbs='push login' + case $_tool in + skopeo) _verbs='copy sync login' ;; + crane) _verbs='push copy append auth' ;; + oras) _verbs='push login' ;; + *) : ;; + esac + _hit='' + for _v in $_verbs; do + if [ "$_first" = "$_v" ] || [ "$_second" = "$_v" ]; then + _hit=$_v + fi + done + case $_first in + buildx) + case " $* " in + *' --push '* | *push=true* | *type=registry*) _hit=push ;; + *) : ;; + esac + ;; + *) : ;; + esac + if [ -n "$_hit" ]; then + refuse "\`$_tool ... $_hit\` would send an image to a registry, or log in to send one. This repository never publishes an image: the harness image holds harnesses the project may not redistribute." \ + "Build and keep it local: make image. images/harness/README.md says why." + fi +} + # A quoted run of several words is a value, not options. # # The words this guard walks are the call split on whitespace with the quotes still in @@ -705,6 +757,9 @@ check_part() { case ${1##*/} in git) check_git "$@" ;; gh) check_gh "$@" ;; + docker | podman | buildah | nerdctl | wslc | wslc.exe | skopeo | crane | oras) + check_image_push "$@" + ;; *) : ;; esac } diff --git a/.claude/hooks/guard-bash.test.sh b/.claude/hooks/guard-bash.test.sh index d3198ba..1654092 100755 --- a/.claude/hooks/guard-bash.test.sh +++ b/.claude/hooks/guard-bash.test.sh @@ -105,6 +105,34 @@ allow 'git stash push -u -m podling-names-wip' allow 'git stash list' allow 'git stash apply 0d2e4a1' +# An image never leaves this machine for a registry (images/harness/README.md) +refuse 'podman push durable-memory/harness:dev ghcr.io/apache/harness:dev' +refuse 'docker push ghcr.io/apache/harness' +refuse 'wslc.exe push durable-memory/harness:dev' +refuse 'docker image push ghcr.io/apache/harness' +refuse 'podman manifest push harness docker://ghcr.io/apache/harness' +refuse 'docker -H tcp://builder:2375 push ghcr.io/apache/harness' +refuse 'docker buildx build --push -t ghcr.io/apache/harness images/harness' +refuse 'docker buildx build --output type=registry images/harness' +refuse 'buildah push harness docker://ghcr.io/apache/harness' +refuse 'skopeo copy containers-storage:harness docker://ghcr.io/apache/harness' +refuse 'crane push harness.tar ghcr.io/apache/harness' +refuse 'oras push ghcr.io/apache/harness:1 file' +refuse 'echo "$TOKEN" | podman login ghcr.io -u me --password-stdin' +refuse 'make image && podman push durable-memory/harness:dev ghcr.io/apache/harness' +refuse "sh -c 'podman push durable-memory/harness:dev ghcr.io/apache/harness'" +refuse 'sudo docker push ghcr.io/apache/harness' +allow 'podman build -t durable-memory/harness:dev images/harness' +allow 'wslc.exe build -t durable-memory/harness:dev images/harness' +allow 'docker build --pull -t harness:abc images/harness' +allow 'podman pull docker.io/library/node:24' +allow 'podman images' +allow 'podman image ls' +allow 'podman save durable-memory/harness:dev -o harness.tar' +allow 'podman run --rm -v "$PWD":/r:Z -w /r docker.io/library/node:24 sh scripts/bump-harness-versions.sh' +allow 'docker buildx build -t harness images/harness' +allow 'skopeo inspect docker://docker.io/library/node:24' + # The pre-commit gate refuse 'git commit --no-verify -m "fix: thing"' refuse 'git commit -n -m "fix: thing"' @@ -555,7 +583,7 @@ allow "gh api -X PATCH repos/a/b/issues/comments/1 -F body=@$signed" allow "gh api -X PATCH repos/a/b/issues/comments/1 -F body=@$unsigned" # reading through those same paths is most of what the api is used for here, and stays allowed # whatever happens to the gap above -allow 'gh api repos/sstriker/durable-memory/pulls/8/comments' +allow 'gh api repos/apache/tooling-memory/pulls/8/comments' allow 'gh api repos/a/b/pulls/1/reviews --jq .state' # Prose about posting is not posting, even inside the body of a post. A signed comment # explaining how to reply - quoting the command on a line of its own - was counted as a second @@ -592,7 +620,7 @@ allow 'gh pr view 800 --json body' allow 'gh pr diff 800' allow 'gh pr checks 800' allow 'gh run view 123 --log-failed' -allow 'gh api repos/sstriker/durable-memory/pulls/8/comments' +allow 'gh api repos/apache/tooling-memory/pulls/8/comments' allow 'gh pr edit 800 --add-label ready' # no body flag: the editor opens and a person types it themselves allow 'gh pr create --fill' diff --git a/.github/workflows/image.yml b/.github/workflows/image.yml index d743986..e7c8c17 100644 --- a/.github/workflows/image.yml +++ b/.github/workflows/image.yml @@ -1,5 +1,9 @@ # The harness image: built when its inputs change, to show the Dockerfile still builds, and -# never published; machines build their own with `make image`. Pins are bumped by a daily +# never published; machines build their own with `make image`. It holds harnesses under +# their vendors' own licences, Claude Code and Copilot CLI among them, which the project +# may not redistribute, and it is built from unreleased source (images/harness/README.md). +# So this workflow's token gets no `packages` permission, and scripts/no-image-push.sh, +# run by `make check`, fails any file here that would send an image to a registry. Pins are bumped by a daily # check that opens a pull request, and a pull request that touches them has its lockfile # checked against package.json. Never on a run: `memory launch` uses the tag it is given. name: image @@ -17,6 +21,7 @@ on: permissions: contents: write pull-requests: write + packages: none jobs: # Daily: bump the pins and open (or update) a pull request. CI runs on it like any PR diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml deleted file mode 100644 index b368433..0000000 --- a/.github/workflows/pages.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Hosted pages - -# The pages as plain files on GitHub Pages, for `amem --hosted `. Run by hand only: -# publishing is a decision, not a side effect of a merge. Needs Pages enabled with -# "GitHub Actions" as the source in the repository's settings, and a custom domain: the -# copy needs an origin of its own, and user.github.io is shared by every site of the -# account (local/README.md, "A hosted page"). -on: - workflow_dispatch: - -permissions: - contents: read - pages: write - id-token: write - -concurrency: - group: pages - cancel-in-progress: false - -jobs: - deploy: - runs-on: ubuntu-24.04 - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - run: sh scripts/hosted.sh _site - - uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 - - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 - with: - path: _site - - id: deployment - uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 diff --git a/AGENTS.md b/AGENTS.md index 1f0877c..f045224 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,6 +55,11 @@ with an explicit request from the user, ask before overriding it. it; do not relax that, and give a crate that needs foreign calls its own binding crate rather than a third exception. `make check` holds both halves (`scripts/unsafe-crates.sh`, and clippy for the binding's Windows target). +- **No image leaves this machine.** Nothing here pushes an image to a registry or logs in + to one: the harness image holds harnesses the project may not redistribute, and it is + built from unreleased source. `scripts/no-image-push.sh` holds the files to this in + `make check`, and the guard holds what a session types. images/harness/README.md says + why. - **Nothing reaches outside its root.** The wrapper is a confined process: it touches the record, its own state, and its grants file, each behind a rooted operator, and nothing else on the host. A name is validated before it becomes a path. Code that opens a file @@ -70,7 +75,7 @@ to the repository is committed under `.claude/`: | Path | What it is | | ---- | ---------- | | `.claude/settings.json` | the permission allowlist (cargo, make, and read-only `git` and `gh`) and the hook registration | -| `.claude/hooks/guard-bash.sh` | refuses a command that stages with a wildcard, reaches into the shared stash, passes `--no-verify`, pipes `cargo` or `make` into `head` or `tail`, or posts an issue or pull request body that does not say who wrote it | +| `.claude/hooks/guard-bash.sh` | refuses a command that stages with a wildcard, reaches into the shared stash, passes `--no-verify`, pipes `cargo` or `make` into `head` or `tail`, pushes an image or logs in to a registry, or posts an issue or pull request body that does not say who wrote it | | `.claude/hooks/guard-bash.test.sh` | what the guard refuses, and what it must go on allowing. Run by `make check` | | `.claude/commands/adversarial.md` | the prompt the pre-PR pass is given, and the classes it attacks | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ebcf382..a68e4af 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,8 +20,8 @@ ## Setup ```sh -git clone git@github.com:sstriker/durable-memory.git -cd durable-memory +git clone git@github.com:apache/tooling-memory.git +cd tooling-memory make init # installs the pre-commit hook make check # format, clippy, tests, guard, docs-only and conformance tests ``` diff --git a/Cargo.toml b/Cargo.toml index 10db483..af50168 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ version = "0.1.0" edition = "2021" rust-version = "1.85" license = "Apache-2.0" -repository = "https://github.com/sstriker/durable-memory" +repository = "https://github.com/apache/tooling-memory" [workspace.dependencies] thiserror = "2" diff --git a/Makefile b/Makefile index 2995f98..d94312c 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ endif NEXTEST := $(shell cargo nextest --version 2>/dev/null) TEST_CMD := $(if $(NEXTEST),cargo nextest run --workspace --no-fail-fast,cargo test --workspace) -.PHONY: demo-ready podman-ready e2e hosted install init check verify fmt fmt-check clippy test guard-test docs-only-test conformance-test conformance deny machete coverage wasm-check run build wslcsdk-test wslcsdk unsafe-check windows-target \ +.PHONY: demo-ready podman-ready e2e hosted install init check verify fmt fmt-check clippy test guard-test docs-only-test no-image-push-test conformance-test conformance deny machete coverage wasm-check run build wslcsdk-test wslcsdk unsafe-check windows-target \ local-up local-down local-logs local-key local-memory launch usage golden drift taps-check taps-build wasip2-target reader # ---- the local stack: LiteLLM and Postgres in containers, the memory root on disk. @@ -173,7 +173,7 @@ local-memory: else target/release/amem --root "$$MEMORY_ROOT" init --domain dom-me; fi # `make check` is the pre-commit gate: fast, and what every commit must pass. -check: wasip2-target fmt-check clippy test guard-test docs-only-test wslcsdk-test conformance-test unsafe-check taps-check +check: wasip2-target fmt-check clippy test guard-test docs-only-test no-image-push-test wslcsdk-test conformance-test unsafe-check taps-check # `make verify` is what CI runs: the gate plus dependencies, coverage, and the wasm build. verify: check deny machete coverage wasm-check @@ -197,6 +197,8 @@ guard-test: sh .claude/hooks/guard-bash.test.sh docs-only-test: sh scripts/docs-only.test.sh +no-image-push-test: + sh scripts/no-image-push.test.sh wslcsdk-test: sh scripts/fetch-wslcsdk.test.sh conformance-test: diff --git a/crates/memory/src/open.rs b/crates/memory/src/open.rs index 052e1ef..19e59b2 100644 --- a/crates/memory/src/open.rs +++ b/crates/memory/src/open.rs @@ -407,9 +407,9 @@ mod tests { for bad in [ "http://example.org/", "ftp://x/", - "sstriker.github.io", + "someone.github.io", "https://", - "https://sstriker.github.io/durable-memory/", + "https://someone.github.io/tooling-memory/", "http://127.0.0.1:1@evil.example/", "http://localhost:8000\\@evil.example/", "https://user:pw@amem.example/", diff --git a/images/harness/Dockerfile b/images/harness/Dockerfile index 5fb2157..1fd0f06 100644 --- a/images/harness/Dockerfile +++ b/images/harness/Dockerfile @@ -61,4 +61,4 @@ COPY memory /opt/memory/memory RUN chmod 0755 /opt/memory/memory && /opt/memory/memory --version LABEL org.opencontainers.image.title="durable-memory harness" \ - org.opencontainers.image.source="https://github.com/sstriker/durable-memory" + org.opencontainers.image.source="https://github.com/apache/tooling-memory" diff --git a/images/harness/README.md b/images/harness/README.md index f9ae619..ae7e7b1 100644 --- a/images/harness/README.md +++ b/images/harness/README.md @@ -55,3 +55,22 @@ is rebuilt when those move, and on a clock for the one that moves silently: Locally, `make image` is for developing the launcher itself. The pull request the bot opens runs CI like any other once a maintainer approves the workflow run, since it is opened with the workflow's own token. + +## Never published + +Nobody publishes this image, and nothing in this repository can push it to a registry. +Each machine builds its own with `make image`. There are two reasons: + +- The image holds harnesses under their vendors' own licences. Claude Code and Copilot + CLI are two of them, and the project may not redistribute them. The Apache release + policy says that each artifact that the project distributes contains only code with a + suitable licence. +- The image is built from unreleased source. The Apache release policy says that the + project does not publish unreleased material outside its development community. + +Three parts hold this: + +- `scripts/no-image-push.sh`, run by `make check`, fails when a script, a workflow, the + `Makefile` or a Dockerfile would push an image or log in to a registry. +- `.claude/hooks/guard-bash.sh` refuses such a command when a session types it. +- `.github/workflows/image.yml` gives its token no `packages` permission. diff --git a/local/README.md b/local/README.md index 52f958f..271cca6 100644 --- a/local/README.md +++ b/local/README.md @@ -917,14 +917,13 @@ measures is on screen while the harness runs. ### A hosted page The pages are plain files, so a static host can serve them: `make hosted` puts them in -`target/hosted`, and the `Hosted pages` workflow publishes them to GitHub Pages when it is -run by hand. It has not been run: Pages on this private repository needs a paid plan and -Actions running, and enabling it with "GitHub Actions" as the source. The copy needs an -origin of its own, served at its root, because every page on an origin reads what the -browser keeps for it, the paired key included: a project site at -`sstriker.github.io/durable-memory/` shares `sstriker.github.io` with every other site of -the account, so the site gets a custom domain, and `amem --hosted` refuses an address -with a path. To try it on this machine, serve `target/hosted` +`target/hosted`. This repository does not publish them. A copy built from unreleased +source is unreleased material, and the Apache release policy says that the project does +not publish that outside its development community. The copy needs an origin of its own, +served at its root, because every page on an origin reads what the browser keeps for it, +the paired key included: a project site at `someone.github.io/tooling-memory/` shares +`someone.github.io` with every other site of the account, so a copy needs a domain of its +own, and `amem --hosted` refuses an address with a path. To try it on this machine, serve `target/hosted` (`python3 -m http.server -d target/hosted 8000`) and run `amem --hosted http://localhost:8000/`. A server already running over the root keeps the origins it started with; `amem --hosted` against one started without it says so. `amem --hosted ` (or `AMEM_HOSTED=` in the environment) then lets diff --git a/scripts/no-image-push.sh b/scripts/no-image-push.sh new file mode 100755 index 0000000..1e35909 --- /dev/null +++ b/scripts/no-image-push.sh @@ -0,0 +1,79 @@ +#!/bin/sh +# Nothing in this repository pushes an image to a registry, and this fails when something +# would. The harness image holds harnesses under their vendors' own licences, Claude Code +# and Copilot CLI among them, which the project may not redistribute, and an image built +# from unreleased source is not something the project may publish either (images/harness/ +# README.md says why). Each machine builds its own with `make image`. +# +# It reads every file git knows or would add, except Markdown, these two scripts and the +# guard and its tests, which name the same commands to refuse them. It fails on a shell +# line, a workflow step, a Makefile recipe or a Dockerfile that runs a push, logs in to a +# registry, or grants a workflow `packages: write`, and on Rust that passes "push" or +# "login" as a command's argument. A file it cannot read is a failure, not a pass. +# +# scripts/no-image-push.sh [file...] default: the repository's files +# +# Run by `make check`. + +set -eu + +here=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) +root=$(CDPATH='' cd -- "$here/.." && pwd) + +# a push or a registry login by any of the tools that can make one, the actions and +# options that push as they build, and the workflow permission a push needs +command_pattern='(^|[^[:alnum:]_-])(docker|podman|buildah|nerdctl|wslc(\.exe)?)([[:space:]]+(image|manifest))?[[:space:]]+(push|login)([^[:alnum:]_-]|$)' +command_pattern="$command_pattern"'|buildx[^|;&]*--push|build-push-action|push-to-registry' +command_pattern="$command_pattern"'|(^|[^[:alnum:]_-])skopeo[[:space:]]+(copy|sync|login)|(^|[^[:alnum:]_-])crane[[:space:]]+(push|copy|append|auth)' +command_pattern="$command_pattern"'|(^|[^[:alnum:]_-])oras[[:space:]]+(push|login)|(^|[^[:alnum:]_-])ko[[:space:]]+(publish|build)' +command_pattern="$command_pattern"'|packages:[[:space:]]*write|type=registry|push=true' +rust_pattern='\.args?\([[:space:]]*(\[[^]]*)?"(push|login)"' + +failures=0 + +check() { + file=$1 + if [ ! -r "$file" ]; then + echo "no-image-push: cannot read $file" >&2 + failures=$((failures + 1)) + return + fi + case $file in + *.rs) pattern=$rust_pattern ;; + *) pattern=$command_pattern ;; + esac + if hits=$(grep -n -I -E -i -- "$pattern" "$file"); then + printf '%s\n' "$hits" | while IFS= read -r hit; do + echo "no-image-push: $file:$hit" >&2 + done + failures=$((failures + 1)) + fi +} + +if [ $# -gt 0 ]; then + for file in "$@"; do check "$file"; done +else + cd "$root" + list=$(git ls-files --cached --others --exclude-standard) || { + echo "no-image-push: git could not list the files, so nothing was checked" >&2 + exit 1 + } + [ -n "$list" ] || { echo "no-image-push: git listed no files" >&2; exit 1; } + # a name with a newline in it would split; git quotes such names, and none exist here + while IFS= read -r file; do + case $file in + *.md | scripts/no-image-push.sh | scripts/no-image-push.test.sh) continue ;; + .claude/hooks/guard-bash.sh | .claude/hooks/guard-bash.test.sh) continue ;; + crates/web/static/vendor/*) continue ;; + esac + [ -f "$file" ] || continue + check "$file" + done <&2 + exit 1 +fi diff --git a/scripts/no-image-push.test.sh b/scripts/no-image-push.test.sh new file mode 100755 index 0000000..3a7094b --- /dev/null +++ b/scripts/no-image-push.test.sh @@ -0,0 +1,104 @@ +#!/bin/sh +# What no-image-push.sh refuses, and what it must go on allowing. A wrong pass lets an +# image out, so the refusals are the cases that matter; the allowed ones keep it from +# being switched off for refusing the repository's own builds. The last check runs it over +# the repository as it stands, which must pass. +# +# Run by `make check`. + +set -eu + +here=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) +script="$here/no-image-push.sh" +dir=$(mktemp -d) +trap 'rm -rf "$dir"' EXIT + +failures=0 +n=0 + +# check yes|no NAME LINE: a file NAME holding LINE, and whether the script lets it through +check() { + want=$1 name=$2 line=$3 + n=$((n + 1)) + mkdir -p "$dir/$n" + file="$dir/$n/$name" + printf '%s\n' "$line" > "$file" + status=0 + sh "$script" "$file" 2>/dev/null || status=$? + if [ "$want" = pass ] && [ "$status" -ne 0 ]; then + printf 'refused, should pass: %s: %s\n' "$name" "$line" >&2 + failures=$((failures + 1)) + elif [ "$want" = refuse ] && [ "$status" -eq 0 ]; then + printf 'passed, should be refused: %s: %s\n' "$name" "$line" >&2 + failures=$((failures + 1)) + fi +} + +# a push, by each tool that can make one, as a script, a recipe or a step would run it +check refuse run.sh 'podman push durable-memory/harness:dev ghcr.io/x/harness:dev' +check refuse run.sh 'docker push ghcr.io/x/harness' +check refuse Makefile ' wslc.exe push $(IMAGE)' +check refuse Makefile ' wslc push $(IMAGE)' +check refuse run.sh 'buildah push harness docker://ghcr.io/x/harness' +check refuse run.sh 'nerdctl push x/harness' +check refuse run.sh 'docker image push ghcr.io/x/harness' +check refuse run.sh 'podman manifest push harness docker://ghcr.io/x/harness' +check refuse run.sh 'cd images && podman push x' +check refuse run.sh 'PODMAN PUSH x' +check refuse ci.yml ' run: docker buildx build --push -t ghcr.io/x/h images/harness' +check refuse ci.yml ' - uses: docker/build-push-action@v6' +check refuse ci.yml ' - uses: redhat-actions/push-to-registry@v2' +check refuse ci.yml ' packages: write' +check refuse ci.yml ' run: docker buildx build --output type=registry .' +check refuse ci.yml ' run: docker buildx build --output type=image,push=true .' +check refuse run.sh 'skopeo copy containers-storage:x docker://ghcr.io/x/h' +check refuse run.sh 'crane push image.tar ghcr.io/x/h' +check refuse run.sh 'oras push ghcr.io/x/h:1 file' +check refuse run.sh 'ko publish ./cmd/x' +# a login is the step before a push, and nothing here needs one +check refuse run.sh 'echo "$T" | podman login ghcr.io -u x --password-stdin' +check refuse ci.yml ' run: docker login -u x -p "$T"' +# Rust that runs a push +check refuse main.rs 'Command::new("podman").arg("push").arg(&tag)' +check refuse main.rs 'Command::new(wslc).args(["push", &tag])' +check refuse main.rs 'let c = cmd.args( [ "login", "ghcr.io" ]);' + +# what the repository does and must go on doing +check pass Makefile ' else podman build -t $(IMAGE) images/harness; fi' +check pass Makefile ' wslc.exe build -t $(IMAGE) "$$(wslpath -w images/harness)"; \' +check pass ci.yml ' run: docker build --pull -t harness:${{ github.sha }} images/harness' +check pass ci.yml ' git push -f origin bot/harness-pins' +check pass ci.yml ' contents: write' +check pass ci.yml ' packages: none' +check pass ci.yml ' packages: read' +check pass run.sh 'podman pull docker.io/library/node:24' +check pass run.sh 'podman run --rm -v "$PWD":/r:Z -w /r docker.io/library/node:24 sh x.sh' +check pass run.sh 'podman save harness | tar -x' +check pass run.sh 'git push origin HEAD' +check pass run.sh 'ssh-copy-id x' +check pass main.rs 'v.push(item);' +check pass main.rs 'Command::new("git").arg("pushd")' +check pass main.rs 'let s = "push";' +check pass run.sh 'pushd /tmp && popd' +check pass run.sh 'docker-push-helper --help' + +# a file it cannot read is a failure, not a pass +n=$((n + 1)) +status=0 +sh "$script" "$dir/missing" 2>/dev/null || status=$? +if [ "$status" -eq 0 ]; then + echo 'a missing file passed, should fail' >&2 + failures=$((failures + 1)) +fi + +# the repository as it stands +if ! sh "$script"; then + echo 'the repository itself does not pass' >&2 + failures=$((failures + 1)) +fi + +if [ "$failures" -ne 0 ]; then + echo "no-image-push.test.sh: $failures of $n cases failed" >&2 + exit 1 +fi +echo "no-image-push.sh: every case holds" From fbb6c8373ba5359e3e77cd686e48f4ac313e5217 Mon Sep 17 00:00:00 2001 From: Sander Striker Date: Tue, 27 Oct 2026 12:48:58 +0100 Subject: [PATCH 2/4] fix: read a push as a shell reads it, and fail when the check cannot look The adversarial pass got many pushes past both breakers. Examples: an engine in a variable, a continued line, quoted words, docker build --push, crane cp, and Rust and Python that pass "push" as an argument. The check also skipped files with odd names and passed on a grep error. Both breakers now read the command as a shell does. They cover more tools and verbs, and GitHub releases. The check fails when git or awk fails. Each new rule has a test that fails when the rule is removed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Bhj4Ck4y5iwZUsjpgQx8A2 --- .claude/hooks/guard-bash.sh | 122 ++++++++++++----- .claude/hooks/guard-bash.test.sh | 74 ++++++++++ .github/workflows/image.yml | 5 +- AGENTS.md | 12 +- images/harness/README.md | 21 ++- scripts/no-image-push.sh | 225 ++++++++++++++++++++++++------- scripts/no-image-push.test.sh | 206 +++++++++++++++++++++++++--- 7 files changed, 551 insertions(+), 114 deletions(-) diff --git a/.claude/hooks/guard-bash.sh b/.claude/hooks/guard-bash.sh index c2b5e4c..689cfe9 100755 --- a/.claude/hooks/guard-bash.sh +++ b/.claude/hooks/guard-bash.sh @@ -123,6 +123,7 @@ command=$( sed -e 's/\\n/\ /g' -e 's/\\[rt]/ /g' -e 's/\\"/"/g' -e 's/\\\\/\\/g' | drop_heredoc_bodies | + sed -e ':a' -e '/\\$/N' -e 's/\\\n/ /' -e 'ta' | tr '\n' ';' ) @@ -162,7 +163,34 @@ strip_prefixes() { *) : ;; esac case ${_rest%%[[:space:]]*} in - sudo | env | time | nohup | exec | command | *=*) : ;; + time | nohup | exec | command | then | do | else | if | while | until | '!' | eval | *=*) : ;; + # these take options of their own before the command, and some options a value: + # `sudo -u root podman`, `env -i PATH=x podman`, `nice -n 5 podman`, `xargs -I{} podman` + sudo | doas | env | nice | ionice | xargs) + _rest=${_rest#*[[:space:]]} + while :; do + case ${_rest%%[[:space:]]*} in + -u | -g | -C | -h | -p | -n | -c | -I | -L | -P | -s | -d | -E | -i | -S | -H | -[a-zA-Z] | -[a-zA-Z]* | --*) + case ${_rest%%[[:space:]]*} in + -u | -g | -C | -h | -p | -n | -c | -I | -L | -P | -s | -d) _skip_value=yes ;; + *) _skip_value='' ;; + esac + case $_rest in + *[[:space:]]*) _rest=${_rest#*[[:space:]]} ;; + *) break ;; + esac + if [ -n "$_skip_value" ]; then + case $_rest in + *[[:space:]]*) _rest=${_rest#*[[:space:]]} ;; + *) break ;; + esac + fi + ;; + *) break ;; + esac + done + continue + ;; # `timeout 600 make test` runs make, and so does `timeout --signal=KILL 600`. timeout) _rest=${_rest#*[[:space:]]} @@ -263,52 +291,71 @@ check_git_stash() { esac } -# Nothing in this repository pushes an image, or logs in to a registry to push one -# (images/harness/README.md, "Never published"). The harness image carries harnesses -# under their vendors' own licences, which the project may not redistribute, so a push -# from here is never right, whatever the tag. scripts/no-image-push.sh holds the files to -# the same rule; this holds what a session types. +# Nothing in this repository pushes an image, releases one, or logs in to a registry to +# push one (images/harness/README.md, "Never published"). The harness image carries +# harnesses under their vendors' own licences, which the project may not redistribute, so +# a push from here is never right, whatever the tag. scripts/no-image-push.sh holds the +# files to the same rule, read the same way; this holds what a session types. # -# The subcommand is the first word that is not an option, or the second where an option -# takes a value (`docker -H host push`); `image push` and `manifest push` are pushes too. +# Quotes and backslashes in a word do not count (`"podman" push`), and a command named by +# a variable (`$ENGINE push`) is read as an engine. The verb is the first word that is not +# an option, or the second after a group (`image push`, `compose push`) or after an option +# that took the first as its value (`-H host push`): `podman run --name login` is a run. check_image_push() { - _tool=${1##*/} + _tool=$(printf '%s' "${1##*/}" | tr -d "\"'\\\\") shift - _first='' _second='' + case $_tool in + '$'*) _tool='$' ;; + *) : ;; + esac + _first='' _second='' _before='' for _w in "$@"; do + _w=$(printf '%s' "$_w" | tr -d "\"'\\\\") case $_w in - -*) continue ;; + --push | *push=true* | *type=registry*) + refuse "\`$_tool ... $_w\` would send an image to a registry. This repository never publishes an image: the harness image holds harnesses the project may not redistribute." \ + "Build and keep it local: make image. images/harness/README.md says why." + ;; + -*) + [ -n "$_first" ] || _before=$_w + continue + ;; *) : ;; esac if [ -z "$_first" ]; then _first=$_w - else + elif [ -z "$_second" ]; then _second=$_w - break fi done - _verbs='push login' case $_tool in skopeo) _verbs='copy sync login' ;; - crane) _verbs='push copy append auth' ;; - oras) _verbs='push login' ;; - *) : ;; + crane | gcrane) _verbs='push copy cp append auth' ;; + oras) _verbs='push cp copy login' ;; + regctl) _verbs='copy put login' ;; + ko) _verbs='publish build resolve apply' ;; + *) _verbs='push login' ;; esac _hit='' for _v in $_verbs; do - if [ "$_first" = "$_v" ] || [ "$_second" = "$_v" ]; then - _hit=$_v - fi + [ "$_first" = "$_v" ] && _hit=$_v done + _group='' case $_first in - buildx) - case " $* " in - *' --push '* | *push=true* | *type=registry*) _hit=push ;; - *) : ;; - esac - ;; + image | images | manifest | artifact | compose | system | registry | farm | buildx) _group=yes ;; *) : ;; esac + case $_before in + '' | *=*) : ;; + *) _group=yes ;; + esac + if [ -z "$_hit" ] && [ -n "$_group" ]; then + for _v in $_verbs; do + [ "$_second" = "$_v" ] && _hit=$_v + done + [ "$_first $_second" = "farm build" ] && _hit='farm build' + [ "$_first $_second" = "buildx imagetools" ] && _hit='buildx imagetools' + fi if [ -n "$_hit" ]; then refuse "\`$_tool ... $_hit\` would send an image to a registry, or log in to send one. This repository never publishes an image: the harness image holds harnesses the project may not redistribute." \ "Build and keep it local: make image. images/harness/README.md says why." @@ -683,6 +730,13 @@ check_gh() { *) break ;; esac done + case "${1:-} ${2:-}" in + 'release create' | 'release upload') + refuse "\`gh ${1} ${2}\` makes or fills a GitHub release. This repository never publishes a release there: an Apache release is voted on and goes to downloads.apache.org." \ + "Leave releases to the release process. images/harness/README.md says why nothing here publishes." + ;; + *) : ;; + esac case "${1:-} ${2:-}" in # `gh api` reaches the same comments, and is the only way one is edited. It is not here: # writing that branch cost three of the five defects this rule was reviewed for, in a @@ -727,8 +781,10 @@ check_part() { case ${_head##*/} in sh | bash | dash | ash | zsh | ksh) case $_stripped in - *[[:space:]]-c[[:space:]]*) - _inner=${_stripped#*[[:space:]]-c[[:space:]]} + # -c alone, or at the end of a cluster: `bash -lc`, `sh -xc` + *[[:space:]]-c[[:space:]]* | *[[:space:]]-[a-zA-Z]*c[[:space:]]*) + _inner=$(printf '%s\n' "$_stripped" | + sed 's/^[^[:space:]]*\([[:space:]][[:space:]]*-[a-zA-Z]*\)*[[:space:]][[:space:]]*-[a-zA-Z]*c[[:space:]][[:space:]]*//') # The command string is quoted as a whole, and the quotes around it # are the shell's rather than part of what runs. case $_inner in @@ -754,10 +810,16 @@ check_part() { if [ "$#" -eq 0 ]; then return 0 fi - case ${1##*/} in + # the command's name with its quotes and backslashes gone: `"podman" push` runs podman + _cmd=$(printf '%s' "${1##*/}" | tr -d "\"'\\\\") + case $_cmd in git) check_git "$@" ;; gh) check_gh "$@" ;; - docker | podman | buildah | nerdctl | wslc | wslc.exe | skopeo | crane | oras) + docker | podman | podman-remote | buildah | nerdctl | wslc | wslc.exe | docker-compose | \ + podman-compose | skopeo | crane | gcrane | oras | regctl | ctr | helm | ko) + check_image_push "$@" + ;; + '$'*) check_image_push "$@" ;; *) : ;; diff --git a/.claude/hooks/guard-bash.test.sh b/.claude/hooks/guard-bash.test.sh index 1654092..473be14 100755 --- a/.claude/hooks/guard-bash.test.sh +++ b/.claude/hooks/guard-bash.test.sh @@ -122,7 +122,81 @@ refuse 'echo "$TOKEN" | podman login ghcr.io -u me --password-stdin' refuse 'make image && podman push durable-memory/harness:dev ghcr.io/apache/harness' refuse "sh -c 'podman push durable-memory/harness:dev ghcr.io/apache/harness'" refuse 'sudo docker push ghcr.io/apache/harness' +refuse 'nerdctl push x/harness' +refuse 'wslc push durable-memory/harness:dev' +refuse 'podman-remote push x' +refuse 'docker-compose push' +refuse 'podman-compose push' +refuse 'docker compose push' +refuse 'podman artifact push x' +refuse 'ctr images push ghcr.io/apache/harness' +refuse 'helm push chart.tgz oci://ghcr.io/apache' +refuse 'helm registry login ghcr.io' +refuse 'podman farm build -t ghcr.io/apache/harness .' +refuse 'podman --remote push x' +refuse 'docker build --push -t ghcr.io/apache/harness .' +refuse 'docker buildx build --output type=image,push=true .' +refuse 'docker buildx imagetools create -t ghcr.io/apache/harness a b' +refuse 'crane copy a ghcr.io/apache/harness' +refuse 'crane cp a ghcr.io/apache/harness' +refuse 'crane append -b a -f l.tar -t ghcr.io/apache/harness' +refuse 'crane auth login ghcr.io' +refuse 'gcrane push x' +refuse 'skopeo sync --src dir x --dest docker ghcr.io/apache' +refuse 'skopeo login ghcr.io' +refuse 'oras login ghcr.io' +refuse 'oras cp a ghcr.io/apache/harness' +refuse 'regctl image copy a ghcr.io/apache/harness' +refuse 'ko publish ./cmd/x' +refuse 'gh release upload v1 harness.tar' +refuse 'gh release create v1 harness.tar' +refuse '"podman" push x' +refuse "podman 'push' x" +refuse 'podman "push" x' +refuse 'p\odman push x' +refuse '"skopeo" copy containers-storage:harness docker://ghcr.io/apache/harness' +refuse '$ENGINE push x' +refuse '"$ENGINE" push x' +refuse '${DOCKER:-docker} push x' +refuse '/usr/bin/podman push x' +refuse 'podman \ +push x' +refuse 'docker buildx build \ + --push -t ghcr.io/apache/harness .' +refuse 'if true; then podman push x; fi' +refuse 'for t in a b; do podman push "$t"; done' +refuse '! podman push x' +refuse 'echo x | xargs podman push' +refuse 'xargs -I{} podman push {} < tags' +refuse 'eval podman push x' +refuse 'nice -n 5 podman push x' +refuse 'doas podman push x' +refuse 'sudo -u root podman push x' +refuse 'sudo -E podman push x' +refuse 'env -i PATH=/usr/bin podman push x' +refuse "sh -lc 'podman push x'" +refuse "bash -xc 'podman push x'" +# the words in front of a command hide a git rule no better than an image rule +refuse 'if true; then git add -A; fi' +refuse 'sudo -u me git add -A' +refuse "bash -lc 'git add -A'" allow 'podman build -t durable-memory/harness:dev images/harness' +allow 'podman run --rm --name login alpine true' +allow 'podman run --rm -e push alpine true' +allow 'podman inspect login' +allow 'podman logs push' +allow 'podman rm -f login' +allow 'podman load -i harness.tar' +allow 'podman image inspect durable-memory/harness:dev' +allow 'podman compose up -d' +allow 'podman export "$(podman create x)" | tar -x -C rootfs' +allow 'exec "$runtime" run --rm x' +allow '"$ENGINE" save x -o x.tar' +allow 'gh release download release-1 -R google/gvisor -p x.tar.bz2' +allow 'gh release list' +allow 'crane digest ghcr.io/apache/harness' +allow 'grep -n "podman push" scripts/no-image-push.test.sh' +allow 'make no-image-push-test' allow 'wslc.exe build -t durable-memory/harness:dev images/harness' allow 'docker build --pull -t harness:abc images/harness' allow 'podman pull docker.io/library/node:24' diff --git a/.github/workflows/image.yml b/.github/workflows/image.yml index e7c8c17..2ffc3ff 100644 --- a/.github/workflows/image.yml +++ b/.github/workflows/image.yml @@ -2,8 +2,9 @@ # never published; machines build their own with `make image`. It holds harnesses under # their vendors' own licences, Claude Code and Copilot CLI among them, which the project # may not redistribute, and it is built from unreleased source (images/harness/README.md). -# So this workflow's token gets no `packages` permission, and scripts/no-image-push.sh, -# run by `make check`, fails any file here that would send an image to a registry. Pins are bumped by a daily +# scripts/no-image-push.sh, run by `make check`, fails any file here that would send an +# image to a registry, and `packages: none` below says so in this file (with other +# permissions named, GitHub gives none for packages anyway). Pins are bumped by a daily # check that opens a pull request, and a pull request that touches them has its lockfile # checked against package.json. Never on a run: `memory launch` uses the tag it is given. name: image diff --git a/AGENTS.md b/AGENTS.md index f045224..d8287e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,11 +55,11 @@ with an explicit request from the user, ask before overriding it. it; do not relax that, and give a crate that needs foreign calls its own binding crate rather than a third exception. `make check` holds both halves (`scripts/unsafe-crates.sh`, and clippy for the binding's Windows target). -- **No image leaves this machine.** Nothing here pushes an image to a registry or logs in - to one: the harness image holds harnesses the project may not redistribute, and it is - built from unreleased source. `scripts/no-image-push.sh` holds the files to this in - `make check`, and the guard holds what a session types. images/harness/README.md says - why. +- **No image leaves this machine.** Nothing here pushes an image to a registry, logs in + to one, or makes a GitHub release: the harness image holds harnesses the project may not + redistribute, and it is built from unreleased source. `scripts/no-image-push.sh` holds + the files to this in `make check`, and the guard holds what a session types. + images/harness/README.md says why. - **Nothing reaches outside its root.** The wrapper is a confined process: it touches the record, its own state, and its grants file, each behind a rooted operator, and nothing else on the host. A name is validated before it becomes a path. Code that opens a file @@ -75,7 +75,7 @@ to the repository is committed under `.claude/`: | Path | What it is | | ---- | ---------- | | `.claude/settings.json` | the permission allowlist (cargo, make, and read-only `git` and `gh`) and the hook registration | -| `.claude/hooks/guard-bash.sh` | refuses a command that stages with a wildcard, reaches into the shared stash, passes `--no-verify`, pipes `cargo` or `make` into `head` or `tail`, pushes an image or logs in to a registry, or posts an issue or pull request body that does not say who wrote it | +| `.claude/hooks/guard-bash.sh` | refuses a command that stages with a wildcard, reaches into the shared stash, passes `--no-verify`, pipes `cargo` or `make` into `head` or `tail`, pushes an image, logs in to a registry or makes a GitHub release, or posts an issue or pull request body that does not say who wrote it | | `.claude/hooks/guard-bash.test.sh` | what the guard refuses, and what it must go on allowing. Run by `make check` | | `.claude/commands/adversarial.md` | the prompt the pre-PR pass is given, and the classes it attacks | diff --git a/images/harness/README.md b/images/harness/README.md index ae7e7b1..7bd25e4 100644 --- a/images/harness/README.md +++ b/images/harness/README.md @@ -68,9 +68,18 @@ Each machine builds its own with `make image`. There are two reasons: - The image is built from unreleased source. The Apache release policy says that the project does not publish unreleased material outside its development community. -Three parts hold this: - -- `scripts/no-image-push.sh`, run by `make check`, fails when a script, a workflow, the - `Makefile` or a Dockerfile would push an image or log in to a registry. -- `.claude/hooks/guard-bash.sh` refuses such a command when a session types it. -- `.github/workflows/image.yml` gives its token no `packages` permission. +Three parts hold this. They stop an accident. They are not a security boundary: a person +who means to push can write a push that they do not read. + +- `scripts/no-image-push.sh`, run by `make check`, reads each file as a shell reads a + command. It fails when a file would push an image, log in to a registry, make or fill a + GitHub release, or give a workflow `packages: write`. This includes Rust, Python and + JavaScript that pass `"push"` as an argument. +- `.claude/hooks/guard-bash.sh` refuses the same commands when a session types them. +- `.github/workflows/image.yml` names `packages: none`. GitHub already gives no + `packages` permission to a workflow that names other permissions, so this line only + makes the rule visible. + +A workflow that a branch changes runs before `make check` can stop it. Only a setting of +the repository or of the organisation can stop that first run. Ask ASF Infra to turn off +GitHub Packages for this repository if that matters. diff --git a/scripts/no-image-push.sh b/scripts/no-image-push.sh index 1e35909..31ffa0a 100755 --- a/scripts/no-image-push.sh +++ b/scripts/no-image-push.sh @@ -1,15 +1,32 @@ #!/bin/sh -# Nothing in this repository pushes an image to a registry, and this fails when something -# would. The harness image holds harnesses under their vendors' own licences, Claude Code -# and Copilot CLI among them, which the project may not redistribute, and an image built -# from unreleased source is not something the project may publish either (images/harness/ -# README.md says why). Each machine builds its own with `make image`. +# Nothing in this repository pushes an image to a registry or releases one, and this fails +# when something would. The harness image holds harnesses under their vendors' own +# licences, Claude Code and Copilot CLI among them, which the project may not +# redistribute, and an image built from unreleased source is not something the project may +# publish either (images/harness/README.md says why). Each machine builds its own with +# `make image`. # -# It reads every file git knows or would add, except Markdown, these two scripts and the -# guard and its tests, which name the same commands to refuse them. It fails on a shell -# line, a workflow step, a Makefile recipe or a Dockerfile that runs a push, logs in to a -# registry, or grants a workflow `packages: write`, and on Rust that passes "push" or -# "login" as a command's argument. A file it cannot read is a failure, not a pass. +# It reads each file the way a shell would read a command in it: a line continued with a +# backslash is one line, quotes around a word do not count, the words in front of a +# command (`sudo -E`, `env`, `xargs`, `then`, `sh -c`, a YAML `run:`, a make `@`) are passed +# over, and an engine named by a variable (`"$runtime" push`, `$(ENGINE) push`) is an +# engine. It fails on: +# +# - a push or a registry login by an engine or a registry tool, with its subcommand found +# past the options (`docker -H host push`) and through `image`, `manifest`, `compose` +# and the like (`docker image push`, `docker compose push`), and the other verbs that +# send an image (`crane cp`, `skopeo copy`, `ko publish`, `podman farm build`); +# - `--push`, `push=true` or `type=registry` on such a command; +# - the actions that push or log in, `imagetools`, a GitHub release made or uploaded to, +# and a workflow that grants `packages: write` or `write-all`; +# - in Rust, Python and JavaScript, a string that is exactly "push", or "login" in a file +# that names an engine, since those are a command's arguments however they are passed. +# +# It is a breaker against an accident, not a boundary: a person who means to push can +# write a push it does not read. It reads every file git knows or would add, except +# Markdown, these two scripts, the guard and its tests (which name the same commands to +# refuse them) and the vendored scripts under crates/web/static/vendor. A file it cannot +# read, a name git lists that is not a file, and a failure of git or awk all fail the run. # # scripts/no-image-push.sh [file...] default: the repository's files # @@ -20,60 +37,172 @@ set -eu here=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) root=$(CDPATH='' cd -- "$here/.." && pwd) -# a push or a registry login by any of the tools that can make one, the actions and -# options that push as they build, and the workflow permission a push needs -command_pattern='(^|[^[:alnum:]_-])(docker|podman|buildah|nerdctl|wslc(\.exe)?)([[:space:]]+(image|manifest))?[[:space:]]+(push|login)([^[:alnum:]_-]|$)' -command_pattern="$command_pattern"'|buildx[^|;&]*--push|build-push-action|push-to-registry' -command_pattern="$command_pattern"'|(^|[^[:alnum:]_-])skopeo[[:space:]]+(copy|sync|login)|(^|[^[:alnum:]_-])crane[[:space:]]+(push|copy|append|auth)' -command_pattern="$command_pattern"'|(^|[^[:alnum:]_-])oras[[:space:]]+(push|login)|(^|[^[:alnum:]_-])ko[[:space:]]+(publish|build)' -command_pattern="$command_pattern"'|packages:[[:space:]]*write|type=registry|push=true' -rust_pattern='\.args?\([[:space:]]*(\[[^]]*)?"(push|login)"' +engines='docker|podman|podman-remote|buildah|nerdctl|wslc|wslc[.]exe|docker-compose|podman-compose|skopeo|crane|gcrane|oras|regctl|ctr|helm|ko' -failures=0 +# shellcheck disable=SC2016 # the program is awk's, and its $ are awk's +program=' +function base(s, n, parts) { n = split(s, parts, "/"); return parts[n] } +function unquote(s) { gsub(/["\047\\]/, "", s); return s } +function engine(s) { return s ~ ("^(" engines ")$") } +function prefix(s) { + if (s ~ /^(sudo|doas|env|time|nohup|exec|command|nice|ionice|xargs|eval|then|do|else|if|while|until|!|-|run|run:|sh|bash|dash|zsh|ksh|ash)$/) return 1 + if (s ~ /^-/ && seen_prefix) return 1 + if (s ~ /^[a-z_][a-z0-9_]*=/) return 1 + return s ~ /:$/ +} +function verbs(tool) { + if (tool == "skopeo") return " copy sync login " + if (tool == "crane" || tool == "gcrane") return " push copy cp append auth " + if (tool == "oras") return " push cp copy login " + if (tool == "regctl") return " copy put login " + if (tool == "ko") return " publish build resolve apply " + return " push login " +} +# One command, its words in w[t..n] with the tool at w[t]: what in it sends an image out, +# or "" for nothing. +function judge(t, n, tool, i, first, second, before, v) { + tool = w[t] + if (tool ~ /^\$/) tool = "$"; else tool = base(tool) + first = ""; second = ""; before = "" + for (i = t + 1; i <= n; i++) { + if (w[i] == "--push" || w[i] ~ /push=true/ || w[i] ~ /type=registry/) return "--push" + if (w[i] ~ /^-/) { if (first == "") before = w[i]; continue } + if (first == "") { first = w[i]; continue } + if (second == "") second = w[i] + } + v = verbs(tool) + if (first != "" && index(v, " " first " ")) return first + # the verb after a group (`image push`, `compose push`), or after an option that took + # the first word as its value (`-H host push`) + if (first ~ /^(image|images|manifest|artifact|compose|system|registry|farm|buildx)$/ || (before != "" && before !~ /=/)) { + if (second != "" && index(v, " " second " ")) return second + if (first == "farm" && second == "build") return "farm build" + } + return "" +} +function report(line) { printf "%s:%d: %s\n", name, NR, line; found = 1 } +function scan(line, low, n, i, k, t, c, word, words) { + low = tolower(line) + if (low ~ /build-push-action|push-to-registry|login-action|podman-login|bake-action|action-gh-release|imagetools|write-all/ \ + || low ~ /packages[^#]*:[^#]*write/ \ + || low ~ /(^|[^[:alnum:]_-])gh[[:space:]]+release[[:space:]]+(create|upload)/) { + report(line) + return + } + gsub(/\$\([A-Za-z0-9_]+\)|\$\{[^}]*\}/, "$V", low) + gsub(/&&|\|\||[;|&(){}`]/, " ; ", low) + n = split(low, words, /[[:space:]]+/) + k = 0; t = 0; seen_prefix = 0 + for (i = 1; i <= n + 1; i++) { + word = (i <= n) ? unquote(words[i]) : ";" + if (word == ";") { + if (t > 0 && judge(t, k) != "") { report(line); return } + k = 0; t = 0; seen_prefix = 0 + continue + } + if (word == "") continue + k++; w[k] = word + if (t != 0) continue + if (prefix(word)) { seen_prefix = 1; continue } + c = word + sub(/^[@+-]+/, "", c) + c = base(c) + if (c != "" && (engine(c) || c ~ /^\$/)) { w[k] = c; t = k; continue } + t = -1 + } +} +function code(line, low) { + if (line ~ /["\047]push["\047]/) { report(line); return } + if (names_engine && line ~ /["\047]login["\047]/) { report(line); return } + low = tolower(line) + if (low ~ ("(" engines ")[[:space:]]+((image|manifest)[[:space:]]+)?(push|login)([^[:alnum:]_-]|$)")) report(line) +} +{ + gsub(/\r/, "") + if (is_code) { code($0); next } + if ($0 ~ /\\$/) { held = held substr($0, 1, length($0) - 1) " "; next } + scan(held $0) + held = "" +} +END { + if (held != "") scan(held) + exit found ? 1 : 0 +} +' check() { file=$1 - if [ ! -r "$file" ]; then + if [ ! -f "$file" ] || [ ! -r "$file" ]; then echo "no-image-push: cannot read $file" >&2 - failures=$((failures + 1)) - return + return 2 fi + is_code=0 + names_engine=0 case $file in - *.rs) pattern=$rust_pattern ;; - *) pattern=$command_pattern ;; + *.rs | *.py | *.js | *.mjs | *.cjs | *.ts) + is_code=1 + if tr -d '\000' < "$file" | grep -q -E "[\"']($engines)[\"']"; then names_engine=1; fi + ;; + esac + # NUL bytes out first: a binary file is read as text, and UTF-16 text becomes ASCII + status=0 + out=$(tr -d '\000' < "$file" | + awk -v name="$file" -v engines="$engines" -v is_code="$is_code" \ + -v names_engine="$names_engine" "$program" 2>&1) || status=$? + case $status in + 0) return 0 ;; + 1) + printf '%s\n' "$out" | sed 's/^/no-image-push: /' >&2 + return 1 + ;; + *) + echo "no-image-push: awk failed on $file: $out" >&2 + return 2 + ;; esac - if hits=$(grep -n -I -E -i -- "$pattern" "$file"); then - printf '%s\n' "$hits" | while IFS= read -r hit; do - echo "no-image-push: $file:$hit" >&2 - done - failures=$((failures + 1)) - fi } -if [ $# -gt 0 ]; then - for file in "$@"; do check "$file"; done +skipped() { + case $1 in + *.md | scripts/no-image-push.sh | scripts/no-image-push.test.sh) return 0 ;; + .claude/hooks/guard-bash.sh | .claude/hooks/guard-bash.test.sh) return 0 ;; + crates/web/static/vendor/*) return 0 ;; + *) return 1 ;; + esac +} + +failures=0 +if [ "${1:-}" = --listed ]; then + # called by the whole-repository run below, with the names git gave + shift + for file in "$@"; do + skipped "$file" && continue + if [ ! -e "$file" ] && [ ! -L "$file" ] && [ -n "$(git ls-files --deleted -- "$file")" ]; then + continue + fi + check "$file" || failures=$((failures + 1)) + done +elif [ $# -gt 0 ]; then + for file in "$@"; do check "$file" || failures=$((failures + 1)); done else cd "$root" - list=$(git ls-files --cached --others --exclude-standard) || { + if ! git -c core.quotePath=false ls-files -z --cached --others --exclude-standard > /dev/null; then echo "no-image-push: git could not list the files, so nothing was checked" >&2 exit 1 - } - [ -n "$list" ] || { echo "no-image-push: git listed no files" >&2; exit 1; } - # a name with a newline in it would split; git quotes such names, and none exist here - while IFS= read -r file; do - case $file in - *.md | scripts/no-image-push.sh | scripts/no-image-push.test.sh) continue ;; - .claude/hooks/guard-bash.sh | .claude/hooks/guard-bash.test.sh) continue ;; - crates/web/static/vendor/*) continue ;; - esac - [ -f "$file" ] || continue - check "$file" - done <&2 + exit 1 + fi + status=0 + git -c core.quotePath=false ls-files -z --cached --others --exclude-standard | + xargs -0 sh "$here/no-image-push.sh" --listed || status=$? + [ "$status" -eq 0 ] || exit 1 + exit 0 fi if [ "$failures" -ne 0 ]; then - echo "no-image-push: $failures file(s) would push an image or log in to a registry; this repository never does (images/harness/README.md)" >&2 + echo "no-image-push: $failures file(s) would push or release an image, or log in to a registry; this repository never does (images/harness/README.md)" >&2 exit 1 fi diff --git a/scripts/no-image-push.test.sh b/scripts/no-image-push.test.sh index 3a7094b..302071b 100755 --- a/scripts/no-image-push.test.sh +++ b/scripts/no-image-push.test.sh @@ -1,8 +1,9 @@ #!/bin/sh # What no-image-push.sh refuses, and what it must go on allowing. A wrong pass lets an # image out, so the refusals are the cases that matter; the allowed ones keep it from -# being switched off for refusing the repository's own builds. The last check runs it over -# the repository as it stands, which must pass. +# being switched off for refusing the repository's own builds. The whole-repository run is +# tested on a repository made here, with a push in a workflow and in a file whose name git +# would quote, and then run over this repository as it stands, which must pass. # # Run by `make check`. @@ -16,73 +17,162 @@ trap 'rm -rf "$dir"' EXIT failures=0 n=0 -# check yes|no NAME LINE: a file NAME holding LINE, and whether the script lets it through +# check refuse|pass NAME TEXT: a file NAME holding TEXT, and whether the script lets it +# through. TEXT may hold several lines. check() { - want=$1 name=$2 line=$3 + want=$1 name=$2 text=$3 n=$((n + 1)) mkdir -p "$dir/$n" file="$dir/$n/$name" - printf '%s\n' "$line" > "$file" + printf '%s\n' "$text" > "$file" status=0 sh "$script" "$file" 2>/dev/null || status=$? if [ "$want" = pass ] && [ "$status" -ne 0 ]; then - printf 'refused, should pass: %s: %s\n' "$name" "$line" >&2 + printf 'refused, should pass: %s: %s\n' "$name" "$text" >&2 failures=$((failures + 1)) - elif [ "$want" = refuse ] && [ "$status" -eq 0 ]; then - printf 'passed, should be refused: %s: %s\n' "$name" "$line" >&2 + elif [ "$want" = refuse ] && [ "$status" -ne 1 ]; then + printf 'not refused (exit %s), should be: %s: %s\n' "$status" "$name" "$text" >&2 failures=$((failures + 1)) fi } -# a push, by each tool that can make one, as a script, a recipe or a step would run it +# a push, by each tool that can make one check refuse run.sh 'podman push durable-memory/harness:dev ghcr.io/x/harness:dev' check refuse run.sh 'docker push ghcr.io/x/harness' check refuse Makefile ' wslc.exe push $(IMAGE)' check refuse Makefile ' wslc push $(IMAGE)' check refuse run.sh 'buildah push harness docker://ghcr.io/x/harness' check refuse run.sh 'nerdctl push x/harness' +check refuse run.sh 'podman-remote push x' +check refuse run.sh '/usr/bin/podman push x' check refuse run.sh 'docker image push ghcr.io/x/harness' check refuse run.sh 'podman manifest push harness docker://ghcr.io/x/harness' +check refuse run.sh 'podman artifact push x' +check refuse run.sh 'docker compose push' +check refuse run.sh 'docker-compose push' +check refuse run.sh 'podman-compose push' +check refuse run.sh 'ctr images push ghcr.io/x/h' +check refuse run.sh 'helm push chart.tgz oci://ghcr.io/x' +check refuse run.sh 'helm registry login ghcr.io' +check refuse run.sh 'podman farm build -t ghcr.io/x/h .' +# past the options, through the words in front, and across a continued line +check refuse run.sh 'docker -H tcp://builder:2375 push ghcr.io/x/h' +check refuse run.sh 'podman --remote push x' check refuse run.sh 'cd images && podman push x' +check refuse run.sh 'sudo -E podman push x' +check refuse run.sh 'env -i PATH=/usr/bin podman push x' +check refuse run.sh 'echo x | xargs podman push' +check refuse run.sh 'if true; then podman push x; fi' +check refuse run.sh 'for t in a b; do podman push "$t"; done' +check refuse run.sh '! podman push x' +check refuse run.sh "sh -c 'podman push x'" +check refuse run.sh "bash -lc 'podman push x'" +check refuse run.sh 'podman \ + push x' +check refuse run.sh 'docker buildx build \ + --push -t ghcr.io/x/h .' check refuse run.sh 'PODMAN PUSH x' +# quotes and variables +check refuse run.sh '"podman" push x' +check refuse run.sh "podman 'push' x" +check refuse run.sh '"$runtime" push "$image" ghcr.io/x/h' +check refuse run.sh '$ENGINE push x' +check refuse run.sh '${DOCKER:-docker} push x' +check refuse Makefile ' $(ENGINE) push $(IMAGE)' +check refuse Makefile ' @podman push $(IMAGE)' +# the other ways an image goes out +check refuse run.sh 'docker build --push -t ghcr.io/x/h .' check refuse ci.yml ' run: docker buildx build --push -t ghcr.io/x/h images/harness' -check refuse ci.yml ' - uses: docker/build-push-action@v6' -check refuse ci.yml ' - uses: redhat-actions/push-to-registry@v2' -check refuse ci.yml ' packages: write' check refuse ci.yml ' run: docker buildx build --output type=registry .' check refuse ci.yml ' run: docker buildx build --output type=image,push=true .' +check refuse run.sh 'docker buildx imagetools create -t ghcr.io/x/h a b' check refuse run.sh 'skopeo copy containers-storage:x docker://ghcr.io/x/h' +check refuse run.sh 'skopeo sync --src dir x --dest docker ghcr.io/x' check refuse run.sh 'crane push image.tar ghcr.io/x/h' +check refuse run.sh 'crane cp a ghcr.io/x/h' +check refuse run.sh 'crane append -b a -f l.tar -t ghcr.io/x/h' +check refuse run.sh 'gcrane push x' check refuse run.sh 'oras push ghcr.io/x/h:1 file' +check refuse run.sh 'oras cp a ghcr.io/x/h' +check refuse run.sh 'regctl image copy a ghcr.io/x/h' check refuse run.sh 'ko publish ./cmd/x' +check refuse run.sh 'gh release upload v1 harness.tar' +check refuse run.sh 'gh release create v1 harness.tar' # a login is the step before a push, and nothing here needs one check refuse run.sh 'echo "$T" | podman login ghcr.io -u x --password-stdin' check refuse ci.yml ' run: docker login -u x -p "$T"' -# Rust that runs a push +check refuse run.sh 'skopeo login ghcr.io' +check refuse run.sh 'oras login ghcr.io' +check refuse run.sh 'crane auth login ghcr.io' +# workflows +check refuse ci.yml ' - uses: docker/build-push-action@v6' +check refuse ci.yml ' - uses: redhat-actions/push-to-registry@v2' +check refuse ci.yml ' - uses: docker/login-action@v3' +check refuse ci.yml ' - uses: redhat-actions/podman-login@v1' +check refuse ci.yml ' - uses: docker/bake-action@v5' +check refuse ci.yml ' - uses: softprops/action-gh-release@v2' +check refuse ci.yml ' packages: write' +check refuse ci.yml ' packages: "write"' +check refuse ci.yml ' packages : write' +check refuse ci.yml 'permissions: write-all' +check refuse ci.yml ' - run: | + docker build -t x . + docker push x' +check refuse Dockerfile 'RUN podman push x' +# code that runs a push, however the arguments are passed check refuse main.rs 'Command::new("podman").arg("push").arg(&tag)' check refuse main.rs 'Command::new(wslc).args(["push", &tag])' -check refuse main.rs 'let c = cmd.args( [ "login", "ghcr.io" ]);' +check refuse main.rs 'run(rt, &["push", image])' +check refuse main.rs 'run_ok(&s(&["push", tag]))' +check refuse main.rs 'let args = vec![ + "push", + tag, +];' +check refuse main.rs 'const VERB: &str = "push";' +check refuse main.rs 'cmd.arg("podman push x")' +check refuse main.rs 'Command::new("podman").args(["login", "ghcr.io"])' +check refuse x.py 'subprocess.run(["podman", "push", tag])' +check refuse x.py "os.system('docker push x')" +check refuse x.mjs 'spawn("podman", ["push", t])' +check refuse x.ts "execFile('docker', ['login'])" # what the repository does and must go on doing check pass Makefile ' else podman build -t $(IMAGE) images/harness; fi' -check pass Makefile ' wslc.exe build -t $(IMAGE) "$$(wslpath -w images/harness)"; \' +check pass Makefile ' wslc.exe build -t $(IMAGE) "$$(wslpath -w images/harness)"; \ + elif command -v wslc >/dev/null 2>&1; then wslc build -t $(IMAGE) images/harness; \ + else podman build -t $(IMAGE) images/harness; fi' check pass ci.yml ' run: docker build --pull -t harness:${{ github.sha }} images/harness' check pass ci.yml ' git push -f origin bot/harness-pins' +check pass ci.yml ' gh pr create --fill --base main --head bot/harness-pins' check pass ci.yml ' contents: write' +check pass ci.yml ' pull-requests: write' check pass ci.yml ' packages: none' check pass ci.yml ' packages: read' +check pass ci.yml ' - uses: actions/upload-artifact@v7' check pass run.sh 'podman pull docker.io/library/node:24' check pass run.sh 'podman run --rm -v "$PWD":/r:Z -w /r docker.io/library/node:24 sh x.sh' +check pass run.sh 'podman run --rm --name login alpine true' +check pass run.sh 'podman inspect login' +check pass run.sh 'podman logs push' check pass run.sh 'podman save harness | tar -x' +check pass run.sh 'podman load -i harness.tar' +check pass run.sh 'podman export "$(podman create x)" | tar -x -C rootfs' +check pass run.sh 'podman compose up -d' +check pass run.sh 'exec "$runtime" run --rm -v "$root":/r x' +check pass run.sh '"$ENGINE" save x -o x.tar' +check pass run.sh 'skopeo inspect docker://docker.io/library/node:24' +check pass run.sh 'gh release download release-1 -R google/gvisor -p x.tar.bz2' check pass run.sh 'git push origin HEAD' -check pass run.sh 'ssh-copy-id x' -check pass main.rs 'v.push(item);' -check pass main.rs 'Command::new("git").arg("pushd")' -check pass main.rs 'let s = "push";' +check pass run.sh 'echo "$x" push' check pass run.sh 'pushd /tmp && popd' check pass run.sh 'docker-push-helper --help' +check pass Dockerfile 'RUN npm ci --no-fund --no-audit' +check pass main.rs 'v.push(item);' +check pass main.rs 'Command::new("git").arg("pushd")' +check pass main.rs '(_, AuthMode::Subscription) => (!f.logged_in.contains(&h)).then(|| "login".to_string()),' +check pass x.ts 'items.push(x); const pushed = 1;' -# a file it cannot read is a failure, not a pass +# a file it cannot read, and a directory, fail; they do not pass n=$((n + 1)) status=0 sh "$script" "$dir/missing" 2>/dev/null || status=$? @@ -90,15 +180,87 @@ if [ "$status" -eq 0 ]; then echo 'a missing file passed, should fail' >&2 failures=$((failures + 1)) fi +n=$((n + 1)) +status=0 +sh "$script" "$dir" 2>/dev/null || status=$? +if [ "$status" -eq 0 ]; then + echo 'a directory passed, should fail' >&2 + failures=$((failures + 1)) +fi +# an awk that fails is a failure, not a pass +n=$((n + 1)) +mkdir -p "$dir/badbin" +printf '#!/bin/sh\nexit 2\n' > "$dir/badbin/awk" +chmod +x "$dir/badbin/awk" +printf 'podman build -t x .\n' > "$dir/ok.sh" +status=0 +PATH="$dir/badbin:$PATH" sh "$script" "$dir/ok.sh" 2>/dev/null || status=$? +if [ "$status" -eq 0 ]; then + echo 'a run where awk failed passed, should fail' >&2 + failures=$((failures + 1)) +fi +# NUL bytes, and UTF-16 text, do not hide a push +n=$((n + 1)) +printf 'podman push x\n\000\n' > "$dir/nul.sh" +printf 'p\000o\000d\000m\000a\000n\000 \000p\000u\000s\000h\000 \000x\000\n\000' > "$dir/utf16.ps1" +for f in "$dir/nul.sh" "$dir/utf16.ps1"; do + status=0 + sh "$script" "$f" 2>/dev/null || status=$? + if [ "$status" -ne 1 ]; then + echo "a push in $f passed, should be refused" >&2 + failures=$((failures + 1)) + fi +done -# the repository as it stands +# the whole-repository run: a repository with a push in a workflow and in a file whose +# name git quotes, and Markdown that mentions one, must fail and name both files +n=$((n + 1)) +repo="$dir/repo" +mkdir -p "$repo/scripts" "$repo/.github/workflows" +cp "$script" "$repo/scripts/no-image-push.sh" +printf ' - run: docker push x\n' > "$repo/.github/workflows/x.yml" +printf 'podman push x\n' > "$repo/scripts/d\303\251ploy.sh" +printf 'podman push x\n' > "$repo/scripts/tab x.sh" +printf 'Never run `podman push`.\n' > "$repo/README.md" +git -C "$repo" init -q +git -C "$repo" add -A +status=0 +err=$(sh "$repo/scripts/no-image-push.sh" 2>&1) || status=$? +case $status:$err in + 1:*x.yml*déploy.sh*tab*x.sh* | 1:*x.yml*tab*x.sh*déploy.sh* | 1:*déploy.sh*x.yml*) : ;; + *) + printf 'the whole-repository run missed a push (exit %s): %s\n' "$status" "$err" >&2 + failures=$((failures + 1)) + ;; +esac +case $err in + *déploy.sh*) : ;; + *) echo 'the whole-repository run skipped a file whose name git quotes' >&2; failures=$((failures + 1)) ;; +esac +case $err in + *tab*) : ;; + *) echo 'the whole-repository run skipped a file with a tab in its name' >&2; failures=$((failures + 1)) ;; +esac +# outside a repository, git cannot list: that fails, it does not pass +n=$((n + 1)) +mkdir -p "$dir/norepo/scripts" +cp "$script" "$dir/norepo/scripts/no-image-push.sh" +status=0 +GIT_CEILING_DIRECTORIES="$dir" sh "$dir/norepo/scripts/no-image-push.sh" 2>/dev/null || status=$? +if [ "$status" -eq 0 ]; then + echo 'a run where git could not list the files passed, should fail' >&2 + failures=$((failures + 1)) +fi + +# this repository as it stands +n=$((n + 1)) if ! sh "$script"; then echo 'the repository itself does not pass' >&2 failures=$((failures + 1)) fi if [ "$failures" -ne 0 ]; then - echo "no-image-push.test.sh: $failures of $n cases failed" >&2 + echo "no-image-push.test.sh: $failures failures in $n cases" >&2 exit 1 fi echo "no-image-push.sh: every case holds" From 922f9aad0c47ec9e8a8ceb2cd069b114ee3e7285 Mon Sep 17 00:00:00 2001 From: Sander Striker Date: Tue, 27 Oct 2026 14:03:15 +0100 Subject: [PATCH 3/4] fix: answer the second adversarial pass on the image breaker The pass found three faults that this branch made: - In a linked worktree's pre-commit hook, the test acted on the real repository. The test now unsets the git variables, and it stages nothing. - The guard looped forever on a bare env, sudo or xargs. - The guard recursed on bash -o pipefail -c. Both breakers now pass over the options of the words in front of a command, and over the values of an engine's options. They refuse compose -f pushes, release and login actions, and bake files. They no longer refuse imagetools inspect, a pull with skopeo, or --debug logs push. Each guard case has a time limit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Bhj4Ck4y5iwZUsjpgQx8A2 --- .claude/hooks/guard-bash.sh | 171 ++++++++++++++++++------------- .claude/hooks/guard-bash.test.sh | 57 ++++++++++- images/harness/README.md | 15 ++- scripts/no-image-push.sh | 109 ++++++++++++++------ scripts/no-image-push.test.sh | 145 +++++++++++++++++++++++++- 5 files changed, 389 insertions(+), 108 deletions(-) diff --git a/.claude/hooks/guard-bash.sh b/.claude/hooks/guard-bash.sh index 689cfe9..f6f6795 100755 --- a/.claude/hooks/guard-bash.sh +++ b/.claude/hooks/guard-bash.sh @@ -123,7 +123,7 @@ command=$( sed -e 's/\\n/\ /g' -e 's/\\[rt]/ /g' -e 's/\\"/"/g' -e 's/\\\\/\\/g' | drop_heredoc_bodies | - sed -e ':a' -e '/\\$/N' -e 's/\\\n/ /' -e 'ta' | + sed -e ':a' -e '$!{' -e '/\\$/{' -e 'N' -e 's/\\\n/ /' -e 'ta' -e '}' -e '}' | tr '\n' ';' ) @@ -165,44 +165,47 @@ strip_prefixes() { case ${_rest%%[[:space:]]*} in time | nohup | exec | command | then | do | else | if | while | until | '!' | eval | *=*) : ;; # these take options of their own before the command, and some options a value: - # `sudo -u root podman`, `env -i PATH=x podman`, `nice -n 5 podman`, `xargs -I{} podman` - sudo | doas | env | nice | ionice | xargs) - _rest=${_rest#*[[:space:]]} + # `sudo -u root podman`, `env -i PATH=x podman`, `nice -n 5 podman`, `xargs -I{} podman`. + # A wrapper with nothing after it (`ls | xargs`, a bare `env`) runs nothing to read. + sudo | doas | env | nice | ionice | xargs | timeout) + _wrapper=${_rest%%[[:space:]]*} + case $_rest in + *[[:space:]]*) _rest=${_rest#*[[:space:]]} ;; + *) + _rest='' + break + ;; + esac while :; do - case ${_rest%%[[:space:]]*} in - -u | -g | -C | -h | -p | -n | -c | -I | -L | -P | -s | -d | -E | -i | -S | -H | -[a-zA-Z] | -[a-zA-Z]* | --*) - case ${_rest%%[[:space:]]*} in - -u | -g | -C | -h | -p | -n | -c | -I | -L | -P | -s | -d) _skip_value=yes ;; - *) _skip_value='' ;; - esac - case $_rest in - *[[:space:]]*) _rest=${_rest#*[[:space:]]} ;; - *) break ;; - esac - if [ -n "$_skip_value" ]; then - case $_rest in - *[[:space:]]*) _rest=${_rest#*[[:space:]]} ;; - *) break ;; - esac - fi - ;; + _word=${_rest%%[[:space:]]*} + case $_word in + -*) : ;; + # `timeout 600 make test`: the duration before the command + [0-9]*) [ "$_wrapper" = timeout ] || break ;; *) break ;; esac - done - continue - ;; - # `timeout 600 make test` runs make, and so does `timeout --signal=KILL 600`. - timeout) - _rest=${_rest#*[[:space:]]} - while :; do - case ${_rest%%[[:space:]]*} in - -* | [0-9]*) : ;; - *) break ;; + _skip_value='' + case $_wrapper:$_word in + sudo:-[ugChpUrtTD] | doas:-[uC] | env:-[uCS] | nice:-n | ionice:-[cnp] | \ + xargs:-[InLPsdEa] | timeout:-[sk]) _skip_value=yes ;; + *) : ;; esac case $_rest in *[[:space:]]*) _rest=${_rest#*[[:space:]]} ;; - *) break ;; + *) + _rest='' + break + ;; esac + if [ -n "$_skip_value" ]; then + case $_rest in + *[[:space:]]*) _rest=${_rest#*[[:space:]]} ;; + *) + _rest='' + break + ;; + esac + fi done continue ;; @@ -298,34 +301,51 @@ check_git_stash() { # files to the same rule, read the same way; this holds what a session types. # # Quotes and backslashes in a word do not count (`"podman" push`), and a command named by -# a variable (`$ENGINE push`) is read as an engine. The verb is the first word that is not -# an option, or the second after a group (`image push`, `compose push`) or after an option -# that took the first as its value (`-H host push`): `podman run --name login` is a run. +# a variable (`$ENGINE push`) is read as an engine, unless the variable names git. The verb +# is the first word that is not an option or an option's value, or the next after a group +# (`image push`, `compose -f a.yml push`): `docker -H host push` pushes, and `podman run +# --name login` and `docker --debug logs push` do not. check_image_push() { _tool=$(printf '%s' "${1##*/}" | tr -d "\"'\\\\") shift case $_tool in + '$'*git* | '$'*GIT*) return 0 ;; '$'*) _tool='$' ;; *) : ;; esac - _first='' _second='' _before='' + _n1='' _n2='' _n3='' _last='' _value='' _all=' ' for _w in "$@"; do _w=$(printf '%s' "$_w" | tr -d "\"'\\\\") + _all="$_all$_w " case $_w in --push | *push=true* | *type=registry*) refuse "\`$_tool ... $_w\` would send an image to a registry. This repository never publishes an image: the harness image holds harnesses the project may not redistribute." \ "Build and keep it local: make image. images/harness/README.md says why." ;; - -*) - [ -n "$_first" ] || _before=$_w + *) : ;; + esac + if [ -n "$_value" ]; then + _value='' + continue + fi + case $_w in + -[HcfnlpC] | --host | --context | --config | --log-level | --url | --connection | --identity | \ + --root | --runroot | --storage-driver | --namespace | --address | --kubeconfig | --file | \ + --project-name | --profile | --env-file | --project-directory | --name | --network | --tag | \ + --creds | --authfile | --platform) + _value=yes continue ;; + -*) continue ;; *) : ;; esac - if [ -z "$_first" ]; then - _first=$_w - elif [ -z "$_second" ]; then - _second=$_w + _last=$_w + if [ -z "$_n1" ]; then + _n1=$_w + elif [ -z "$_n2" ]; then + _n2=$_w + elif [ -z "$_n3" ]; then + _n3=$_w fi done case $_tool in @@ -338,24 +358,30 @@ check_image_push() { esac _hit='' for _v in $_verbs; do - [ "$_first" = "$_v" ] && _hit=$_v + [ "$_n1" = "$_v" ] && _hit=$_v done - _group='' - case $_first in - image | images | manifest | artifact | compose | system | registry | farm | buildx) _group=yes ;; + case $_n1 in + image | images | manifest | artifact | compose | system | registry) + for _v in $_verbs; do + [ "$_n2" = "$_v" ] && _hit=$_v + done + ;; *) : ;; esac - case $_before in - '' | *=*) : ;; - *) _group=yes ;; + [ "$_n1 $_n2" = "farm build" ] && _hit='farm build' + [ "$_n1 $_n2 $_n3" = "buildx imagetools create" ] && _hit='imagetools create' + case $_tool:$_n1:$_all in + buildah:commit:*' docker://'*) _hit='commit docker://' ;; + # a copy or sync to a local store pulls, and a local ko build pushes nothing + skopeo:copy:* | skopeo:sync:*) + case $_last in + oci:* | oci-archive:* | dir:* | docker-archive:* | containers-storage:* | docker-daemon:*) _hit='' ;; + *) : ;; + esac + ;; + ko:*:*' --local '* | ko:*:*' --push=false '*) _hit='' ;; + *) : ;; esac - if [ -z "$_hit" ] && [ -n "$_group" ]; then - for _v in $_verbs; do - [ "$_second" = "$_v" ] && _hit=$_v - done - [ "$_first $_second" = "farm build" ] && _hit='farm build' - [ "$_first $_second" = "buildx imagetools" ] && _hit='buildx imagetools' - fi if [ -n "$_hit" ]; then refuse "\`$_tool ... $_hit\` would send an image to a registry, or log in to send one. This repository never publishes an image: the harness image holds harnesses the project may not redistribute." \ "Build and keep it local: make image. images/harness/README.md says why." @@ -731,7 +757,7 @@ check_gh() { esac done case "${1:-} ${2:-}" in - 'release create' | 'release upload') + 'release create' | 'release upload' | 'release edit') refuse "\`gh ${1} ${2}\` makes or fills a GitHub release. This repository never publishes a release there: an Apache release is voted on and goes to downloads.apache.org." \ "Leave releases to the release process. images/harness/README.md says why nothing here publishes." ;; @@ -781,21 +807,26 @@ check_part() { case ${_head##*/} in sh | bash | dash | ash | zsh | ksh) case $_stripped in - # -c alone, or at the end of a cluster: `bash -lc`, `sh -xc` + # -c alone, or at the end of a cluster (`bash -lc`, `sh -xc`), after any other + # options, `-o pipefail` and `--login` among them *[[:space:]]-c[[:space:]]* | *[[:space:]]-[a-zA-Z]*c[[:space:]]*) _inner=$(printf '%s\n' "$_stripped" | - sed 's/^[^[:space:]]*\([[:space:]][[:space:]]*-[a-zA-Z]*\)*[[:space:]][[:space:]]*-[a-zA-Z]*c[[:space:]][[:space:]]*//') - # The command string is quoted as a whole, and the quotes around it - # are the shell's rather than part of what runs. - case $_inner in - "'"*"'" | '"'*'"') - _inner=${_inner#?} - _inner=${_inner%?} - ;; - *) : ;; - esac - check_line "$_inner" - return 0 + sed -E 's/^[^[:space:]]+([[:space:]]+(-[a-zA-Z]*o[[:space:]]+[^-[:space:]][^[:space:]]*|--[a-z-]+|-[a-zA-Z]+))*[[:space:]]+-[a-zA-Z]*c[[:space:]]+//') + # What the pattern could not take apart is not a -c string, and reading it + # again as one would never end: it is read below as the command it is. + if [ "$_inner" != "$_stripped" ]; then + # The command string is quoted as a whole, and the quotes around it + # are the shell's rather than part of what runs. + case $_inner in + "'"*"'" | '"'*'"') + _inner=${_inner#?} + _inner=${_inner%?} + ;; + *) : ;; + esac + check_line "$_inner" + return 0 + fi ;; *) : ;; esac diff --git a/.claude/hooks/guard-bash.test.sh b/.claude/hooks/guard-bash.test.sh index 473be14..505e80c 100755 --- a/.claude/hooks/guard-bash.test.sh +++ b/.claude/hooks/guard-bash.test.sh @@ -29,9 +29,20 @@ payload_for() { "$(as_json_string "$1")" "$(as_json_string "${2:-a command}")" } +# A guard that loops is a guard that stalls every call until the hook times out and then +# lets it run: ten seconds a case turns that into a failure here, where `timeout` exists. +if command -v timeout > /dev/null 2>&1; then + limit='timeout 10' +else + limit='' +fi + run_guard() { status=0 - output=$(payload_for "$1" "${2:-}" | sh "$guard" 2>&1) || status=$? + output=$(payload_for "$1" "${2:-}" | $limit sh "$guard" 2>&1) || status=$? + if [ "$status" -eq 124 ]; then + output="the guard did not finish in ten seconds" + fi } allow() { @@ -180,6 +191,26 @@ refuse "bash -xc 'podman push x'" refuse 'if true; then git add -A; fi' refuse 'sudo -u me git add -A' refuse "bash -lc 'git add -A'" +refuse 'if podman push x; then :; fi' +refuse 'if a; then b; else podman push x; fi' +refuse 'while podman push x; do :; done' +refuse 'until podman push x; do :; done' +refuse 'ionice -c 3 podman push x' +refuse 'sudo -g wheel podman push x' +refuse 'sudo -n podman push x' +refuse 'env -u FOO podman push x' +refuse 'env -i podman push x' +refuse 'xargs -n 1 podman push < tags' +refuse 'timeout -s KILL 600 docker push x' +refuse 'timeout 600 docker push x' +refuse 'docker compose -f local/compose.yml push' +refuse 'podman compose -f a.yml -f b.yml push' +refuse 'ctr -n k8s.io images push x' +refuse 'docker --context remote image push x' +refuse 'buildah commit ctr docker://ghcr.io/apache/harness' +refuse 'gh release edit v1 --draft=false' +refuse "bash -o pipefail -c 'podman push x'" +refuse "bash --login -c 'git add -A'" allow 'podman build -t durable-memory/harness:dev images/harness' allow 'podman run --rm --name login alpine true' allow 'podman run --rm -e push alpine true' @@ -197,6 +228,30 @@ allow 'gh release list' allow 'crane digest ghcr.io/apache/harness' allow 'grep -n "podman push" scripts/no-image-push.test.sh' allow 'make no-image-push-test' +allow 'docker buildx imagetools inspect node:24' +allow 'docker --debug logs push' +allow 'podman --remote run --rm --name login alpine true' +allow 'skopeo copy docker://docker.io/library/node:24 oci:node' +allow 'ko build --local ./cmd/x' +allow '$GIT push origin main' +allow '"$git" push origin HEAD' +# a wrapper with nothing after it runs nothing; reading it must end +allow 'env' +allow 'git status; env' +allow 'echo a b | xargs' +allow 'sudo' +allow 'timeout' +# a shell's own options before -c, and a script run with options of its own +allow "bash -o pipefail -c 'cargo test 2>&1 | tee /tmp/t.log'" +allow "bash -euo pipefail -c 'make check'" +allow "bash --login -c 'make check'" +allow "bash --norc -c 'cargo build'" +allow 'bash -x scripts/foo.sh src tests' +allow 'sh scripts/x.sh -v --spec foo' +allow 'sudo apt-get install -y podman' +allow 'env FOO=1 cargo test' +allow 'nice -n 5 make check' +allow 'ls | xargs -0 grep -l x' allow 'wslc.exe build -t durable-memory/harness:dev images/harness' allow 'docker build --pull -t harness:abc images/harness' allow 'podman pull docker.io/library/node:24' diff --git a/images/harness/README.md b/images/harness/README.md index 7bd25e4..a80f656 100644 --- a/images/harness/README.md +++ b/images/harness/README.md @@ -80,6 +80,15 @@ who means to push can write a push that they do not read. `packages` permission to a workflow that names other permissions, so this line only makes the rule visible. -A workflow that a branch changes runs before `make check` can stop it. Only a setting of -the repository or of the organisation can stop that first run. Ask ASF Infra to turn off -GitHub Packages for this repository if that matters. +What they do not stop: + +- A push written to get past them, for example an engine found with `$(command -v podman)`, + or a command run through `cmd.exe /c` or `powershell.exe`. +- An image saved to a file and then uploaded some other way, for example as a workflow + artifact. +- A workflow that a branch changes. It runs before `make check` can stop it. Only a + setting of the repository or of the organisation can stop that first run. Ask ASF Infra + to turn off GitHub Packages for this repository if that matters. + +`scripts/no-image-push.sh` also refuses a Rust, Python or JavaScript string that is +exactly `"push"`, whatever it is for. Write such a word another way. diff --git a/scripts/no-image-push.sh b/scripts/no-image-push.sh index 31ffa0a..3562be4 100755 --- a/scripts/no-image-push.sh +++ b/scripts/no-image-push.sh @@ -8,9 +8,11 @@ # # It reads each file the way a shell would read a command in it: a line continued with a # backslash is one line, quotes around a word do not count, the words in front of a -# command (`sudo -E`, `env`, `xargs`, `then`, `sh -c`, a YAML `run:`, a make `@`) are passed -# over, and an engine named by a variable (`"$runtime" push`, `$(ENGINE) push`) is an -# engine. It fails on: +# command (`sudo -u root`, `env -i`, `xargs -n 1`, `timeout 600`, `then`, `sh -c`, a YAML +# `run:`, a make `@`) are passed over with their options, an engine's own options are +# passed over with their values (`docker -H host push`, `compose -f a.yml push`), and an +# engine named by a variable (`"$runtime" push`, `$(ENGINE) push`) is an engine, unless +# the variable names git. It fails on: # # - a push or a registry login by an engine or a registry tool, with its subcommand found # past the options (`docker -H host push`) and through `image`, `manifest`, `compose` @@ -25,8 +27,9 @@ # It is a breaker against an accident, not a boundary: a person who means to push can # write a push it does not read. It reads every file git knows or would add, except # Markdown, these two scripts, the guard and its tests (which name the same commands to -# refuse them) and the vendored scripts under crates/web/static/vendor. A file it cannot -# read, a name git lists that is not a file, and a failure of git or awk all fail the run. +# refuse them), the vendored scripts under crates/web/static/vendor, and a binary file not +# yet added. A file it cannot read, a name git lists that is not a file, git listing +# nothing, and a failure of git or awk all fail the run. # # scripts/no-image-push.sh [file...] default: the repository's files # @@ -44,11 +47,25 @@ program=' function base(s, n, parts) { n = split(s, parts, "/"); return parts[n] } function unquote(s) { gsub(/["\047\\]/, "", s); return s } function engine(s) { return s ~ ("^(" engines ")$") } -function prefix(s) { - if (s ~ /^(sudo|doas|env|time|nohup|exec|command|nice|ionice|xargs|eval|then|do|else|if|while|until|!|-|run|run:|sh|bash|dash|zsh|ksh|ash)$/) return 1 - if (s ~ /^-/ && seen_prefix) return 1 - if (s ~ /^[a-z_][a-z0-9_]*=/) return 1 - return s ~ /:$/ +# The words in front of a command, and the options each takes before the command: a word +# here is passed over, and so is the value of an option that takes one. The line is in +# lower case by now, so `-C` and `-c` are one option here. +function wrapper(s) { return s ~ /^(sudo|doas|env|nice|ionice|xargs|timeout|time|nohup|exec|command|eval|then|do|else|if|while|until|!|-|run|run:|sh|bash|dash|zsh|ksh|ash)$/ } +function takes_value(tool, o) { + if (tool == "sudo") return o ~ /^-[ugchprtd]$/ + if (tool == "doas") return o ~ /^-[uc]$/ + if (tool == "env") return o ~ /^-[ucs]$/ + if (tool == "nice") return o ~ /^-n$/ + if (tool == "ionice") return o ~ /^-[cnp]$/ + if (tool == "xargs") return o ~ /^-[inlpsdea]$/ + if (tool == "timeout") return o ~ /^-[sk]$/ + if (tool ~ /^(sh|bash|dash|zsh|ksh|ash)$/) return o ~ /^-[a-z]*o$/ + return 0 +} +# An engine option that takes the next word as its value: `docker -H host push`, +# `docker compose -f a.yml push`, `ctr -n ns images push`. +function engine_value(o) { + return o ~ /^(-[hcfnlp]|--(host|context|config|log-level|url|connection|identity|root|runroot|storage-driver|namespace|address|kubeconfig|file|project-name|profile|env-file|project-directory|name|network|tag|creds|authfile|platform))$/ } function verbs(tool) { if (tool == "skopeo") return " copy sync login " @@ -58,55 +75,69 @@ function verbs(tool) { if (tool == "ko") return " publish build resolve apply " return " push login " } +function local_target(s) { return s ~ /^(oci|oci-archive|dir|docker-archive|containers-storage|docker-daemon):/ } # One command, its words in w[t..n] with the tool at w[t]: what in it sends an image out, # or "" for nothing. -function judge(t, n, tool, i, first, second, before, v) { +function judge(t, n, tool, i, m, nw, value, v, last) { tool = w[t] if (tool ~ /^\$/) tool = "$"; else tool = base(tool) - first = ""; second = ""; before = "" + m = 0; value = 0 for (i = t + 1; i <= n; i++) { if (w[i] == "--push" || w[i] ~ /push=true/ || w[i] ~ /type=registry/) return "--push" - if (w[i] ~ /^-/) { if (first == "") before = w[i]; continue } - if (first == "") { first = w[i]; continue } - if (second == "") second = w[i] + if (value) { value = 0; continue } + if (w[i] ~ /^-/) { if (w[i] !~ /=/ && engine_value(w[i])) value = 1; continue } + nw[++m] = w[i] } + if (m == 0) return "" v = verbs(tool) - if (first != "" && index(v, " " first " ")) return first - # the verb after a group (`image push`, `compose push`), or after an option that took - # the first word as its value (`-H host push`) - if (first ~ /^(image|images|manifest|artifact|compose|system|registry|farm|buildx)$/ || (before != "" && before !~ /=/)) { - if (second != "" && index(v, " " second " ")) return second - if (first == "farm" && second == "build") return "farm build" + last = nw[m] + if (index(v, " " nw[1] " ")) { + if (tool == "skopeo" && nw[1] != "login" && local_target(last)) return "" + if (tool == "ko" && (index(" " joined " ", " --local ") || index(joined, "--push=false"))) return "" + return nw[1] } + if (nw[1] ~ /^(image|images|manifest|artifact|compose|system|registry)$/ && m >= 2 && index(v, " " nw[2] " ")) return nw[2] + if (nw[1] == "farm" && nw[2] == "build") return "farm build" + if (nw[1] == "buildx" && nw[2] == "imagetools" && nw[3] == "create") return "imagetools create" + if (tool == "buildah" && nw[1] == "commit" && index(joined, "docker://")) return "commit docker://" return "" } function report(line) { printf "%s:%d: %s\n", name, NR, line; found = 1 } -function scan(line, low, n, i, k, t, c, word, words) { +function scan(line, low, n, i, k, t, c, word, words, opts_of, value) { low = tolower(line) - if (low ~ /build-push-action|push-to-registry|login-action|podman-login|bake-action|action-gh-release|imagetools|write-all/ \ - || low ~ /packages[^#]*:[^#]*write/ \ - || low ~ /(^|[^[:alnum:]_-])gh[[:space:]]+release[[:space:]]+(create|upload)/) { + if (low ~ /build-push-action|push-to-registry|bake-action|copytoregistry|deploy-pages|write-all/ \ + || low ~ /uses:[^#]*(release|login|ecr|ghcr|registry|docker-build-push)/ \ + || low ~ /(packages|pages)[^#]*:[^#]*write/ \ + || low ~ /type=registry|push=true|push:[[:space:]]*true|"push"[[:space:]]*:[[:space:]]*true/ \ + || low ~ /(^|[^[:alnum:]_-])gh[[:space:]]+release[[:space:]]+(create|upload|edit)/ \ + || low ~ /(^|[^[:alnum:]_-])gh[[:space:]]+api[^#]*\/(releases|packages)[^#]*(-x|--method|-f|--field|--raw-field|--input)/ \ + || low ~ ("[\"\047](" engines ")[\"\047][[:space:]]*,[[:space:]]*[\"\047](push|login)[\"\047]")) { report(line) return } gsub(/\$\([A-Za-z0-9_]+\)|\$\{[^}]*\}/, "$V", low) gsub(/&&|\|\||[;|&(){}`]/, " ; ", low) n = split(low, words, /[[:space:]]+/) - k = 0; t = 0; seen_prefix = 0 + k = 0; t = 0; opts_of = ""; value = 0; joined = "" for (i = 1; i <= n + 1; i++) { word = (i <= n) ? unquote(words[i]) : ";" if (word == ";") { if (t > 0 && judge(t, k) != "") { report(line); return } - k = 0; t = 0; seen_prefix = 0 + k = 0; t = 0; opts_of = ""; value = 0; joined = "" continue } if (word == "") continue - k++; w[k] = word + k++; w[k] = word; joined = joined " " word if (t != 0) continue - if (prefix(word)) { seen_prefix = 1; continue } + if (value) { value = 0; continue } + if (opts_of != "" && word ~ /^-/) { value = takes_value(opts_of, word); continue } + if (opts_of == "timeout" && word ~ /^[0-9]/) continue + if (wrapper(word)) { opts_of = word; continue } + if (word ~ /^[a-z_][a-z0-9_]*=/ || word ~ /:$/) continue c = word sub(/^[@+-]+/, "", c) c = base(c) + if (c ~ /^\$/ && c ~ /git/) { t = -1; continue } if (c != "" && (engine(c) || c ~ /^\$/)) { w[k] = c; t = k; continue } t = -1 } @@ -114,13 +145,16 @@ function scan(line, low, n, i, k, t, c, word, words) { function code(line, low) { if (line ~ /["\047]push["\047]/) { report(line); return } if (names_engine && line ~ /["\047]login["\047]/) { report(line); return } + if (names_engine && line ~ /"[^"]*[[:space:]]push([[:space:]][^"]*)?"/) { report(line); return } low = tolower(line) if (low ~ ("(" engines ")[[:space:]]+((image|manifest)[[:space:]]+)?(push|login)([^[:alnum:]_-]|$)")) report(line) } +# a line that ends in an odd number of backslashes goes on to the next +function continued(s) { return match(s, /\\+$/) && RLENGTH % 2 == 1 } { gsub(/\r/, "") if (is_code) { code($0); next } - if ($0 ~ /\\$/) { held = held substr($0, 1, length($0) - 1) " "; next } + if (continued($0)) { held = held substr($0, 1, length($0) - 1) " "; next } scan(held $0) held = "" } @@ -172,11 +206,18 @@ skipped() { } failures=0 -if [ "${1:-}" = --listed ]; then - # called by the whole-repository run below, with the names git gave +if [ "${1:-}" = --listed ] || [ "${1:-}" = --listed-untracked ]; then + # called by the whole-repository run below, with the names git gave. A file not yet + # added that git would call binary, an image saved as a tarball say, is not read: it + # reaches the repository only by a commit, and is read from then on. + untracked='' + [ "$1" = --listed-untracked ] && untracked=yes shift for file in "$@"; do skipped "$file" && continue + if [ -n "$untracked" ] && [ -f "$file" ] && [ -s "$file" ] && ! grep -I -q '' "$file"; then + continue + fi if [ ! -e "$file" ] && [ ! -L "$file" ] && [ -n "$(git ls-files --deleted -- "$file")" ]; then continue fi @@ -196,8 +237,10 @@ else exit 1 fi status=0 - git -c core.quotePath=false ls-files -z --cached --others --exclude-standard | + git -c core.quotePath=false ls-files -z --cached | xargs -0 sh "$here/no-image-push.sh" --listed || status=$? + git -c core.quotePath=false ls-files -z --others --exclude-standard | + xargs -0 sh "$here/no-image-push.sh" --listed-untracked || status=$? [ "$status" -eq 0 ] || exit 1 exit 0 fi diff --git a/scripts/no-image-push.test.sh b/scripts/no-image-push.test.sh index 302071b..53f550d 100755 --- a/scripts/no-image-push.test.sh +++ b/scripts/no-image-push.test.sh @@ -9,6 +9,12 @@ set -eu +# A pre-commit hook in a linked worktree runs with GIT_DIR and GIT_INDEX_FILE set, and +# `git -C ` does not override them: the scratch repositories below would be made in, +# and staged into, the real one. None of these may reach the cases. +unset GIT_DIR GIT_INDEX_FILE GIT_WORK_TREE GIT_PREFIX GIT_COMMON_DIR GIT_OBJECT_DIRECTORY \ + GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_NAMESPACE GIT_CEILING_DIRECTORIES + here=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) script="$here/no-image-push.sh" dir=$(mktemp -d) @@ -98,13 +104,62 @@ check refuse run.sh 'regctl image copy a ghcr.io/x/h' check refuse run.sh 'ko publish ./cmd/x' check refuse run.sh 'gh release upload v1 harness.tar' check refuse run.sh 'gh release create v1 harness.tar' +check refuse run.sh 'docker compose -f local/compose.yml push' +check refuse run.sh 'podman compose -f a.yml -f b.yml push' +check refuse run.sh 'docker-compose -f a.yml -f b.yml push' +check refuse run.sh 'ctr -n k8s.io images push x' +check refuse run.sh 'docker --context remote image push x' +check refuse run.sh 'sudo -u root podman push x' +check refuse run.sh 'nice -n 5 podman push x' +check refuse run.sh 'xargs -n 1 podman push < tags' +check refuse run.sh 'timeout 600 docker push x' +check refuse run.sh 'timeout -s KILL 600 docker push x' +check refuse run.sh 'doas podman push x' +check refuse run.sh 'time podman push x' +check refuse run.sh 'nohup podman push x' +check refuse run.sh 'exec podman push x' +check refuse run.sh 'command podman push x' +check refuse run.sh 'ionice -c 3 podman push x' +check refuse run.sh 'eval podman push x' +check refuse run.sh 'if a; then b; else podman push x; fi' +check refuse run.sh 'while true; do podman push x; done' +check refuse run.sh 'until false; do podman push x; done' +check refuse run.sh 'if podman push x; then :; fi' +check refuse run.sh 'while podman push x; do :; done' +check refuse run.sh 'until podman push x; do :; done' +check refuse run.sh "ksh -c 'podman push x'" +check refuse run.sh "zsh -c 'podman push x'" +check refuse run.sh "dash -c 'podman push x'" +check refuse run.sh "bash -o pipefail -c 'podman push x'" +check refuse run.sh 'buildah commit ctr docker://ghcr.io/x/h' +check refuse run.sh 'gh release edit v1 --draft=false' +check refuse run.sh 'gh api repos/apache/x/releases -f tag_name=v1' +check refuse run.sh 'podman push x +' # a login is the step before a push, and nothing here needs one check refuse run.sh 'echo "$T" | podman login ghcr.io -u x --password-stdin' check refuse ci.yml ' run: docker login -u x -p "$T"' check refuse run.sh 'skopeo login ghcr.io' check refuse run.sh 'oras login ghcr.io' check refuse run.sh 'crane auth login ghcr.io' +check refuse ci.yml ' - run: podman push x' +check refuse ci.yml ' deploy: podman push x' # workflows +check refuse ci.yml ' - uses: ncipollo/release-action@v1' +check refuse ci.yml ' - uses: actions/create-release@v1' +check refuse ci.yml ' - uses: goreleaser/goreleaser-action@v6' +check refuse ci.yml ' - uses: googleapis/release-please-action@v4' +check refuse ci.yml ' - uses: aws-actions/amazon-ecr-login@v2' +check refuse ci.yml ' - uses: azure/docker-login@v2' +check refuse ci.yml ' - uses: mr-smithers-excellent/docker-build-push@v6' +check refuse ci.yml ' - uses: macbre/push-to-ghcr@v13' +check refuse ci.yml ' - uses: actions/deploy-pages@v5' +check refuse ci.yml ' pages: write' +check refuse ci.yml ' push: true' +check refuse docker-bake.hcl ' output = ["type=registry"]' +check refuse docker-bake.json '{"target": {"h": {"push": true}}}' +check refuse Dockerfile 'CMD ["podman", "push", "x"]' +check refuse compose.yml ' command: ["docker","login","ghcr.io"]' check refuse ci.yml ' - uses: docker/build-push-action@v6' check refuse ci.yml ' - uses: redhat-actions/push-to-registry@v2' check refuse ci.yml ' - uses: docker/login-action@v3' @@ -132,6 +187,8 @@ check refuse main.rs 'const VERB: &str = "push";' check refuse main.rs 'cmd.arg("podman push x")' check refuse main.rs 'Command::new("podman").args(["login", "ghcr.io"])' check refuse x.py 'subprocess.run(["podman", "push", tag])' +check refuse main.rs 'let rt = "podman"; +let cmd = format!("{rt} push {tag}");' check refuse x.py "os.system('docker push x')" check refuse x.mjs 'spawn("podman", ["push", t])' check refuse x.ts "execFile('docker', ['login'])" @@ -171,6 +228,25 @@ check pass main.rs 'v.push(item);' check pass main.rs 'Command::new("git").arg("pushd")' check pass main.rs '(_, AuthMode::Subscription) => (!f.logged_in.contains(&h)).then(|| "login".to_string()),' check pass x.ts 'items.push(x); const pushed = 1;' +check pass run.sh 'docker buildx imagetools inspect node:24' +check pass run.sh 'docker --debug logs push' +check pass run.sh 'podman --remote run --rm --name login alpine true' +check pass run.sh 'skopeo copy docker://docker.io/library/node:24 oci:node' +check pass run.sh 'ko build --local ./cmd/x' +check pass run.sh '$GIT push origin main' +check pass run.sh '"$git" push origin HEAD' +check pass run.sh 'bash -o pipefail -c "cargo test 2>&1 | tee t.log"' +check pass run.sh 'bash --login -c "make check"' +check pass run.sh 'sudo apt-get install -y podman' +check pass run.sh 'env FOO=1 cargo test' +check pass run.sh 'nice -n 5 make check' +check pass run.sh 'ls | xargs -0 grep -l x' +# an escaped backslash ends the line, so the push on the next one runs +check refuse run.sh 'echo a \\ +podman push x' +check pass main.rs 'return Err("a login is the harness own".into());' +check pass ci.yml ' - uses: actions/checkout@v7' +check pass ci.yml ' - uses: Swatinem/rust-cache@v2' # a file it cannot read, and a directory, fail; they do not pass n=$((n + 1)) @@ -222,8 +298,8 @@ printf ' - run: docker push x\n' > "$repo/.github/workflows/x.yml" printf 'podman push x\n' > "$repo/scripts/d\303\251ploy.sh" printf 'podman push x\n' > "$repo/scripts/tab x.sh" printf 'Never run `podman push`.\n' > "$repo/README.md" +# untracked files are read too, so nothing is staged git -C "$repo" init -q -git -C "$repo" add -A status=0 err=$(sh "$repo/scripts/no-image-push.sh" 2>&1) || status=$? case $status:$err in @@ -241,6 +317,73 @@ case $err in *tab*) : ;; *) echo 'the whole-repository run skipped a file with a tab in its name' >&2; failures=$((failures + 1)) ;; esac +# CR line endings do not break a continued line +n=$((n + 1)) +printf 'podman \\\r\npush x\r\n' > "$dir/crlf.sh" +status=0 +sh "$script" "$dir/crlf.sh" 2>/dev/null || status=$? +if [ "$status" -ne 1 ]; then + echo 'a push across a CRLF continuation passed, should be refused' >&2 + failures=$((failures + 1)) +fi +# a tracked file deleted from the working tree is skipped; a name git lists that is not a +# file, a link to a directory here, fails the run +n=$((n + 1)) +repo2="$dir/repo2" +mkdir -p "$repo2/scripts" +cp "$script" "$repo2/scripts/no-image-push.sh" +printf 'podman build -t x .\n' > "$repo2/gone.sh" +git -C "$repo2" init -q +git -C "$repo2" add gone.sh +rm "$repo2/gone.sh" +status=0 +sh "$repo2/scripts/no-image-push.sh" 2>/dev/null || status=$? +if [ "$status" -ne 0 ]; then + echo 'a tracked file deleted from the working tree failed the run, should be skipped' >&2 + failures=$((failures + 1)) +fi +n=$((n + 1)) +ln -s "$dir" "$repo2/elsewhere" +status=0 +sh "$repo2/scripts/no-image-push.sh" 2>/dev/null || status=$? +if [ "$status" -eq 0 ]; then + echo 'a listed name that is not a file passed, should fail' >&2 + failures=$((failures + 1)) +fi +# an untracked binary file, an image tarball say, is not read; once added, it is +n=$((n + 1)) +repo4="$dir/repo4" +mkdir -p "$repo4/scripts" +cp "$script" "$repo4/scripts/no-image-push.sh" +printf 'layer\000\npodman push x\n' > "$repo4/harness.tar" +git -C "$repo4" init -q +status=0 +sh "$repo4/scripts/no-image-push.sh" 2>/dev/null || status=$? +if [ "$status" -ne 0 ]; then + echo 'an untracked binary file was read, should be skipped' >&2 + failures=$((failures + 1)) +fi +n=$((n + 1)) +git -C "$repo4" add harness.tar +status=0 +sh "$repo4/scripts/no-image-push.sh" 2>/dev/null || status=$? +if [ "$status" -ne 1 ]; then + echo 'a binary file added to the index was not read, should be' >&2 + failures=$((failures + 1)) +fi +# a repository where git lists nothing: nothing was checked, so it fails +n=$((n + 1)) +repo3="$dir/repo3" +mkdir -p "$repo3/scripts" +cp "$script" "$repo3/scripts/no-image-push.sh" +printf '*\n' > "$repo3/.gitignore" +git -C "$repo3" init -q +status=0 +sh "$repo3/scripts/no-image-push.sh" 2>/dev/null || status=$? +if [ "$status" -eq 0 ]; then + echo 'a run where git listed no files passed, should fail' >&2 + failures=$((failures + 1)) +fi # outside a repository, git cannot list: that fails, it does not pass n=$((n + 1)) mkdir -p "$dir/norepo/scripts" From 6f597aa105b0e4fadc86cb818a513fed64196a92 Mon Sep 17 00:00:00 2001 From: Sander Striker Date: Tue, 27 Oct 2026 14:32:02 +0100 Subject: [PATCH 4/4] fix: answer the third adversarial pass on the image breaker The pass found these problems: - The guard joined a line that ends in an escaped backslash, which hid a git add -A on the next line. - Both breakers read any variable in the command position as an engine, and they read the options of a container's own program. - They refused cache pulls, local copies and builds, and secrets actions. Both breakers now count a variable as an engine only when its name says so. They stop at run and exec, and they skip option values. The guard strips the quotes once for each command, not once for each word. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Bhj4Ck4y5iwZUsjpgQx8A2 --- .claude/hooks/guard-bash.sh | 63 +++++++++++++++++++++++--------- .claude/hooks/guard-bash.test.sh | 18 +++++++++ scripts/no-image-push.sh | 32 +++++++++++----- scripts/no-image-push.test.sh | 22 +++++++++++ 4 files changed, 108 insertions(+), 27 deletions(-) diff --git a/.claude/hooks/guard-bash.sh b/.claude/hooks/guard-bash.sh index f6f6795..a59217d 100755 --- a/.claude/hooks/guard-bash.sh +++ b/.claude/hooks/guard-bash.sh @@ -123,7 +123,7 @@ command=$( sed -e 's/\\n/\ /g' -e 's/\\[rt]/ /g' -e 's/\\"/"/g' -e 's/\\\\/\\/g' | drop_heredoc_bodies | - sed -e ':a' -e '$!{' -e '/\\$/{' -e 'N' -e 's/\\\n/ /' -e 'ta' -e '}' -e '}' | + sed -e ':a' -e '$!{' -e '/\\$/{' -e '/^\(\\\\\)*$/b' -e '/[^\\]\(\\\\\)*$/b' -e 'N' -e 's/\\\n/ /' -e 'ta' -e '}' -e '}' | tr '\n' ';' ) @@ -306,33 +306,48 @@ check_git_stash() { # (`image push`, `compose -f a.yml push`): `docker -H host push` pushes, and `podman run # --name login` and `docker --debug logs push` do not. check_image_push() { - _tool=$(printf '%s' "${1##*/}" | tr -d "\"'\\\\") + # the quotes and backslashes out of the whole command at once, then its words again + _unquoted=$(printf '%s' "$*" | tr -d "\"'\\\\") + set -f + # shellcheck disable=SC2086 # splitting into words is the point + set -- $_unquoted + set +f + _tool=${1##*/} shift case $_tool in - '$'*git* | '$'*GIT*) return 0 ;; - '$'*) _tool='$' ;; + # a variable is an engine when its name says so: `$runtime`, `$ENGINE`, `${DOCKER}` + '$'*) + case $(printf '%s' "$_tool" | tr '[:upper:]' '[:lower:]') in + *docker* | *podman* | *engine* | *runtime* | *container* | *ctr* | *wslc* | \ + *buildah* | *nerdctl* | *crane* | *skopeo* | *oras* | *oci*) _tool='$' ;; + *) return 0 ;; + esac + ;; *) : ;; esac _n1='' _n2='' _n3='' _last='' _value='' _all=' ' for _w in "$@"; do - _w=$(printf '%s' "$_w" | tr -d "\"'\\\\") _all="$_all$_w " - case $_w in - --push | *push=true* | *type=registry*) - refuse "\`$_tool ... $_w\` would send an image to a registry. This repository never publishes an image: the harness image holds harnesses the project may not redistribute." \ - "Build and keep it local: make image. images/harness/README.md says why." - ;; - *) : ;; - esac if [ -n "$_value" ]; then _value='' continue fi + # the program a container runs has options of its own: `podman run img x --push` + case $_n1 in + run | exec | create | start) break ;; + *) : ;; + esac case $_w in - -[HcfnlpC] | --host | --context | --config | --log-level | --url | --connection | --identity | \ + --cache-from=*) continue ;; + --push | *push=true* | *type=registry*) + refuse "\`$_tool ... $_w\` would send an image to a registry. This repository never publishes an image: the harness image holds harnesses the project may not redistribute." \ + "Build and keep it local: make image. images/harness/README.md says why." + ;; + -[HcfnlpCe] | --host | --context | --config | --log-level | --url | --connection | --identity | \ --root | --runroot | --storage-driver | --namespace | --address | --kubeconfig | --file | \ --project-name | --profile | --env-file | --project-directory | --name | --network | --tag | \ - --creds | --authfile | --platform) + --creds | --authfile | --platform | --env | --label | --build-arg | --retry-times | --src | \ + --dest | --src-creds | --dest-creds | --format | --cache-from) _value=yes continue ;; @@ -372,14 +387,19 @@ check_image_push() { [ "$_n1 $_n2 $_n3" = "buildx imagetools create" ] && _hit='imagetools create' case $_tool:$_n1:$_all in buildah:commit:*' docker://'*) _hit='commit docker://' ;; - # a copy or sync to a local store pulls, and a local ko build pushes nothing - skopeo:copy:* | skopeo:sync:*) + # a copy or sync to a local store pulls; a local ko build, a crane credential read + # and an oras copy to a layout push nothing + skopeo:copy:*) case $_last in oci:* | oci-archive:* | dir:* | docker-archive:* | containers-storage:* | docker-daemon:*) _hit='' ;; *) : ;; esac ;; - ko:*:*' --local '* | ko:*:*' --push=false '*) _hit='' ;; + skopeo:sync:*' --dest dir '* | skopeo:sync:*' --dest oci '* | skopeo:sync:*' --dest=dir '* | \ + skopeo:sync:*' --dest=oci '*) _hit='' ;; + ko:*:*' --local '* | ko:*:*' -L '* | ko:*:*' --push=false '*) _hit='' ;; + crane:auth:* | gcrane:auth:*) [ "$_n2" = login ] || _hit='' ;; + oras:*:*' --to-oci-layout'*) _hit='' ;; *) : ;; esac if [ -n "$_hit" ]; then @@ -842,7 +862,14 @@ check_part() { return 0 fi # the command's name with its quotes and backslashes gone: `"podman" push` runs podman - _cmd=$(printf '%s' "${1##*/}" | tr -d "\"'\\\\") + _cmd=${1##*/} + case $_cmd in + *\\*) _cmd=$(printf '%s' "$_cmd" | tr -d "\"'\\\\") ;; + *) + _cmd=${_cmd#[\"\']} + _cmd=${_cmd%[\"\']} + ;; + esac case $_cmd in git) check_git "$@" ;; gh) check_gh "$@" ;; diff --git a/.claude/hooks/guard-bash.test.sh b/.claude/hooks/guard-bash.test.sh index 505e80c..b8f6365 100755 --- a/.claude/hooks/guard-bash.test.sh +++ b/.claude/hooks/guard-bash.test.sh @@ -252,6 +252,24 @@ allow 'sudo apt-get install -y podman' allow 'env FOO=1 cargo test' allow 'nice -n 5 make check' allow 'ls | xargs -0 grep -l x' +allow '"$SHELL" -c "git push origin main"' +allow '$CARGO login' +allow '"$PY" scripts/conformance.py --push' +allow 'docker buildx build --cache-from type=registry,ref=ghcr.io/apache/c --load .' +allow 'docker buildx build --cache-from=type=registry,ref=ghcr.io/apache/c --load .' +allow 'docker run -e auto_push=true img' +allow 'podman run --rm img mytool --push' +allow 'skopeo sync --src docker --dest dir docker.io/library/alpine /tmp/mirror' +allow 'skopeo copy docker://node:24 dir:/tmp/node --retry-times 3' +allow 'ko build -L ./cmd/app' +allow 'crane auth get index.docker.io' +allow 'oras copy x --to-oci-layout ./dir' +refuse 'docker buildx build --cache-to type=registry,ref=ghcr.io/apache/c .' +# an escaped backslash ends the line: the command on the next one is read on its own +refuse 'echo foo\\ +git add -A' +refuse 'echo foo\\ +podman push x' allow 'wslc.exe build -t durable-memory/harness:dev images/harness' allow 'docker build --pull -t harness:abc images/harness' allow 'podman pull docker.io/library/node:24' diff --git a/scripts/no-image-push.sh b/scripts/no-image-push.sh index 3562be4..c573d75 100755 --- a/scripts/no-image-push.sh +++ b/scripts/no-image-push.sh @@ -65,7 +65,7 @@ function takes_value(tool, o) { # An engine option that takes the next word as its value: `docker -H host push`, # `docker compose -f a.yml push`, `ctr -n ns images push`. function engine_value(o) { - return o ~ /^(-[hcfnlp]|--(host|context|config|log-level|url|connection|identity|root|runroot|storage-driver|namespace|address|kubeconfig|file|project-name|profile|env-file|project-directory|name|network|tag|creds|authfile|platform))$/ + return o ~ /^(-[hcfnlp]|--(host|context|config|log-level|url|connection|identity|root|runroot|storage-driver|namespace|address|kubeconfig|file|project-name|profile|env-file|project-directory|name|network|tag|creds|authfile|platform|env|label|build-arg|retry-times|src|dest|src-creds|dest-creds|format|cache-from))$/ || o == "-e" } function verbs(tool) { if (tool == "skopeo") return " copy sync login " @@ -83,8 +83,11 @@ function judge(t, n, tool, i, m, nw, value, v, last) { if (tool ~ /^\$/) tool = "$"; else tool = base(tool) m = 0; value = 0 for (i = t + 1; i <= n; i++) { - if (w[i] == "--push" || w[i] ~ /push=true/ || w[i] ~ /type=registry/) return "--push" if (value) { value = 0; continue } + # the program a container runs has options of its own: `podman run img x --push` + if (m >= 1 && nw[1] ~ /^(run|exec|create|start)$/) break + if (w[i] ~ /^--cache-from=/) continue + if (w[i] == "--push" || w[i] ~ /push=true/ || w[i] ~ /type=registry/) return "--push" if (w[i] ~ /^-/) { if (w[i] !~ /=/ && engine_value(w[i])) value = 1; continue } nw[++m] = w[i] } @@ -92,8 +95,13 @@ function judge(t, n, tool, i, m, nw, value, v, last) { v = verbs(tool) last = nw[m] if (index(v, " " nw[1] " ")) { - if (tool == "skopeo" && nw[1] != "login" && local_target(last)) return "" - if (tool == "ko" && (index(" " joined " ", " --local ") || index(joined, "--push=false"))) return "" + # a copy or sync to a local store pulls; a local ko build, a crane credential + # read and an oras copy to a layout push nothing + if (tool == "skopeo" && nw[1] == "copy" && local_target(last)) return "" + if (tool == "skopeo" && nw[1] == "sync" && joined ~ / --dest[ =](dir|oci)( |$)/) return "" + if (tool == "ko" && joined ~ / (--local|-l|--push=false)( |$)/) return "" + if (tool ~ /crane$/ && nw[1] == "auth" && nw[2] != "login") return "" + if (tool == "oras" && joined ~ / --to-oci-layout/) return "" return nw[1] } if (nw[1] ~ /^(image|images|manifest|artifact|compose|system|registry)$/ && m >= 2 && index(v, " " nw[2] " ")) return nw[2] @@ -106,16 +114,21 @@ function report(line) { printf "%s:%d: %s\n", name, NR, line; found = 1 } function scan(line, low, n, i, k, t, c, word, words, opts_of, value) { low = tolower(line) if (low ~ /build-push-action|push-to-registry|bake-action|copytoregistry|deploy-pages|write-all/ \ - || low ~ /uses:[^#]*(release|login|ecr|ghcr|registry|docker-build-push)/ \ + || low ~ /uses:[^#]*(release|login|ghcr|registry|docker-build-push)/ \ || low ~ /(packages|pages)[^#]*:[^#]*write/ \ - || low ~ /type=registry|push=true|push:[[:space:]]*true|"push"[[:space:]]*:[[:space:]]*true/ \ + || low ~ /^[[:space:]]*push:[[:space:]]*true|"push"[[:space:]]*:[[:space:]]*true|output[^#]*type=registry/ \ || low ~ /(^|[^[:alnum:]_-])gh[[:space:]]+release[[:space:]]+(create|upload|edit)/ \ || low ~ /(^|[^[:alnum:]_-])gh[[:space:]]+api[^#]*\/(releases|packages)[^#]*(-x|--method|-f|--field|--raw-field|--input)/ \ || low ~ ("[\"\047](" engines ")[\"\047][[:space:]]*,[[:space:]]*[\"\047](push|login)[\"\047]")) { report(line) return } - gsub(/\$\([A-Za-z0-9_]+\)|\$\{[^}]*\}/, "$V", low) + while (match(low, /\$\([a-z0-9_]+\)/)) low = substr(low, 1, RSTART - 1) "$" substr(low, RSTART + 2, RLENGTH - 3) substr(low, RSTART + RLENGTH) + while (match(low, /\$\{[^}]*\}/)) { + inner = substr(low, RSTART + 2, RLENGTH - 3) + gsub(/[^a-z0-9_]/, "_", inner) + low = substr(low, 1, RSTART - 1) "$" inner substr(low, RSTART + RLENGTH) + } gsub(/&&|\|\||[;|&(){}`]/, " ; ", low) n = split(low, words, /[[:space:]]+/) k = 0; t = 0; opts_of = ""; value = 0; joined = "" @@ -137,8 +150,9 @@ function scan(line, low, n, i, k, t, c, word, words, opts_of, value) { c = word sub(/^[@+-]+/, "", c) c = base(c) - if (c ~ /^\$/ && c ~ /git/) { t = -1; continue } - if (c != "" && (engine(c) || c ~ /^\$/)) { w[k] = c; t = k; continue } + # a variable is an engine when its name says so: `$runtime`, `$ENGINE`, `${DOCKER}` + if (c ~ /^\$/ && c ~ /(docker|podman|engine|runtime|container|ctr|wslc|buildah|nerdctl|crane|skopeo|oras|oci)/) { w[k] = c; t = k; continue } + if (c != "" && engine(c)) { w[k] = c; t = k; continue } t = -1 } } diff --git a/scripts/no-image-push.test.sh b/scripts/no-image-push.test.sh index 53f550d..8842de3 100755 --- a/scripts/no-image-push.test.sh +++ b/scripts/no-image-push.test.sh @@ -88,6 +88,8 @@ check refuse Makefile ' $(ENGINE) push $(IMAGE)' check refuse Makefile ' @podman push $(IMAGE)' # the other ways an image goes out check refuse run.sh 'docker build --push -t ghcr.io/x/h .' +check refuse run.sh 'docker buildx build --cache-to type=registry,ref=ghcr.io/x/c .' +check refuse run.sh 'skopeo sync --src dir x --dest docker ghcr.io/x' check refuse ci.yml ' run: docker buildx build --push -t ghcr.io/x/h images/harness' check refuse ci.yml ' run: docker buildx build --output type=registry .' check refuse ci.yml ' run: docker buildx build --output type=image,push=true .' @@ -246,6 +248,26 @@ check refuse run.sh 'echo a \\ podman push x' check pass main.rs 'return Err("a login is the harness own".into());' check pass ci.yml ' - uses: actions/checkout@v7' +check pass ci.yml ' - uses: 1password/load-secrets-action@v2' +check pass ci.yml ' - uses: aws-actions/aws-secretsmanager-get-secrets@v2' +check pass run.sh 'GIT_PUSH=true make release-notes' +check pass run.sh 'echo "push: true" > settings.yml' +check pass run.sh '"$SHELL" -c "git push origin main"' +check pass run.sh '$CARGO login' +check pass run.sh '"$PY" scripts/conformance.py --push' +check pass run.sh 'sh "$script" push' +check pass run.sh 'bash "$f" login' +check pass Makefile ' $(MAKE) push' +check pass Makefile ' $(MAKE) -C docs login' +check pass run.sh 'docker buildx build --cache-from type=registry,ref=ghcr.io/x/c --load .' +check pass run.sh 'docker buildx build --cache-from=type=registry,ref=ghcr.io/x/c --load .' +check pass run.sh 'docker run -e auto_push=true img' +check pass run.sh 'podman run --rm img mytool --push' +check pass run.sh 'skopeo sync --src docker --dest dir docker.io/library/alpine /tmp/mirror' +check pass run.sh 'skopeo copy docker://node:24 dir:/tmp/node --retry-times 3' +check pass run.sh 'ko build -L ./cmd/app' +check pass run.sh 'crane auth get index.docker.io' +check pass run.sh 'oras copy x --to-oci-layout ./dir' check pass ci.yml ' - uses: Swatinem/rust-cache@v2' # a file it cannot read, and a directory, fail; they do not pass