Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions skills/inter-session/bin/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,40 @@ def _env_float(*keys, default: float) -> float:
return default



def _in_agent_context() -> bool:
"""True when an ancestor process is a Claude Code agent/teammate — a
claude binary (argv[0] "claude" or the versioned install path
.../claude/versions/<v>) carrying --agent-id / --parent-session-id as
exact argv tokens. A shell ancestor merely QUOTING that flag text must not
match. Fail-open: any error (psutil missing, ancestry unreadable)
returns False so the normal connect path is never blocked by the guard.
"""
try:
import psutil
p = psutil.Process().parent()
depth = 0
while p is not None and depth < 15:
try:
cmd = p.cmdline() or []
except Exception:
cmd = [] # e.g. AccessDenied on system procs — keep walking
looks_like_claude = cmd and (
os.path.basename(cmd[0]).startswith("claude")
or "/claude/versions/" in cmd[0]
)
if looks_like_claude:
flags = ("--agent-id", "--parent-session-id")
if any(a == f or a.startswith(f + "=")
for a in cmd[1:] for f in flags):
return True
p = p.parent()
depth += 1
except Exception:
pass
return False


def main() -> int:
# Resolution order for port / idle-shutdown:
# 1. CLI arg (explicit override)
Expand Down Expand Up @@ -408,6 +442,19 @@ def main() -> int:
_print_line(f"[inter-session] invalid label {args.label!r}")
return 1

# Plugin auto-start (monitors.json, no --name) also fires inside
# agent-team teammate / subagent Claude Code processes: each worker then
# joins the bus with a cwd-derived name (roster pollution), and a
# broadcast reaches mid-task workers as an instruction. Skip the
# auto-start path in agent contexts; an explicit --name still connects
# an agent deliberately.
if not args.name and _in_agent_context():
_print_line(
"[inter-session] agent/teammate context detected; skipping "
"auto-connect -- pass --name to connect an agent deliberately"
)
return 0

# Plugin auto-start path: monitors.json doesn't pass --name (so the user
# doesn't have to set INTER_SESSION_NAME). Fall back to a name derived from
# the cwd basename so the listener doesn't show as `(unnamed)`.
Expand Down
113 changes: 113 additions & 0 deletions tests/test_agent_guard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
"""Tests for the agent-context auto-connect guard (client._in_agent_context).

The plugin's monitors.json auto-start fires in EVERY Claude Code process,
including agent-team teammates / subagents spawned as child claude processes
(`claude --agent-id worker@team --parent-session-id <sid> ...`). Each worker
then auto-joins the bus with a cwd-derived name — roster pollution — and a
broadcast reaches mid-task workers as an instruction. The guard detects a
claude agent ancestor and skips the auto-start path only; explicit --name
connects still work.
"""
import sys
import types

from bin import client


class _FakeProc:
"""Minimal psutil.Process stand-in. `cmdline` may be a list or an
exception instance (raised on access, like psutil.AccessDenied)."""

def __init__(self, cmdline, parent=None, pid=1234):
self._cmdline = cmdline
self._parent = parent
self.pid = pid

def cmdline(self):
if isinstance(self._cmdline, Exception):
raise self._cmdline
return self._cmdline

def parent(self):
return self._parent


def _fake_psutil(ancestor_cmdlines):
"""Fake psutil module whose Process().parent() chain yields
`ancestor_cmdlines`, nearest ancestor first."""
top = None
for cmd in reversed(ancestor_cmdlines):
top = _FakeProc(cmd, parent=top)
mod = types.ModuleType("psutil")

class _Self:
def parent(self):
return top

mod.Process = lambda *a, **k: _Self()
return mod


class TestAgentContextGuard:
def test_detects_claude_ancestor_with_agent_flags(self, monkeypatch):
monkeypatch.setitem(sys.modules, "psutil", _fake_psutil([
["/bin/zsh", "-c", "python3 client.py"],
["claude", "--agent-id", "worker-1@session-abc",
"--parent-session-id", "abc"],
]))
assert client._in_agent_context() is True

def test_detects_equals_form_flag(self, monkeypatch):
monkeypatch.setitem(sys.modules, "psutil", _fake_psutil([
["claude", "--agent-id=worker-1@session-abc"],
]))
assert client._in_agent_context() is True

def test_detects_versioned_binary_path(self, monkeypatch):
# Observed live shape: teammates spawn with the versioned install
# path as argv[0] (basename "2.1.204"), not "claude".
monkeypatch.setitem(sys.modules, "psutil", _fake_psutil([
["/home/u/.local/share/claude/versions/2.1.204",
"--agent-id", "w@s", "--parent-session-id", "s"],
]))
assert client._in_agent_context() is True

def test_non_claude_binary_with_flag_tokens_does_not_match(self, monkeypatch):
monkeypatch.setitem(sys.modules, "psutil", _fake_psutil([
["/usr/bin/somethingelse", "--agent-id", "w@s"],
]))
assert client._in_agent_context() is False

def test_plain_interactive_claude_does_not_match(self, monkeypatch):
monkeypatch.setitem(sys.modules, "psutil", _fake_psutil([
["/bin/zsh", "-c", "..."],
["claude"],
["-zsh"],
]))
assert client._in_agent_context() is False

def test_shell_quoting_flag_text_does_not_match(self, monkeypatch):
# A shell ancestor merely QUOTING the flag text (e.g. a test
# command or a script echoing it) must not trip the guard.
monkeypatch.setitem(sys.modules, "psutil", _fake_psutil([
["/bin/bash", "-c", "echo claude --agent-id x@y"],
["claude"],
]))
assert client._in_agent_context() is False

def test_accessdenied_ancestor_does_not_stop_walk(self, monkeypatch):
monkeypatch.setitem(sys.modules, "psutil", _fake_psutil([
RuntimeError("AccessDenied"), # e.g. login/launchd
["claude", "--agent-id", "w@s"],
]))
assert client._in_agent_context() is True

def test_fail_open_without_psutil(self, monkeypatch):
monkeypatch.setitem(sys.modules, "psutil", None)
assert client._in_agent_context() is False

def test_walk_depth_bounded(self, monkeypatch):
# A matching ancestor beyond the depth cap is not reached.
chain = [["/bin/sh", "-c", "x"]] * 20 + [["claude", "--agent-id", "w@s"]]
monkeypatch.setitem(sys.modules, "psutil", _fake_psutil(chain))
assert client._in_agent_context() is False