Files
xarmian a8af168304 fix(cli): session liveness reads unknown, not dead, across a PID namespace (BUG-4090) (#2328)
* fix(cli): session liveness reads unknown, not dead, across a PID namespace (BUG-4090)

The session registry's verdict probed the owner pid with kill(pid, 0), and
a pid means something only inside one PID namespace. From a sandbox or
container shell (bwrap --unshare-pid) every host pid is ESRCH, so
`pad session list` read every live host session dead, and `pad session
prune` - and the prune that follows every `pad session register` -
deleted their records.

Records now carry pid_ns, the namespace the owner pid was captured in,
stamped only when that pid is signalable there. The reader compares:
across a boundary the pid is not probed and a missing socket is not
evidence, so the verdict is unknown (kept by prune, untrusted by the arm
gate). Only a socket answering with a different identity stays dead. A
row without pid_ns read from a non-init namespace keeps a positive pid
probe and turns a negative one into unknown. Headless arm-state files
carry pid_ns too.

Claude-Session: https://claude.ai/code/session_01BpZwJcFxRtM9Wax4fHhDNC

* fix(cli): judge a session's socket by its mount namespace (BUG-4090 r1)

The socket half of the liveness verdict now keys on the mount namespace
the socket was seen in (mnt_ns, stamped only beside a socket stat'ed with
its identity). A reader with another filesystem view gets unknown for a
missing or different node; a row without mnt_ns keeps the old rule, so a
container reading its own rows (or the Nix sandbox) behaves as before.
The unverifiable prose now says "from here (this platform, or another
namespace)", and the bwrap test gains a leg for a pre-fix, unstamped row.

Claude-Session: https://claude.ai/code/session_01BpZwJcFxRtM9Wax4fHhDNC

* fix(cli): a foreign-namespace reader never manufactures dead; chat direction stamps mnt_ns (BUG-4090 r2)

An unstamped row read from a non-init PID namespace now reads alive only
on a start-token match: a mismatch, a vanished pid or a zombie at that
number describes the reader's namespace, not the owner's, and is unknown.
The chat-direction file, the third socket-identity writer, records its
socket's mount namespace like the registry and the arm file. The help and
CLAUDE.md no longer overclaim for rows written before this version.

Claude-Session: https://claude.ai/code/session_01BpZwJcFxRtM9Wax4fHhDNC

* fix(cli): an unidentifiable namespace is a possible boundary on Linux (BUG-4090 r3)

A reader that cannot read /proc/self/ns/{pid,mnt} (a sandbox with no
procfs) no longer treats an unstamped row as same-namespace: its ESRCH or
missing socket is unknown, not dead. A Linux writer in that position
stamps pid_ns/mnt_ns with the sentinel 1, which no reader's namespace
matches, so every reader treats the row as crossed. Off Linux, where
namespaces do not exist, nothing changes.

Claude-Session: https://claude.ai/code/session_01BpZwJcFxRtM9Wax4fHhDNC

* fix(cli): an empty start token is not a match for a foreign-namespace reader (BUG-4090 r4)

An unstamped row with no proc_start, read from a non-init or unreadable
PID namespace, read alive whenever any process answered at its number
there. That can honour a headless arm or disarm on an unrelated process.
With nothing to match, such a reader now says unknown; the bare-probe
residual stays for same-namespace and init readers.

Claude-Session: https://claude.ai/code/session_01BpZwJcFxRtM9Wax4fHhDNC

* test: namespace-correct session fixtures for runs outside the init pid namespace (BUG-4090)

The Nix check runs go test in its build sandbox's PID namespace, where an
unstamped registry row is judged by BUG-4090's foreign-reader rule. Three
cmd/pad tests hand-wrote such rows and expected init-namespace verdicts.
The two dead-pid fixtures are now stamped with the test's own pid_ns, so
they mean "same namespace, dead pid" anywhere; the v1 legacy-row table
assertion follows the test's namespace. internal/cli gains a seam test
that drives the legacy row's verdict through init, non-init and
unreadable readers on every runner.

Claude-Session: https://claude.ai/code/session_01BpZwJcFxRtM9Wax4fHhDNC
2026-10-26 15:34:50 -04:00

43 lines
1.5 KiB
Go

//go:build linux
package cli
import (
"errors"
"os"
"syscall"
)
// initPIDNamespace is the inode of the initial PID namespace. The kernel
// fixes it (PROC_PID_INIT_INO, 0xEFFFFFFC), so a reader can tell it is in
// the host's namespace without anyone having recorded it (BUG-4090).
const initPIDNamespace uint64 = 0xEFFFFFFC
// namespacesSupported: on Linux every process is in some PID and mount
// namespace, so a namespace this process cannot identify (no procfs) is a
// possible boundary, not proof of none (BUG-4090 r3, F1/F2).
const namespacesSupported = true
// currentPIDNamespace returns the inode of this process's PID namespace,
// the identity behind `readlink /proc/self/ns/pid` ("pid:[<inode>]"). A
// pid means something only inside one namespace, so two records — or a
// record and its reader — agree about a pid only when this agrees.
func currentPIDNamespace() (uint64, error) { return currentNamespace("pid") }
// currentMountNamespace is the same for the mount namespace: a path, and
// so a socket's absence or identity, means something only to a reader
// with the recorder's view of the filesystem (BUG-4090 r1, H1/H3).
func currentMountNamespace() (uint64, error) { return currentNamespace("mnt") }
func currentNamespace(kind string) (uint64, error) {
info, err := os.Stat("/proc/self/ns/" + kind)
if err != nil {
return 0, err
}
st, ok := info.Sys().(*syscall.Stat_t)
if !ok || st.Ino == 0 {
return 0, errors.New(kind + " namespace identity unavailable")
}
return st.Ino, nil
}