Skip to content

fix(claude-e2e-verify): decline the credential-coexistence acceptance; run PR code as an unprivileged user - #328

Merged
kyle-sexton merged 2 commits into
mainfrom
fix/288-decline-in-job-credential-coexistence
Jul 31, 2026
Merged

fix(claude-e2e-verify): decline the credential-coexistence acceptance; run PR code as an unprivileged user#328
kyle-sexton merged 2 commits into
mainfrom
fix/288-decline-in-job-credential-coexistence

Conversation

@kyle-sexton

Copy link
Copy Markdown
Contributor

Summary

Implements the #288 ruling (Option B — decline re-ratification, posted under this session's delegated decision authority; human veto stands). The persisted-credential acceptance block at claude-e2e-verify.yml:65-92 is rewritten from accepted to declined-and-mitigated, and the coexistence is closed structurally rather than re-worded:

The teardown mechanism changes from setsid/PID-group signalling to pkill -u of the app uid (strictly broader coverage); the self-caller's inaccurate "degrades to a warning" fork comment is corrected in passing.

Verification

  • actionlint clean on both changed workflows (baseline main: clean).
  • zizmor visible findings identical to main's baseline (1 low artipacked at the checkout step — the documented #1236 constraint; 1 medium adhoc-packages for the pinned global Playwright install — both pre-existing).
  • yq YAML parse clean.
  • Script composition + fd-inheritance semantics (quoted-heredoc prelude, runner-opened log fd inherited by the unprivileged child) verified empirically in a local simulation.
  • The dogfood lane (claude-e2e-verify-self.yml) path-filters on both changed files, so this PR's own CI exercises the isolated lane end-to-end against the fixture app.

Related

Ruling and full rationale, including the settled id-token/fork fact with GitHub-doc citations and the block-scoped embargo correction: #288 (comment)

Fixes #288
Fixes #298
Fixes #299

🤖 Generated with Claude Code

https://claude.ai/code/session_01RhS3T7ShwJgKTrvk2Mvd3C

…; run PR code as an unprivileged user

Rules #288 as Option B (documented at
#288, issuecomment-5138342003): the
persisted-credential acceptance block is declined, not re-ratified, and
the coexistence is closed structurally. All PR-controlled code
(setup-command + app-start-command) now runs as a dedicated unprivileged
user from a .git-free copy of the checkout with a sudo env_reset
environment; the runner-owned checkout is chmod'd o-rwx. This removes
the app's access to the persisted .git/config token, later steps' /proc
environs, the OIDC request env vars, and the runner-ingested
GITHUB_ENV/GITHUB_PATH files (#298's channel). A separate unprivileged
JOB — the direction #288 recorded — cannot work here: jobs run on
separate VMs and the agent must reach the live app over localhost, so
the uid boundary lands in-job.

The header's threat inventory gains the four-credential list, the
corrected visibility:all premise, the corrected fork wording (the agent
step executes and fails under continue-on-error; it is not skipped), a
demoted strip-step rationale (artipacked compliance + self-hosted
cross-job scrub only), and the indirect-prompt-injection entry (#299)
with the recorded decision that the default claude-args deliberately
carries no --allowedTools.

Fails closed on runners without passwordless sudo/useradd rather than
degrading to same-uid execution.

Fixes #288
Fixes #298
Fixes #299

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01RhS3T7ShwJgKTrvk2Mvd3C

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@claude

claude Bot commented Jul 31, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 36s —— View job


Reviewing PR #328

  • Gather context (diff, CLAUDE.md, REVIEW.md)
  • Understand the request (code review of privilege-separation rewrite)
  • Review claude-e2e-verify.yml changes
  • Review claude-e2e-verify-self.yml changes
  • Check alignment with CLAUDE.md security ground rules
  • Post final review

This is the code-review lane; per REVIEW.md I've left vulnerability/credential-exposure findings to the security lane and focused on correctness, design, error handling, and docs.

Findings

🔴 Important — app-under-test account is never removed, so the isolation step breaks on any reused (self-hosted) runner

claude-e2e-verify.yml#L363 creates the app-under-test user with sudo useradd --create-home, but nothing in the workflow ever runs userdel/rm -rf for it — the teardown step at L813-L824 only pkills the uid's processes, not the account itself.

On GitHub-hosted runners this is invisible (the VM is destroyed after the job), but the runner input at L145-L152 explicitly documents self-hosted as a supported target ("Must provide passwordless sudo and useradd"), and the header itself treats self-hosted reuse as a first-class concern elsewhere (the credential-strip step is justified in part as "a genuine cross-job scrub on self-hosted runners," L112-L120). On a persistent self-hosted runner, the second PR run hits useradd: user 'app-under-test' already exists, and since the step runs under set -euo pipefail, the whole job fails at the isolation step — permanently, until an operator manually runs userdel/cleans the home directory. That's a hard, self-inflicted outage on exactly the runner class this PR's own isolation model is designed to support, not a one-off flake.

Suggest adding a sudo userdel -r "$APP_USER" || true (or equivalent) to the teardown step, or making the useradd step idempotent (skip/reset if the account already exists) so the lane survives a non-ephemeral runner.

Fix this →

Everything else

  • The header rewrite (declined-acceptance, corrected credential inventory, indirect-prompt-injection entry) reads as internally consistent with the actual step behavior I traced through the diff.
  • The .git-free copy + chmod o-rwx + sudo env_reset mechanics compose correctly: owner (runner-uid) permissions on $GITHUB_WORKSPACE are untouched by chmod o-rwx, so later runner-uid steps (Playwright install, credential strip, outcome reporting) are unaffected.
  • The switch from setsid/PID-group teardown to pkill -u "$APP_USER" is correctly broader and doesn't reintroduce the old PID-file dependency.
  • No test-coverage gap: the dogfood lane (claude-e2e-verify-self.yml) path-filters on both changed files, so CI on this PR itself exercises the new isolated lane. I didn't independently re-run that CI — noting it as author-claimed/lane-verified, not something I confirmed myself.
  • claude-e2e-verify-self.yml change is a trivial, accurate doc correction with no behavioral impact.

@claude

claude Bot commented Jul 31, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 6m 15s —— View job


Security review

  • Gather context (diff, changed files, CLAUDE.md rules)
  • Review .github/workflows/claude-e2e-verify.yml
  • Review .github/workflows/claude-e2e-verify-self.yml
  • Post findings

Scope: only the two changed files, diffed against origin/main. claude-e2e-verify-self.yml is a two-line comment correction with no behavioral change — no findings there. All findings below are against claude-e2e-verify.yml's new unprivileged-app isolation model. Per instructions, I deferred to zizmor for supply-chain/pin, trigger-pattern, permissions, and template-injection concerns (the PR's own verification notes zizmor is clean/baseline-identical) and focused on the trust-boundary logic of the new isolation.

The core design is sound for the stated primary target (GitHub-hosted ubuntu-24.04): checkout → isolate (useradd, copy sans .git, chown, chmod o-rwx the runner workspace) all complete before any PR-controlled command runs, so the credential-read channels the header claims to close (.git/config, /proc environ of later steps, $GITHUB_ENV/$GITHUB_PATH writes) are genuinely closed on that target. My findings are about places where the mitigation's correctness silently depends on assumptions that aren't verified in-workflow — all become live specifically because runner is a caller-supplied input that explicitly permits self-hosted runners (claude-e2e-verify.yml header, runner input description).


1. IMPORTANT (confidence: PLAUSIBLE) — sudo's env_reset is asserted as closing the OIDC-inheritance channel but is never verified

.github/workflows/claude-e2e-verify.yml#L101-L102 states the mitigation for credential #3 (OIDC minting capability) is: "sudo's env_reset gives the app a clean environment — no ACTIONS_ID_TOKEN_REQUEST_, no step env."* That claim is implemented by plain sudo -u "$APP_USER" -H bash ... at L389 / L429, relying entirely on the runner image's default sudoers Defaults env_reset behavior — nothing in the workflow passes --preserve-env= explicitly restrictively, and nothing checks that env_reset is actually in effect.

The fail-closed check at L356-L362 only verifies that passwordless sudo works (sudo -n true); it says nothing about sudo's environment-handling policy. A self-hosted runner (explicitly an in-scope runner value per the input's own description) whose sudoers config carries Defaults !env_reset or an env_keep list that includes ACTIONS_ID_TOKEN_REQUEST_*/GITHUB_* (not unusual in enterprise images that need to preserve proxy or auth env vars across sudo) would silently hand the unprivileged app process the live OIDC token-minting credentials — reopening exactly the channel this PR's mitigation is built to close, with no error, no warning, and no CI signal that the isolation degraded.

Since this specific guarantee is load-bearing for one of the four listed credentials and the existing check doesn't cover it, consider hardening the isolate step to assert the property directly, e.g. sudo -u "$APP_USER" env | grep -q ACTIONS_ID_TOKEN_REQUEST && { echo "::error::sudo did not reset the environment"; exit 1; }, or force it explicitly with sudo -u "$APP_USER" env -i -H bash ... instead of relying on sudoers defaults.

Fix this →


2. SUGGESTION (confidence: PLAUSIBLE) — workspace protection strips only "other" bits, not group bits

.github/workflows/claude-e2e-verify.yml#L363 creates app-under-test with plain useradd --create-home, and L369 protects the runner-owned checkout with chmod o-rwx "$GITHUB_WORKSPACE" — which strips only the "other" permission bits, leaving "group" untouched. The .git/config-read closure this line is credited for in the header (L89-L100) implicitly assumes useradd's default of creating a new private group per user (true for stock Ubuntu with USERGROUPS_ENAB=yes, so sound on the documented GitHub-hosted target). On a self-hosted image where that default differs (e.g. USERGROUPS_ENAB=no, landing new users in a shared default group), and if that shared group happens to coincide with the checkout's group ownership, group-read/traverse would survive the chmod untouched. chmod 700 (or an explicit assertion that id -g "$APP_USER" doesn't overlap the workspace's group) would remove the assumption instead of relying on it silently holding.


3. SUGGESTION (confidence: PLAUSIBLE) — the app-under-test account and its home directory are never removed

Teardown (L813-L824) only pkill -TERMs the app uid's processes; nothing userdels the account or wipes its home directory. On a persistent (non-ephemeral) self-hosted runner reused across job runs, a second run's useradd --create-home at L363 hard-fails closed today (the account already exists, set -euo pipefail aborts the job) — which is safe as currently written — but it also means any future idempotency fix to that useradd call (e.g. id -u "$APP_USER" &>/dev/null || useradd ..., a natural fix for the reliability problem this currently-hard-failing behavior represents) would need to pair with an explicit home-directory wipe, or stale files left in $HOME/app-under-test by one PR's unprivileged code (cp -a at L366 only overlays the new checkout, it doesn't clear extras) would carry into the next run's isolated app copy. Worth a comment noting the coupling so a future reliability fix doesn't silently drop this.


No findings within the checked-in pull_request-only / no-pull_request_target-or-workflow_run trigger boundary, the credential/permission scoping, or the template-injection discipline (caller strings reach run: only via env, never inlined) — those all read correctly and match the header's claims for the GitHub-hosted default. All three findings above are specifically about the self-hosted-runner surface the runner input opens up, not the primary target.

Branch: fix/288-decline-in-job-credential-coexistence |

@github-actions

Copy link
Copy Markdown

Claude has reviewed this PR 1 time. The lane skips further automatic reviews after 5; deleting this comment resets the count.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e286de2620

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread .github/workflows/claude-e2e-verify.yml Outdated
Comment thread .github/workflows/claude-e2e-verify.yml
…ead them

## Fix

The lane failed its own dogfood run: the app never came up, and the
application log carried the reason —

    bash: /home/runner/work/_temp/app-start-command.sh: Permission denied

Both command scripts were composed into `$RUNNER_TEMP` and then executed
as `app-under-test`, but that uid cannot read runner-owned paths. The
runner-opened log fd already encodes half of this — it exists so the app
never needs WRITE access to a runner-owned path — the miss was that reads
are barred just the same.

Both scripts are now staged into a root-owned `/opt/claude-e2e-verify`
via `install -m 0555 -o root -g root`, readable and executable by the app
uid but not writable by it. Deliberately not the app user's own home: that
directory is app-owned, so a script placed there could be swapped by the
very code it is meant to launch. The staging directory is recreated fresh
each run.

This also repairs `setup-command`, which had the identical defect and
escaped the failure only because the dogfood caller passes none — it was
a latent break for any real consumer.

## Account provisioning is now repeatable (review finding, P1)

`useradd` ran unconditionally, so on a persistent self-hosted runner —
which `inputs.runner` permits — the first run left the account behind and
every later run died at that line under `set -e`.

Creation is now conditional, but reuse is *gated*, not assumed: the
account is stamped with a marker in its GECOS field at creation and the
marker is re-checked before reuse. An `app-under-test` this lane did not
provision belongs to someone else, and adopting it would run PR-controlled
code under whatever that account already carries — group memberships,
sudo rules, existing files — so that case fails closed, matching the
step's existing refusal to degrade to same-uid execution. A reused account
is reset first: processes under the uid killed, the app tree removed and
recopied.

## Ground rules no longer contradict the workflow (review finding, P2)

`CLAUDE.md`'s security section still called the credential coexistence
"accepted, not mitigated in-job", which is the posture this PR replaces —
guidance opposite to the implemented model invites a later change that
restores the weaker one. Only that clause moved; the bound (the
`pull_request`-only tripwire, the fork-PR no-secrets guarantee) and the
"do not widen it" prohibition are untouched, and the mitigation's details
stay owned by the workflow header rather than being restated.

## Verification

- `actionlint` exit 0 on the changed workflow, with `shellcheck` present
  so the embedded `run:` scripts were checked too.
- `markdownlint-cli2` clean on `CLAUDE.md`.
- YAML parses; no whitespace damage.
- Marker gating simulated against synthetic passwd lines: this lane's own
  stamp reuses; a foreign `adduser` GECOS (`Someone Else,,,`) and an empty
  GECOS both fail closed.

The permission fix itself is proven by the dogfood lane, which path-filters
on this workflow and exercises it end-to-end against the fixture app.

## Related

Addresses both review findings on #328.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@claude

claude Bot commented Jul 31, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 4m 25s —— View job


Security review

  • Gather context (diff, changed files, CLAUDE.md rules)
  • Review .github/workflows/claude-e2e-verify.yml
  • Review .github/workflows/claude-e2e-verify-self.yml
  • Post findings

Scope: this PR's two changed workflow files, focused on what changed since my last pass on this branch (commit e286de2) — the new commit 08087c5 ("stage command scripts where the app uid can read them") plus a fresh look at the full isolation model. claude-e2e-verify-self.yml is unchanged in this commit and still has no findings. Per instructions, zizmor's lane covers supply-chain/pin/trigger/permissions/template-injection and is deferred to; this pass is about trust-boundary logic.

Previously reported and now resolved: the useradd --create-home call is no longer unconditional — it's now gated on a GECOS marker check with reuse/refusal logic (L372-L395), closing the "second run on a persistent runner hard-fails at useradd" issue I flagged before. That fix introduces a new issue, below.


1. IMPORTANT (confidence: PLAUSIBLE) — the reused app-under-test account is only partially reset, so PR-controlled code can plant state that survives into a later, unrelated PR's run (and collides with a concurrently-running one)

The new reuse path at L379-L390 only removes $app_home/app:

sudo pkill -KILL -u "$APP_USER" || true
app_home="$(getent passwd "$APP_USER" | cut -d: -f6)"
sudo rm -rf "$app_home/app"

Everything else under $app_home (the account's actual $HOME, since setup-command/app-start-command run via sudo -u "$APP_USER" -H bash ... at L431 and L472) survives across runs on a persistent (self-hosted) runner. setup-command is caller-supplied but PR-controlled input still reaches it (the caller's dependency-install command runs against the PR's own manifests/lockfiles), so a malicious PR can have it drop ~/.ssh/authorized_keys (persistent SSH foothold as app-under-test on the runner host, for whoever controls the corresponding key), a poisoned ~/.npmrc/~/.condarc/etc. (config read by the next PR's dependency install under the same reused $HOME, e.g. a malicious registry/authToken), or other dotfiles — none of which the reset step above touches.

This is compounded by the concurrency: block at L268-L279: it's keyed per (PR number, head SHA), so it only cancels superseded runs of the same PR — it does nothing to stop two different PRs from running concurrently on the same physical self-hosted runner (a common way to get parallelism when self-hosting: multiple runner-service instances on one host, sharing the same OS account table). Two concurrent jobs would then share the same static APP_USER/$app_home/$APP_SCRIPT_DIR, so one PR's job can pkill -u app-under-test (killing the other's server mid-test) or overwrite $app_home/app while the other job is still reading/serving it.

Both issues share a root cause: the isolation model treats the account/home/script-dir as ambient shared state rather than scoping it per job. Since the isolation credibility here rests specifically on the self-hosted-reuse case (the runner input's own description says self-hosted is supported, and GitHub-hosted images are ephemeral so none of this is visible there), this is worth hardening rather than leaving implicit:

  • Scope $APP_USER/$app_home/$APP_SCRIPT_DIR per $GITHUB_RUN_ID (or at least fully userdel -r+recreate rather than reusing the account), and/or
  • Document that this lane must not be pointed at a self-hosted runner pool that allows concurrent job execution on the same host.

Fix this →


2. IMPORTANT (confidence: PLAUSIBLE) — still open from my previous pass: sudo's env_reset is relied on but never verified

Unchanged by the new commit. L353 and the header both credit sudo's default env_reset with stripping ACTIONS_ID_TOKEN_REQUEST_*/step env from the unprivileged app user, but this is never asserted — a self-hosted runner (the runner input's own documented target) with a sudoers config carrying env_keep for those vars, or !env_reset, would silently leak OIDC-minting capability into the app-under-test process with no CI signal. Still suggest either asserting the property (sudo -u "$APP_USER" env | grep -q ACTIONS_ID_TOKEN_REQUEST && exit 1) or forcing it (sudo -u "$APP_USER" env -i -H bash ...) instead of depending on sudoers defaults.


3. SUGGESTION (confidence: PLAUSIBLE) — still open from my previous pass: chmod o-rwx on the checkout strips only "other" bits, not "group"

Unchanged. L400 is sound on the documented GitHub-hosted target (per-user private groups by default), but implicitly assumes that useradd default; a self-hosted image with USERGROUPS_ENAB=no landing app-under-test in a group that overlaps the checkout's group ownership would leave group-read/traverse open. chmod 700 would remove the assumption instead of relying on it holding.


No new findings within the trigger boundary, credential/permission scoping, or template-injection discipline — those are unchanged by this commit and still read correctly. The new script-staging mechanism itself ($APP_SCRIPT_DIR, root-owned 0555 scripts) is sound: it closes a real read-permission gap on $RUNNER_TEMP and isn't writable by the app uid, so no TOCTOU there.

Branch: fix/288-decline-in-job-credential-coexistence |

@kyle-sexton
kyle-sexton merged commit 78dccef into main Jul 31, 2026
38 checks passed
@kyle-sexton
kyle-sexton deleted the fix/288-decline-in-job-credential-coexistence branch July 31, 2026 03:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant