fix(runner): the hardened scratch tmpfs was noexec, so the credential never reached git - #119
Merged
Merged
Conversation
… 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.
Contributor
Quality Report — ConductionNL/hermiq @
|
| 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.
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.
Found while clearing
builder-write-access-hardeningtask 2.4 against the live sidecar.The defect
tmpfs: [- /tmp]is not "a writable scratch". Docker mounts a tmpfsrw,nosuid,nodev,noexecunless told otherwise — and the scratch tree is executed from, twice:stage.jswrites theGIT_ASKPASShelper into it and git executes it to obtain the credential;Measured on one image (
ghcr.io/conductionnl/hermiq-llm-runner:stage-proof, same digest both runs) with only the mount options varying,git ls-remoteagainst a private repo so ASKPASS is actually invoked:/tmpoptionsGIT_ASKPASSrw,nosuid,nodev,noexec(Docker default)fatal: cannot exec '/tmp/…/askpass.sh': Permission denied→could not read Username …: terminal prompts disabledEACCESrw,exec,nosuid,nodev,size=2gremote: Invalid username or token— the credential reached the forgeSo the hardened posture as shipped dropped the credential on the floor and could not run a gate suite at all, while every
docker inspectassertion about it still passed. Same shape as hermiq#96's jail: a control observed only by inspection, never by exercise.git ls-remoteagainst 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.jspins the whole posture and is honest that it is static — a compose file is not a container. Mutation-checked: restore the bare- /tmpform 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 intotest.shas (l). Full suite 26 passed, 0 failed.Three operator facts, measured live
rejectionForAllowDenyLists()only applies the list when it is non-empty. Against the live PDP with nofetchAllowlistset,github.com,api.github.com,codeload.github.com,objects.githubusercontent.com,raw.githubusercontent.comandcodeberg.orgall returnedallowed: true— and the only refusal in the run wasevil.example.comfailing DNS, not policy.allowed: truefor 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'sweb.fetchtool, so "allow the forge host only" cannot currently be expressed for the proxy alone.ICacheFactory::isAvailable()returnstruein the APCu fallback case, so nothing reports the missing distributed store. Read the class back instead:createDistributed()handing youOC\Memcache\APCuis the fault.manual_installdaemon, which carries onlyappid/port/secretinoc_ex_apps— AppAPI does not own the container lifecycle. All the hardening flags are create-time, so they must be supplied atdocker runand asserted on the running container.🤖 Generated with Claude Code