Board task: OB-GAP-091 · Repo: totalwindupflightsystems/off-by-one · Fix commit: a45239c (.github/workflows/ci.yml) · First bad master: 13604b4 (2026-09-21 22:45Z) · Verified run: 35748864969 — all 4 jobs green.
I confirmed the diagnosis against the live repo: fetched the before (13604b4) and after (a45239c) workflow, pulled the exact commit diff, verified run 35748864969 is success with all 4 jobs green, and reproduced the underlying uid_map EPERM on a Ubuntu host with kernel.apparmor_restrict_unprivileged_userns=1. Solution written to ~/bwrap-uid-map-permission-denied-solution.md:
bwrap: setting up uid map: Permission denied on Ubuntu 24.04+ GitHub Actions runnersBoard task: OB-GAP-091 · Repo: totalwindupflightsystems/off-by-one · Fix commit: a45239c (.github/workflows/ci.yml) · First bad master: 13604b4 (2026-09-21 22:45Z) · Verified run: 35748864969 — all 4 jobs green.
After the OB-GAP-087 step began installing bubblewrap so the sandbox tests would actually run, bwrap-dependent Go tests flipped from SKIP → FAIL on GitHub Actions (ubuntu-latest / Ubuntu 24.04):
--- FAIL: TestSandbox_Run_RealBwrap (exit status 1)
--- FAIL: TestSandbox_Run_GitAvailable
--- FAIL: TestBSandboxRunner_RoundTrip
bwrap: setting up uid map: Permission denied
The tests did not regress because of a code change. They went from skipping (no bwrap present) to executing, and executing exposed a host-kernel/AppArmor policy.
Ubuntu 24.04+ ships unprivileged user-namespace hardening:
kernel.apparmor_restrict_unprivileged_userns=1
When an unconfined process creates an unprivileged user namespace, the kernel transitions it into the AppArmor profile unprivileged_userns:
# /etc/apparmor.d/unprivileged_userns
profile unprivileged_userns {
audit deny capability,
audit deny change_profile,
allow userns,
...
}
bubblewrap runs unprivileged, so:
unshare(CLONE_NEWUSER) — allowed./proc/<pid>/uid_map (via the setuid newuidmap helper when it lacks CAP_SETUID in the parent namespace).EPERM:
bwrap: setting up uid map: Permission denied.This is not a bwrap bug and not a Go-code bug. Identical tests pass where the knob is 0; CI had never exercised them before because bwrap was not installed.
$ cat /proc/sys/kernel/apparmor_restrict_unprivileged_userns
1
$ unshare --user --map-root-user echo OK
unshare: write failed /proc/self/uid_map: Operation not permitted # RED
$ sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
$ unshare --user --map-root-user echo OK
OK # GREEN
$ sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=1
$ unshare --user --map-root-user echo OK
unshare: write failed /proc/self/uid_map: Operation not permitted # RED
The failing syscall is the write to uid_map, exactly matching the bwrap error string.
In .github/workflows/ci.yml, between the bubblewrap install step and Test (short), add:
# Ubuntu 24.04 runner images ship kernel.apparmor_restrict_unprivileged_userns=1
# (AppArmor unprivileged-userns hardening). bwrap gets far enough to create
# its user namespace, then dies writing the uid map:
# "bwrap: setting up uid map: Permission denied"
# so the OB-GAP-087 install alone flipped the sandbox tests from SKIP to
# FAIL (OB-GAP-091). Lift the restriction so the sandbox stays exercised
# (the point of OB-GAP-087) instead of degrading to skip-on-error.
- name: Lift AppArmor unprivileged-userns restriction
# Fail-open by design: a kernel without the knob has no restriction to
# lift, and the setuid fallback step below covers that case.
run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || echo "restrict knob not present on this kernel; skipping"
# Belt-and-suspenders for future runner images: setuid-root bwrap runs its
# privileged path and is immune to the unprivileged-userns restriction.
- name: Ensure setuid-root bubblewrap fallback
run: |
BWRAP="$(command -v bwrap)"
test -n "$BWRAP" || { echo "bwrap not found"; exit 1; }
test "$(stat -c %a "$BWRAP")" = "4755" || { sudo chown root:root "$BWRAP" && sudo chmod 4755 "$BWRAP"; }
echo "bwrap at $BWRAP: $(stat -c '%U:%G %a' "$BWRAP")"
Why both steps:
sysctl … || echo — directly removes the restriction so the normal unprivileged bwrap path works; || echo keeps it fail-open on kernels lacking the knob.chown root:root + chmod 4755 makes bwrap take its privileged path, which does not depend on an unprivileged user namespace and is immune to the restriction. Future-proofs against runner images that drop the knob or ship non-setuid bwrap.Do not turn the sandbox tests into skip-on-error. The point of OB-GAP-087 was to stop skipping and actually exercise the sandbox.
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || \
echo "restrict knob not present on this kernel; skipping"
BWRAP="$(command -v bwrap)"
sudo chown root:root "$BWRAP" && sudo chmod 4755 "$BWRAP"
stat -c '%U:%G %a %n' "$BWRAP" # expect: root:root 4755 /usr/bin/bwrap
bwrap --ro-bind / / --dev /dev --proc /proc echo BWOK
CI gate (authoritative). Run 35748864969, head_sha=a45239c…, conclusion=success. All four jobs green:
| Job | Result |
|---|---|
| Go 1.26 | ✅ success |
| Transport retry self-test | ✅ success |
| Binary seed probe | ✅ success |
| Deploy gate self-test | ✅ success |
The Go 1.26 job log shows:
kernel.apparmor_restrict_unprivileged_userns = 0bwrap at /usr/bin/bwrap: root:root 4755internal/sandbox and internal/solver packages okSKIP entries — the sandbox is genuinely exercised.Local mechanism check (any Ubuntu 24.04+ host with the knob set):
cat /proc/sys/kernel/apparmor_restrict_unprivileged_userns # -> 1
unshare --user --map-root-user echo OK
# unshare: write failed /proc/self/uid_map: Operation not permitted
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
BWRAP="$(command -v bwrap)"; sudo chown root:root "$BWRAP"; sudo chmod 4755 "$BWRAP"
unshare --user --map-root-user echo OK # -> OK
bwrap --ro-bind / / --dev /dev --proc /proc echo BWOK # -> BWOK
Go suite (on a fixed host):
go test -short -count=1 ./internal/sandbox/... ./internal/solver/...
# expect: ok .../internal/sandbox ok .../internal/solver (no SKIP)
.github/workflows/ci.yml — the two steps above (commit a45239c, OB-GAP-091).# Evidence - Problem class: bwrap-uid-map-permission-denied - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-22T15:48:38.821Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "SYMPTOM: after CI began installing bubblewrap (OB-GAP-087 bundle, first bad master 13604b4 at 2026-09-21 22:45Z), bwrap-dependent Go tests flipped from SKIP to FAIL on GitHub Actions: TestSandbox_Run_RealBwrap (exit status 1), TestSandbox_Run_GitAvailable and TestBSandboxRunner_RoundTrip (stderr: bwrap: setting up uid map: Permission denied). ROOT CAUSE: ubuntu-24.04 runner images ship kernel.apparmor_restrict_unprivileged_userns=1 (Ubuntu 24.04 AppArmor unprivileged-userns hardening). bwrap runs unprivileged, creates a user namespace, then denies the WRITE to /proc/<pid>/uid_map when mapping uids (setuid newuidmap path) - EPERM from AppArmor. Not a bwrap or Go-code bug: on hosts where the knob is 0 the same tests pass. Locally proven RED->GREEN->RED: unshare --user --map-root-user fails with 'write failed /proc/self/uid_map: Operation not permitted' at knob=1, succeeds at knob=0, fails again after restore. FIX (commit a45239c, .github/workflows/ci.yml): (1) before the test step, 'sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0' with a fail-open '|| echo' guard for kernels without the knob; (2) belt-and-suspenders: force setuid-root bwrap (chown root:root + chmod 4755) - the setuid privileged path is immune to the unprivileged-userns restriction, so future runner images that drop the knob or ship non-setuid bwrap still work. Do NOT degrade to skip-on-error: the point of installing bwrap was to exercise the sandbox (OB-GAP-087). VERIFICATION: CI run 35748864969 at a45239c all 4 jobs green; Go 1.26 job log shows knob lifted to 0, 'bwrap at /usr/bin/bwrap: root:root 4755', internal/sandbox + internal/solver ok, zero SKIPs.", "environment": "GitHub Actions ubuntu-latest (ubuntu-24.04) runner; kernel.apparmor_restrict_unprivileged_userns=1 by default; bubblewrap via apt; reproduces on any Ubuntu 24.04+ host with the same sysctl set", "language": "bash", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "bwrap-uid-map-permission-denied", "provider": "openrouter", "solved_at": "2026-09-22T15:48:38.821Z", "version": ""}I confirmed the diagnosis against the live repo: fetched the before (13604b4) and after (a45239c) workflow, pulled the exact commit diff, verified run 35748864969 is success with all 4 jobs green, and reproduced the underlying uid_map EPERM on a Ubuntu host with kernel.apparmor_restrict_unprivileged_userns=1. Solution written to ~/bwrap-uid-map-permission-denied-solution.md:
bwrap: setting up uid map: Permission denied on Ubuntu 24.04+ GitHub Actions runnersBoard task: OB-GAP-091 · Repo: totalwindupflightsystems/off-by-one · Fix commit: a45239c (.github/workflows/ci.yml) · First bad master: 13604b4 (2026-09-21 22:45Z) · Verified run: 35748864969 — all 4 jobs green.
After the OB-GAP-087 step began installing bubblewrap so the sandbox tests would actually run, bwrap-dependent Go tests flipped from SKIP → FAIL on GitHub Actions (ubuntu-latest / Ubuntu 24.04):
--- FAIL: TestSandbox_Run_RealBwrap (exit status 1)
--- FAIL: TestSandbox_Run_GitAvailable
--- FAIL: TestBSandboxRunner_RoundTrip
bwrap: setting up uid map: Permission denied
The tests did not regress because of a code change. They went from skipping (no bwrap present) to executing, and executing exposed a host-kernel/AppArmor policy.
Ubuntu 24.04+ ships unprivileged user-namespace hardening:
kernel.apparmor_restrict_unprivileged_userns=1
When an unconfined process creates an unprivileged user namespace, the kernel transitions it into the AppArmor profile unprivileged_userns:
# /etc/apparmor.d/unprivileged_userns
profile unprivileged_userns {
audit deny capability,
audit deny change_profile,
allow userns,
...
}
bubblewrap runs unprivileged, so:
unshare(CLONE_NEWUSER) — allowed./proc/<pid>/uid_map (via the setuid newuidmap helper when it lacks CAP_SETUID in the parent namespace).EPERM:
bwrap: setting up uid map: Permission denied.This is not a bwrap bug and not a Go-code bug. Identical tests pass where the knob is 0; CI had never exercised them before because bwrap was not installed.
$ cat /proc/sys/kernel/apparmor_restrict_unprivileged_userns
1
$ unshare --user --map-root-user echo OK
unshare: write failed /proc/self/uid_map: Operation not permitted # RED
$ sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
$ unshare --user --map-root-user echo OK
OK # GREEN
$ sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=1
$ unshare --user --map-root-user echo OK
unshare: write failed /proc/self/uid_map: Operation not permitted # RED
The failing syscall is the write to uid_map, exactly matching the bwrap error string.
In .github/workflows/ci.yml, between the bubblewrap install step and Test (short), add:
# Ubuntu 24.04 runner images ship kernel.apparmor_restrict_unprivileged_userns=1
# (AppArmor unprivileged-userns hardening). bwrap gets far enough to create
# its user namespace, then dies writing the uid map:
# "bwrap: setting up uid map: Permission denied"
# so the OB-GAP-087 install alone flipped the sandbox tests from SKIP to
# FAIL (OB-GAP-091). Lift the restriction so the sandbox stays exercised
# (the point of OB-GAP-087) instead of degrading to skip-on-error.
- name: Lift AppArmor unprivileged-userns restriction
# Fail-open by design: a kernel without the knob has no restriction to
# lift, and the setuid fallback step below covers that case.
run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || echo "restrict knob not present on this kernel; skipping"
# Belt-and-suspenders for future runner images: setuid-root bwrap runs its
# privileged path and is immune to the unprivileged-userns restriction.
- name: Ensure setuid-root bubblewrap fallback
run: |
BWRAP="$(command -v bwrap)"
test -n "$BWRAP" || { echo "bwrap not found"; exit 1; }
test "$(stat -c %a "$BWRAP")" = "4755" || { sudo chown root:root "$BWRAP" && sudo chmod 4755 "$BWRAP"; }
echo "bwrap at $BWRAP: $(stat -c '%U:%G %a' "$BWRAP")"
Why both steps:
sysctl … || echo — directly removes the restriction so the normal unprivileged bwrap path works; || echo keeps it fail-open on kernels lacking the knob.chown root:root + chmod 4755 makes bwrap take its privileged path, which does not depend on an unprivileged user namespace and is immune to the restriction. Future-proofs against runner images that drop the knob or ship non-setuid bwrap.Do not turn the sandbox tests into skip-on-error. The point of OB-GAP-087 was to stop skipping and actually exercise the sandbox.
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || \
echo "restrict knob not present on this kernel; skipping"
BWRAP="$(command -v bwrap)"
sudo chown root:root "$BWRAP" && sudo chmod 4755 "$BWRAP"
stat -c '%U:%G %a %n' "$BWRAP" # expect: root:root 4755 /usr/bin/bwrap
bwrap --ro-bind / / --dev /dev --proc /proc echo BWOK
CI gate (authoritative). Run 35748864969, head_sha=a45239c…, conclusion=success. All four jobs green:
| Job | Result |
|---|---|
| Go 1.26 | ✅ success |
| Transport retry self-test | ✅ success |
| Binary seed probe | ✅ success |
| Deploy gate self-test | ✅ success |
The Go 1.26 job log shows:
kernel.apparmor_restrict_unprivileged_userns = 0bwrap at /usr/bin/bwrap: root:root 4755internal/sandbox and internal/solver packages okSKIP entries — the sandbox is genuinely exercised.Local mechanism check (any Ubuntu 24.04+ host with the knob set):
cat /proc/sys/kernel/apparmor_restrict_unprivileged_userns # -> 1
unshare --user --map-root-user echo OK
# unshare: write failed /proc/self/uid_map: Operation not permitted
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
BWRAP="$(command -v bwrap)"; sudo chown root:root "$BWRAP"; sudo chmod 4755 "$BWRAP"
unshare --user --map-root-user echo OK # -> OK
bwrap --ro-bind / / --dev /dev --proc /proc echo BWOK # -> BWOK
Go suite (on a fixed host):
go test -short -count=1 ./internal/sandbox/... ./internal/solver/...
# expect: ok .../internal/sandbox ok .../internal/solver (no SKIP)
.github/workflows/ci.yml — the two steps above (commit a45239c, OB-GAP-091).# Evidence - Problem class: bwrap-uid-map-permission-denied - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-22T15:48:38.821Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "SYMPTOM: after CI began installing bubblewrap (OB-GAP-087 bundle, first bad master 13604b4 at 2026-09-21 22:45Z), bwrap-dependent Go tests flipped from SKIP to FAIL on GitHub Actions: TestSandbox_Run_RealBwrap (exit status 1), TestSandbox_Run_GitAvailable and TestBSandboxRunner_RoundTrip (stderr: bwrap: setting up uid map: Permission denied). ROOT CAUSE: ubuntu-24.04 runner images ship kernel.apparmor_restrict_unprivileged_userns=1 (Ubuntu 24.04 AppArmor unprivileged-userns hardening). bwrap runs unprivileged, creates a user namespace, then denies the WRITE to /proc/<pid>/uid_map when mapping uids (setuid newuidmap path) - EPERM from AppArmor. Not a bwrap or Go-code bug: on hosts where the knob is 0 the same tests pass. Locally proven RED->GREEN->RED: unshare --user --map-root-user fails with 'write failed /proc/self/uid_map: Operation not permitted' at knob=1, succeeds at knob=0, fails again after restore. FIX (commit a45239c, .github/workflows/ci.yml): (1) before the test step, 'sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0' with a fail-open '|| echo' guard for kernels without the knob; (2) belt-and-suspenders: force setuid-root bwrap (chown root:root + chmod 4755) - the setuid privileged path is immune to the unprivileged-userns restriction, so future runner images that drop the knob or ship non-setuid bwrap still work. Do NOT degrade to skip-on-error: the point of installing bwrap was to exercise the sandbox (OB-GAP-087). VERIFICATION: CI run 35748864969 at a45239c all 4 jobs green; Go 1.26 job log shows knob lifted to 0, 'bwrap at /usr/bin/bwrap: root:root 4755', internal/sandbox + internal/solver ok, zero SKIPs.", "environment": "GitHub Actions ubuntu-latest (ubuntu-24.04) runner; kernel.apparmor_restrict_unprivileged_userns=1 by default; bubblewrap via apt; reproduces on any Ubuntu 24.04+ host with the same sysctl set", "language": "bash", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "bwrap-uid-map-permission-denied", "provider": "openrouter", "solved_at": "2026-09-22T15:48:38.821Z", "version": ""}