Skip to content

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
mainfrom
fix/absolute-mode-for-copied-scripts
Open

fix(docker): give COPYd scripts an absolute mode, and un-blind the guard that could not fail#142
dlovell wants to merge 4 commits into
mainfrom
fix/absolute-mode-for-copied-scripts

Conversation

@dlovell

@dlovell dlovell commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Fixes #129.

Supersedes #133, which was merged by mistake in place of #113 and then unmerged: main was
force-pushed back to 97430dc and this branch restored at the same head, 2fb9353. The change
is unmodified — same commits, same head SHA — and #133's review discussion remains the record.

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:

$ chmod 700 probe.sh && stat -c %a probe.sh
700
$ ( umask 022; chmod +x probe.sh ) && stat -c %a probe.sh
711

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

bash: /usr/local/bin/setup-env: Permission denied

— a message pointing at the exec bit, which is the one thing that is fine.

A host umask of 0077 is enough to trigger it. Git tracks only the exec bit, so a 0700 working
tree file still reads as 100755 in the index: git status is clean and nothing at the repo level
can 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 this
take a while to find.

Of the three files that chmod +x line covers, only setup-env.sh is exposed: setup-claude.py
and audit-hook come from $DEV_BASE_DIR (Nix store, r-xr-xr-x), and install-system.sh is
COPY --from=project too but is run as bash /tmp/install-system.sh by root, so its mode never
matters. setup-env.sh is the only source that is both a user's working-tree file and executed
later as vscode.

The change

chmod 755 — an absolute mode — in both Dockerfiles, so the image's behaviour no longer depends on
the contributor's umask. The RUN layer is kept rather than moving to COPY --chmod=: it applies
to three destinations from two contexts, and one RUN reads better than three COPY flags.

Verified end-to-end on both routes, building from a deliberately 0700 overlay:

source mode image mode setup-env sync-if-needed as vscode
chmod 755 (this PR) 0700 0755 exit 0
chmod +x (before) 0700 0711 Permission denied, exit 126

The guard that could not fail

nix-base.yml already asserted on exactly this file:

docker run --rm nix-base-tail:ci sh -c 'claude --version && test -x /usr/local/bin/setup-env'

It cannot go red for this bug, for two independent reasons. The build context is ./defaults from
a fresh checkout at 0755, so the bad mode never arises in CI; and no USER is set on the image,
so the assertion runs as root, where both -x and -r are true on 0711. Confirmed against a
deliberately broken image:

as root,   test -x on 0711: TRUE       <- the old assertion
as root,   test -r on 0711: TRUE
as vscode, test -r on 0711: FALSE
as vscode, running it:      Permission denied (126)

Both workflows now build from a 0700 overlay — the input a umask-0077 contributor has and CI has
never seen — and assert by running the script as vscode, the only vantage the read bit binds
from. docker-build.yml chmods its overlay in place (one build: after this fix the image's modes
are input-independent, so the hostile source is the only informative one); nix-base.yml adds a
second tail build, since its existing one also guards claude --version on the pristine overlay.

The hermetic guard

tests/test-copied-script-modes.sh asserts that every script COPYd into /usr/local/bin by
either 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/bin is covered the day it is added.

Recorded mutations (ADR-0005 §2), against the fixed tree at 14 passed / 0 failed:

  • both chmod 755 reverted to chmod +x8 passed, 6 failed, expected: absolute /
    actual: relative (+x), once per script per Dockerfile.
  • both chmod lines deleted outright → 2 passed, 12 failed; the assert_nonempty anchor goes
    red 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 inputs
can 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-assertion
baseline captured on origin/main before any edit — the new suite's 14 are the whole delta, and
no existing assertion changed. pre-commit run over every changed file: shellcheck, yamllint, and
hadolint all pass.

Not addressed here

#130 — the image fingerprint hashes file contents only (config_hash cats each input), so a
mode-only fix to a COPY source does not invalidate the tag, and ensure_image short-circuits on
presence and reuses the stale image. That is why this bug survived a rebuild once diagnosed; the
only way through was docker rmi on 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

t and others added 4 commits August 5, 2026 19:58
`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.
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.

RUN chmod +x cannot restore a read bit: a 0700 overlay setup-env.sh builds an image vscode cannot run, and CI's test -x guard is blind to it

1 participant