fix(docker): give COPYd scripts an absolute mode, and un-blind the guard that could not fail - #142
Open
dlovell wants to merge 4 commits into
Open
fix(docker): give COPYd scripts an absolute mode, and un-blind the guard that could not fail#142dlovell wants to merge 4 commits into
dlovell wants to merge 4 commits into
Conversation
`COPY` preserves the source file's mode, and one of the copied sources —
`setup-env.sh` — comes from a project overlay, i.e. a contributor's working
tree, where the mode is whatever their umask made it. `chmod +x` adds execute
bits but cannot restore a missing read bit:
0700 (umask 0077 checkout) + chmod +x -> 0711
The copy is root-owned in the image, so the owner bits stop applying to vscode,
and bash must read an interpreted script to execute it. Every container entry
then dies on `setup-env: Permission denied` — pointing at the exec bit, which
is the one thing that is fine. Fixes #129.
Both Dockerfiles now apply `chmod 755`. Verified end-to-end on both routes,
building from a deliberately 0700 overlay: the image lands 755 and
`setup-env sync-if-needed` runs clean as vscode; with `chmod +x` restored the
same build lands 0711 and exits 126.
The CI assertion that existed for exactly this file could not fail for this
reason. `nix-base.yml` ran `test -x /usr/local/bin/setup-env`, but no USER is
set on the image, so it ran as root — where both -x and -r are true on 0711.
Confirmed against a deliberately broken image: the old assertion passes, the
new one fails. Both workflows now build from a 0700 overlay and assert by
running the script as vscode, which is the only vantage the read bit binds.
The hermetic guard derives the checked set from each Dockerfile's COPY
destinations (ADR-0005 §3, rung 2), so a fourth script into /usr/local/bin is
covered the day it is added. `dockerfile_copy_dests` joins line continuations
via the same helper as the source parsers, whose extraction is the only change
to the shared lib.
This does not address the sibling gap in #130: the image fingerprint hashes
file contents only, so a mode-only fix to a COPY source does not invalidate the
tag and `ensure_image` reuses the stale image. That is why this bug survived a
rebuild once diagnosed, and it stays open.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The guard added alongside the #129 fix passed on a tree with #129 fully live, two ways — both verified against the merged tree: - a comment above the line naming the paths with the good mode, while the instruction itself said `chmod +x` (14 passed, 0 failed); - `chmod 755 <two paths> \ && chmod +x /usr/local/bin/setup-env`, since the mode was read as the first one appearing in the instruction, whichever path that chmod named (14 passed, 0 failed). The first is #110's comment-blind parse and #97's comment-satisfies- coverage, recurring inside a new guard — and the fix commit put mode prose directly above the line the guard parses, so only backticks were keeping it green. The second matters because `Dockerfile:55` already ends a RUN with `&& chmod +x`, so it is the shape a fourth script most plausibly arrives in. `applied_mode` now drops comments before parsing, tracks the chmod mode per `&&` segment, and takes a `COPY --chmod=` only from the instruction whose DESTINATION is the file (or the directory it lands in), never from any line the path merely appears on. `dockerfile_copy_dests` resolves a directory destination to the paths that land in it — previously `COPY a.sh b.sh /usr/local/bin/` yielded the directory, which matches no chmod and reds a legitimate refactor — and normalises the exec/JSON COPY form, whose brackets and quotes made its destination drop silently out of the derived set (#86's shape). A globbed source under a directory destination is unresolvable without the build context, so the directory is emitted and the per-file check fails closed. Both parsers are now exercised against Dockerfiles written to fool them, which is the only way this guard can go quietly vacuous while the two real ones stay compliant. Per ADR-0005 §2 the header records the amended PAIR, and each rule in `applied_mode` was disabled in turn to confirm exactly one fixture catches it; that run is also what exposed the segment-carry fixture as missing, the reversed chain it was written from having pinned nothing. tests/run-all: 29 suites, 909 assertions, 0 failed, against an 895 baseline on this machine at the unmodified head — the 14 new fixture assertions are the whole delta. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RhntBnmnwwd1rUhkoHqwUR
The push of 354c4ee updated the PR but created no workflow run: seven minutes on, that commit had zero check runs while Actions was enabled and all four workflows active. Nothing in the diff explains it — it touches only tests/, and test.yml's `pull_request` trigger carries no path filter — so this is an empty commit to re-fire `synchronize`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RhntBnmnwwd1rUhkoHqwUR
The PR was CONFLICTING against main, which is why neither push produced a workflow run: GitHub cannot compute refs/pull/142/merge for a dirty PR, so no `pull_request` event is ever dispatched. Not latency, and not the empty commit's fault — no run had been created repo-wide since main moved to b48d468. One conflict, in .github/workflows/docker-build.yml, and it is additive on both sides: this branch appends a step running setup-env as vscode (#129), main appends dev/check-image-mount-parents (#106/#113). Both assert on the same devcontainer-ci:test image and neither depends on the other, so both are kept, in that order. The trigger-path lists merged without conflict, main's two entries included. tests/run-all on the merged tree: 31 suites, 1053 assertions, 0 failed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #129.
Supersedes #133, which was merged by mistake in place of #113 and then unmerged:
mainwasforce-pushed back to
97430dcand this branch restored at the same head,2fb9353. The changeis unmodified — same commits, same head SHA — and #133's review discussion remains the record.
COPYpreserves the source file's mode, and one of the copied sources —setup-env.sh— comesfrom a project overlay, i.e. a contributor's working tree, where the mode is whatever their umask
made it.
chmod +xadds execute bits but cannot restore a missing read bit:The copy is root-owned in the image, so the owner bits stop applying to
vscode, and bash mustread an interpreted script to execute it. Every container entry then dies on
— a message pointing at the exec bit, which is the one thing that is fine.
A host umask of
0077is enough to trigger it. Git tracks only the exec bit, so a0700workingtree file still reads as
100755in the index:git statusis clean and nothing at the repo levelcan see it. The overlay build context is the main tree's
.devcontainer/(
lib/git.sh:26), so a healthy worktree copy is not the file being built — which is what made thistake a while to find.
Of the three files that
chmod +xline covers, onlysetup-env.shis exposed:setup-claude.pyand
audit-hookcome from$DEV_BASE_DIR(Nix store,r-xr-xr-x), andinstall-system.shisCOPY --from=projecttoo but is run asbash /tmp/install-system.shby root, so its mode nevermatters.
setup-env.shis the only source that is both a user's working-tree file and executedlater as
vscode.The change
chmod 755— an absolute mode — in both Dockerfiles, so the image's behaviour no longer depends onthe contributor's umask. The
RUNlayer is kept rather than moving toCOPY --chmod=: it appliesto three destinations from two contexts, and one
RUNreads better than threeCOPYflags.Verified end-to-end on both routes, building from a deliberately
0700overlay:setup-env sync-if-neededas vscodechmod 755(this PR)chmod +x(before)Permission denied, exit 126The guard that could not fail
nix-base.ymlalready asserted on exactly this file:It cannot go red for this bug, for two independent reasons. The build context is
./defaultsfroma fresh checkout at
0755, so the bad mode never arises in CI; and noUSERis set on the image,so the assertion runs as root, where both
-xand-rare true on0711. Confirmed against adeliberately broken image:
Both workflows now build from a
0700overlay — the input a umask-0077 contributor has and CI hasnever seen — and assert by running the script as
vscode, the only vantage the read bit bindsfrom.
docker-build.ymlchmods its overlay in place (one build: after this fix the image's modesare input-independent, so the hostile source is the only informative one);
nix-base.ymladds asecond tail build, since its existing one also guards
claude --versionon the pristine overlay.The hermetic guard
tests/test-copied-script-modes.shasserts that every script COPYd into/usr/local/binbyeither Dockerfile gets an absolute mode. Per ADR-0005 §3 it derives at rung 2: the checked set is
parsed out of each Dockerfile's COPY destinations, not restated, so a fourth script into
/usr/local/binis covered the day it is added.Recorded mutations (ADR-0005 §2), against the fixed tree at 14 passed / 0 failed:
chmod 755reverted tochmod +x→ 8 passed, 6 failed,expected: absolute/actual: relative (+x), once per script per Dockerfile.assert_nonemptyanchor goesred alongside, which is the fail-open case (a destination with no mode applied) it exists for.
The only change to the shared lib is additive:
dockerfile_copy_dests(sources answer "what inputscan change the image"; a mode guard needs the other end), plus extraction of the
continuation-joining pipeline both parsers now share. The four existing suites that consume the lib
are unchanged and green.
Verification
tests/run-all: 29 suites, 889 assertions, 0 failed, against a 28-suite / 875-assertionbaseline captured on
origin/mainbefore any edit — the new suite's 14 are the whole delta, andno existing assertion changed.
pre-commit runover every changed file: shellcheck, yamllint, andhadolint all pass.
Not addressed here
#130 — the image fingerprint hashes file contents only (
config_hashcats each input), so amode-only fix to a COPY source does not invalidate the tag, and
ensure_imageshort-circuits onpresence and reuses the stale image. That is why this bug survived a rebuild once diagnosed; the
only way through was
docker rmion the tag. It is independent of this change and stays open.After this PR the image's modes are input-independent by construction, which downgrades #130 from
live to latent — but the fingerprint is still under-specified for any future mode-carrying input.
🤖 Generated with Claude Code