From 2ac7866d93c54993d0c7857963877a718429182b Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Sat, 31 Oct 2026 10:29:31 -0700 Subject: [PATCH 1/3] fix(kill): ignore a parent PID that a newer process reused ProtectedReason walks parent links to protect Driga's and Chat's ancestors and descendants. Windows keeps the PID of an exited parent, and a newer, unrelated process can reuse it, so the walk could mark that process as Driga's parent and refuse to stop it. TestChatCommandsBuiltBinary failed this way on Windows CI: "PID 732: Driga, Chat, or an ancestor/descendant is protected" for the test's own fixture. A parent link now counts only when the parent started no later than the child; a missing or unknown start time keeps the link. --- internal/kill/protected_test.go | 53 +++++++++++++++++++++++++++++++++ internal/kill/snapshot.go | 15 ++++++++-- 2 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 internal/kill/protected_test.go diff --git a/internal/kill/protected_test.go b/internal/kill/protected_test.go new file mode 100644 index 0000000..4137735 --- /dev/null +++ b/internal/kill/protected_test.go @@ -0,0 +1,53 @@ +package kill + +import ( + "os" + "os/user" + "runtime" + "testing" + "time" + + "github.com/labstack/driga/internal/model" +) + +func ownedProc(t *testing.T, pid, ppid int32, exe string, start time.Time) model.Proc { + t.Helper() + p := model.Proc{PID: pid, PPID: ppid, Exe: exe, Cmdline: exe, Argv: []string{exe}, StartTime: start, OwnerKnown: true, OwnerUID: uint32(os.Getuid())} + if runtime.GOOS == "windows" { + u, err := user.Current() + if err != nil { + t.Fatal(err) + } + p.OwnerUID, p.OwnerSID = 0, u.Uid + } + return p +} + +// Windows keeps the PID of an exited parent, so a Driga process can name a +// parent PID that a newer, unrelated process now has. That process is neither +// Driga's ancestor nor its descendant. +func TestProtectedReasonIgnoresReusedParentPID(t *testing.T) { + now := time.Now() + driga := ownedProc(t, 500, 732, "/opt/driga", now.Add(-time.Hour)) + target := ownedProc(t, 732, 900, "/bin/sleep", now) + live := []model.Proc{driga, target, ownedProc(t, 900, 1, "/bin/sh", now.Add(-2*time.Hour))} + if reason := ProtectedReason(target, live, nil); reason != "" { + t.Fatalf("process with a reused PID protected: %s", reason) + } + // A real parent, started before Driga, stays protected. + target.StartTime = now.Add(-2 * time.Hour) + live[1] = target + if reason := ProtectedReason(target, live, nil); reason == "" { + t.Fatal("Driga's parent not protected") + } + // A child Driga started after itself stays protected; a stale child link + // from a process older than its claimed parent does not. + child := ownedProc(t, 800, 500, "/bin/sleep", now) + if reason := ProtectedReason(child, []model.Proc{driga, child}, nil); reason == "" { + t.Fatal("Driga's child not protected") + } + child.StartTime = now.Add(-2 * time.Hour) + if reason := ProtectedReason(child, []model.Proc{driga, child}, nil); reason != "" { + t.Fatalf("older process with a reused parent PID protected: %s", reason) + } +} diff --git a/internal/kill/snapshot.go b/internal/kill/snapshot.go index 6a804a2..e5247cc 100644 --- a/internal/kill/snapshot.go +++ b/internal/kill/snapshot.go @@ -67,12 +67,23 @@ func ProtectedReason(p model.Proc, live, protected []model.Proc) string { blocked[r.PID] = true } } + // A parent PID names the parent only if that process started first: + // Windows keeps the PID of an exited parent, and a newer process can + // reuse it. + started := map[int32]time.Time{} + for _, r := range live { + started[r.PID] = r.StartTime + } + parentOf := func(r model.Proc) bool { + at, ok := started[r.PPID] + return !ok || at.IsZero() || r.StartTime.IsZero() || !at.After(r.StartTime) + } // Ancestors are protected, but their unrelated descendants are not. Only // explicit Chat/Driga roots propagate protection downwards. for changed := true; changed; { changed = false for _, r := range live { - if roots[r.PPID] && !roots[r.PID] { + if roots[r.PPID] && !roots[r.PID] && parentOf(r) { roots[r.PID] = true blocked[r.PID] = true changed = true @@ -82,7 +93,7 @@ func ProtectedReason(p model.Proc, live, protected []model.Proc) string { for changed := true; changed; { changed = false for _, r := range live { - if blocked[r.PID] && r.PPID > 0 && !blocked[r.PPID] { + if blocked[r.PID] && r.PPID > 0 && !blocked[r.PPID] && parentOf(r) { blocked[r.PPID] = true changed = true } From 336321308dd6890e5426e0c0966f68c18d3f56f8 Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Sat, 31 Oct 2026 10:34:51 -0700 Subject: [PATCH 2/3] fix(kill): compute protection once on derive.ProcIndex Address review: reuse derive.ProcIndex for parent links, so only Windows ignores a reused parent PID and Unix keeps every live link. Driga's own ancestors come from the index rather than an unchecked os.Getppid, a broker root whose PID a newer process reused no longer protects it, and process selection builds the protected set once per snapshot. --- internal/command/process.go | 3 +- internal/kill/protected_test.go | 52 +++++++++++------ internal/kill/snapshot.go | 99 ++++++++++++++++++--------------- 3 files changed, 90 insertions(+), 64 deletions(-) diff --git a/internal/command/process.go b/internal/command/process.go index 31594c1..987998c 100644 --- a/internal/command/process.go +++ b/internal/command/process.go @@ -160,6 +160,7 @@ func ResolveProcessTargets(ctx context.Context, plat platform.Platform, p Parsed } var targets []model.Proc seen := map[int32]bool{} + protection := kill.NewProtection(ps, protected) for _, proc := range ps { name := processName(proc.Exe) match := slices.Contains(s.pids, proc.PID) @@ -186,7 +187,7 @@ func ResolveProcessTargets(ctx context.Context, plat platform.Platform, p Parsed if !match || seen[proc.PID] { continue } - if reason := kill.ProtectedReason(proc, ps, protected); reason != "" { + if reason := protection.Reason(proc); reason != "" { return nil, fmt.Errorf("PID %d: %s", proc.PID, reason) } if proc.Zombie { diff --git a/internal/kill/protected_test.go b/internal/kill/protected_test.go index 4137735..9af25c1 100644 --- a/internal/kill/protected_test.go +++ b/internal/kill/protected_test.go @@ -10,6 +10,9 @@ import ( "github.com/labstack/driga/internal/model" ) +// PIDs far above any the test runner's own processes have. +const fakePID = 1 << 30 + func ownedProc(t *testing.T, pid, ppid int32, exe string, start time.Time) model.Proc { t.Helper() p := model.Proc{PID: pid, PPID: ppid, Exe: exe, Cmdline: exe, Argv: []string{exe}, StartTime: start, OwnerKnown: true, OwnerUID: uint32(os.Getuid())} @@ -24,30 +27,43 @@ func ownedProc(t *testing.T, pid, ppid int32, exe string, start time.Time) model } // Windows keeps the PID of an exited parent, so a Driga process can name a -// parent PID that a newer, unrelated process now has. That process is neither -// Driga's ancestor nor its descendant. -func TestProtectedReasonIgnoresReusedParentPID(t *testing.T) { +// parent PID that a newer, unrelated process now has. On Windows that process +// is neither Driga's ancestor nor its descendant; elsewhere a PPID always +// names the live parent. +func TestProtectionReusedParentPID(t *testing.T) { now := time.Now() - driga := ownedProc(t, 500, 732, "/opt/driga", now.Add(-time.Hour)) - target := ownedProc(t, 732, 900, "/bin/sleep", now) - live := []model.Proc{driga, target, ownedProc(t, 900, 1, "/bin/sh", now.Add(-2*time.Hour))} - if reason := ProtectedReason(target, live, nil); reason != "" { + driga := ownedProc(t, fakePID+1, fakePID+2, "/opt/driga", now.Add(-time.Hour)) + newer := ownedProc(t, fakePID+2, fakePID+3, "/bin/sleep", now) + shell := ownedProc(t, fakePID+3, 1, "/bin/sh", now.Add(-2*time.Hour)) + reason := NewProtection([]model.Proc{driga, newer, shell}, nil).Reason(newer) + if runtime.GOOS == "windows" && reason != "" { t.Fatalf("process with a reused PID protected: %s", reason) } - // A real parent, started before Driga, stays protected. - target.StartTime = now.Add(-2 * time.Hour) - live[1] = target - if reason := ProtectedReason(target, live, nil); reason == "" { + if runtime.GOOS != "windows" && reason == "" { + t.Fatal("Driga's parent not protected") + } + // A real parent, started before Driga, stays protected everywhere. + older := ownedProc(t, fakePID+2, fakePID+3, "/bin/sleep", now.Add(-2*time.Hour)) + if NewProtection([]model.Proc{driga, older, shell}, nil).Reason(older) == "" { t.Fatal("Driga's parent not protected") } - // A child Driga started after itself stays protected; a stale child link - // from a process older than its claimed parent does not. - child := ownedProc(t, 800, 500, "/bin/sleep", now) - if reason := ProtectedReason(child, []model.Proc{driga, child}, nil); reason == "" { + child := ownedProc(t, fakePID+4, fakePID+1, "/bin/sleep", now) + if NewProtection([]model.Proc{driga, child}, nil).Reason(child) == "" { t.Fatal("Driga's child not protected") } - child.StartTime = now.Add(-2 * time.Hour) - if reason := ProtectedReason(child, []model.Proc{driga, child}, nil); reason != "" { - t.Fatalf("older process with a reused parent PID protected: %s", reason) +} + +// A broker root that exited and whose PID a newer process reused no longer +// protects that process. +func TestProtectionExitedBrokerRoot(t *testing.T) { + now := time.Now() + reused := ownedProc(t, fakePID+5, 1, "/bin/sleep", now) + exited := reused + exited.StartTime = now.Add(-time.Hour) + if reason := NewProtection([]model.Proc{reused}, []model.Proc{exited}).Reason(reused); reason != "" { + t.Fatalf("reused root PID protected: %s", reason) + } + if NewProtection([]model.Proc{reused}, []model.Proc{reused}).Reason(reused) == "" { + t.Fatal("live broker root not protected") } } diff --git a/internal/kill/snapshot.go b/internal/kill/snapshot.go index e5247cc..50b9ccc 100644 --- a/internal/kill/snapshot.go +++ b/internal/kill/snapshot.go @@ -13,6 +13,7 @@ import ( "syscall" "time" + "github.com/labstack/driga/internal/derive" "github.com/labstack/driga/internal/model" "github.com/labstack/driga/internal/platform" ) @@ -45,61 +46,69 @@ func Owned(p model.Proc) bool { // ProtectedReason uses live graph membership on each call, including new Chat // descendants and ancestors. The broker supplies its complete protected roots. func ProtectedReason(p model.Proc, live, protected []model.Proc) string { - if p.PID <= 1 { - return "system PID or process group is refused" - } - if !Owned(p) { - return "process owner is unknown or differs from your user" - } - if p.Partial || p.StartTime.IsZero() || p.Exe == "" || p.Cmdline == "" || len(p.Argv) == 0 { - return "process identity is incomplete" + return NewProtection(live, protected).Reason(p) +} + +// Protection is the set of processes one live snapshot protects: Driga and +// Chat processes, their descendants, and the ancestors of all of them. +// Parent links follow derive.ProcIndex, which on Windows ignores a parent PID +// that a newer process reused after the parent exited. +type Protection struct{ blocked map[int32]bool } + +// NewProtection computes the protected set once, so callers checking many +// targets against one snapshot do not rebuild it per target. +func NewProtection(live, protected []model.Proc) Protection { + idx := derive.NewProcIndex(live) + blocked := map[int32]bool{} + ancestors := func(pid int32) { + for _, a := range idx.Ancestors(pid) { + blocked[a] = true + } + } + self := int32(os.Getpid()) + blocked[self] = true + if _, ok := idx.Get(self); ok { + ancestors(self) + } else { + blocked[int32(os.Getppid())] = true + } + root := func(pid int32) { + blocked[pid] = true + ancestors(pid) + // Only explicit Chat/Driga roots propagate protection downwards. + for _, d := range idx.Descendants(pid) { + blocked[d.PID] = true + } } - roots := map[int32]bool{} - blocked := map[int32]bool{int32(os.Getpid()): true, int32(os.Getppid()): true} for _, r := range protected { - roots[r.PID] = true - blocked[r.PID] = true + // A broker root that exited and whose PID a newer process reused no + // longer protects that process or its family. + if now, ok := idx.Get(r.PID); ok && !r.StartTime.IsZero() && !now.StartTime.IsZero() && !now.StartTime.Equal(r.StartTime) { + continue + } + root(r.PID) } for _, r := range live { name := strings.ToLower(filepath.Base(strings.ReplaceAll(r.Exe, "\\", "/"))) if name == "driga" || name == "driga.exe" || strings.HasPrefix(name, "driga-") || r.Role == "chat" || strings.Contains(r.Cmdline, model.ChatPreamble) { - roots[r.PID] = true - blocked[r.PID] = true + root(r.PID) } } - // A parent PID names the parent only if that process started first: - // Windows keeps the PID of an exited parent, and a newer process can - // reuse it. - started := map[int32]time.Time{} - for _, r := range live { - started[r.PID] = r.StartTime - } - parentOf := func(r model.Proc) bool { - at, ok := started[r.PPID] - return !ok || at.IsZero() || r.StartTime.IsZero() || !at.After(r.StartTime) - } - // Ancestors are protected, but their unrelated descendants are not. Only - // explicit Chat/Driga roots propagate protection downwards. - for changed := true; changed; { - changed = false - for _, r := range live { - if roots[r.PPID] && !roots[r.PID] && parentOf(r) { - roots[r.PID] = true - blocked[r.PID] = true - changed = true - } - } + return Protection{blocked: blocked} +} + +// Reason is why p may not be signalled, or "" when it may. +func (pr Protection) Reason(p model.Proc) string { + if p.PID <= 1 { + return "system PID or process group is refused" } - for changed := true; changed; { - changed = false - for _, r := range live { - if blocked[r.PID] && r.PPID > 0 && !blocked[r.PPID] && parentOf(r) { - blocked[r.PPID] = true - changed = true - } - } + if !Owned(p) { + return "process owner is unknown or differs from your user" + } + if p.Partial || p.StartTime.IsZero() || p.Exe == "" || p.Cmdline == "" || len(p.Argv) == 0 { + return "process identity is incomplete" } - if blocked[p.PID] { + if pr.blocked[p.PID] { return "Driga, Chat, or an ancestor/descendant is protected" } return "" From 070110ab576d1a6e807fc6117418ab1b0f66a0cf Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Sat, 31 Oct 2026 10:43:50 -0700 Subject: [PATCH 3/3] fix(kill): keep protection fail-safe while ignoring reused parent PIDs Address review: descendants follow every parent link again, so a broker root that exited still protects its children; an ancestor walk stops only at a Windows parent that provably started after its child; a missing self still protects the parent's ancestry; a broker root is skipped only when a clearly newer process holds its PID; nested roots are walked once; identity checks run before the graph is built. Tests run both platform modes. --- internal/kill/protected_test.go | 55 ++++++++++-------- internal/kill/snapshot.go | 99 ++++++++++++++++++++++++--------- 2 files changed, 106 insertions(+), 48 deletions(-) diff --git a/internal/kill/protected_test.go b/internal/kill/protected_test.go index 9af25c1..0b11b0f 100644 --- a/internal/kill/protected_test.go +++ b/internal/kill/protected_test.go @@ -35,35 +35,46 @@ func TestProtectionReusedParentPID(t *testing.T) { driga := ownedProc(t, fakePID+1, fakePID+2, "/opt/driga", now.Add(-time.Hour)) newer := ownedProc(t, fakePID+2, fakePID+3, "/bin/sleep", now) shell := ownedProc(t, fakePID+3, 1, "/bin/sh", now.Add(-2*time.Hour)) - reason := NewProtection([]model.Proc{driga, newer, shell}, nil).Reason(newer) - if runtime.GOOS == "windows" && reason != "" { - t.Fatalf("process with a reused PID protected: %s", reason) + live := []model.Proc{driga, newer, shell} + if reason := newProtection(live, nil, true).Reason(newer); reason != "" { + t.Fatalf("Windows: process with a reused PID protected: %s", reason) } - if runtime.GOOS != "windows" && reason == "" { - t.Fatal("Driga's parent not protected") + if newProtection(live, nil, true).Reason(shell) != "" { + t.Fatal("Windows: the reused PID's parent protected") } - // A real parent, started before Driga, stays protected everywhere. - older := ownedProc(t, fakePID+2, fakePID+3, "/bin/sleep", now.Add(-2*time.Hour)) - if NewProtection([]model.Proc{driga, older, shell}, nil).Reason(older) == "" { - t.Fatal("Driga's parent not protected") + if newProtection(live, nil, false).Reason(newer) == "" { + t.Fatal("Unix: Driga's parent not protected") } - child := ownedProc(t, fakePID+4, fakePID+1, "/bin/sleep", now) - if NewProtection([]model.Proc{driga, child}, nil).Reason(child) == "" { - t.Fatal("Driga's child not protected") + // A real parent, started before Driga, and Driga's children stay + // protected everywhere, as does a child older than its root. + older := ownedProc(t, fakePID+2, fakePID+3, "/bin/sleep", now.Add(-2*time.Hour)) + child := ownedProc(t, fakePID+4, fakePID+1, "/bin/sleep", now.Add(-3*time.Hour)) + for _, windows := range []bool{false, true} { + pr := newProtection([]model.Proc{driga, older, shell, child}, nil, windows) + if pr.Reason(older) == "" || pr.Reason(shell) == "" || pr.Reason(child) == "" { + t.Fatalf("windows=%v: Driga's family not protected", windows) + } } } -// A broker root that exited and whose PID a newer process reused no longer -// protects that process. -func TestProtectionExitedBrokerRoot(t *testing.T) { +// A broker root protects its children even after it exited, and stops +// protecting a PID only when a clearly newer process holds it. +func TestProtectionBrokerRoots(t *testing.T) { now := time.Now() - reused := ownedProc(t, fakePID+5, 1, "/bin/sleep", now) - exited := reused - exited.StartTime = now.Add(-time.Hour) - if reason := NewProtection([]model.Proc{reused}, []model.Proc{exited}).Reason(reused); reason != "" { - t.Fatalf("reused root PID protected: %s", reason) + root := ownedProc(t, fakePID+5, 1, "/bin/agent", now.Add(-time.Hour)) + child := ownedProc(t, fakePID+6, fakePID+5, "/bin/sleep", now) + for _, windows := range []bool{false, true} { + if newProtection([]model.Proc{child}, []model.Proc{root}, windows).Reason(child) == "" { + t.Fatalf("windows=%v: exited root's child not protected", windows) + } + } + rounded := root + rounded.StartTime = root.StartTime.Add(500 * time.Millisecond) + if newProtection([]model.Proc{rounded}, []model.Proc{root}, true).Reason(rounded) == "" { + t.Fatal("root with a slightly different start time not protected") } - if NewProtection([]model.Proc{reused}, []model.Proc{reused}).Reason(reused) == "" { - t.Fatal("live broker root not protected") + reused := ownedProc(t, fakePID+5, 1, "/bin/sleep", now) + if reason := newProtection([]model.Proc{reused}, []model.Proc{root}, true).Reason(reused); reason != "" { + t.Fatalf("newer process with a root's PID protected: %s", reason) } } diff --git a/internal/kill/snapshot.go b/internal/kill/snapshot.go index 50b9ccc..dc5f99a 100644 --- a/internal/kill/snapshot.go +++ b/internal/kill/snapshot.go @@ -13,7 +13,6 @@ import ( "syscall" "time" - "github.com/labstack/driga/internal/derive" "github.com/labstack/driga/internal/model" "github.com/labstack/driga/internal/platform" ) @@ -43,47 +42,86 @@ func Owned(p model.Proc) bool { return p.OwnerUID == uint32(os.Getuid()) && p.OwnerSID == "" } -// ProtectedReason uses live graph membership on each call, including new Chat -// descendants and ancestors. The broker supplies its complete protected roots. +// ProtectedReason is why p may not be signalled given one live snapshot and +// the broker's protected roots, or "" when it may. func ProtectedReason(p model.Proc, live, protected []model.Proc) string { + if reason := identityReason(p); reason != "" { + return reason + } return NewProtection(live, protected).Reason(p) } -// Protection is the set of processes one live snapshot protects: Driga and -// Chat processes, their descendants, and the ancestors of all of them. -// Parent links follow derive.ProcIndex, which on Windows ignores a parent PID -// that a newer process reused after the parent exited. +// Protection is the set of processes one live snapshot protects: Driga, Chat +// and the broker's protected roots, all their descendants, and the ancestors +// of all of them. It errs toward protecting: descendants follow every parent +// link, and an ancestor walk stops only at a parent that provably reused an +// exited parent's PID on Windows, where a process keeps its creator's PID. type Protection struct{ blocked map[int32]bool } // NewProtection computes the protected set once, so callers checking many // targets against one snapshot do not rebuild it per target. func NewProtection(live, protected []model.Proc) Protection { - idx := derive.NewProcIndex(live) + return newProtection(live, protected, runtime.GOOS == "windows") +} + +func newProtection(live, protected []model.Proc, windows bool) Protection { + byPID := make(map[int32]model.Proc, len(live)) + children := map[int32][]int32{} + for _, r := range live { + byPID[r.PID] = r + children[r.PPID] = append(children[r.PPID], r.PID) + } blocked := map[int32]bool{} ancestors := func(pid int32) { - for _, a := range idx.Ancestors(pid) { - blocked[a] = true + for seen := map[int32]bool{pid: true}; ; { + cur, ok := byPID[pid] + if !ok || cur.PPID <= 0 || seen[cur.PPID] { + return + } + parent, ok := byPID[cur.PPID] + if ok && windows && !parent.StartTime.IsZero() && !cur.StartTime.IsZero() && parent.StartTime.After(cur.StartTime) { + return + } + pid = cur.PPID + seen[pid] = true + blocked[pid] = true } } - self := int32(os.Getpid()) - blocked[self] = true - if _, ok := idx.Get(self); ok { - ancestors(self) - } else { - blocked[int32(os.Getppid())] = true - } + rooted := map[int32]bool{} root := func(pid int32) { + if rooted[pid] { + return + } blocked[pid] = true ancestors(pid) - // Only explicit Chat/Driga roots propagate protection downwards. - for _, d := range idx.Descendants(pid) { - blocked[d.PID] = true + for pending := []int32{pid}; len(pending) > 0; { + parent := pending[len(pending)-1] + pending = pending[:len(pending)-1] + rooted[parent] = true + for _, child := range children[parent] { + if !rooted[child] { + blocked[child] = true + pending = append(pending, child) + } + } } } + self := int32(os.Getpid()) + blocked[self] = true + if _, ok := byPID[self]; ok { + ancestors(self) + } else { + // Without itself in the snapshot, Driga still protects its parent and + // whatever of the parent's ancestry the snapshot has. + parent := int32(os.Getppid()) + blocked[parent] = true + ancestors(parent) + } for _, r := range protected { - // A broker root that exited and whose PID a newer process reused no - // longer protects that process or its family. - if now, ok := idx.Get(r.PID); ok && !r.StartTime.IsZero() && !now.StartTime.IsZero() && !now.StartTime.Equal(r.StartTime) { + // A newer process holding the PID of a broker root that exited is + // not that root. Only a clearly later start proves it; any other + // mismatch keeps the protection. + if now, ok := byPID[r.PID]; ok && !r.StartTime.IsZero() && !now.StartTime.IsZero() && now.StartTime.After(r.StartTime.Add(time.Second)) { continue } root(r.PID) @@ -99,6 +137,18 @@ func NewProtection(live, protected []model.Proc) Protection { // Reason is why p may not be signalled, or "" when it may. func (pr Protection) Reason(p model.Proc) string { + if reason := identityReason(p); reason != "" { + return reason + } + if pr.blocked[p.PID] { + return "Driga, Chat, or an ancestor/descendant is protected" + } + return "" +} + +// identityReason refuses a target whose own identity rules out a signal, +// before any process graph is built. +func identityReason(p model.Proc) string { if p.PID <= 1 { return "system PID or process group is refused" } @@ -108,9 +158,6 @@ func (pr Protection) Reason(p model.Proc) string { if p.Partial || p.StartTime.IsZero() || p.Exe == "" || p.Cmdline == "" || len(p.Argv) == 0 { return "process identity is incomplete" } - if pr.blocked[p.PID] { - return "Driga, Chat, or an ancestor/descendant is protected" - } return "" }