Skip to content

run-claude-cli: a process a leg detaches outlives the leg's timeout #799

Closed

Description

@kyle-sexton

Problem

When a leg times out or overflows, run-claude-cli kills the CLI, but a process the leg detached (for example setsid nohup ... & from a Bash tool call) keeps running until the job ends. --die-with-parent (legCommand, lane.cjs) sets PR_SET_PDEATHSIG on the one process bwrap execs. prctl(2) clears that setting in a forked child, so it never reaches the CLI's own children. Before #798 the CLI was spawned directly, and the same gap existed.

Reported by the security review on #798: #798 (comment)

Reproduced locally (bwrap 0.11.1): a fake CLI that spawns a detached: true child and then times out under the leg's bwrap. The child wrote its marker 3 s after the leg was killed.

Impact: the survivor holds no credential. legEnv sets CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1, and the hosted probe on #798 recorded credential-in-subprocess= (empty). On a GitHub-hosted runner it dies when the job ends. It can still run past the leg's timeout inside the job.

Why --unshare-pid alone does not work

Adding --unshare-pid to the leg's bwrap argv kills the detached child (a test showed this). But with --dev-bind / /, /proc is still the host's procfs inside the new namespace, so a bwrap nested in the leg fails:

$ bwrap --die-with-parent --unshare-pid --dev-bind / / -- bwrap --die-with-parent --unshare-pid --unshare-net --ro-bind / / --proc /proc --dev /dev -- sh -c 'echo ok'
bwrap: open /proc/3/ns/ns failed: No such file or directory

The same nested call without the outer --unshare-pid works. The CLI's Bash sandbox is a nested bwrap like this one, so the flag as tried would break every Bash-enabled lane.

Next step

Try --unshare-pid together with --proc /proc on the outer bwrap, or another way to reap the leg's process tree, such as a cgroup or killing the process group. Prove it on a hosted runner with the run-claude-cli-ro-binds-probe workflow in pr-pipeline-sandbox (#173 shape), adding a detached-child check, before the change lands.

Activity

  1. added
    needs-triageNot yet classified. Floor until a type and one priority tier are set.
    on Oct 19, 2026
  2. kyle-sexton commented on Oct 20, 2026

    @kyle-sexton
    MemberAuthor

    Design decision for this issue (GitHub Security & Cost with Lane Funding, 2026-10-20, under the owner's overnight procedure):

    Run the API-key leg and the subscription leg as separate jobs, not as two legs of one step. Draft #801 (a post-leg process reap) is superseded and will be closed when the split PR opens.

    Why. A same-job reap only covers processes. Within one job, leg 1 shares the runner user, the filesystem and the launcher's environment with leg 2, so isolation would also need a private pid namespace, a fresh HOME and workspace, a fixed root-owned PATH and absolute tool paths, and blocked schedulers. --unshare-pid on the outer bwrap already broke the CLI's nested bwrap in the v0.53.0 work. Separate jobs get that isolation from the platform: "each GitHub-hosted runner is a new virtual machine" (github-hosted-runners), executing "within ephemeral and clean isolated virtual machines" (secure-use).

    Shape.

    1. run-claude-cli runs one leg per invocation: either the key or the token, never both, and there is no in-step fallback.
    2. Job B (token) runs only when job A's lane-class output is credit-stop. The output is set by the launcher after it truncates the runner's output files. Job B validates it against a closed enum and never interpolates it into run:. A forged credit stop costs only subscription usage; that residual is accepted.
    3. Job B runs on a fixed GitHub-hosted label (ubuntu-24.04), never a caller-supplied runner. It does a fresh checkout, reads only trusted inputs, and does no cache restore and no artifact download from job A.
    4. The split lives inside the reusable workflow where one exists (intake-triage), so callers change once. pr-verify gets the same two-job shape on its side.
    5. Lane outcome reporting reads both jobs, so a credit stop followed by a leg-2 success is one lane record. Write lanes take the patch from whichever job ran.
    6. verifyManagedSettings also requires allowManagedHooksOnly or disableAllHooks.

    Not removed by this design: job B still reads the lane's untrusted text, so prompt injection under the token stays bounded by the lane's tool limits. That limit applies equally to the single-job design.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    needs-triageNot yet classified. Floor until a type and one priority tier are set.

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions