diff --git a/.claude/hooks/guard-bash.sh b/.claude/hooks/guard-bash.sh index a4c320d..a59217d 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 '$!{' -e '/\\$/{' -e '/^\(\\\\\)*$/b' -e '/[^\\]\(\\\\\)*$/b' -e 'N' -e 's/\\\n/ /' -e 'ta' -e '}' -e '}' | tr '\n' ';' ) @@ -162,19 +163,49 @@ strip_prefixes() { *) : ;; esac case ${_rest%%[[:space:]]*} in - sudo | env | time | nohup | exec | command | *=*) : ;; - # `timeout 600 make test` runs make, and so does `timeout --signal=KILL 600`. - timeout) - _rest=${_rest#*[[:space:]]} + 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`. + # 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 - -* | [0-9]*) : ;; + _word=${_rest%%[[:space:]]*} + case $_word in + -*) : ;; + # `timeout 600 make test`: the duration before the command + [0-9]*) [ "$_wrapper" = timeout ] || break ;; *) break ;; esac + _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 ;; @@ -263,6 +294,120 @@ check_git_stash() { esac } +# 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. +# +# 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, 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() { + # 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 + # 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 + _all="$_all$_w " + 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 + --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 | --env | --label | --build-arg | --retry-times | --src | \ + --dest | --src-creds | --dest-creds | --format | --cache-from) + _value=yes + continue + ;; + -*) continue ;; + *) : ;; + esac + _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 + skopeo) _verbs='copy sync 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 + [ "$_n1" = "$_v" ] && _hit=$_v + done + case $_n1 in + image | images | manifest | artifact | compose | system | registry) + for _v in $_verbs; do + [ "$_n2" = "$_v" ] && _hit=$_v + done + ;; + *) : ;; + esac + [ "$_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; 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 + ;; + 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 + 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 @@ -631,6 +776,13 @@ check_gh() { *) break ;; esac done + case "${1:-} ${2:-}" in + '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." + ;; + *) : ;; + 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 @@ -675,19 +827,26 @@ check_part() { case ${_head##*/} in sh | bash | dash | ash | zsh | ksh) case $_stripped in - *[[:space:]]-c[[:space:]]*) - _inner=${_stripped#*[[:space:]]-c[[: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 + # -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 -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 @@ -702,9 +861,25 @@ 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=${1##*/} + case $_cmd in + *\\*) _cmd=$(printf '%s' "$_cmd" | tr -d "\"'\\\\") ;; + *) + _cmd=${_cmd#[\"\']} + _cmd=${_cmd%[\"\']} + ;; + esac + case $_cmd in git) check_git "$@" ;; gh) check_gh "$@" ;; + 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 "$@" + ;; *) : ;; esac } diff --git a/.claude/hooks/guard-bash.test.sh b/.claude/hooks/guard-bash.test.sh index d3198ba..b8f6365 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() { @@ -105,6 +116,170 @@ 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' +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'" +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' +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 '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 '"$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' +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 +730,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 +767,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..2ffc3ff 100644 --- a/.github/workflows/image.yml +++ b/.github/workflows/image.yml @@ -1,5 +1,10 @@ # 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). +# 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 @@ -17,6 +22,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..d8287e7 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, 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 @@ -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, 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/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..a80f656 100644 --- a/images/harness/README.md +++ b/images/harness/README.md @@ -55,3 +55,40 @@ 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. 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. + +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/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..c573d75 --- /dev/null +++ b/scripts/no-image-push.sh @@ -0,0 +1,265 @@ +#!/bin/sh +# 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 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 -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` +# 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), 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 +# +# Run by `make check`. + +set -eu + +here=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) +root=$(CDPATH='' cd -- "$here/.." && pwd) + +engines='docker|podman|podman-remote|buildah|nerdctl|wslc|wslc[.]exe|docker-compose|podman-compose|skopeo|crane|gcrane|oras|regctl|ctr|helm|ko' + +# 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 ")$") } +# 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|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 " + 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 " +} +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, m, nw, value, v, last) { + tool = w[t] + if (tool ~ /^\$/) tool = "$"; else tool = base(tool) + m = 0; value = 0 + for (i = t + 1; i <= n; i++) { + 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] + } + if (m == 0) return "" + v = verbs(tool) + last = nw[m] + if (index(v, " " nw[1] " ")) { + # 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] + 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, 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|ghcr|registry|docker-build-push)/ \ + || low ~ /(packages|pages)[^#]*:[^#]*write/ \ + || 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 + } + 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 = "" + 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; opts_of = ""; value = 0; joined = "" + continue + } + if (word == "") continue + k++; w[k] = word; joined = joined " " word + if (t != 0) 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) + # 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 + } +} +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 (continued($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 [ ! -f "$file" ] || [ ! -r "$file" ]; then + echo "no-image-push: cannot read $file" >&2 + return 2 + fi + is_code=0 + names_engine=0 + case $file in + *.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 +} + +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 ] || [ "${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 + check "$file" || failures=$((failures + 1)) + done +elif [ $# -gt 0 ]; then + for file in "$@"; do check "$file" || failures=$((failures + 1)); done +else + cd "$root" + 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 + fi + count=$(git -c core.quotePath=false ls-files -z --cached --others --exclude-standard | tr -cd '\000' | wc -c) + if [ "$count" -eq 0 ]; then + echo "no-image-push: git listed no files, so nothing was checked" >&2 + exit 1 + fi + status=0 + 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 + +if [ "$failures" -ne 0 ]; then + 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 new file mode 100755 index 0000000..8842de3 --- /dev/null +++ b/scripts/no-image-push.test.sh @@ -0,0 +1,431 @@ +#!/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 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`. + +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) +trap 'rm -rf "$dir"' EXIT + +failures=0 +n=0 + +# 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 text=$3 + n=$((n + 1)) + mkdir -p "$dir/$n" + file="$dir/$n/$name" + 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" "$text" >&2 + failures=$((failures + 1)) + 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 +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 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 .' +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' +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' +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 '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 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'])" + +# 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)"; \ + 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 '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;' +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: 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 +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 +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 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" +# untracked files are read too, so nothing is staged +git -C "$repo" init -q +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 +# 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" +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 failures in $n cases" >&2 + exit 1 +fi +echo "no-image-push.sh: every case holds"