Skip to content

fix(runner): the hardened scratch tmpfs was noexec, so the credential never reached git - #119

Merged
rubenvdlinde merged 1 commit into
developmentfrom
fix/hardened-tmpfs-must-allow-exec
Aug 2, 2026
Merged

fix(runner): the hardened scratch tmpfs was noexec, so the credential never reached git#119
rubenvdlinde merged 1 commit into
developmentfrom
fix/hardened-tmpfs-must-allow-exec

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

Found while clearing builder-write-access-hardening task 2.4 against the live sidecar.

The defect

tmpfs: [- /tmp] is not "a writable scratch". Docker mounts a tmpfs rw,nosuid,nodev,noexec unless told otherwise — and the scratch tree is executed from, twice:

  1. stage.js writes the GIT_ASKPASS helper into it and git executes it to obtain the credential;
  2. the stage's command child is a script in the cloned tree, spawned with the clone as its cwd.

Measured on one image (ghcr.io/conductionnl/hermiq-llm-runner:stage-proof, same digest both runs) with only the mount options varying, git ls-remote against a private repo so ASKPASS is actually invoked:

/tmp options GIT_ASKPASS command child
rw,nosuid,nodev,noexec (Docker default) fatal: cannot exec '/tmp/…/askpass.sh': Permission deniedcould not read Username …: terminal prompts disabled EACCES
rw,exec,nosuid,nodev,size=2g remote: Invalid username or token — the credential reached the forge runs

So the hardened posture as shipped dropped the credential on the floor and could not run a gate suite at all, while every docker inspect assertion about it still passed. Same shape as hermiq#96's jail: a control observed only by inspection, never by exercise.

⚠️ My first version of this test was invalid — I ran git ls-remote against a public repo, which never invokes ASKPASS, so both configurations "passed". The private-repo run is the one that discriminates.

The default 64 M tmpfs size is also too small for a repository clone, so size= is set for the same reason.

The ratchet

deploy.posture.test.js pins the whole posture and is honest that it is static — a compose file is not a container. Mutation-checked: restore the bare - /tmp form and exactly case 3 goes red (/tmp is not mounted exec — GIT_ASKPASS and the command child will fail); every other case stays green. Wired into test.sh as (l). Full suite 26 passed, 0 failed.

Three operator facts, measured live

  • An EMPTY allowlist is an OPEN door. rejectionForAllowDenyLists() only applies the list when it is non-empty. Against the live PDP with no fetchAllowlist set, github.com, api.github.com, codeload.github.com, objects.githubusercontent.com, raw.githubusercontent.com and codeberg.org all returned allowed: true — and the only refusal in the run was evil.example.com failing DNS, not policy. allowed: true for the forge is therefore not evidence the forge is allowlisted; verify with a host that resolves and should be refused. The list is also a single global setting shared with the agent's web.fetch tool, so "allow the forge host only" cannot currently be expressed for the proxy alone.
  • ICacheFactory::isAvailable() returns true in the APCu fallback case, so nothing reports the missing distributed store. Read the class back instead: createDistributed() handing you OC\Memcache\APCu is the fault.
  • The live container is not the compose container. The shared instance runs the sidecar on AppAPI's manual_install daemon, which carries only appid/port/secret in oc_ex_appsAppAPI does not own the container lifecycle. All the hardening flags are create-time, so they must be supplied at docker run and asserted on the running container.

🤖 Generated with Claude Code

… never reached git

`tmpfs: [- /tmp]` is not "a writable scratch". Docker mounts a tmpfs
`rw,nosuid,nodev,NOEXEC` unless told otherwise, and the scratch tree is
executed from twice:

  1. `stage.js` writes the `GIT_ASKPASS` helper into it and git EXECUTES it to
     obtain the credential;
  2. the stage's command child is a script in the CLONED tree, spawned with the
     clone as its cwd.

Measured on one image with only the mount options varying:

  noexec  -> `fatal: cannot exec '/tmp/.../askpass.sh': Permission denied`
             then `could not read Username ...: terminal prompts disabled`;
             the command child fails EACCES.
  exec    -> git reaches the forge with the credential; the command child runs.

So the hardened posture as shipped dropped the credential on the floor and
could not run a gate suite at all — while every `docker inspect` assertion
about it still passed. That is the same shape as hermiq#96's jail: a control
observed only by inspection, never by exercise. The default 64M size is also
too small for a repository clone, so `size=` is set for the same reason.

`deploy.posture.test.js` pins the whole posture as a ratchet, and is honest
that it is static. Mutation-checked: restore the bare `- /tmp` form and exactly
the tmpfs case goes red. Full suite 26/26.

egress-allowlist.md gains three operator facts measured on the live instance:

- an EMPTY allowlist is an OPEN door, not a closed one — the guard only applies
  the list when it is non-empty, so `allowed: true` for the forge is NOT
  evidence the forge is allowlisted. Verify with a host that RESOLVES and
  should be refused, never with one that fails DNS. The list is also a single
  global setting shared with the agent's `web.fetch` tool, so "allow the forge
  only" cannot currently be expressed for the proxy alone;
- `ICacheFactory::isAvailable()` returns TRUE in the APCu fallback case, so
  nothing reports the missing distributed store; read the class back instead;
- the live container is not the compose container. An ExApp on AppAPI's
  `manual_install` daemon carries only appid/port/secret — AppAPI does not own
  the container lifecycle — so the hardening flags, all create-time, must be
  supplied at `docker run` and asserted on the RUNNING container.
@rubenvdlinde
rubenvdlinde requested a review from Rem-Dam as a code owner August 2, 2026 17:55
@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/hermiq @ 5458433

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
composer ✅ 117/117
npm ✅ 657/657
PHPUnit
Newman
Playwright ⏭️

Coverage: 85.3% (93/109 statements)


Quality workflow — 2026-08-02 17:59 UTC

Download the full PDF report from the workflow artifacts.

@rubenvdlinde
rubenvdlinde merged commit 848be5c into development Aug 2, 2026
34 checks passed
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.

1 participant