[object Object]

← back to Terminal Status

Narrow the CHILD_SESSION paint guard so a bridge session that owns its own tty can paint (TK-12167)

5933ea13f0a4f98480f51f7cbddec57aeade1cb1 · 2026-09-24 14:22:01 -0700 · Steve Abrams

owner_for_paint() refused ANY caller carrying CLAUDE_CODE_CHILD_SESSION
unconditionally -- correct for a true Agent-tool subagent (TK-11791's
same-PID mirror bug) but also caught a Remote Control (bridge) session
that genuinely owns its own pane, since that flag is carried in both
cases. Live evidence: pid 33946 on ttys003 resolved owner ==
{tty: ttys003, pid: 33946} (itself) via current_owner(), yet every
dot-script and `ticket` bind was refused for an hour, leaving the tab
on a stale ticket label.

Fix: add _owns_tty_directly(), which reuses the existing
ancestors()/current_owner() machinery to verify the resolved owner is
our own closest runtime ancestor with no second claude/codex process
in between (always true today once current_owner() succeeds -- kept
explicit for defense-in-depth). Rescue a CHILD_SESSION-tagged paint
only when that check passes AND CLAUDE_CODE_BRIDGE_SESSION_ID is also
present -- a marker set only for a genuine bridge connection, never a
plain internal subagent call, so TK-11791's existing "subagent
REFUSES" fixture (byte-identical ancestry, no bridge id) is unchanged.
Undeterminable ownership still fails closed regardless of the bridge
marker.

Tests: extended both `selftest` and test_terminal_status.py with the
three required cases -- (a) CHILD_SESSION + bridge id + direct
ownership -> paints (confirmed this fails against the pre-fix guard
via a scratch copy), (b) CHILD_SESSION + bridge id + a genuine
intermediate claude/codex process -> still refused, (c) undeterminable
ownership -> still refused. Full selftest 14/14 and the unittest suite
(test_terminal_status.py + test_stop_verdict.py +
test_tk_required_fp_metric.py, 114 tests) all green.

Live-verified from the actual affected bridge session (pid 33946,
ttys003): `current --paintable` now exits 0, `greendot.sh` paints
successfully, and `terminal_status.py ticket 12167` binds directly
with no --tty/--force workaround.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V29xaoBwcFwJyR4KC43ptn

Files touched

Diff

commit 5933ea13f0a4f98480f51f7cbddec57aeade1cb1
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Sep 24 14:22:01 2026 -0700

    Narrow the CHILD_SESSION paint guard so a bridge session that owns its own tty can paint (TK-12167)
    
    owner_for_paint() refused ANY caller carrying CLAUDE_CODE_CHILD_SESSION
    unconditionally -- correct for a true Agent-tool subagent (TK-11791's
    same-PID mirror bug) but also caught a Remote Control (bridge) session
    that genuinely owns its own pane, since that flag is carried in both
    cases. Live evidence: pid 33946 on ttys003 resolved owner ==
    {tty: ttys003, pid: 33946} (itself) via current_owner(), yet every
    dot-script and `ticket` bind was refused for an hour, leaving the tab
    on a stale ticket label.
    
    Fix: add _owns_tty_directly(), which reuses the existing
    ancestors()/current_owner() machinery to verify the resolved owner is
    our own closest runtime ancestor with no second claude/codex process
    in between (always true today once current_owner() succeeds -- kept
    explicit for defense-in-depth). Rescue a CHILD_SESSION-tagged paint
    only when that check passes AND CLAUDE_CODE_BRIDGE_SESSION_ID is also
    present -- a marker set only for a genuine bridge connection, never a
    plain internal subagent call, so TK-11791's existing "subagent
    REFUSES" fixture (byte-identical ancestry, no bridge id) is unchanged.
    Undeterminable ownership still fails closed regardless of the bridge
    marker.
    
    Tests: extended both `selftest` and test_terminal_status.py with the
    three required cases -- (a) CHILD_SESSION + bridge id + direct
    ownership -> paints (confirmed this fails against the pre-fix guard
    via a scratch copy), (b) CHILD_SESSION + bridge id + a genuine
    intermediate claude/codex process -> still refused, (c) undeterminable
    ownership -> still refused. Full selftest 14/14 and the unittest suite
    (test_terminal_status.py + test_stop_verdict.py +
    test_tk_required_fp_metric.py, 114 tests) all green.
    
    Live-verified from the actual affected bridge session (pid 33946,
    ttys003): `current --paintable` now exits 0, `greendot.sh` paints
    successfully, and `terminal_status.py ticket 12167` binds directly
    with no --tty/--force workaround.
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01V29xaoBwcFwJyR4KC43ptn
---
 terminal_status.py      | 122 ++++++++++++++++++++++++++++++++++++++++++------
 test_terminal_status.py |  68 ++++++++++++++++++++++++++-
 2 files changed, 176 insertions(+), 14 deletions(-)

diff --git a/terminal_status.py b/terminal_status.py
index 8ae94fe..e538186 100644
--- a/terminal_status.py
+++ b/terminal_status.py
@@ -457,6 +457,25 @@ def paint_forced():
     return os.environ.get("CLAUDE_COLORDOTS_FORCE") == "1"
 
 
+def _owns_tty_directly(rows, owner):
+    """True iff OUR OWN process ancestry resolves `owner` directly, with no
+    second claude/codex process anywhere between us and it.
+
+    current_owner()'s ancestors() walk only ever inspects the CLOSEST
+    runtime-having ancestor: it returns on that ancestor's first match (a
+    verified sole live tty owner) or raises (a headless/nested-through-a-
+    separate-process agent) -- it never walks PAST a non-owning claude/codex
+    to find a further one. So whenever current_owner() succeeds at all, the
+    returned owner is, by construction, that single closest ancestor -- this
+    is therefore always true after a successful current_owner() call today.
+    Kept as an explicit, defensive re-check (not inferred from "it didn't
+    raise") so the invariant stays enforced even if current_owner()'s
+    implementation changes later, per the TK-12167 fix below.
+    """
+    runtime_ancestors = [p for p in ancestors(os.getpid(), rows) if p.runtime]
+    return len(runtime_ancestors) == 1 and runtime_ancestors[0].owner() == owner
+
+
 def owner_for_paint(rows, force=False, boot=False, refetch=processes):
     # CANONICAL paintability guard — the SINGLE decision both engines defer to
     # (color.sh's /color hue painter AND this engine's dot setters), so they
@@ -467,11 +486,32 @@ def owner_for_paint(rows, force=False, boot=False, refetch=processes):
     # bug: an Agent-tool subagent / bridge session runs INSIDE the parent claude
     # process, so its ancestry is byte-identical to the parent's — same
     # CLAUDE_PID, same ttys — and current_owner happily resolves the PARENT's
-    # tty (proven live TK-11791). Painting then corrupts Steve's live pane. The
-    # only bash-level signal that a run is a child/bridge context is
-    # CLAUDE_CODE_CHILD_SESSION, which a normally-launched top-level tab leaves
-    # UNSET (memory colordots-sweeps-refuse-bridge-sessions). So the rail is:
-    # refuse an own-session paint whenever that flag is set.
+    # tty (proven live TK-11791). Painting a genuine same-PID subagent then
+    # corrupts Steve's live pane, so CLAUDE_CODE_CHILD_SESSION (set on any
+    # tool-spawned shell that is NOT the top-level's own direct turn) has to
+    # stay part of the rail for that class.
+    #
+    # TK-12167: that same flag is ALSO carried by a Remote Control (bridge)
+    # session that genuinely OWNS its own pane — alongside
+    # CLAUDE_CODE_BRIDGE_SESSION_ID and CLAUDE_CODE_ENTRYPOINT — so the flag
+    # alone over-refused it too (proven live: pid 33946 on ttys003, `current`
+    # already resolves owner == {tty: ttys003, pid: 33946}, the session's OWN
+    # process, yet every dot-script paint was refused for an hour). Narrow the
+    # refusal from "the flag is set" to "the flag is set AND we are not a
+    # verified direct-owning bridge": a bridge session is identified by
+    # CLAUDE_CODE_BRIDGE_SESSION_ID (set only for a genuine Remote Control
+    # connection, never for a plain internal Agent-tool subagent call), and
+    # "direct-owning" is the _owns_tty_directly() ownership check above —
+    # current_owner() having resolved OUR OWN ancestry straight to its live
+    # sole tty owner, with no other claude/codex process sitting in between.
+    # A true subagent or a genuinely nested/headless child claude process
+    # fails one or both of those: it carries no CLAUDE_CODE_BRIDGE_SESSION_ID
+    # of its own (same-PID mirror-bug case — TK-11791's own selftest fixture),
+    # or current_owner() has already raised on a real intermediate process
+    # before this guard is even reached (the nested-through-a-separate-process
+    # case) — so it is still refused either way. Ownership that cannot be
+    # determined at all (current_owner() raising) is always refused, never
+    # rescued by the bridge marker — fail CLOSED.
     #
     # boot=True bypasses the rail: a SessionStart/UserPromptSubmit/Stop HOOK only
     # ever fires for a real pane-owning session, NEVER for a subagent, so a
@@ -481,11 +521,14 @@ def owner_for_paint(rows, force=False, boot=False, refetch=processes):
     # KNOWN to own its pane. Neither loosens the headless/nested refusal above.
     owner = current_owner(rows, refetch=refetch)
     if not boot and not force and not paint_forced() and os.environ.get("CLAUDE_CODE_CHILD_SESSION"):
-        raise StatusError(
-            "subagent/bridge context; refusing to paint (it would write to the "
-            "parent session's live pane). Use --tty for an external verified "
-            "target, --boot from a session hook, or --force if this session owns "
-            "its pane.")
+        is_verified_bridge = (bool(os.environ.get("CLAUDE_CODE_BRIDGE_SESSION_ID"))
+                               and _owns_tty_directly(rows, owner))
+        if not is_verified_bridge:
+            raise StatusError(
+                "subagent/bridge context; refusing to paint (it would write to the "
+                "parent session's live pane). Use --tty for an external verified "
+                "target, --boot from a session hook, or --force if this session owns "
+                "its pane.")
     return owner
 
 
@@ -509,8 +552,14 @@ def selftest():
         print(("  PASS " if ok else "  FAIL ") + name)
 
     # 1. Top-level (flag UNSET) → PAINTS the resolved owner.
-    env = {k: v for k, v in os.environ.items() if k != "CLAUDE_CODE_CHILD_SESSION"
-           and k != "CLAUDE_COLORDOTS_FORCE"}
+    # TK-12167: also scrub CLAUDE_CODE_BRIDGE_SESSION_ID from the baseline so
+    # this selftest is hermetic when it happens to run INSIDE a real bridge
+    # session's own shell (it does, routinely) — without this, cases 1-4 below
+    # would silently inherit a real bridge id from the ambient environment and
+    # the new bridge-only cases 5-7 would prove nothing.
+    env = {k: v for k, v in os.environ.items()
+           if k not in ("CLAUDE_CODE_CHILD_SESSION", "CLAUDE_COLORDOTS_FORCE",
+                        "CLAUDE_CODE_BRIDGE_SESSION_ID")}
     with patch_environ(env):
         try:
             got = owner_for_paint(rows, refetch=refetch)
@@ -518,7 +567,11 @@ def selftest():
         except StatusError as exc:
             case("top-level interactive session PAINTS (got refusal: %s)" % exc, False)
 
-    # 2. Subagent/bridge (flag SET) → REFUSES, even though ancestry resolves the parent.
+    # 2. Subagent/bridge (flag SET, NO bridge id) → REFUSES, even though ancestry
+    #    resolves the parent. This fixture is deliberately identical in ps
+    #    ancestry to case 1 -- it models TK-11791's real same-PID mirror bug (an
+    #    Agent-tool subagent carries CLAUDE_CODE_CHILD_SESSION but never its own
+    #    CLAUDE_CODE_BRIDGE_SESSION_ID), so it must keep failing after TK-12167.
     with patch_environ(dict(env, CLAUDE_CODE_CHILD_SESSION="1")):
         try:
             got = owner_for_paint(rows, refetch=refetch)
@@ -540,6 +593,49 @@ def selftest():
         except StatusError as exc:
             case("--force opt-in PAINTS (got refusal: %s)" % exc, False)
 
+    # TK-12167 (a): CHILD_SESSION set + a genuine bridge id + the caller owns the
+    # tty DIRECTLY (same rows as cases 1/2 -- pid 33946 on ttys003 live evidence)
+    # → PAINTS. This is the exact case that regressed before the fix above: with
+    # only the guard's ORIGINAL unconditional flag check, this assertion fails.
+    with patch_environ(dict(env, CLAUDE_CODE_CHILD_SESSION="1",
+                            CLAUDE_CODE_BRIDGE_SESSION_ID="session_test")):
+        try:
+            got = owner_for_paint(rows, refetch=refetch)
+            case("bridge session owning its tty directly PAINTS", got == want)
+        except StatusError as exc:
+            case("bridge session owning its tty directly PAINTS (got refusal: %s)" % exc, False)
+
+    # TK-12167 (b): CHILD_SESSION set + a genuine bridge id, but a real
+    # INTERMEDIATE claude process (its own separate pid, detached tty) sits
+    # between the caller and the tty owner → still REFUSED. The bridge marker
+    # never rescues a genuinely nested/headless child process.
+    b_owner = Process(4242, 1, "ttys098", "Wed Sep 9 08:35:08 2026", "/usr/bin/claude")
+    b_intermediate = Process(9000, 4242, "??", "Wed Sep 9 08:35:08 2026", "/usr/bin/claude")
+    b_me = Process(pid, 9000, "??", "Wed Sep 9 08:35:08 2026", "python3")
+    b_rows = {4242: b_owner, 9000: b_intermediate, pid: b_me}
+    b_refetch = lambda **_: b_rows
+    with patch_environ(dict(env, CLAUDE_CODE_CHILD_SESSION="1",
+                            CLAUDE_CODE_BRIDGE_SESSION_ID="session_test")):
+        try:
+            got = owner_for_paint(b_rows, refetch=b_refetch)
+            case("intermediate claude process REFUSES (painted %s instead!)" % (got.tty,), False)
+        except StatusError:
+            case("intermediate claude process REFUSES", True)
+
+    # TK-12167 (c): CHILD_SESSION set + a genuine bridge id, but ownership is
+    # UNDETERMINABLE (no claude/codex anywhere in ancestry at all) → REFUSED.
+    # Fails CLOSED: the bridge marker alone is never sufficient.
+    c_me = Process(pid, 1, "??", "Wed Sep 9 08:35:08 2026", "python3")
+    c_rows = {pid: c_me}
+    c_refetch = lambda **_: c_rows
+    with patch_environ(dict(env, CLAUDE_CODE_CHILD_SESSION="1",
+                            CLAUDE_CODE_BRIDGE_SESSION_ID="session_test")):
+        try:
+            got = owner_for_paint(c_rows, refetch=c_refetch)
+            case("undeterminable ownership REFUSES (painted %s instead!)" % (got.tty,), False)
+        except StatusError:
+            case("undeterminable ownership REFUSES", True)
+
     # 5. TK-11870 backfill: (a) repaints an owner WITH a valid record, (b) NEVER
     #    flattens that valid gated dot to green (the key safety invariant — the
     #    injected fault is a purple/gated tab that must survive a sweep), and (c)
diff --git a/test_terminal_status.py b/test_terminal_status.py
index f354487..f48befe 100644
--- a/test_terminal_status.py
+++ b/test_terminal_status.py
@@ -527,13 +527,18 @@ class StatusTests(unittest.TestCase):
         # contexts (a subagent runs inside the parent process); only the
         # CLAUDE_CODE_CHILD_SESSION flag distinguishes them, so the flag is the
         # rail. Mirrors the CLI `selftest`, kept here so the standard suite covers it.
+        # TK-12167: CLAUDE_CODE_BRIDGE_SESSION_ID is also scrubbed from the
+        # baseline `env` so this stays hermetic when the suite runs INSIDE a
+        # real bridge session's own shell (routine) -- otherwise the ambient
+        # bridge id would leak into the "subagent" case below and flip it.
         owner_proc = ts.Process(4242, 1, "ttys099", self.owner.started, "/usr/bin/claude")
         me = ts.Process(os.getpid(), 4242, "??", self.owner.started, "python3")
         rows = {4242: owner_proc, os.getpid(): me}
         refetch = lambda **_: rows
         want = owner_proc.owner()
         env = {k: v for k, v in os.environ.items()
-               if k not in ("CLAUDE_CODE_CHILD_SESSION", "CLAUDE_COLORDOTS_FORCE")}
+               if k not in ("CLAUDE_CODE_CHILD_SESSION", "CLAUDE_COLORDOTS_FORCE",
+                            "CLAUDE_CODE_BRIDGE_SESSION_ID")}
         with patch.dict(os.environ, env, clear=True):
             self.assertEqual(ts.owner_for_paint(rows, refetch=refetch), want)
         with patch.dict(os.environ, dict(env, CLAUDE_CODE_CHILD_SESSION="1"), clear=True):
@@ -546,6 +551,67 @@ class StatusTests(unittest.TestCase):
                         clear=True):
             self.assertEqual(ts.owner_for_paint(rows, refetch=refetch), want)
 
+    def test_owner_for_paint_bridge_session_paints_when_it_owns_its_tty_directly(self):
+        # TK-12167 (a). Live-evidenced bug: pid 33946 on ttys003, a Remote
+        # Control bridge session, resolves owner == its own process directly
+        # (same rows shape as the mirror-bug fixture above) yet was refused
+        # solely because CLAUDE_CODE_CHILD_SESSION was set. The additional
+        # CLAUDE_CODE_BRIDGE_SESSION_ID marker (never carried by a plain
+        # internal Agent-tool subagent) now lets a session that verifiably
+        # owns its own pane paint.
+        owner_proc = ts.Process(4242, 1, "ttys099", self.owner.started, "/usr/bin/claude")
+        me = ts.Process(os.getpid(), 4242, "??", self.owner.started, "python3")
+        rows = {4242: owner_proc, os.getpid(): me}
+        refetch = lambda **_: rows
+        want = owner_proc.owner()
+        env = {k: v for k, v in os.environ.items()
+               if k not in ("CLAUDE_CODE_CHILD_SESSION", "CLAUDE_COLORDOTS_FORCE",
+                            "CLAUDE_CODE_BRIDGE_SESSION_ID")}
+        with patch.dict(os.environ,
+                        dict(env, CLAUDE_CODE_CHILD_SESSION="1",
+                             CLAUDE_CODE_BRIDGE_SESSION_ID="session_test"),
+                        clear=True):
+            self.assertEqual(ts.owner_for_paint(rows, refetch=refetch), want)
+
+    def test_owner_for_paint_bridge_marker_never_rescues_an_intermediate_agent(self):
+        # TK-12167 (b). A genuinely nested/headless child claude process (its
+        # own separate pid, no tty of its own) sitting between the caller and
+        # the real tty owner must stay refused even with a bridge id present --
+        # the marker identifies bridge IDENTITY, not a free pass past a real
+        # intermediate agent. current_owner() already raises for this shape
+        # (a headless/nested-through-a-separate-process agent), before
+        # owner_for_paint's bridge check is ever reached.
+        owner_proc = ts.Process(4242, 1, "ttys098", self.owner.started, "/usr/bin/claude")
+        intermediate = ts.Process(9000, 4242, "??", self.owner.started, "/usr/bin/claude")
+        me = ts.Process(os.getpid(), 9000, "??", self.owner.started, "python3")
+        rows = {4242: owner_proc, 9000: intermediate, os.getpid(): me}
+        refetch = lambda **_: rows
+        env = {k: v for k, v in os.environ.items()
+               if k not in ("CLAUDE_CODE_CHILD_SESSION", "CLAUDE_COLORDOTS_FORCE",
+                            "CLAUDE_CODE_BRIDGE_SESSION_ID")}
+        with patch.dict(os.environ,
+                        dict(env, CLAUDE_CODE_CHILD_SESSION="1",
+                             CLAUDE_CODE_BRIDGE_SESSION_ID="session_test"),
+                        clear=True):
+            with self.assertRaises(ts.StatusError):
+                ts.owner_for_paint(rows, refetch=refetch)
+
+    def test_owner_for_paint_bridge_marker_never_rescues_undeterminable_ownership(self):
+        # TK-12167 (c). No claude/codex anywhere in ancestry at all (ownership
+        # cannot be determined) → refused, fail CLOSED, even with a bridge id.
+        me = ts.Process(os.getpid(), 1, "??", self.owner.started, "python3")
+        rows = {os.getpid(): me}
+        refetch = lambda **_: rows
+        env = {k: v for k, v in os.environ.items()
+               if k not in ("CLAUDE_CODE_CHILD_SESSION", "CLAUDE_COLORDOTS_FORCE",
+                            "CLAUDE_CODE_BRIDGE_SESSION_ID")}
+        with patch.dict(os.environ,
+                        dict(env, CLAUDE_CODE_CHILD_SESSION="1",
+                             CLAUDE_CODE_BRIDGE_SESSION_ID="session_test"),
+                        clear=True):
+            with self.assertRaises(ts.StatusError):
+                ts.owner_for_paint(rows, refetch=refetch)
+
     def test_paint_failure_is_reported_and_auditable(self):
         def fail(*args):
             raise OSError("fixture: unavailable terminal")

← 199e156 Fix --all broadcast footgun when label precedes --all flag (  ·  back to Terminal Status  ·  Auto-bind a ticket for unbound sessions from the UserPromptS 6d68776 →