Skip to content

fix(docker): give COPYd scripts an absolute mode, and un-blind the guard that could not fail - #133

Open
dlovell wants to merge 1 commit 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#133
dlovell wants to merge 1 commit into
mainfrom
fix/absolute-mode-for-copied-scripts

Conversation

@dlovell

@dlovell dlovell commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Fixes #129.

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

`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>
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