SLOPSHOPPER

net-connections

Pane listing every network connection this session made, traced back to the prompt and command that caused it

newpaneguardcommandprocessnetwork
v0.1.0MITupdated 2026-10-04soefrey/net-connections
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · net-connections
│ ┃ Network connections ✕ › fix the failing auth test and add an audit log call │ ┃ NETWORK CONNECTIONS · 2 connections (1 unce… │ ┃ l: list g: by prompt h: by host │ z: local t ⏺ Read(src/auth.ts) │ ┃ Showing: view: list · kind: all · local too… ⎿ Read 6 lines │ ┃ a: all 2 m: model 0 w: web 0 s: shell 2 x: m ⏺ Update(src/auth.ts) │ ┃ # time kind host d… ⎿ Added 2 lines, removed 1 line │ ┃ ✓~ 8 01:53:20 SHELL git remote 20m ⏺ Bash(bun test) │ ┃ ✗? 6 01:53:20 SHELL unknown (script) 20m ⎿ 3 pass, 1 fail │ ┃ Enter drills in · ~ inferred from command t… │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /net │ ┃ ⎿ net-connections: Network pane opened (9 connections so far). │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Network connections
NETWORK CONNECTIONS · 2 connections (1 uncertain) · 0 hosts… l: list g: by prompt h: by host │ z: local tool calls: hidde Showing: view: list · kind: all · local tool calls: hidden a: all 2 m: model 0 w: web 0 s: shell 2 x: mcp 0 v: service # time kind host dur from status … ✓~ 8 01:53:20 SHELL git remote 20ms P1 do… ✗? 6 01:53:20 SHELL unknown (script) 20ms P1 er… Enter drills in · ~ inferred from command text · ? runs a s…
README

net-connections

A Claude Code mod that adds a Network connections pane: every network connection a session makes, traced back to the prompt and the command that caused it.

The pane beside a Claude Code session

What it shows

KindSourceEvidence
MODELeach Messages API request (turn step): model, tokens, time to first chunkobserved
WEBWebFetch / WebSearch: status, size, the tool's output, and on request the page's raw bodyobserved
SHELLBash / PowerShell commands: hosts read off the command text (curl, git push, pip install, …)~ inferred, ? uncertain (scripts, interpreters)
SHELL ↳#nWindows: connections the processes of shell call #n actually opened, seen in the TCP tableobserved
MCP / SVCMCP tool calls, claude.ai connectors and servicesobserved
SHAREfile access on UNC paths, mapped network drives and network mountsobserved / inferred

Local tool calls (Read, Edit, …) are kept too, so each prompt's calls are complete, but hidden by default.

Use

  • /net opens the pane, also while a turn is running.
  • /net clear empties the log. /net drives (or /net mounts) lists network drives.
  • Click the pane (or press ctrl+x then tab) so it holds the keyboard. ↑/↓ then select a connection in the list, Enter opens its details, and b goes back with that connection still selected. Esc returns to the prompt.
  • Views: l list, g by prompt, h by host, z show or hide local calls. n/p page to older/newer.
  • Kind filters in the list: a all, m model, w web, s shell, x mcp, v service, f share, k local. u clears a prompt or host you drilled into.
  • The cyan Showing: line at the top of the pane says which view, kind filter, prompt or host scope, and local-call setting are active.
  • In a connection's details: p/n newer/older, o all from its prompt, t all to its host, c copy.
  • WebFetch details: w tool output, r raw page (fetched again by the pane, listed as its own connection), f fetch it again, j/k parts of a long body.
  • Poller rows: u jumps to the call that caused it; on a call, 1–9 jump to the connections seen under it.

The poller (Windows)

The engine reports a shell call, not what its processes connect to. While shell calls run, the mod runs a small PowerShell loop (hooks/poller.ts) that reads the TCP table and the process list through the Win32 API every 25 ms and reports connections of processes Claude Code started. It stops 30 s after the last shell call.

Here python check_sites.py shows no host in its command line, so the call itself is only ? uncertain; the poller saw the three connections the script made:

A shell call with the connections the poller saw under it

Limits: a connection that opens and closes between two looks is missed; a process whose parent already exited (a daemonized child) cannot be traced to Claude Code; with parallel shell calls the call a connection is attributed to may be off.

WebFetch: tool output and raw page

Mods only see what WebFetch hands back: its processed output. r fetches the page again and shows the raw body, in parts for long pages; w switches back to the tool output.

The raw body of a fetched page

Grouped views

By prompt (g) and by host (h). The by-host view also lists hosts by set: i the hosts this session reached, e only the new ones, y all known hosts, including those only earlier sessions used (dim, marked earlier).

Connections grouped by prompt

The by-host view with the i set (this session) selected; e and y switch the set.

Connections grouped by host

New hosts

With the option flagNewHosts (on by default; a row in the config menu), a host no earlier session connected to is marked ★ in the list, ★ NEW in the by-host view, ★ NEW HOST in its details, and counted in the header. Only hosts the engine or poller actually observed are remembered; a host guessed from command text (~ or ?) does not count as seen. Hosts are remembered across sessions, so the flag shows only in the session that first saw the host. The first run flags every host, as none are known yet. With the option off, hosts are still remembered, so switching it on later does not flag everything.

Privacy

  • The connection log stays in the session's memory. The only thing written to disk is the list of host names seen so far (the mod's $.store, key knownHosts, at most 5000 hosts), used for the new-host flag above. Nothing is sent anywhere.
  • Command lines are recorded in full, with two exceptions: home directories are shown as ~, and credentials (Authorization headers, bearer tokens, --password=…, API_KEY=…, user:pass@ in URLs, well-known token formats) are masked as *** before they are kept.
  • The only requests the mod makes itself are the raw-page fetches you ask for with r.

Install

Clone into a folder and load it as a plugin directory:

claude --plugin-dir /path/to/net-connections

Claude Code writes the API types into .claude-plugin/types/ when it loads the mod; tsc -p . type-checks it after that, and claude plugin test . runs the tests.

Source 4 files
hooks/register.tsx 1269 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderInput } from 'claude-code'
3
4import type { NetConn, NetDetail, NetKind, NetMount, NetMounts, NetPrompt, NetRaw, NetStatus, NetView } from '../types'
5import {
6  detectMounts,
7  detectShare,
8  detectShell,
9  detectUncertain,
10  driveMounts,
11  hostOf,
12  parseCimDrives,
13  parseMountOutput,
14  parseNetUse,
15  maskHome,
16  PLACEHOLDER_HOSTS,
17  protocolOf,
18  redact,
19} from './detect'
20import type { Target } from './detect'
21import { POLL_SCRIPT, parsePollLine, portProtocol, shortCommand } from './poller'
22
23const PANE = 'net-connections'
24const TITLE = 'Network connections'
25const MAX_CONNS = 1000
26const MAX_PROMPTS = 300
27
28const conns = atom({ plugin: 'net-connections', key: 'conns' } as const, [])
29const prompts = atom({ plugin: 'net-connections', key: 'prompts' } as const, [])
30const current = atom({ plugin: 'net-connections', key: 'current' } as const, null)
31const agents = atom({ plugin: 'net-connections', key: 'agents' } as const, {})
32const view = atom({ plugin: 'net-connections', key: 'view' } as const, { mode: 'list', filter: 'all', page: 0 })
33const mounts = atom({ plugin: 'net-connections', key: 'mounts' } as const, { platform: 'unknown', list: [], at: 0 })
34const raw = atom({ plugin: 'net-connections', key: 'raw' } as const, {})
35const newHosts = atom({ plugin: 'net-connections', key: 'newHosts' } as const, [])
36
37// Hosts seen in any session, kept in `$.store` between sessions.
38const KNOWN_KEY = 'knownHosts'
39const MAX_KNOWN = 5000
40
41// A raw body kept past this is cut; the pane says how much it left out.
42const RAW_MAX = 100_000
43const MAX_RAW = 20
44// A rendered tree holds at most 100,000 characters and a Text at most 10,000,
45// so the raw body is drawn a page at a time, in Texts of a few lines each.
46const RAW_PAGE = 8_000
47const RAW_CHUNK = 2_000
48
49/**
50 * Text a tree may hold: newlines for every line break, tab and newline the
51 * only control characters, and no bidirectional overrides, which could show
52 * a command as other than it ran.
53 */
54const printable = (s: string) =>
55  s.replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, '\ufffd')
56
57/** What the pane keeps of a string it records: printable, credentials masked, home directories as `~`. */
58const clean = (s: string) => maskHome(redact(printable(s)))
59
60const cleanDetails = (ds: readonly NetDetail[]) => ds.map(d => ({ key: clean(d.key), value: clean(d.value) }))
61
62function cleanConn<T extends Partial<NetConn>>(c: T): T {
63  const out: Partial<NetConn> = { ...c }
64  for (const k of ['source', 'host', 'url', 'reason', 'statusText', 'command'] as const) {
65    const v = out[k]
66    if (typeof v === 'string') out[k] = clean(v)
67  }
68  if (out.details) out.details = cleanDetails(out.details)
69  return out as T
70}
71
72/** Cuts `s` into pieces of at most `n` characters, at a line break where one is near. */
73function chunks(s: string, n: number): string[] {
74  const out: string[] = []
75  let i = 0
76  while (i < s.length) {
77    let j = Math.min(s.length, i + n)
78    if (j < s.length) {
79      const nl = s.lastIndexOf('\n', j)
80      if (nl > i + n / 2) j = nl + 1
81    }
82    out.push(s.slice(i, j).replace(/\n$/, ''))
83    i = j
84  }
85  return out
86}
87
88const MOUNT_REFRESH_MS = 10 * 60 * 1000
89// Locale-independent, unlike `net use`: one `Z:|\\server\share` line per mapped drive.
90const CIM_DRIVES = "Get-CimInstance Win32_LogicalDisk -Filter 'DriveType=4' | ForEach-Object { $_.DeviceID + '|' + $_.ProviderName }"
91
92// Tools that talk to claude.ai / Anthropic services rather than the local machine.
93const SERVICE_TOOLS = new Set([
94  'Artifact', 'ArtifactData', 'ArtifactComments', 'ArtifactCheck', 'DesignSync', 'ClaudeDesign',
95  'RemoteTrigger', 'ReadNotifications', 'SendUserFile', 'SendFile', 'FetchInboxMessage',
96  'SearchMcpRegistry', 'SearchPlugins', 'SearchSkills', 'SendFeedback', 'Projects',
97])
98const SHELL_TOOLS = new Set(['Bash', 'PowerShell', 'Monitor'])
99// Path arguments a file tool may point at a UNC share through.
100const PATH_FIELDS = ['file_path', 'notebook_path', 'path'] as const
101
102const KIND_COLOR: Record<NetKind, string> = {
103  model: 'magenta',
104  web: 'cyan',
105  shell: 'yellow',
106  mcp: 'blue',
107  service: 'green',
108  share: '#ff8700',
109  local: 'gray',
110}
111const KIND_LABEL: Record<NetKind, string> = {
112  model: 'MODEL',
113  web: 'WEB',
114  shell: 'SHELL',
115  mcp: 'MCP',
116  service: 'SVC',
117  share: 'SHARE',
118  local: 'LOCAL',
119}
120// The mark beside a row's status: how sure the pane is that it touched the network.
121const EVIDENCE_MARK: Record<NetConn['confidence'], string> = {
122  observed: ' ',
123  inferred: '~',
124  uncertain: '?',
125  local: ' ',
126}
127const EVIDENCE_TEXT: Record<NetConn['confidence'], string> = {
128  observed: 'observed by the engine',
129  inferred: 'inferred from the command text',
130  uncertain: 'uncertain: the command runs code whose connections are not visible',
131  local: 'local: no known network access',
132}
133
134const STATUS_MARK: Record<NetStatus, { glyph: string; color: string }> = {
135  running: { glyph: '…', color: 'yellow' },
136  ok: { glyph: '✓', color: 'green' },
137  error: { glyph: '✗', color: 'red' },
138  denied: { glyph: '⊘', color: 'gray' },
139}
140
141type $ = EngineInterface
142type Loose = Record<string, unknown>
143
144// ---------- formatting ----------
145
146const pad2 = (n: number) => String(n).padStart(2, '0')
147const fmtTime = (ms: number) => {
148  const d = new Date(ms)
149  return `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`
150}
151const fmtDur = (ms?: number) =>
152  ms === undefined ? '' : ms < 1000 ? `${Math.round(ms)}ms` : ms < 60_000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.floor(ms / 60_000)}m${Math.round((ms % 60_000) / 1000)}s`
153const fmtBytes = (b?: number) =>
154  b === undefined ? '' : b < 1024 ? `${b}B` : b < 1024 * 1024 ? `${(b / 1024).toFixed(1)}KB` : `${(b / 1024 / 1024).toFixed(1)}MB`
155const fmtNum = (n: number) => (n >= 10_000 ? `${(n / 1000).toFixed(1)}k` : String(n))
156const oneLine = (s: string) => s.replace(/\s+/g, ' ').trim()
157const trunc = (s: string, n: number) => (n <= 1 ? '' : s.length > n ? `${s.slice(0, n - 1)}…` : s)
158const durOf = (c: NetConn) => (c.endedAt === undefined ? undefined : c.endedAt - c.startedAt)
159
160// ---------- recording ----------
161
162let idCounter = 0
163const mkId = (now: number) => `${now.toString(36)}-${(idCounter++).toString(36)}`
164
165let apiHostCache: string | undefined
166async function apiHost($: $): Promise<string> {
167  if (apiHostCache !== undefined) return apiHostCache
168  const base = await $.env.get('ANTHROPIC_BASE_URL')
169  if (base) apiHostCache = hostOf(base)
170  else if (await $.env.get('CLAUDE_CODE_USE_BEDROCK')) apiHostCache = `bedrock-runtime.${(await $.env.get('AWS_REGION')) ?? 'us-east-1'}.amazonaws.com`
171  else if (await $.env.get('CLAUDE_CODE_USE_VERTEX')) apiHostCache = `${(await $.env.get('CLOUD_ML_REGION')) ?? 'us-east5'}-aiplatform.googleapis.com`
172  else apiHostCache = 'api.anthropic.com'
173  return apiHostCache
174}
175
176async function originFor($: $, agentId: string | undefined): Promise<string | undefined> {
177  const cur = (await read($, current)) ?? undefined
178  if (agentId === undefined) return cur
179  const known = (await read($, agents))[agentId]
180  if (known !== undefined) return known
181  if (cur !== undefined) await update($, agents, m => (agentId in m ? m : { ...m, [agentId]: cur }))
182  return cur
183}
184
185// The `flagNewHosts` option, set by `register`.
186let flagNewHosts = true
187
188// Store reads and writes run one after the other, so two hosts noted at once both land.
189let noting: Promise<unknown> = Promise.resolve()
190
191/**
192 * Records a host in the cross-session store. A host the store had not seen
193 * before is new in this session: it joins `newHosts`, which the pane flags
194 * until the session ends. The store always learns hosts, so switching the
195 * flag on later does not flag everything.
196 */
197function noteHost($: $, host: string | undefined): Promise<unknown> {
198  if (host === undefined || host === '' || PLACEHOLDER_HOSTS.has(host)) return noting
199  noting = noting.then(async () => {
200    const stored = await $.store.get(KNOWN_KEY)
201    const known = Array.isArray(stored) ? stored.filter((h): h is string => typeof h === 'string') : []
202    if (known.includes(host)) return
203    await $.store.set(KNOWN_KEY, [...known, host].slice(-MAX_KNOWN))
204    if (flagNewHosts) await update($, newHosts, list => (list.includes(host) ? list : [...list, host]))
205  }).catch(() => {})
206  return noting
207}
208
209async function addConn($: $, c: Omit<NetConn, 'seq'>) {
210  const kept = cleanConn(c)
211  await update($, conns, list => [...list, { ...kept, seq: (list.at(-1)?.seq ?? 0) + 1 }].slice(-MAX_CONNS))
212  // Only a host the engine or poller saw counts: a host guessed from command text may never be contacted.
213  if (kept.kind !== 'local' && kept.confidence === 'observed') await noteHost($, kept.host)
214}
215
216async function patchConn($: $, id: string, p: Partial<NetConn>, more: NetDetail[] = []) {
217  const kept = cleanConn(p)
218  const added = cleanDetails(more)
219  await update($, conns, list =>
220    list.map(c => (c.id === id ? { ...c, ...kept, details: [...c.details, ...added] } : c)),
221  )
222  if (kept.host !== undefined && (await read($, conns)).find(c => c.id === id)?.confidence === 'observed') await noteHost($, kept.host)
223}
224
225async function addPrompt($: $, p: Omit<NetPrompt, 'seq'>) {
226  const kept = { ...p, text: clean(p.text) }
227  await update($, prompts, list => [...list, { ...kept, seq: (list.at(-1)?.seq ?? 0) + 1 }].slice(-MAX_PROMPTS))
228  await update($, current, () => p.id)
229}
230
231function inputSummary(e: Loose): string {
232  const { tool: _t, tool_use_id: _i, agentId: _a, consent: _c, ...args } = e
233  try {
234    return JSON.stringify(args, null, 0)
235  } catch {
236    return String(args)
237  }
238}
239
240type Classified = { kind: NetKind; targets: Target[]; command?: string; confidence: NetConn['confidence'] }
241
242async function classify($: $, e: Loose): Promise<Classified> {
243  const tool = String(e.tool)
244  if (tool === 'WebFetch' && typeof e.url === 'string') {
245    return { kind: 'web', confidence: 'observed', command: e.url, targets: [{ host: hostOf(e.url), url: e.url, protocol: protocolOf(e.url), reason: 'WebFetch tool' }] }
246  }
247  if (tool === 'WebSearch') {
248    return { kind: 'web', confidence: 'observed', command: String(e.query ?? ''), targets: [{ host: await apiHost($), protocol: 'HTTPS', reason: 'server-side web search run by the API' }] }
249  }
250  if (SHELL_TOOLS.has(tool)) {
251    const ws = (e.ws as Loose | undefined)?.url
252    if (typeof ws === 'string') {
253      return { kind: 'shell', confidence: 'observed', command: ws, targets: [{ host: hostOf(ws), url: ws, protocol: protocolOf(ws), reason: 'Monitor WebSocket' }] }
254    }
255    if (typeof e.command !== 'string') return localCall(tool, e)
256    const shares = (await shareTargets($, e.command)).map(t => ({ ...t, kind: 'share' as const }))
257    const targets = [...detectShell(e.command), ...shares]
258    if (targets.length > 0) return { kind: targets.length === shares.length ? 'share' : 'shell', confidence: 'inferred', command: e.command, targets }
259    const maybe = detectUncertain(e.command)
260    if (maybe !== undefined) return { kind: 'shell', confidence: 'uncertain', command: e.command, targets: [maybe] }
261    return localCall(tool, e)
262  }
263  if (tool.startsWith('mcp__')) {
264    const [, server = '?', name = '?'] = tool.split('__')
265    if (server.startsWith('claude_ai_')) {
266      return { kind: 'service', confidence: 'observed', command: `${server} → ${name}`, targets: [{ host: 'claude.ai (MCP proxy)', protocol: 'HTTPS', reason: `claude.ai connector ${server.slice(10)}` }] }
267    }
268    return { kind: 'mcp', confidence: 'observed', command: `${server} → ${name}`, targets: [{ host: `mcp:${server}`, protocol: 'MCP', reason: 'MCP server call (stdio servers stay local; http/sse servers go out)' }] }
269  }
270  if (SERVICE_TOOLS.has(tool)) {
271    const url = typeof e.url === 'string' ? e.url : undefined
272    return { kind: 'service', confidence: 'observed', command: tool, targets: [{ host: url ? hostOf(url) : 'claude.ai', url, protocol: 'HTTPS', reason: `${tool} talks to claude.ai` }] }
273  }
274  const paths = PATH_FIELDS.map(k => e[k]).filter((v): v is string => typeof v === 'string')
275  const shares = (await Promise.all(paths.map(path => shareTargets($, path)))).flat()
276  if (shares.length > 0) {
277    return { kind: 'share', confidence: 'observed', command: paths.join(' '), targets: shares.map(t => ({ ...t, reason: `${tool} on a ${t.reason}` })) }
278  }
279  return localCall(tool, e)
280}
281
282/** Any other tool call: recorded as local so each prompt's calls are complete. */
283function localCall(tool: string, e: Loose): Classified {
284  const what = ['command', 'file_path', 'notebook_path', 'pattern', 'path', 'skill', 'query', 'description', 'prompt']
285    .map(k => e[k])
286    .find((v): v is string => typeof v === 'string' && v !== '')
287  return {
288    kind: 'local',
289    confidence: 'local',
290    command: what ?? trunc(inputSummary(e), 200),
291    targets: [{ host: 'local', protocol: '—', reason: `${tool} has no known network access` }],
292  }
293}
294
295function resultDetails(tool: string, r: Loose | undefined, text: string | undefined): { p: Partial<NetConn>; more: NetDetail[] } {
296  const more: NetDetail[] = []
297  const p: Partial<NetConn> = {}
298  if (r === undefined) return { p, more }
299  if (tool === 'WebFetch') {
300    if (typeof r.code === 'number') p.statusText = `HTTP ${r.code} ${String(r.codeText ?? '')}`.trim()
301    if (typeof r.bytes === 'number') p.bytes = r.bytes
302    if (typeof r.durationMs === 'number') more.push({ key: 'Fetch time', value: fmtDur(r.durationMs) })
303    if (typeof r.url === 'string') more.push({ key: 'Final URL', value: r.url })
304    if (typeof r.result === 'string' && r.result.trim()) more.push({ key: 'Response', value: trunc(r.result.trim(), 2000) })
305  } else if (tool === 'WebSearch') {
306    const hits: string[] = []
307    for (const block of (r.results as unknown[]) ?? []) {
308      if (typeof block === 'object' && block !== null) {
309        for (const hit of ((block as Loose).content as Loose[]) ?? []) hits.push(`${String(hit.title)} — ${String(hit.url)}`)
310      }
311    }
312    p.statusText = `${hits.length} results`
313    if (typeof r.searchCount === 'number') more.push({ key: 'Searches', value: String(r.searchCount) })
314    if (typeof r.durationSeconds === 'number') more.push({ key: 'Search time', value: fmtDur(r.durationSeconds * 1000) })
315    const hosts = [...new Set(hits.map(h => hostOf(h.split(' — ').at(-1) ?? '')))]
316    if (hosts.length) more.push({ key: 'Result hosts', value: hosts.join(', ') })
317    hits.slice(0, 10).forEach((h, i) => more.push({ key: `Result ${i + 1}`, value: h }))
318    const notes = ((r.results as unknown[]) ?? []).filter((x): x is string => typeof x === 'string' && x.trim() !== '')
319    if (notes.length) more.push({ key: 'Response', value: trunc(notes.join('\n').trim(), 2000) })
320  } else if (SHELL_TOOLS.has(tool)) {
321    if (r.interrupted === true) p.statusText = 'interrupted'
322    if (typeof r.backgroundTaskId === 'string') more.push({ key: 'Background task', value: r.backgroundTaskId })
323    if (typeof r.timedOutAfterMs === 'number') more.push({ key: 'Timed out after', value: fmtDur(r.timedOutAfterMs) })
324    if (r.dangerouslyDisableSandbox === true) more.push({ key: 'Sandbox', value: 'overridden' })
325    if (typeof r.stdout === 'string') p.bytes = r.stdout.length + (typeof r.stderr === 'string' ? r.stderr.length : 0)
326    if (typeof r.stderr === 'string' && r.stderr.trim()) more.push({ key: 'stderr (head)', value: trunc(r.stderr.trim(), 400) })
327    if (typeof r.stdout === 'string' && r.stdout.trim()) more.push({ key: 'stdout (head)', value: trunc(r.stdout.trim(), 400) })
328  } else if (text !== undefined) {
329    p.bytes = text.length
330    more.push({ key: 'Response (head)', value: trunc(oneLine(text), 300) })
331  }
332  return { p, more }
333}
334
335/**
336 * Fetches a WebFetch connection's URL again, as the pane's own request, and
337 * keeps the raw body under the connection's id. WebFetch hands mods only its
338 * processed output, so this second request is the only way to the bytes; it
339 * is listed as a connection of its own.
340 */
341async function fetchRaw($: $, c: NetConn) {
342  const url = c.details.find(d => d.key === 'Final URL')?.value ?? c.url
343  if (url === undefined) return
344  const at = await $.clock.now()
345  const put = (r: NetRaw) => update($, raw, m => Object.fromEntries([...Object.entries(m).filter(([k]) => k !== c.id), [c.id, r]].slice(-MAX_RAW)))
346  await put({ state: 'loading', url, at })
347  const id = mkId(at)
348  await addConn($, {
349    id,
350    kind: 'web',
351    source: 'net-connections',
352    host: hostOf(url),
353    url,
354    protocol: protocolOf(url),
355    confidence: 'observed',
356    reason: `raw response of #${c.seq}, fetched again by this pane`,
357    startedAt: at,
358    status: 'running',
359    promptId: c.promptId,
360    command: url,
361    details: [],
362  })
363  try {
364    const res = await $.http.fetch(url)
365    const end = await $.clock.now()
366    await put({ state: 'ok', url, at, status: res.status, headers: res.headers, text: printable(res.text.slice(0, RAW_MAX)), length: res.text.length })
367    await patchConn($, id, { endedAt: end, status: res.ok ? 'ok' : 'error', statusText: `HTTP ${res.status}`, bytes: res.text.length }, [
368      { key: 'Content type', value: res.headers['content-type'] ?? '—' },
369    ])
370  } catch (err) {
371    const end = await $.clock.now()
372    const msg = String((err as Error)?.message ?? err)
373    await put({ state: 'error', url, at, error: msg })
374    await patchConn($, id, { endedAt: end, status: 'error', statusText: trunc(msg, 120) })
375  }
376}
377
378// ---------- watching shell processes (Windows) ----------
379
380// The poller (./poller) stops this long after the last shell call ended, once nothing it saw is open.
381const WATCH_IDLE_MS = 30_000
382const WATCH_MAX_MS = 60 * 60 * 1000
383// A shell call's shell starts a moment before the hook runs; the poller counts processes from this far back.
384const WATCH_SLACK_MS = 5_000
385const MAX_SHELL_CALLS = 200
386
387type ShellCall = {
388  tool: string
389  toolUseId?: string
390  agentId?: string
391  promptId?: string
392  command?: string
393  start: number
394  end?: number
395  /** The call's own rows, which list what the poller saw it connect to. */
396  connIds: string[]
397}
398
399
400const shellCalls: ShellCall[] = []
401const namesByAddr = new Map<string, string[]>()
402let watching = false
403// Set once the poller could not start (no PowerShell, a refused spawn): shell calls stay inferred.
404let watchBroken = false
405let lastShellActivity = 0
406let onWindowsCache: boolean | undefined
407
408async function onWindows($: $): Promise<boolean> {
409  onWindowsCache ??= (await $.env.get('OS')) === 'Windows_NT'
410  return onWindowsCache
411}
412
413/** The shell call running when a process Claude Code started came up: the latest one, a second's slack each side. */
414function callAt(started: number): ShellCall | undefined {
415  let best: ShellCall | undefined
416  for (const c of shellCalls) {
417    if (c.start - 1000 <= started && (c.end === undefined || started <= c.end + 1000) && (best === undefined || c.start > best.start)) best = c
418  }
419  return best
420}
421
422/**
423 * Runs the poller while shell calls run and a while after, recording each
424 * connection it sees as an observed row of the call whose processes made it.
425 * One poller for the session: started by the first shell call, it stops once
426 * idle and the next shell call starts it again.
427 */
428async function watchShells($: $) {
429  if (watching || watchBroken) return
430  watching = true
431  const t0 = (await $.clock.now()) - WATCH_SLACK_MS
432  const open = new Map<string, string>()
433  const byAddr = new Map<string, string[]>()
434  const byTop = new Map<number, ShellCall | null>()
435  let buf = ''
436  let ready = false
437  try {
438    const stream = $.process.spawn({
439      argv: ['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', 'iex ([Console]::In.ReadToEnd())'],
440      env: { NETCONN_T0: String(t0), NETCONN_MAX_MS: String(WATCH_MAX_MS), NETCONN_EVERY_MS: '25' },
441      input: POLL_SCRIPT,
442    })
443    for await (const chunk of stream) {
444      if (chunk.stream !== 'stdout') continue
445      buf += chunk.text
446      for (let nl = buf.indexOf('\n'); nl >= 0; nl = buf.indexOf('\n')) {
447        const ev = parsePollLine(buf.slice(0, nl).trim())
448        buf = buf.slice(nl + 1)
449        if (ev === undefined) continue
450        const at = await $.clock.now()
451        if (ev.ev === 'ready') ready = true
452        else if (ev.ev === 'open') {
453          if (!byTop.has(ev.top)) byTop.set(ev.top, callAt(ev.topStart) ?? null)
454          const call = byTop.get(ev.top) ?? undefined
455          const names = namesByAddr.get(ev.addr)
456          const proc = `${ev.name ?? 'process'} (PID ${ev.pid})`
457          const id = mkId(at)
458          await addConn($, {
459            id,
460            kind: 'shell',
461            source: call?.tool ?? 'Claude Code',
462            host: names?.[0] ?? ev.addr,
463            protocol: portProtocol(ev.port),
464            confidence: 'observed',
465            reason: `${proc} connected; seen in the TCP table`,
466            seenByPoller: true,
467            startedAt: at,
468            status: 'running',
469            promptId: call?.promptId ?? (await originFor($, undefined)),
470            agentId: call?.agentId,
471            toolUseId: call?.toolUseId,
472            command: ev.cmd ? shortCommand(ev.cmd) : ev.name,
473            details: [
474              { key: 'Process', value: proc },
475              ...(ev.cmd ? [{ key: 'Command line', value: ev.cmd }] : []),
476              { key: 'Remote', value: `${ev.addr} port ${ev.port}` },
477              { key: 'Local', value: ev.local },
478              ...(names ? [{ key: 'DNS names', value: names.join(', ') }] : []),
479              {
480                key: 'Started by',
481                value: call
482                  ? `${call.tool} call${call.command ? `: ${trunc(oneLine(call.command), 300)}` : ''}`
483                  : 'a process Claude Code started outside any shell call (a hook, an MCP server, git, …)',
484              },
485            ],
486          })
487          open.set(ev.key, id)
488          byAddr.set(ev.addr, [...(byAddr.get(ev.addr) ?? []), id])
489        } else if (ev.ev === 'close') {
490          const id = open.get(ev.key)
491          open.delete(ev.key)
492          if (id !== undefined) await patchConn($, id, { endedAt: at, status: 'ok', statusText: 'closed' })
493        } else if (ev.ev === 'names') {
494          namesByAddr.set(ev.addr, ev.names)
495          for (const id of byAddr.get(ev.addr) ?? []) {
496            await patchConn($, id, { host: ev.names[0] }, [{ key: 'DNS names', value: ev.names.join(', ') }])
497          }
498          byAddr.delete(ev.addr)
499        }
500      }
501      const idle = !shellCalls.some(c => c.end === undefined) && open.size === 0
502      if (idle && (await $.clock.now()) - lastShellActivity > WATCH_IDLE_MS) break
503    }
504  } catch {
505    watchBroken = true
506  } finally {
507    // A poller that never got ready will not get ready the next time either.
508    if (!ready) watchBroken = true
509    watching = false
510    const end = await $.clock.now()
511    for (const id of open.values()) await patchConn($, id, { endedAt: end, status: 'ok', statusText: 'last seen' })
512    if (!watchBroken && shellCalls.some(c => c.end === undefined)) void watchShells($)
513  }
514}
515
516// ---------- drawing ----------
517
518function setView($: $, fn: (v: NetView) => NetView) {
519  return update($, view, fn)
520}
521
522const MAX_BACK = 30
523
524/** Moves to another view and remembers the one it leaves, for `b`. */
525function navigate($: $, fn: (v: NetView) => NetView) {
526  return update($, view, v => {
527    const { back = [], ...here } = v
528    return { ...fn(v), back: [...back, here].slice(-MAX_BACK) }
529  })
530}
531
532/**
533 * Returns to the view `navigate` left; with none left, to the list. Back in
534 * the list, the ring goes to the row the details were of.
535 */
536async function goBack($: $) {
537  const from = await read($, view)
538  const now = await update($, view, (v): NetView => {
539    const back = v.back ?? []
540    const prev = back.at(-1)
541    return prev !== undefined ? { ...prev, back: back.slice(0, -1) } : { ...v, mode: 'list', back: [] }
542  })
543  if (from.mode === 'detail' && from.selected !== undefined && now.mode === 'list') {
544    selectedRow = `c-${from.selected}`
545    await $.ui.focus({ requestId: PANE, key: selectedRow }).catch(() => {})
546  }
547}
548
549/**
550 * The file-share targets a path or command names: UNC paths (Windows only;
551 * `\\` and `//` mean nothing of the kind on macOS and Linux) and paths on a
552 * network drive or mount.
553 */
554async function shareTargets($: $, text: string): Promise<Target[]> {
555  const m = await read($, mounts)
556  return [...(m.platform === 'posix' ? [] : detectShare(text)), ...detectMounts(text, m.list)]
557}
558
559async function run($: $, argv: readonly string[]): Promise<string | undefined> {
560  try {
561    const r = await $.process.run(argv, { timeoutMs: 15_000 })
562    return r.exitCode === 0 ? r.stdout : undefined
563  } catch {
564    return undefined // not installed on this system
565  }
566}
567
568/**
569 * Reads the network drives (Windows: CIM, else `net use`) or mounts (macOS,
570 * Linux: `mount`) into state. A failed lookup keeps the last list and says why.
571 */
572async function loadMounts($: $): Promise<NetMounts> {
573  const isWindows = (await $.env.get('OS')) === 'Windows_NT'
574  const now = await $.clock.now()
575  let list: NetMount[] | undefined
576  let error: string | undefined
577  if (isWindows) {
578    const cim = await run($, ['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', CIM_DRIVES])
579    if (cim !== undefined) list = driveMounts(parseCimDrives(cim))
580    else {
581      const net = await run($, ['net', 'use'])
582      if (net !== undefined) list = driveMounts(parseNetUse(net))
583      else error = 'neither Get-CimInstance nor net use answered'
584    }
585  } else {
586    const out = await run($, ['mount'])
587    if (out !== undefined) list = parseMountOutput(out)
588    else error = 'mount did not answer'
589  }
590  const platform = isWindows ? 'windows' : 'posix'
591  return update($, mounts, prev => ({
592    platform,
593    list: list ?? prev.list,
594    at: list ? now : prev.at,
595    isLoaded: list !== undefined || prev.isLoaded === true,
596    ...(error ? { error } : {}),
597  }))
598}
599
600function mountLine(m: NetMount): string {
601  return `${m.root} → ${m.source} (${m.protocol})`
602}
603
604export const register: Register = (on, options) => {
605  flagNewHosts = options.flagNewHosts !== false
606  let lastCommand: { id: string; at: number; used: boolean } | undefined
607
608  on('session.start', async ($, e, next) => {
609    await $.command.register({
610      name: 'net',
611      description: 'Show the network connections this session made, by prompt and command',
612      // Runs at once while a turn is in flight: opening the pane never needs to wait for the turn or interrupt it.
613      immediate: true,
614    })
615    if (e.isInteractive) void $.ui.open({ id: PANE, title: TITLE })
616    void loadMounts($)
617    $.clock.every(MOUNT_REFRESH_MS, () => void loadMounts($))
618    return next(e)
619  })
620
621  on('command.run', { command: 'net' }, async ($, e) => {
622    const arg = e.args.trim()
623    if (arg === 'clear') {
624      await update($, conns, () => [])
625      await update($, prompts, () => [])
626      await update($, current, () => null)
627      await update($, agents, () => ({}))
628      await setView($, () => ({ mode: 'list', filter: 'all', page: 0 }))
629      return { text: 'Network log cleared.' }
630    }
631    if (arg === 'drives' || arg === 'mounts') {
632      const m = await loadMounts($)
633      const what = m.platform === 'windows' ? 'mapped network drives' : 'network mounts'
634      if (m.error !== undefined && m.isLoaded !== true) return { text: `Could not list ${what}: ${m.error}.` }
635      const head = m.list.length === 0 ? `No ${what}.` : `${what[0]!.toUpperCase()}${what.slice(1)}:\n${m.list.map(x => `  ${mountLine(x)}`).join('\n')}`
636      return { text: m.error !== undefined ? `${head}\n(last lookup failed: ${m.error}; showing the previous list)` : head }
637    }
638    const opened = await $.ui.open({ id: PANE, title: TITLE, focus: true })
639    const n = (await read($, conns)).length
640    const so = `${n} connection${n === 1 ? '' : 's'} so far`
641    if (!opened.isPlaced) return { text: `Network pane is open but not shown here (${so}): ${opened.reason}` }
642    return { text: `Network pane opened (${so}).` }
643  })
644
645  // Slash commands are origins too: a command that runs a turn keeps its name.
646  on('command.run', async ($, e, next) => {
647    if (e.command === 'net') return next(e)
648    const now = await $.clock.now()
649    const id = `cmd-${mkId(now)}`
650    await addPrompt($, { id, kind: 'command', text: `/${e.command}${e.args ? ` ${e.args}` : ''}`, at: now })
651    lastCommand = { id, at: now, used: false }
652    return next(e)
653  })
654
655  on('turn.start', async ($, e, next) => {
656    const now = await $.clock.now()
657    if (lastCommand !== undefined && !lastCommand.used && now - lastCommand.at < 5000) {
658      lastCommand.used = true
659      await update($, current, () => lastCommand!.id)
660    } else {
661      await addPrompt($, {
662        id: e.turnId,
663        kind: e.text ? 'prompt' : 'other',
664        text: e.text || '(continuation / background notification)',
665        at: now,
666      })
667    }
668    return next(e)
669  })
670
671  // Every model request is an HTTPS stream to the API.
672  on('turn.step', async function* ($, e, next) {
673    const t0 = await $.clock.now()
674    const id = mkId(t0)
675    const host = await apiHost($)
676    await addConn($, {
677      id,
678      kind: 'model',
679      source: e.agentId ? 'subagent model request' : 'model request',
680      host,
681      url: `https://${host}/v1/messages`,
682      protocol: 'HTTPS (SSE stream)',
683      confidence: 'observed',
684      reason: 'turn step: one Messages API request',
685      startedAt: t0,
686      status: 'running',
687      promptId: await originFor($, e.agentId),
688      agentId: e.agentId,
689      command: `${e.model} · step ${e.index} · ${e.messageCount} messages`,
690      details: [
691        { key: 'Model', value: e.model },
692        ...(e.effort !== undefined ? [{ key: 'Effort', value: String(e.effort) }] : []),
693        { key: 'Turn', value: e.turnId },
694        { key: 'Step', value: String(e.index) },
695        { key: 'Messages sent', value: String(e.messageCount) },
696      ],
697    })
698    let firstAt: number | undefined
699    let chunks = 0
700    let outChars = 0
701    try {
702      const stream = next(e)
703      for await (const chunk of stream) {
704        if (firstAt === undefined && chunk.kind !== 'engine') firstAt = await $.clock.now()
705        chunks += 1
706        if (chunk.kind === 'text') outChars += chunk.text.length
707        if (chunk.kind === 'input') outChars += chunk.json.length
708        yield chunk
709      }
710      const r = await stream.result
711      const end = await $.clock.now()
712      const u = r.usage
713      const more: NetDetail[] = [
714        { key: 'Time to first chunk', value: firstAt === undefined ? '—' : fmtDur(firstAt - t0) },
715        { key: 'Stream chunks', value: String(chunks) },
716        { key: 'Stop reason', value: String(r.stopReason ?? 'none (failed or interrupted)') },
717      ]
718      if (u) {
719        more.push(
720          { key: 'Answered by', value: u.model },
721          { key: 'Input tokens', value: String(u.input_tokens) },
722          { key: 'Cache read tokens', value: String(u.cache_read_input_tokens) },
723          { key: 'Cache write tokens', value: String(u.cache_creation_input_tokens) },
724          { key: 'Output tokens', value: String(u.output_tokens) },
725        )
726      }
727      if (r.toolUses.length) more.push({ key: 'Tools requested', value: r.toolUses.map(t => String((t as Loose).name ?? (t as Loose).tool ?? '?')).join(', ') })
728      if (r.answer) more.push({ key: 'Answer (head)', value: trunc(oneLine(r.answer), 300) })
729      await patchConn(
730        $,
731        id,
732        {
733          endedAt: end,
734          status: r.stopReason === null ? 'error' : 'ok',
735          statusText: u ? `${fmtNum(u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens)} in / ${fmtNum(u.output_tokens)} out` : String(r.stopReason ?? 'no response'),
736          bytes: outChars || undefined,
737        },
738        more,
739      )
740      return r
741    } catch (err) {
742      await patchConn($, id, { endedAt: await $.clock.now(), status: 'error', statusText: String((err as Error)?.message ?? err).slice(0, 120) })
743      throw err
744    }
745  })
746
747  // Tools that reach the network: web, shell commands that look networked, MCP, claude.ai services.
748  on('tool.call', async ($, e, next) => {
749    const loose = e as unknown as Loose
750    const found = await classify($, loose)
751    const t0 = await $.clock.now()
752    const promptId = await originFor($, e.agentId)
753    const ids: string[] = []
754    for (const t of found.targets) {
755      const id = mkId(t0)
756      ids.push(id)
757      await addConn($, {
758        id,
759        kind: t.kind ?? found.kind,
760        source: String(e.tool),
761        host: t.host,
762        url: t.url,
763        protocol: t.protocol,
764        confidence: found.confidence,
765        reason: t.reason,
766        startedAt: t0,
767        status: 'running',
768        promptId,
769        agentId: e.agentId,
770        toolUseId: e.tool_use_id,
771        command: found.command,
772        details: [{ key: 'Tool input', value: trunc(inputSummary(loose), 600) }],
773      })
774    }
775    let shell: ShellCall | undefined
776    if (SHELL_TOOLS.has(String(e.tool)) && (await onWindows($))) {
777      shell = { tool: String(e.tool), toolUseId: e.tool_use_id, agentId: e.agentId, promptId, command: typeof loose.command === 'string' ? loose.command : undefined, start: t0, connIds: ids }
778      shellCalls.push(shell)
779      shellCalls.splice(0, Math.max(0, shellCalls.length - MAX_SHELL_CALLS))
780      lastShellActivity = t0
781      void watchShells($)
782    }
783    let ran
784    try {
785      ran = await next(e)
786    } catch (err) {
787      const end = await $.clock.now()
788      if (shell) {
789        shell.end = end
790        lastShellActivity = end
791      }
792      for (const id of ids) await patchConn($, id, { endedAt: end, status: 'error', statusText: String((err as Error)?.message ?? err).slice(0, 120) })
793      throw err
794    }
795    const end = await $.clock.now()
796    if (shell) {
797      shell.end = end
798      lastShellActivity = end
799    }
800    const status: NetStatus = ran.deny !== undefined ? 'denied' : ran.isError ? 'error' : 'ok'
801    const { p, more } = resultDetails(String(e.tool), ran.result as Loose | undefined, ran.text)
802    if (ran.deny !== undefined) more.push({ key: 'Denied', value: ran.deny })
803    if (ran.isError && ran.text) more.push({ key: 'Error', value: trunc(oneLine(ran.text), 400) })
804    for (const id of ids) {
805      await patchConn($, id, { endedAt: end, status, statusText: p.statusText ?? (status === 'ok' ? 'done' : status), ...(p.bytes !== undefined ? { bytes: p.bytes } : {}) }, more)
806    }
807    return ran
808  })
809
810  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
811    const { Box } = $.ui.resolve(e)
812    const body = await renderPane($, e)
813    return (
814      <Box flexDirection="column" flexGrow={1} backgroundColor="black" minHeight={e.viewport?.rows}>
815        {body}
816      </Box>
817    )
818  })
819
820  // The selected row follows the ring over the list's rows; the ring in other views, or under the pointer, leaves it be.
821  on('ui.focus', { component: 'Pane', requestId: PANE }, ($, e, next) => {
822    if (e.element?.startsWith('c-')) selectedRow = e.element
823    return next(e)
824  })
825
826  // Up and down move the ring through the list's rows instead of scrolling the pane.
827  on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
828    if (e.origin.kind !== 'person' || Math.abs(e.by) !== 1 || listRows.length === 0) return next(e)
829    const at = selectedRow === undefined ? -1 : listRows.indexOf(selectedRow)
830    const to = at < 0 ? (e.by > 0 ? 0 : listRows.length - 1) : Math.min(listRows.length - 1, Math.max(0, at + e.by))
831    if (to !== at) {
832      selectedRow = listRows[to]!
833      await $.ui.focus({ requestId: PANE, key: selectedRow })
834    }
835    return {}
836  })
837}
838
839// The list view's row buttons, top to bottom, as last drawn; empty in every other view.
840let listRows: string[] = []
841// The list row last selected (its button key): by the arrows, a click or Enter, or the ring.
842let selectedRow: string | undefined
843
844/** The pane's content; `ui.render` paints it on a black ground. */
845async function renderPane($: $, e: RenderInput<'Pane'>) {
846  const { Box, Text, Button } = $.ui.resolve(e)
847  listRows = []
848  const all = await read($, conns)
849  const ps = await read($, prompts)
850  const v = await read($, view)
851  const fresh = new Set(await read($, newHosts))
852  const width = Math.max(30, e.props.bodyColumns)
853  const rows = Math.max(8, (e.viewport?.rows ?? 30) - 2)
854  const promptById = new Map(ps.map(p => [p.id, p]))
855  // A row the poller saw belongs to the tool call with its tool use id: that call's own row.
856  const isSeen = (c: NetConn) => c.seenByPoller === true
857  const callRows = new Map<string, NetConn>()
858  for (const c of all) if (c.toolUseId !== undefined && !isSeen(c) && !callRows.has(c.toolUseId)) callRows.set(c.toolUseId, c)
859  const parentOf = (c: NetConn) => (isSeen(c) && c.toolUseId !== undefined ? callRows.get(c.toolUseId) : undefined)
860  const seenOf = new Map<string, NetConn[]>()
861  for (const c of all) {
862    const p = parentOf(c)
863    if (p !== undefined) seenOf.set(p.id, [...(seenOf.get(p.id) ?? []), c])
864  }
865  const promptLabel = (id?: string) => {
866    const p = id === undefined ? undefined : promptById.get(id)
867    return p === undefined ? '(no prompt)' : `${p.kind === 'command' ? 'C' : 'P'}${p.seq}`
868  }
869
870  const showLocal = v.showLocal === true
871  const net = all.filter(c => c.kind !== 'local')
872  const networked = (c: NetConn) => c.kind === 'local' && seenOf.has(c.id)
873  const nNetworked = all.filter(networked).length
874  const counts: Record<string, number> = { all: showLocal ? all.length : net.length + nNetworked, model: 0, web: 0, shell: nNetworked, mcp: 0, service: 0, share: 0, local: 0 }
875  for (const c of all) counts[c.kind] = (counts[c.kind] ?? 0) + 1
876  const uncertain = net.filter(c => c.confidence === 'uncertain').length
877  const hosts = new Set(net.map(c => c.host).filter(h => !PLACEHOLDER_HOSTS.has(h)))
878  const tokIn = all.reduce((n, c) => n + Number(c.details.find(d => d.key === 'Input tokens')?.value ?? 0) + Number(c.details.find(d => d.key === 'Cache read tokens')?.value ?? 0), 0)
879  const tokOut = all.reduce((n, c) => n + Number(c.details.find(d => d.key === 'Output tokens')?.value ?? 0), 0)
880  const running = net.filter(c => c.status === 'running').length
881  const nNew = [...hosts].filter(h => fresh.has(h)).length
882
883  // What the view shows right now, since the buttons alone make the choice hard to see.
884  const viewName = { list: 'list', detail: 'details', prompts: 'by prompt', hosts: 'by host' }[v.mode]
885  const showing = [
886    `view: ${viewName}`,
887    ...(v.mode === 'list'
888      ? [
889          `kind: ${v.filter === 'all' ? 'all' : KIND_LABEL[v.filter].toLowerCase()}`,
890          ...(v.promptId !== undefined ? [`prompt: ${promptLabel(v.promptId || undefined)}`] : []),
891          ...(v.host !== undefined ? [`host: ${v.host}`] : []),
892        ]
893      : []),
894    ...(v.mode === 'hosts' ? [`hosts: ${{ session: 'this session', new: 'new in this session', known: 'all known' }[v.hostSet ?? 'session']}`] : []),
895    `local tool calls: ${showLocal ? 'shown' : 'hidden'}`,
896  ].join(' · ')
897
898  const header = (
899    <Box flexDirection="column">
900      <Text wrap="truncate-end">
901        <Text bold color="cyan">NETWORK CONNECTIONS</Text>
902        <Text bold>{` · ${net.length} connections`}</Text>
903        {uncertain > 0 ? <Text color="yellow">{` (${uncertain} uncertain)`}</Text> : null}
904        {counts.share ? <Text color={KIND_COLOR.share} bold>{` · ⇄ ${counts.share} file-share access${counts.share === 1 ? '' : 'es'}`}</Text> : null}
905        <Text dimColor>{` · ${hosts.size} hosts · ${counts.local} local tool calls · ${ps.length} prompts · ${fmtNum(tokIn)}↑ ${fmtNum(tokOut)}↓ tokens`}</Text>
906        {running > 0 ? <Text color="yellow">{` · ${running} open`}</Text> : null}
907        {nNew > 0 ? <Text color="green" bold>{` · ★ ${nNew} new host${nNew === 1 ? '' : 's'}`}</Text> : null}
908      </Text>
909      <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
910        <Button key="v-list" plain hotkey="l" dimColor={v.mode !== 'list'} label="list" onPress={() => setView($, x => ({ ...x, mode: 'list', page: 0, back: [] }))} />
911        <Button key="v-prompts" plain hotkey="g" dimColor={v.mode !== 'prompts'} label="by prompt" onPress={() => setView($, x => ({ ...x, mode: 'prompts', page: 0, back: [] }))} />
912        <Button key="v-hosts" plain hotkey="h" dimColor={v.mode !== 'hosts'} label="by host" onPress={() => setView($, x => ({ ...x, mode: 'hosts', page: 0, back: [] }))} />
913        <Text dimColor>│</Text>
914        <Button key="toggle-local" plain hotkey="z" dimColor={!showLocal} label={`local tool calls: ${showLocal ? 'shown' : 'hidden'}`} onPress={() => setView($, x => ({ ...x, showLocal: x.showLocal !== true, page: 0 }))} />
915      </Box>
916      <Text color="cyan" wrap="truncate-end">{`Showing: ${showing}`}</Text>
917    </Box>
918  )
919
920  // ---- detail ----
921  if (v.mode === 'detail') {
922    const list = scoped(all, v, networked)
923    const idx = list.findIndex(c => c.id === v.selected)
924    const c = idx >= 0 ? list[idx] : all.find(x => x.id === v.selected)
925    if (c === undefined) {
926      return (
927        <Box flexDirection="column">
928          {header}
929          <Text dimColor>That connection is gone.</Text>
930          <Button key="back" hotkey="b" label="back" onPress={() => goBack($)} />
931        </Box>
932      )
933    }
934    const p = c.promptId ? promptById.get(c.promptId) : undefined
935    const parent = parentOf(c)
936    const seen = seenOf.get(c.id) ?? []
937    const linked = new Set([parent?.id, ...seen.map(x => x.id)])
938    const siblings = all.filter(x => x.promptId === c.promptId && x.id !== c.id && !linked.has(x.id))
939    const rowLabel = (x: NetConn) => trunc(`#${x.seq} ${KIND_LABEL[x.kind]} ${x.host}  ${oneLine(x.command ?? x.statusText ?? '')}`, width - 4)
940    const mark = STATUS_MARK[c.status]
941    const keyW = 20
942    const field = (k: string, value: string, color?: string) => (
943      <Box flexDirection="row">
944        <Box width={keyW} flexShrink={0}><Text dimColor>{k}</Text></Box>
945        <Box flexGrow={1}><Text wrap="wrap" color={color}>{trunc(printable(value), 9_000)}</Text></Box>
946      </Box>
947    )
948    const go = (to?: NetConn) => to && setView($, x => ({ ...x, selected: to.id }))
949    const copyText = c.url ?? c.command ?? c.host
950
951    // WebFetch: its processed output, or the page's raw body fetched again on request.
952    const isFetch = c.source === 'WebFetch' && c.url !== undefined
953    const showRaw = v.response === 'raw'
954    const r = isFetch ? (await read($, raw))[c.id] : undefined
955    const toolOut = c.details.find(d => d.key === 'Response')?.value
956    const body = r?.text === undefined ? '' : printable(r.text)
957    const rawPages = Math.max(1, Math.ceil(body.length / RAW_PAGE))
958    const rawPage = Math.min(Math.max(0, v.rawPage ?? 0), rawPages - 1)
959    const pageText = body.slice(rawPage * RAW_PAGE, (rawPage + 1) * RAW_PAGE)
960    const pickRaw = () => {
961      void setView($, x => ({ ...x, response: 'raw', rawPage: 0 }))
962      if (r === undefined || r.state === 'error') void fetchRaw($, c)
963    }
964    const responseSection = (
965      <Box flexDirection="column" marginTop={1}>
966        <Box flexDirection="row" columnGap={1}>
967          <Text bold>Response</Text>
968          <Button key="resp-tool" plain hotkey="w" dimColor={showRaw} label="tool output" onPress={() => setView($, x => ({ ...x, response: 'tool' }))} />
969          <Button key="resp-raw" plain hotkey="r" dimColor={!showRaw} label="raw page" onPress={pickRaw} />
970          {showRaw && r !== undefined && r.state !== 'loading' ? (
971            <Button key="resp-refetch" plain hotkey="f" label="fetch again" onPress={() => void fetchRaw($, c)} />
972          ) : null}
973        </Box>
974        {!showRaw ? (
975          <Text wrap="wrap" dimColor={toolOut === undefined}>{toolOut ?? (c.status === 'running' ? 'Still running.' : 'WebFetch returned no output.')}</Text>
976        ) : r === undefined ? (
977          <Text dimColor>Press r to fetch the page.</Text>
978        ) : r.state === 'loading' ? (
979          <Text color="yellow">{`Fetching ${r.url} …`}</Text>
980        ) : r.state === 'error' ? (
981          <Text color="red" wrap="wrap">{`Fetch failed: ${r.error ?? 'unknown error'}`}</Text>
982        ) : (
983          <Box flexDirection="column">
984            <Text wrap="wrap" color="yellow">{`Fetched again by this pane at ${fmtTime(r.at)}, not the bytes WebFetch received: mods only see the tool's output. The page may have changed, or answer this request differently.`}</Text>
985            {field('Status', `HTTP ${r.status ?? '?'}`, r.status !== undefined && r.status < 400 ? undefined : 'red')}
986            {field('Content type', r.headers?.['content-type'] ?? '—')}
987            {field('Length', `${r.length ?? 0} chars${(r.length ?? 0) > RAW_MAX ? ` — first ${RAW_MAX} shown` : ''}`)}
988            {rawPages > 1 ? (
989              <Box flexDirection="row" columnGap={1} marginTop={1}>
990                <Button key="raw-prev" plain hotkey="k" dimColor={rawPage === 0} label="previous part" onPress={() => setView($, x => ({ ...x, rawPage: Math.max(0, rawPage - 1) }))} />
991                <Text dimColor>{`part ${rawPage + 1}/${rawPages}`}</Text>
992                <Button key="raw-next" plain hotkey="j" dimColor={rawPage >= rawPages - 1} label="next part" onPress={() => setView($, x => ({ ...x, rawPage: Math.min(rawPages - 1, rawPage + 1) }))} />
993              </Box>
994            ) : null}
995            <Text> </Text>
996            {body === '' ? <Text dimColor>(empty body)</Text> : chunks(pageText, RAW_CHUNK).map((t, i) => <Text key={`raw-${rawPage}-${i}`} wrap="wrap">{t}</Text>)}
997          </Box>
998        )}
999      </Box>
1000    )
1001    return (
1002      <Box flexDirection="column">
1003        {header}
1004        <Box flexDirection="row" columnGap={1} marginTop={1}>
1005          <Button key="back" plain hotkey="b" label="back" onPress={() => goBack($)} />
1006          <Button key="prev" plain hotkey="p" dimColor={idx <= 0} label="newer" onPress={() => go(list[idx - 1])} />
1007          <Button key="next" plain hotkey="n" dimColor={idx < 0 || idx >= list.length - 1} label="older" onPress={() => go(list[idx + 1])} />
1008          <Button key="same-prompt" plain hotkey="o" label="all from this prompt" onPress={() => navigate($, x => ({ ...x, mode: 'list', promptId: c.promptId ?? '', host: undefined, filter: 'all', page: 0 }))} />
1009          <Button key="same-host" plain hotkey="t" label="all to this host" onPress={() => navigate($, x => ({ ...x, mode: 'list', host: c.host, promptId: undefined, filter: 'all', page: 0 }))} />
1010          <Button key="copy" plain hotkey="c" label="copy" onPress={press => void $.ui.copy({ text: copyText, surface: press.surface })} />
1011        </Box>
1012        <Text> </Text>
1013        <Text wrap="truncate-end">
1014          <Text color={mark.color}>{mark.glyph} </Text>
1015          <Text color={KIND_COLOR[c.kind]} bold>{KIND_LABEL[c.kind]} </Text>
1016          <Text bold>{c.host}</Text>
1017          {fresh.has(c.host) ? <Text color="green" bold>{'  ★ NEW HOST'}</Text> : null}
1018          <Text dimColor>{`  #${c.seq}`}</Text>
1019        </Text>
1020        {c.kind === 'share' ? (
1021          <Box flexDirection="column" borderStyle="round" borderColor={KIND_COLOR.share} paddingX={1} marginY={1}>
1022            <Text color={KIND_COLOR.share} bold>⇄ File-share access — not a web or API connection</Text>
1023            <Text wrap="wrap">{`The path lies on the network file system ${c.url ?? c.host}. The operating system reaches it over ${c.protocol}, opening or reusing a session to ${c.host} for the file access itself; nothing goes to a web server or API.`}</Text>
1024            <Text wrap="wrap" dimColor>{'Duration and size are those of the tool call; the file-system session may outlive it, be shared with other programs, and serve reads from a local cache.'}</Text>
1025          </Box>
1026        ) : null}
1027        {field('Status', `${c.status}${c.statusText ? ` — ${c.statusText}` : ''}`, mark.color)}
1028        {c.url ? field('URL', c.url) : null}
1029        {field('Protocol', c.protocol)}
1030        {field('Evidence', `${EVIDENCE_TEXT[c.confidence]} — ${c.reason}`, c.confidence === 'inferred' || c.confidence === 'uncertain' ? 'yellow' : c.confidence === 'local' ? 'gray' : undefined)}
1031        {field('Started', `${fmtTime(c.startedAt)}`)}
1032        {field('Duration', c.endedAt === undefined ? 'still open' : fmtDur(durOf(c)))}
1033        {c.bytes !== undefined ? field(c.kind === 'model' ? 'Streamed out' : 'Size', c.kind === 'model' ? `${c.bytes} chars` : fmtBytes(c.bytes)) : null}
1034        {field('Made by', c.source + (c.toolUseId ? ` (${c.toolUseId})` : ''))}
1035        {c.agentId ? field('Subagent', c.agentId) : null}
1036        {parent !== undefined ? (
1037          <Box flexDirection="column" marginTop={1}>
1038            <Text bold>Caused by</Text>
1039            <Text wrap="wrap" dimColor>{`The ${parent.source} call whose processes opened this connection; the engine reports the call, the poller saw the connection.`}</Text>
1040            <Button key="parent" plain hotkey="u" label={`↑ ${rowLabel(parent)}`} onPress={() => navigate($, x => ({ ...x, selected: parent.id }))} />
1041          </Box>
1042        ) : null}
1043        {seen.length > 0 ? (
1044          <Box flexDirection="column" marginTop={1}>
1045            <Text bold>{`Observed connections of this call (${seen.length})`}</Text>
1046            <Text wrap="wrap" dimColor>{"What this call's processes connected to, as the poller saw it in the TCP table."}</Text>
1047            {seen.slice(0, 20).map((x, i) => (
1048              <Button key={`seen-${x.id}`} plain hotkey={i < 9 ? String(i + 1) : undefined} label={`↳ ${rowLabel(x)}`} onPress={() => navigate($, v2 => ({ ...v2, selected: x.id }))} />
1049            ))}
1050          </Box>
1051        ) : null}
1052        <Text> </Text>
1053        <Text bold>Origin</Text>
1054        {field(promptLabel(c.promptId), p ? `${fmtTime(p.at)}  ${trunc(oneLine(p.text), 600)}` : 'before any prompt (session start)')}
1055        {c.command ? field(c.kind === 'model' ? 'Request' : 'Command', trunc(c.command, 800)) : null}
1056        <Text> </Text>
1057        <Text bold>Details</Text>
1058        {c.details.filter(d => !(isFetch && d.key === 'Response')).map(d => field(d.key, d.value))}
1059        {isFetch ? responseSection : null}
1060        {siblings.length > 0 ? (
1061          <Box flexDirection="column" marginTop={1}>
1062            <Text bold>{`Other connections from ${promptLabel(c.promptId)} (${siblings.length})`}</Text>
1063            {siblings.slice(-8).reverse().map(s => (
1064              <Button key={`sib-${s.id}`} plain dimColor label={trunc(`#${s.seq} ${KIND_LABEL[s.kind]} ${s.host} ${s.statusText ?? ''}`, width - 2)} onPress={() => navigate($, x => ({ ...x, selected: s.id }))} />
1065            ))}
1066          </Box>
1067        ) : null}
1068      </Box>
1069    )
1070  }
1071
1072  // ---- by prompt ----
1073  if (v.mode === 'prompts') {
1074    const byPrompt = new Map<string, NetConn[]>()
1075    for (const c of all) {
1076      const k = c.promptId ?? ''
1077      byPrompt.set(k, [...(byPrompt.get(k) ?? []), c])
1078    }
1079    const items = [...ps].reverse().map(p => ({ p, cs: byPrompt.get(p.id) ?? [] }))
1080    if (byPrompt.has('')) items.push({ p: { id: '', seq: 0, kind: 'other', text: '(before any prompt)', at: 0 }, cs: byPrompt.get('') ?? [] })
1081    const room = rows - 7
1082    const pages = Math.max(1, Math.ceil(items.length / room))
1083    const page = Math.min(v.page, pages - 1)
1084    return (
1085      <Box flexDirection="column">
1086        {header}
1087        <Text dimColor>{'Press a prompt to list its connections.'}</Text>
1088        {items.length === 0 ? <Text dimColor>No prompts yet.</Text> : null}
1089        {items.slice(page * room, page * room + room).map(({ p, cs }) => {
1090          const kinds = (['model', 'web', 'shell', 'mcp', 'service', 'share', 'local'] as const).map(k => [k, cs.filter(c => c.kind === k).length] as const).filter(([, n]) => n > 0)
1091          const tag = p.id === '' ? '—' : `${p.kind === 'command' ? 'C' : 'P'}${p.seq}`
1092          const netCount = cs.filter(c => c.kind !== 'local').length
1093          const summary = kinds.map(([k, n]) => `${n} ${KIND_LABEL[k].toLowerCase()}`).join(', ') || 'no tool calls'
1094          const label = `${tag.padEnd(4)} ${p.at ? fmtTime(p.at) : '        '}  ${String(netCount).padStart(3)} net  ${summary.padEnd(34)} ${oneLine(p.text)}`
1095          return (
1096            <Button key={`p-${p.id || 'none'}`} plain dimColor={netCount === 0} label={trunc(label, width - 1)} onPress={() => navigate($, x => ({ ...x, mode: 'list', promptId: p.id, host: undefined, filter: 'all', page: 0 }))} />
1097          )
1098        })}
1099        {pager(page, pages)}
1100      </Box>
1101    )
1102  }
1103
1104  // ---- by host ----
1105  if (v.mode === 'hosts') {
1106    const agg = new Map<string, { n: number; err: number; bytes: number; last: number; kinds: Set<NetKind>; prompts: Set<string> }>()
1107    for (const c of net) {
1108      const a = agg.get(c.host) ?? { n: 0, err: 0, bytes: 0, last: 0, kinds: new Set(), prompts: new Set() }
1109      a.n += 1
1110      if (c.status === 'error') a.err += 1
1111      a.bytes += c.kind === 'model' ? 0 : c.bytes ?? 0
1112      a.last = Math.max(a.last, c.startedAt)
1113      a.kinds.add(c.kind)
1114      if (c.promptId) a.prompts.add(c.promptId)
1115      agg.set(c.host, a)
1116    }
1117    // Real hosts first; then the rows whose command named no destination.
1118    const byCount = (x: [string, { n: number }], y: [string, { n: number }]) => y[1].n - x[1].n
1119    const entries = [...agg.entries()]
1120    const sessionItems = [...entries.filter(([h]) => !PLACEHOLDER_HOSTS.has(h)).sort(byCount), ...entries.filter(([h]) => PLACEHOLDER_HOSTS.has(h)).sort(byCount)]
1121    const hostSet = v.hostSet ?? 'session'
1122    // `known` adds the hosts earlier sessions used, newest first, after the ones this session reached.
1123    const stored = hostSet === 'known' ? await $.store.get(KNOWN_KEY) : undefined
1124    const earlier = (Array.isArray(stored) ? stored.filter((h): h is string => typeof h === 'string') : []).reverse().filter(h => !agg.has(h))
1125    const items: [string, (typeof entries)[number][1] | undefined][] =
1126      hostSet === 'new'
1127        ? sessionItems.filter(([h]) => fresh.has(h))
1128        : hostSet === 'known'
1129          ? [...sessionItems, ...earlier.map((h): [string, undefined] => [h, undefined])]
1130          : sessionItems
1131    const mnt = await read($, mounts)
1132    const mountWord = mnt.platform === 'windows' ? 'mapped drives' : 'network mounts'
1133    const room = rows - 9
1134    const pages = Math.max(1, Math.ceil(items.length / room))
1135    const page = Math.min(v.page, pages - 1)
1136    return (
1137      <Box flexDirection="column">
1138        {header}
1139        <Box flexDirection="row" columnGap={1}>
1140          <Text color={KIND_COLOR.share} wrap="truncate-end">
1141            {mnt.isLoaded !== true
1142              ? mnt.error !== undefined
1143                ? `Could not list ${mountWord}: ${mnt.error}.`
1144                : `Looking up ${mountWord}…`
1145              : mnt.list.length === 0
1146                ? `No ${mountWord}.`
1147                : trunc(`⇄ ${mountWord[0]!.toUpperCase()}${mountWord.slice(1)}: ${mnt.list.map(mountLine).join(' · ')}`, width - 22)}
1148          </Text>
1149          <Button key="refresh-mounts" plain hotkey="r" label={`refresh ${mnt.platform === 'windows' ? 'drives' : 'mounts'}`} onPress={() => void loadMounts($)} />
1150        </Box>
1151        <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
1152          <Button key="hs-session" plain hotkey="i" dimColor={hostSet !== 'session'} label="this session" onPress={() => setView($, x => ({ ...x, hostSet: 'session', page: 0 }))} />
1153          <Button key="hs-new" plain hotkey="e" dimColor={hostSet !== 'new'} label={`new in this session ${nNew}`} onPress={() => setView($, x => ({ ...x, hostSet: 'new', page: 0 }))} />
1154          <Button key="hs-known" plain hotkey="y" dimColor={hostSet !== 'known'} label="all known" onPress={() => setView($, x => ({ ...x, hostSet: 'known', page: 0 }))} />
1155        </Box>
1156        <Text dimColor>{`${'count'.padStart(5)}  ${'last'.padEnd(8)}  ${'prompts'.padStart(7)}  ${'kinds'.padEnd(14)} host`}</Text>
1157        {items.length === 0 ? <Text dimColor>{hostSet === 'new' ? 'No new hosts in this session.' : hostSet === 'known' ? 'No hosts known yet.' : 'No connections yet.'}</Text> : null}
1158        {items.slice(page * room, page * room + room).map(([host, a], i, shown) => {
1159          if (a === undefined) return <Text key={`hr-${host}`} dimColor wrap="truncate-end">{`${'—'.padStart(5)}  ${'earlier'.padEnd(8)}  ${'—'.padStart(7)}  ${''.padEnd(14)} ${host}`}</Text>
1160          const kinds = [...a.kinds].map(k => KIND_LABEL[k].toLowerCase()).join(',')
1161          const label = `${String(a.n).padStart(5)}  ${fmtTime(a.last)}  ${String(a.prompts.size).padStart(7)}  ${kinds.padEnd(14)} ${fresh.has(host) ? '★ NEW ' : ''}${host}${a.err ? `  (${a.err} failed)` : ''}`
1162          const placeholder = PLACEHOLDER_HOSTS.has(host)
1163          const firstPlaceholder = placeholder && (i === 0 || !PLACEHOLDER_HOSTS.has(shown[i - 1]![0]))
1164          return (
1165            <Box key={`hr-${host}`} flexDirection="column">
1166              {firstPlaceholder ? <Text dimColor>{'No host named (the command text does not say where it connects):'}</Text> : null}
1167              <Button key={`h-${host}`} plain dimColor={placeholder} label={trunc(label, width - 1)} onPress={() => navigate($, x => ({ ...x, mode: 'list', host, promptId: undefined, filter: 'all', page: 0 }))} />
1168            </Box>
1169          )
1170        })}
1171        {pager(page, pages)}
1172      </Box>
1173    )
1174  }
1175
1176  // ---- list ----
1177  const list = scoped(all, v, networked)
1178  const room = rows - 10
1179  const pages = Math.max(1, Math.ceil(list.length / room))
1180  const page = Math.min(v.page, pages - 1)
1181  const scopeText =
1182    v.promptId !== undefined
1183      ? `${promptLabel(v.promptId || undefined)}: ${oneLine(promptById.get(v.promptId)?.text ?? '(before any prompt)')}`
1184      : v.host !== undefined
1185        ? `host ${v.host}`
1186        : undefined
1187  const filters: readonly ['all' | NetKind, string, string][] = [
1188    ['all', 'a', 'all'],
1189    ['model', 'm', 'model'],
1190    ['web', 'w', 'web'],
1191    ['shell', 's', 'shell'],
1192    ['mcp', 'x', 'mcp'],
1193    ['service', 'v', 'service'],
1194    ['share', 'f', 'share'],
1195    ['local', 'k', 'local'],
1196  ]
1197  const hostW = Math.max(12, Math.min(34, Math.floor(width * 0.3)))
1198  return (
1199    <Box flexDirection="column">
1200      {header}
hooks/detect.ts 347 lines
1// Reads a shell command for the network endpoints it is likely to reach.
2// Nothing here sees a socket: it is the command's text, so every target is
3// `inferred` and says which part of the command pointed at the network.
4
5import type { NetMount } from '../types'
6
7export type Target = {
8  /** Set when this target is another kind than the call's other targets (a share). */
9  kind?: 'share'
10  host: string
11  url?: string
12  protocol: string
13  reason: string
14}
15
16const URL_RE = /\b((?:https?|wss?|ftp|sftp|ssh|git):\/\/[^\s'"`<>|;)\]]+)/gi
17const SCP_RE = /(?:^|\s)(?:[\w.-]+@)?([a-z0-9-]+(?:\.[a-z0-9-]+)+):(?!\/\/)[\w~./-]+/gi
18
19type Rule = {
20  test: RegExp
21  host: string | ((m: RegExpMatchArray) => string | undefined)
22  protocol: string
23  reason: string
24  /** Only when no URL in the command already names the endpoint. */
25  ifNoUrl?: true
26}
27
28const RULES: readonly Rule[] = [
29  { test: /\b(?:npm|pnpm|yarn)\s+(?:i|install|add|ci|update|upgrade|up|publish|view|info|outdated|audit|dlx|create|exec)\b/i, host: 'registry.npmjs.org', protocol: 'HTTPS', reason: 'npm-family package command' },
30  { test: /\b(?:npx|bunx|pnpx)\s/i, host: 'registry.npmjs.org', protocol: 'HTTPS', reason: 'package runner may download the package' },
31  { test: /\bbun\s+(?:i|install|add|update|x)\b/i, host: 'registry.npmjs.org', protocol: 'HTTPS', reason: 'bun package command' },
32  { test: /\b(?:pip3?|python3?\s+-m\s+pip)\s+(?:install|download)\b|\buv\s+(?:pip\s+install|add|sync|lock)\b|\bpoetry\s+(?:install|add|update|lock)\b|\bpipx\s+(?:install|run)\b/i, host: 'pypi.org', protocol: 'HTTPS', reason: 'Python package command' },
33  { test: /\bcargo\s+(?:build|install|fetch|update|add|run|test|check|publish)\b/i, host: 'index.crates.io', protocol: 'HTTPS', reason: 'cargo may fetch crates' },
34  { test: /\bgo\s+(?:get|install|mod\s+(?:download|tidy))\b/i, host: 'proxy.golang.org', protocol: 'HTTPS', reason: 'Go module command' },
35  { test: /\b(?:gem\s+install|bundle\s+(?:install|update))\b/i, host: 'rubygems.org', protocol: 'HTTPS', reason: 'Ruby gem command' },
36  { test: /\b(?:dotnet\s+(?:restore|add\s+\S+\s+package|tool\s+install)|nuget\s+install)\b/i, host: 'api.nuget.org', protocol: 'HTTPS', reason: '.NET package command' },
37  { test: /\b(?:mvn|gradlew?)\b/i, host: 'repo.maven.apache.org', protocol: 'HTTPS', reason: 'JVM build may resolve dependencies' },
38  { test: /\b(?:docker|podman)\s+(?:pull|push|login|run|build|compose\s+(?:pull|up|build))\b/i, host: 'registry-1.docker.io', protocol: 'HTTPS', reason: 'container registry command' },
39  { test: /\bgh\s+[a-z]/i, host: 'api.github.com', protocol: 'HTTPS', reason: 'GitHub CLI' },
40  { test: /\bgit\s+(?:clone|fetch|pull|push|ls-remote|remote\s+update|submodule\s+update)\b/i, host: 'git remote', protocol: 'git/HTTPS/SSH', reason: 'git command talks to a remote', ifNoUrl: true },
41  { test: /\b(?:winget)\s+(?:install|upgrade|update|search|source)\b/i, host: 'cdn.winget.microsoft.com', protocol: 'HTTPS', reason: 'winget package command' },
42  { test: /\b(?:choco)\s+(?:install|upgrade|search)\b/i, host: 'community.chocolatey.org', protocol: 'HTTPS', reason: 'Chocolatey package command' },
43  { test: /\bscoop\s+(?:install|update|search)\b/i, host: 'github.com', protocol: 'HTTPS', reason: 'Scoop package command' },
44  { test: /\bbrew\s+(?:install|update|upgrade|tap)\b/i, host: 'formulae.brew.sh', protocol: 'HTTPS', reason: 'Homebrew command' },
45  { test: /\b(?:apt|apt-get|dnf|yum|apk)\s+(?:install|update|upgrade|add)\b/i, host: 'system package mirror', protocol: 'HTTP(S)', reason: 'system package manager' },
46  { test: /(?:^|[;&|(]|\bsudo\s|\btime\s)\s*(?:ssh|scp|sftp|rsync|mosh)\s+(?:-\S+\s+)*(?:[\w.-]+@)?([a-z0-9][\w.-]*)/i, host: m => m[1], protocol: 'SSH', reason: 'remote shell / copy command' },
47  { test: /(?:^|[;&|(]|\bsudo\s|\btime\s)\s*(?:ping|nslookup|dig|host|traceroute|tracert|telnet|nc|ncat|whois)\s+(?:-\S+\s+)*([a-z0-9][\w.-]*)/i, host: m => m[1], protocol: 'ICMP/DNS/TCP', reason: 'network diagnostic command' },
48  { test: /\b(?:Test-NetConnection|tnc|Resolve-DnsName|Test-Connection)\s+(?:-\w+\s+)*([a-z0-9][\w.-]*)/i, host: m => m[1], protocol: 'TCP/DNS/ICMP', reason: 'PowerShell network cmdlet' },
49  { test: /\b(?:curl|wget|Invoke-WebRequest|iwr|Invoke-RestMethod|irm|http|https|xh)\b/i, host: 'unknown host', protocol: 'HTTP(S)', reason: 'HTTP client in command', ifNoUrl: true },
50  { test: /\bclaude\s+plugin\s+(?:install|marketplace\s+(?:add|update))\b/i, host: 'github.com', protocol: 'HTTPS', reason: 'plugin install fetches from a marketplace', ifNoUrl: true },
51]
52
53/**
54 * Host names that stand for a destination the command text does not name:
55 * kept on the row, but not counted or listed as hosts.
56 */
57export const PLACEHOLDER_HOSTS: ReadonlySet<string> = new Set(['unknown (script)', 'unknown host', 'git remote', 'system package mirror', 'local'])
58
59export function hostOf(url: string): string {
60  try {
61    return new URL(url).host || url
62  } catch {
63    return url
64  }
65}
66
67export function protocolOf(url: string): string {
68  const scheme = url.split(':')[0]?.toLowerCase() ?? ''
69  return scheme === '' ? 'HTTP(S)' : scheme.toUpperCase()
70}
71
72// The body of a heredoc is data being written, not a command: its words and URLs connect to nothing.
73const HEREDOC_RE = /(<<-?\s*(['"]?)(\w+)\2[^\n]*\n)[\s\S]*?\n[ \t]*\3(?=\s|$)/g
74
75export function detectShell(raw: string): Target[] {
76  const command = raw.replace(HEREDOC_RE, '$1')
77  const found = new Map<string, Target>()
78  const add = (t: Target) => {
79    const was = found.get(t.host)
80    if (was === undefined) found.set(t.host, t)
81    else if (!was.reason.includes(t.reason)) found.set(t.host, { ...was, reason: `${was.reason}; ${t.reason}` })
82  }
83
84  for (const m of command.matchAll(URL_RE)) {
85    const url = (m[1] ?? "").replace(/[.,]+$/, '')
86    add({ host: hostOf(url), url, protocol: protocolOf(url), reason: 'URL in command' })
87  }
88  if (/\bgit\b/i.test(command)) {
89    for (const m of command.matchAll(SCP_RE)) {
90      add({ host: m[1] ?? "", protocol: 'SSH', reason: 'git SSH remote in command' })
91    }
92  }
93  const hasUrl = found.size > 0
94  for (const rule of RULES) {
95    if (rule.ifNoUrl && hasUrl) continue
96    const m = command.match(rule.test)
97    if (m === null) continue
98    const host = typeof rule.host === 'string' ? rule.host : rule.host(m)
99    if (host === undefined || host === '' || /^-/.test(host)) continue
100    add({ host, protocol: rule.protocol, reason: rule.reason })
101  }
102
103  return [...found.values()]
104}
105
106// Commands that run code the text does not show: a script, an interpreter, a
107// build or test runner. Whatever they connect to is invisible from here.
108const SCRIPT_RULES: readonly [RegExp, string][] = [
109  [/(?:^|[\s;&|(])(?:python3?|py|node|deno|bun|ruby|perl|php|java|pwsh|powershell|bash|sh|zsh|Rscript|julia)(?:\.exe)?\s+(?!-(?:-?version|V|v|h|-help)\b)\S/i, 'starts an interpreter'],
110  [/(?:^|[;&|(])\s*(?:&\s*|\.\s+|source\s+|call\s+)?["']?[\w.:\\/-]*\.(?:sh|ps1|py|js|mjs|cjs|ts|rb|pl|bat|cmd|exe)\b/i, 'runs a script or program file'],
111  [/\b(?:go|cargo|dotnet)\s+(?:run|test)\b|\b(?:npm|pnpm|yarn|bun)\s+(?:run|test|start|exec)\b|\b(?:make|cmake|pytest|jest|vitest|mocha|tox|nox)\b/i, 'runs a build, test or task runner'],
112  [/\b(?:Start-Process|Invoke-Expression|iex|Invoke-Command|icm|Start-Job)\b/i, 'starts code PowerShell cannot show'],
113]
114
115/** A target for a command that may connect through code its text does not show. */
116export function detectUncertain(command: string): Target | undefined {
117  const reasons = SCRIPT_RULES.filter(([re]) => re.test(command)).map(([, why]) => why)
118  if (reasons.length === 0) return undefined
119  return { host: 'unknown (script)', protocol: 'unknown', reason: `${reasons.join('; ')}: connections it makes are not visible` }
120}
121
122// UNC paths (`\\server\share\…`, or `//server/share/…` as a POSIX shell spells
123// one): file access that Windows carries over the network, SMB by default,
124// WebDAV for `\\server@SSL\…`. Not an HTTP or API connection.
125const UNC_RE = /(?:^|[\s"'=(,;|&>])(?:\\\\|\/\/)([A-Za-z0-9][\w.-]*)(@SSL)?(?:@(\d+))?[\\/]([^\\/\s"'|;&<>]+)/g
126
127export function detectShare(text: string): Target[] {
128  const found = new Map<string, Target>()
129  for (const m of text.matchAll(UNC_RE)) {
130    const server = m[1] ?? ''
131    const share = m[4] ?? ''
132    if (server === '' || server === '.' || server === '?') continue // \\.\ and \\?\ are local device paths
133    const isDav = m[2] !== undefined || m[3] !== undefined
134    const key = `${server.toLowerCase()}/${share.toLowerCase()}`
135    if (found.has(key)) continue
136    found.set(key, {
137      host: server,
138      url: `\\\\${server}\\${share}`,
139      protocol: isDav ? `WebDAV over HTTP${m[2] ? 'S' : ''}${m[3] ? ` (port ${m[3]})` : ''}` : 'SMB (TCP 445)',
140      reason: `UNC path to share "${share}" on ${server}`,
141    })
142  }
143  return [...found.values()]
144}
145
146// ---------- network drives and mounts ----------
147
148const PROTOCOL_BY_FS: readonly [RegExp, string][] = [
149  [/^(?:cifs|smb3?|smbfs)$/i, 'SMB (TCP 445)'],
150  [/^nfs\d?$/i, 'NFS (TCP 2049)'],
151  [/^afpfs$/i, 'AFP (TCP 548)'],
152  [/^(?:webdav|davfs|fuse\.davfs2?)$/i, 'WebDAV over HTTP(S)'],
153  [/^(?:fuse\.)?sshfs$/i, 'SFTP over SSH (TCP 22)'],
154  [/^(?:ceph|fuse\.ceph(?:-fuse)?)$/i, 'Ceph'],
155  [/^(?:glusterfs|fuse\.glusterfs)$/i, 'GlusterFS'],
156  [/^fuse\.rclone$/i, 'rclone (remote storage)'],
157  [/^9p$/i, '9P'],
158]
159// FUSE types that mount anything; network only when the source looks remote.
160const GENERIC_FUSE = /^(?:macfuse|osxfuse|fuse)$/i
161const GVFS = /^fuse\.gvfsd-fuse$/i
162
163/** The host a mount source names: `//user@server/share`, `server:/export`, `user@host:/p`, a URL. */
164export function hostOfSource(source: string): string {
165  if (/^[a-z][\w+.-]*:\/\//i.test(source)) return hostOf(source)
166  return source
167    .replace(/^\/\//, '')
168    .replace(/^[^@/]*@/, '')
169    .replace(/[:/].*$/, '')
170    .replace(/;.*$/, '')
171}
172
173/** The mapped network drives of a Windows lookup, as mounts. */
174export function driveMounts(drives: Readonly<Record<string, string>>): NetMount[] {
175  return Object.entries(drives).map(([letter, unc]) => {
176    const server = unc.replace(/^\\\\/, '').split('\\')[0] ?? unc
177    const dav = /@SSL|@\d+/i.test(server)
178    return {
179      root: letter.toUpperCase(),
180      source: unc,
181      host: server.replace(/@.*$/, ''),
182      protocol: dav ? `WebDAV over HTTP${/@SSL/i.test(server) ? 'S' : ''}` : 'SMB (TCP 445)',
183      fsType: 'drive',
184    }
185  })
186}
187
188/**
189 * The network file systems in `mount` output, Linux (`src on /p type T (opts)`)
190 * or macOS (`src on /p (T, opts)`). Local disks, pseudo file systems and `/`
191 * itself are left out.
192 */
193export function parseMountOutput(stdout: string): NetMount[] {
194  const mounts: NetMount[] = []
195  for (const line of stdout.split(/\r?\n/)) {
196    const m = line.match(/^(.+?) on (\/.*?) (?:type (\S+) \(|\(([^,)]+))/)
197    if (m === null) continue
198    const source = m[1] ?? ''
199    const root = (m[2] ?? '').replace(/\/+$/, '') || '/'
200    const fsType = (m[3] ?? m[4] ?? '').trim()
201    if (root === '/') continue
202    if (GVFS.test(fsType)) {
203      mounts.push({ root, source: 'GNOME gvfs', host: '(per share)', protocol: 'gvfs', fsType })
204      continue
205    }
206    const known = PROTOCOL_BY_FS.find(([re]) => re.test(fsType))
207    const remoteFuse = GENERIC_FUSE.test(fsType) && /^[^\s/]+@[^\s:]+:|^[^\s/:]+:\//.test(source)
208    if (known === undefined && !remoteFuse) continue
209    mounts.push({ root, source, host: hostOfSource(source), protocol: known?.[1] ?? 'SFTP/remote (FUSE)', fsType })
210  }
211  return mounts
212}
213
214// gvfs keeps each share in a directory named like `smb-share:server=nas,share=media`.
215const GVFS_PROTOCOL: Record<string, string> = {
216  'smb-share': 'SMB (TCP 445)',
217  sftp: 'SFTP over SSH (TCP 22)',
218  dav: 'WebDAV over HTTP',
219  davs: 'WebDAV over HTTPS',
220  nfs: 'NFS (TCP 2049)',
221  ftp: 'FTP (TCP 21)',
222  'afp-volume': 'AFP (TCP 548)',
223}
224
225function gvfsTarget(mount: NetMount, rest: string): Target | undefined {
226  const dir = rest.split('/')[0] ?? ''
227  const m = dir.match(/^([\w-]+):(.*)$/)
228  if (m === null) return undefined
229  const params = Object.fromEntries((m[2] ?? '').split(',').map(kv => kv.split('=') as [string, string]))
230  const host = params.server ?? params.host
231  if (host === undefined) return undefined
232  return {
233    host,
234    url: `${mount.root}/${dir}`,
235    protocol: GVFS_PROTOCOL[m[1] ?? ''] ?? `gvfs ${m[1]}`,
236    reason: `GNOME gvfs mount ${dir}${params.share ? ` (share ${params.share})` : ''}`,
237  }
238}
239
240// A drive letter in a path: `Z:\…`, `Z:/…`, or `/z/…` as Git Bash spells it.
241const DRIVE_RE = /(?:^|[\s"'=(,;|&>])(?:([A-Za-z]):[\\/]|\/([A-Za-z])\/)/g
242const PATH_START = /[\s"'=(,;|&>]/
243const PATH_END = /[\s"'/|;&<>)]/
244
245/**
246 * Targets for paths on network drives or mounts. A drive root (`Z:`) matches
247 * its letter in any spelling; a mount point matches as a whole path prefix,
248 * spaces included (`/Volumes/Team Share/…`), the longest mount winning.
249 */
250export function detectMounts(text: string, mounts: readonly NetMount[]): Target[] {
251  const found = new Map<string, Target>()
252  const drives = new Map(mounts.filter(m => /^[A-Z]:$/.test(m.root)).map(m => [m.root, m]))
253  if (drives.size > 0) {
254    for (const m of text.matchAll(DRIVE_RE)) {
255      const mount = drives.get(`${(m[1] ?? m[2] ?? '').toUpperCase()}:`)
256      if (mount === undefined || found.has(mount.root)) continue
257      found.set(mount.root, {
258        host: mount.host,
259        url: mount.source,
260        protocol: mount.protocol,
261        reason: `mapped network drive ${mount.root} → ${mount.source}`,
262      })
263    }
264  }
265  const points = mounts.filter(m => m.root.startsWith('/')).sort((a, b) => b.root.length - a.root.length)
266  const claimed: [number, number][] = []
267  for (const mount of points) {
268    for (let at = text.indexOf(mount.root); at !== -1; at = text.indexOf(mount.root, at + 1)) {
269      const end = at + mount.root.length
270      const before = at === 0 ? ' ' : text[at - 1] ?? ' '
271      const after = text[end] ?? ' '
272      if (!PATH_START.test(before) || !PATH_END.test(after)) continue
273      if (claimed.some(([s, e]) => at >= s && at < e)) continue // a longer mount already took it
274      claimed.push([at, end])
275      if (GVFS.test(mount.fsType)) {
276        const t = after === '/' ? gvfsTarget(mount, text.slice(end + 1)) : undefined
277        if (t !== undefined && !found.has(t.url ?? '')) found.set(t.url ?? '', t)
278        continue
279      }
280      if (found.has(mount.root)) continue
281      found.set(mount.root, {
282        host: mount.host,
283        url: mount.source,
284        protocol: mount.protocol,
285        reason: `network mount ${mount.root} (${mount.fsType}) → ${mount.source}`,
286      })
287    }
288  }
289  return [...found.values()]
290}
291
292/** Reads the `Z:|\\server\share` lines the session-start CIM query prints. */
293export function parseCimDrives(stdout: string): Record<string, string> {
294  const drives: Record<string, string> = {}
295  for (const line of stdout.split(/\r?\n/)) {
296    const m = line.trim().match(/^([A-Za-z]:)\|(\\\\.+)$/)
297    if (m?.[1] && m[2]) drives[m[1].toUpperCase()] = m[2].trim()
298  }
299  return drives
300}
301
302/** Reads `net use` output, whatever the display language: a letter, then a UNC path. */
303export function parseNetUse(stdout: string): Record<string, string> {
304  const drives: Record<string, string> = {}
305  for (const line of stdout.split(/\r?\n/)) {
306    const m = line.match(/(?:^|\s)([A-Za-z]:)\s+(\\\\\S+)/)
307    if (m?.[1] && m[2]) drives[m[1].toUpperCase()] = m[2]
308  }
309  return drives
310}
311
312// ---------- secrets ----------
313
314// Credentials a command line or tool input may carry: the pane shows commands
315// in full, so each is masked before it is kept.
316const SECRET_RULES: readonly [RegExp, string][] = [
317  // user:password@ in a URL
318  [/\b([a-z][\w+.-]*:\/\/)[^\s/@:'"]+:[^\s/@'"]+@/gi, '$1***:***@'],
319  // Authorization: Bearer … / Basic … headers
320  [/(\bauthorization\s*[:=]\s*(?:bearer|basic|token|digest)?\s*)[^\s'"]+/gi, '$1***'],
321  [/(\bbearer\s+)[\w.~+/=-]{8,}/gi, '$1***'],
322  // --password x, --token=x, -Password x
323  [/(--?(?:password|passwd|pwd|token|api-?key|secret|client-?secret|auth)(?:=|\s+))("[^"]*"|'[^']*'|[^\s'"]+)/gi, '$1***'],
324  // PASSWORD=x, api_key: "x", access-token=x
325  [/(\b[\w-]*(?:password|passwd|secret|token|api[_-]?key|access[_-]?key|private[_-]?key)\s*[=:]\s*)("[^"]*"|'[^']*'|[^\s&,;'"]+)/gi, '$1***'],
326  // well-known token formats wherever they stand
327  [/\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_\w{20,}|sk-(?:ant-)?[\w-]{20,}|xox[abprs]-[\w-]{10,}|AKIA[0-9A-Z]{16}|AIza[\w-]{35}|glpat-[\w-]{20,})\b/g, '***'],
328]
329
330/** `text` with the credentials it carries replaced by `***`. */
331export function redact(text: string): string {
332  return SECRET_RULES.reduce((s, [re, to]) => s.replace(re, to), text)
333}
334
335// ---------- home directories ----------
336
337// A user's home directory in a path, as Windows, Git Bash, macOS and Linux spell it.
338const HOME_RULES: readonly [RegExp, string][] = [
339  [/\b[A-Za-z]:[\\/]Users[\\/][^\\/\s"'<>|:*?]+/gi, '~'],
340  [/(^|[\s"'=(:,;|&>])\/(?:[A-Za-z]\/Users|Users|home)\/[^/\s"'<>|:]+/g, '$1~'],
341]
342
343/** `text` with home directories shown as `~`, so a recorded path does not name the user. */
344export function maskHome(text: string): string {
345  return HOME_RULES.reduce((s, [re, to]) => s.replace(re, to), text)
346}
347
hooks/poller.ts 217 lines
1/**
2 * The poller: a PowerShell loop that watches the TCP table for connections of
3 * processes Claude Code started (shell calls, scripts, the tools they run) and
4 * writes one JSON line per event. The engine reports a Bash or PowerShell call,
5 * never what its processes connect to; this is how the pane sees it.
6 *
7 * Windows only. The table and the process list come from the Win32 API
8 * (`GetExtendedTcpTable`, a Toolhelp snapshot), a millisecond or two per look,
9 * so the loop looks every 25 ms (about 2% of one core) and catches most
10 * connections a short `curl` opens. Missed: one that opens and closes between
11 * two looks, and a process whose parent already exited (its line to Claude
12 * Code is gone, as for a daemonized child).
13 *
14 * Lines, one JSON object each:
15 * - `ready`: `{ root, rootName }`, the Claude Code process whose descendants it watches
16 * - `open`: `{ key, pid, name, cmd, addr, port, local, top, topStart }`; `top` is
17 *   the descendant started by Claude Code itself (a shell call's shell), `topStart`
18 *   its start in Unix milliseconds
19 * - `close`: `{ key }`, the connection left the table
20 * - `names`: `{ addr, names }`, the DNS cache's names for an address
21 * - `tick`: nothing happened for a while; lets the reader stop the loop
22 * - `end`: the time limit ran out
23 *
24 * Read from standard input (`-Command 'iex ([Console]::In.ReadToEnd())'`), so
25 * no quoting of a command line or size limit of the environment touches it.
26 * `NETCONN_T0` (Unix ms) is the earliest start of a process it counts,
27 * `NETCONN_MAX_MS` how long it runs, `NETCONN_EVERY_MS` the pause between looks.
28 */
29export const POLL_SCRIPT = String.raw`
30$ErrorActionPreference = 'SilentlyContinue'
31Add-Type -TypeDefinition @'
32using System;
33using System.Collections.Generic;
34using System.Net;
35using System.Runtime.InteropServices;
36public static class NetConnTable {
37  [DllImport("iphlpapi.dll")]
38  static extern uint GetExtendedTcpTable(IntPtr table, ref int size, bool order, int af, int cls, uint reserved);
39  [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
40  struct Entry { public uint size, usage, pid; public IntPtr heap; public uint module, threads, ppid; public int prio; public uint flags; [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] public string exe; }
41  [DllImport("kernel32.dll", SetLastError = true)] static extern IntPtr CreateToolhelp32Snapshot(uint flags, uint pid);
42  [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] static extern bool Process32FirstW(IntPtr h, ref Entry e);
43  [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] static extern bool Process32NextW(IntPtr h, ref Entry e);
44  [DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr h);
45  static int Port(int p) { return ((p & 0xFF) << 8) | ((p >> 8) & 0xFF); }
46  static bool Remote(IPAddress a, int port) {
47    if (port == 0 || IPAddress.IsLoopback(a) || a.Equals(IPAddress.Any) || a.Equals(IPAddress.IPv6Any)) return false;
48    if (a.IsIPv4MappedToIPv6 && IPAddress.IsLoopback(a.MapToIPv4())) return false;
49    return true;
50  }
51  // Connections to a remote host, of processes not in skip: "pid|local|remote addr|remote port".
52  public static List<string> Rows(HashSet<int> skip) {
53    var res = new List<string>();
54    foreach (int af in new[] { 2, 23 }) {
55      int size = 0;
56      GetExtendedTcpTable(IntPtr.Zero, ref size, false, af, 5, 0);
57      size += 4096;
58      IntPtr buf = Marshal.AllocHGlobal(size);
59      try {
60        if (GetExtendedTcpTable(buf, ref size, false, af, 5, 0) != 0) continue;
61        int n = Marshal.ReadInt32(buf);
62        for (int i = 0; i < n; i++) {
63          if (af == 2) {
64            IntPtr r = buf + 4 + i * 24;
65            int pid = Marshal.ReadInt32(r, 20), rp = Port(Marshal.ReadInt32(r, 16));
66            if (pid <= 0 || skip.Contains(pid)) continue;
67            var ra = new IPAddress((uint)Marshal.ReadInt32(r, 12));
68            if (!Remote(ra, rp)) continue;
69            res.Add(pid + "|" + new IPAddress((uint)Marshal.ReadInt32(r, 4)) + ":" + Port(Marshal.ReadInt32(r, 8)) + "|" + ra + "|" + rp);
70          } else {
71            IntPtr r = buf + 4 + i * 56;
72            int pid = Marshal.ReadInt32(r, 52), rp = Port(Marshal.ReadInt32(r, 44));
73            if (pid <= 0 || skip.Contains(pid)) continue;
74            byte[] l = new byte[16], rb = new byte[16];
75            Marshal.Copy(r + 24, rb, 0, 16);
76            var ra = new IPAddress(rb);
77            if (!Remote(ra, rp)) continue;
78            Marshal.Copy(r, l, 0, 16);
79            res.Add(pid + "|[" + new IPAddress(l) + "]:" + Port(Marshal.ReadInt32(r, 20)) + "|" + ra + "|" + rp);
80          }
81        }
82      } finally { Marshal.FreeHGlobal(buf); }
83    }
84    return res;
85  }
86  public static Dictionary<int, string[]> Procs() {
87    var res = new Dictionary<int, string[]>();
88    IntPtr h = CreateToolhelp32Snapshot(2, 0);
89    if (h == IntPtr.Zero || h == new IntPtr(-1)) return res;
90    try {
91      var e = new Entry();
92      e.size = (uint)Marshal.SizeOf(typeof(Entry));
93      for (bool ok = Process32FirstW(h, ref e); ok; ok = Process32NextW(h, ref e)) res[(int)e.pid] = new[] { e.ppid.ToString(), e.exe };
94    } finally { CloseHandle(h); }
95    return res;
96  }
97}
98'@
99$t0 = [DateTimeOffset]::FromUnixTimeMilliseconds([int64]$env:NETCONN_T0).LocalDateTime
100$maxMs = [int64]$env:NETCONN_MAX_MS
101$every = [int]$env:NETCONN_EVERY_MS
102if ($every -le 0) { $every = 25 }
103if ($maxMs -le 0) { $maxMs = 600000 }
104$me = $PID
105$procs = [NetConnTable]::Procs()
106$root = [int]$procs[$me][0]
107$x = $root
108for ($hops = 0; $hops -lt 8 -and $procs.ContainsKey($x); $hops++) {
109  if ($procs[$x][1] -like 'claude*') { $root = $x; break }
110  $x = [int]$procs[$x][0]
111}
112if ($env:NETCONN_ROOT) { $root = [int]$env:NETCONN_ROOT }
113$top = @{}
114$skip = New-Object 'System.Collections.Generic.HashSet[int]'
115$fresh = $false
116function TopOf($id) {
117  if ($top.ContainsKey($id)) { return $top[$id] }
118  if (-not $procs.ContainsKey($id) -and -not $script:fresh) { $script:procs = [NetConnTable]::Procs(); $script:fresh = $true }
119  $r = 0
120  if ($procs.ContainsKey($id) -and $id -ne $me) {
121    $started = (Get-Process -Id $id).StartTime
122    if ($started -and $started -ge $t0) {
123      $parent = [int]$procs[$id][0]
124      if ($parent -eq $root) { $r = $id } elseif ($parent -ne $id -and $parent -gt 0) { $r = TopOf $parent }
125    }
126  }
127  $top[$id] = $r
128  if ($r -eq 0) { [void]$skip.Add($id) }
129  return $r
130}
131$watch = [Diagnostics.Stopwatch]::StartNew()
132$lastOut = 0
133function Emit($o) { [Console]::Out.WriteLine(($o | ConvertTo-Json -Compress)); $script:lastOut = $watch.ElapsedMilliseconds }
134Emit @{ ev = 'ready'; root = $root; rootName = $procs[$root][1] }
135$open = @{}
136$pending = @{}
137$lastDns = 0
138while ($watch.ElapsedMilliseconds -lt $maxMs) {
139  $fresh = $false
140  $now = @{}
141  foreach ($row in [NetConnTable]::Rows($skip)) {
142    $f = $row.Split('|')
143    $procId = [int]$f[0]
144    $addr = $f[2]
145    $t = TopOf $procId
146    if ($t -gt 0) {
147      $now[$row] = 1
148      if (-not $open.ContainsKey($row)) {
149        $open[$row] = 1
150        $pending[$addr] = 0
151        $cmd = (Get-CimInstance Win32_Process -Filter ('ProcessId=' + $procId) -Property CommandLine).CommandLine
152        $topStart = ([DateTimeOffset](Get-Process -Id $t).StartTime).ToUnixTimeMilliseconds()
153        Emit @{ ev = 'open'; key = $row; pid = $procId; name = $procs[$procId][1]; cmd = $cmd; addr = $addr; port = [int]$f[3]; local = $f[1]; top = $t; topStart = $topStart }
154      }
155    }
156  }
157  foreach ($k in @($open.Keys)) {
158    if (-not $now.ContainsKey($k)) { $open.Remove($k); Emit @{ ev = 'close'; key = $k } }
159  }
160  if ($pending.Count -gt 0 -and $watch.ElapsedMilliseconds - $lastDns -gt 400) {
161    $lastDns = $watch.ElapsedMilliseconds
162    $cache = @(Get-DnsClientCache)
163    foreach ($a in @($pending.Keys)) {
164      $names = @($cache | Where-Object { $_.Data -eq $a } | ForEach-Object { $_.Entry } | Select-Object -Unique)
165      if ($names.Count -gt 0) { Emit @{ ev = 'names'; addr = $a; names = $names }; $pending.Remove($a) }
166      elseif ($pending[$a] -ge 10) { $pending.Remove($a) }
167      else { $pending[$a]++ }
168    }
169  }
170  if ($watch.ElapsedMilliseconds - $lastOut -gt 500) { Emit @{ ev = 'tick' } }
171  Start-Sleep -Milliseconds $every
172}
173Emit @{ ev = 'end' }
174`
175
176export type PollEvent =
177  | { ev: 'ready'; root: number; rootName?: string }
178  | { ev: 'open'; key: string; pid: number; name?: string; cmd?: string | null; addr: string; port: number; local: string; top: number; topStart: number }
179  | { ev: 'close'; key: string }
180  | { ev: 'names'; addr: string; names: string[] }
181  | { ev: 'tick' }
182  | { ev: 'end' }
183
184/** One line of the poller's output, or undefined for anything else it printed. */
185export function parsePollLine(line: string): PollEvent | undefined {
186  try {
187    const o = JSON.parse(line) as { ev?: unknown }
188    return typeof o === 'object' && o !== null && typeof o.ev === 'string' ? (o as PollEvent) : undefined
189  } catch {
190    return undefined
191  }
192}
193
194const PORTS: Record<number, string> = {
195  21: 'FTP', 22: 'SSH', 25: 'SMTP', 53: 'DNS over TCP', 80: 'HTTP', 110: 'POP3', 143: 'IMAP', 389: 'LDAP',
196  443: 'HTTPS', 445: 'SMB', 465: 'SMTPS', 587: 'SMTP', 636: 'LDAPS', 853: 'DNS over TLS', 993: 'IMAPS',
197  995: 'POP3S', 1433: 'SQL Server', 3306: 'MySQL', 3389: 'RDP', 5432: 'PostgreSQL', 5672: 'AMQP',
198  6379: 'Redis', 8080: 'HTTP (8080)', 8443: 'HTTPS (8443)', 9418: 'git', 27017: 'MongoDB',
199}
200
201/**
202 * A command line with its program named by file name alone
203 * (`"C:\Python\python.exe" x.py` → `python.exe x.py`): the folder a program
204 * lives in says little in a list and often names the user.
205 */
206export function shortCommand(cmd: string): string {
207  const m = cmd.trim().match(/^(?:"([^"]+)"|(\S+))([\s\S]*)$/)
208  if (m === null) return cmd
209  const program = m[1] ?? m[2] ?? ''
210  return `${program.split(/[\\/]/).pop() ?? program}${m[3] ?? ''}`
211}
212
213/** What a remote TCP port usually carries. */
214export function portProtocol(port: number): string {
215  return `${PORTS[port] ?? 'TCP'} (TCP ${port})`
216}
217
types/index.d.ts 135 lines
1/**
2 * `share`: file access over a UNC path (SMB/WebDAV), network traffic but no API or web request.
3 * `local`: a tool call with no known network access, kept so every prompt's calls are complete. */
4export type NetKind = 'model' | 'web' | 'shell' | 'mcp' | 'service' | 'share' | 'local'
5
6export type NetStatus = 'running' | 'ok' | 'error' | 'denied'
7
8export type NetDetail = { key: string; value: string }
9
10export type NetConn = {
11  id: string
12  seq: number
13  kind: NetKind
14  /** The tool that made it (`WebFetch`, `Bash`, `mcp__x__y`), or `model`. */
15  source: string
16  host: string
17  url?: string
18  protocol: string
19  /**
20   * `observed`: the engine reported it; `inferred`: read off a command;
21   * `uncertain`: the command starts a script or interpreter that may connect;
22   * `local`: no known network access.
23   */
24  confidence: 'observed' | 'inferred' | 'uncertain' | 'local'
25  /** Why it counts as a connection (what in the command pointed at the network). */
26  reason: string
27  startedAt: number
28  endedAt?: number
29  status: NetStatus
30  statusText?: string
31  bytes?: number
32  promptId?: string
33  agentId?: string
34  toolUseId?: string
35  /** The command or tool input that caused it. */
36  command?: string
37  /**
38   * Set on a connection the poller saw in the TCP table: it belongs to the
39   * tool call row with the same `toolUseId`.
40   */
41  seenByPoller?: true
42  details: NetDetail[]
43}
44
45export type NetPrompt = {
46  id: string
47  seq: number
48  kind: 'prompt' | 'command' | 'other'
49  text: string
50  at: number
51}
52
53export type NetView = {
54  mode: 'list' | 'detail' | 'prompts' | 'hosts'
55  selected?: string
56  filter: 'all' | NetKind
57  promptId?: string
58  host?: string
59  page: number
60  /** Whether lists show `local` tool calls; absent means hidden. */
61  showLocal?: boolean
62  /** Which hosts the by-host view lists; absent means those this session reached. */
63  hostSet?: 'session' | 'new' | 'known'
64  /** Which response a WebFetch detail shows: the tool's output (default) or the page's raw body. */
65  response?: 'tool' | 'raw'
66  /** Which part of a raw body the detail shows, from 0. */
67  rawPage?: number
68  /** The views left by drilling in, latest last: what `b` returns to. */
69  back?: NetView[]
70}
71
72/**
73 * A page's raw body, fetched again by the pane on request: a mod never sees
74 * the bytes WebFetch itself received, only the tool's processed output.
75 */
76export type NetRaw = {
77  state: 'loading' | 'ok' | 'error'
78  url: string
79  /** When the pane's own request started, in clock milliseconds. */
80  at: number
81  status?: number
82  headers?: Record<string, string>
83  /** The body as text, cut to the first `RAW_MAX` characters. */
84  text?: string
85  /** The body's full length in characters, before any cut. */
86  length?: number
87  error?: string
88}
89
90/**
91 * A network file system reachable through a local path: a mapped drive on
92 * Windows (`Z:` → `\\server\share`), a mount point on macOS and Linux
93 * (`/Volumes/share` → `//user@server/share`, smbfs).
94 */
95export type NetMount = {
96  /** `Z:` on Windows; the absolute mount point elsewhere. */
97  root: string
98  /** What is mounted: the UNC path, `//server/share`, `server:/export`, a URL. */
99  source: string
100  host: string
101  protocol: string
102  /** The file system type as `mount` names it (`cifs`, `nfs4`, `smbfs`), or `drive`. */
103  fsType: string
104}
105
106export type NetMounts = {
107  /** `unknown` until the first lookup answered. */
108  platform: 'windows' | 'posix' | 'unknown'
109  list: NetMount[]
110  /** When the list was last read, in clock milliseconds. */
111  at: number
112  /** Whether any lookup has answered yet; absent means no. */
113  isLoaded?: boolean
114  /** Why the last lookup failed, when it did. */
115  error?: string
116}
117
118declare module 'claude-code' {
119  interface PluginState {
120    'net-connections': {
121      conns: NetConn[]
122      prompts: NetPrompt[]
123      current: string | null
124      agents: Record<string, string>
125      view: NetView
126      /** Network drives and mounts, read at session start and every 10 minutes. */
127      mounts: NetMounts
128      /** Raw page bodies the pane fetched on request, by connection id. */
129      raw: Record<string, NetRaw>
130      /** Hosts no earlier session had seen, flagged for this session only. */
131      newHosts: string[]
132    }
133  }
134}
135