Skip to content

fix: make an image push fail, and remove the pages workflow - #36

Merged
sstriker merged 4 commits into
mainfrom
fix/image-no-push
Oct 28, 2026
Merged

sstriker merged 4 commits into
mainfrom
fix/image-no-push

Conversation

@sstriker

Copy link
Copy Markdown
Contributor

Summary

Nothing in this repository publishes the harness image, and nothing may. This change writes down why, and adds checks so that a push does not happen by accident. It also removes the workflow that could publish the web pages, and it removes the old repository name.

Why the image must stay local:

  • The image holds harnesses under their vendors' own licences. Claude Code ("SEE LICENSE IN README.md") and Copilot CLI ("SEE LICENSE IN LICENSE.md") are two of them. The project may not redistribute them. The Apache release policy says: "every artifact distributed MUST contain only appropriately licensed code".
  • The image is built from unreleased source. The Apache release policy says: "Projects SHALL publish official releases and SHALL NOT publish unreleased materials outside the development community."

This change does these things:

  1. A repository check, scripts/no-image-push.sh, run by make check.
    • It reads each file in the way that a shell reads a command:
      • a continued line is one line;
      • quotes do not count;
      • it passes over the words in front of a command and their options (sudo -u root, env -i, xargs -n 1, timeout 600, then, sh -c, a YAML run:);
      • it passes over the options of an engine and their values (docker -H host push, compose -f a.yml push);
      • a variable is an engine when its name says so ("$runtime" push, $(ENGINE) push).
    • It fails on:
      • a push or a registry login, by any engine or registry tool;
      • --push, push=true and type=registry;
      • the actions that push, log in or make a release;
      • gh release create, upload and edit;
      • packages: write, pages: write and write-all;
      • in Rust, Python and JavaScript, a string that is exactly "push".
    • It reads every file that git knows or would add, except:
      • Markdown;
      • itself;
      • the guard and its tests;
      • the vendored scripts;
      • a binary file that is not yet added.
    • The run fails when git or awk fails, when git lists nothing, or when a listed name is not a file.
  2. A guard rule. .claude/hooks/guard-bash.sh refuses the same commands when a session types them.
  3. A workflow permission. .github/workflows/image.yml now names packages: none. With other permissions named, GitHub already gives no packages permission, so this line only makes the rule visible.
  4. The pages workflow is removed. .github/workflows/pages.yml could publish the web pages, built from main, to GitHub Pages. That is unreleased material outside the development community. Nobody ran it. make hosted and amem --hosted stay, for a person who hosts a copy for their own use.
  5. The old repository name is removed from Cargo.toml, CONTRIBUTING.md, the image label, the guard tests and a test in crates/memory/src/open.rs. The records in spikes/ keep it.

images/harness/README.md and AGENTS.md give the rule and the reasons. The README also says what the checks do not stop. They stop an accident, and they are not a security boundary. 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, so ASF Infra can turn off GitHub Packages for this repository if that matters.

The guard change also closes older gaps in the git rules. These were allowed on main and are now refused: then git add -A, sudo -u me git add -A, bash -lc 'git add -A', and a bare timeout no longer hangs.

Third-party GitHub Actions

I compared each action in the workflows with the ASF list in apache/infrastructure-actions (actions.yml and approved_patterns.yml):

Action Pin here ASF list
actions/checkout, actions/setup-node, actions/upload-artifact SHA actions/* is allowed with no entry
dtolnay/rust-toolchain 4cda84d5… allowed at 4cda84d5… only
Swatinem/rust-cache SHA allowed at any version
taiki-e/install-action SHA allowed at any version

All are allowed. dtolnay/rust-toolchain is allowed at one commit only, so a bump of that pin needs a change to the ASF list first.

Tests

  • scripts/no-image-push.test.sh has 218 cases:
    • 137 lines that the check must refuse;
    • 68 lines that it must pass, taken from the commands that the repository runs;
    • 13 cases for the whole-repository run and for failures:
      • a file name that git quotes;
      • a tab in a file name;
      • a deleted file;
      • a link to a directory;
      • an untracked binary file;
      • a repository where git lists nothing;
      • a failing git;
      • a failing awk;
      • NUL bytes and UTF-16;
      • CR line endings.
  • .claude/hooks/guard-bash.test.sh has 94 new refusals and 60 new allowed commands. Each guard case now has a time limit of ten seconds, so a guard that loops fails the test.

The new refusals failed against the guard on main:

allowed a command it should refuse: podman push durable-memory/harness:dev ghcr.io/apache/harness:dev
allowed a command it should refuse: docker push ghcr.io/apache/harness
...
16 guard case(s) failed

The guard reads the text of each command and runs none of them.

That was the first 16 cases. For each rule in both breakers, I made the rule wrong in a scratch copy, and a test failed each time. Examples:

  • a check that passes everything: 26 of 43 cases failed (first version);
  • no unquoting: 5 failures;
  • no line join: 2;
  • no $runtime engine: 9;
  • no option values: 5;
  • sudo, xargs, nice with no option values: 1 each;
  • no stop at run: 1;
  • a guard that loops on a bare env: 5 cases failed on the time limit.

make check passes: 718 tests run, 718 passed.

Adversarial pass

Three fresh agents attacked the branch, one after each revision.

  1. The first pass found many ways past the first version: a variable engine, a continued line, quoted words, docker build --push, docker compose push, crane cp, and Rust and Python that pass "push" as an argument. It also found that the check skipped files with odd names, and passed on a grep error. The second commit answers these.
  2. The second pass found three faults that the second commit made:
    • In the pre-commit hook of a linked worktree, the test acted on the real repository. It staged the deletion of the whole tree, and it set core.bare=true. The test now unsets the git variables and stages nothing. I ran the test with the variables that such a hook sets, in a scratch clone with a linked worktree. The index and the shared config did not change.
    • The guard looped forever on a bare env, sudo or xargs.
    • The guard recursed on bash -o pipefail -c.
      It also found more ways past the checks, for example compose -f a.yml push and release actions. The third commit answers these.
  3. The third pass looked only for what the third commit broke. It found no loop in 1,983 inputs, and no damage to a repository. It also found no difference under dash, bash --posix, mawk or busybox. It replayed 2,171 of the repository's commands through the guard, and there were no differences from main. It found a join of a line that ends in an escaped backslash, which hid a git add -A. It also found some refusals in error: imagetools inspect, a pull with skopeo, ko build -L, and a variable that names no engine. The fourth commit answers these.

These stay open:

  • On busybox (Alpine), grep -I does not skip binary files. Thus an untracked tarball is read, and one test case cannot fail there.
  • .claude/commands/adversarial.md still names the old repository. The reader-test PR changes that line.

Posted by Claude on behalf of @sstriker

sstriker and others added 4 commits October 27, 2026 12:23
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bhj4Ck4y5iwZUsjpgQx8A2
…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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bhj4Ck4y5iwZUsjpgQx8A2
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bhj4Ck4y5iwZUsjpgQx8A2
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bhj4Ck4y5iwZUsjpgQx8A2
Copilot AI balanced review requested due to automatic review settings October 28, 2026 00:17

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The change introduces a large, parsing-heavy security scanner with mirrored guards and workflow permission changes whose many bypass edge cases and policy completeness warrant human sign-off.

No open issues 🎉

What changed in this PR

This PR hardens the repository against ever publishing the harness image, which bundles vendor‑licensed CLIs (Claude Code, Copilot CLI) the project may not redistribute and is built from unreleased source. It adds a file scanner and a session-command guard that refuse any image push/registry login/release, documents the policy, removes the GitHub Pages workflow (which could publish unreleased pages), and completes the repository rename from sstriker/durable-memory to apache/tooling-memory.

Changes:

  • New scripts/no-image-push.sh (+ 218-case test), wired into make check, that reads every tracked/added file as a shell would and fails on image pushes, registry logins, releases, packages: write, and bare "push" strings in Rust/Python/JS.
  • Extended .claude/hooks/guard-bash.sh to refuse the same commands when typed (with improved sudo/env/xargs/timeout prefix handling, bash -…c unwrapping, and odd-backslash line joins), plus gh release create/upload/edit.
  • Removed .github/workflows/pages.yml, added packages: none to image.yml, and updated docs/metadata to the new apache/tooling-memory name (internal durable-memory project/image name retained intentionally).
File Description
scripts/​no-image-push.sh New awk+sh scanner refusing image push/login/release paths across the repo.
scripts/​no-image-push.test.sh Comprehensive test suite (refusals, allowances, whole-repo/failure modes).
.claude/​hooks/​guard-bash.sh New check_image_push, release-command refusal, and refined prefix/-c parsing.
.claude/​hooks/​guard-bash.test.sh Adds refuse/allow cases and a per-case timeout to catch guard loops.
Makefile Adds no-image-push-test target and wires it into check.
.github/​workflows/​image.yml Documents the no-publish rationale; declares packages: none.
.github/​workflows/​pages.yml Removed; it could publish unreleased pages to GitHub Pages.
images/​harness/​README.md Adds the "Never published" policy and what the guards do/don't stop.
AGENTS.md Adds the "No image leaves this machine" non-negotiable and updates the guard summary.
local/​README.md Rewrites hosted-pages note to state the repo does not publish.
images/​harness/​Dockerfile Updates image source label to the new repo URL.
Cargo.toml, CONTRIBUTING.md Update repository URL / clone instructions to apache/tooling-memory.
crates/​memory/​src/​open.rs Updates test URL fixtures to the new name (behavior unchanged).

No blocking defects were found. I verified the scanner and guard produce correct accept/reject results on representative cases (e.g. docker compose -f … push, $ENGINE push, gh release create refused; skopeo copy … oci:node, ko build -L, $CARGO login, gh release download allowed), the rename is complete where intended (remaining @sstriker is the maintainer handle; spikes/ and adversarial.md are intentionally deferred), and no documentation still references the removed Pages workflow.

Review effort: Balanced

@sstriker
sstriker merged commit 2f2e914 into main Oct 28, 2026
12 checks passed
@sstriker
sstriker deleted the fix/image-no-push branch October 28, 2026 17:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants