A live node map of every host your agent reaches: packets fly from your machine to github.com, npm, PyPI, MCP servers and unknown hosts, with a log and…


Watch the MP4 · Screenshot · Screenshot
A live node map of the hosts your agent's tool calls reach. Your machine sits in the centre of a radar; every remote host a call is aimed at (github.com, registry.npmjs.org, pypi.org, MCP servers, anything else) is a node on the ring. When a call goes out, a packet flies along the edge in braille sub-pixels at 30 fps and comes back green or red. A sweep turns, first-contact hosts pulse amber, and hosts you didn't expect are drawn red.
It is also a useful security view: a running log of what each tool call was aimed at, from which tool, to which host, and whether it worked. Observation only; it never blocks or changes a call.
It reads the tool calls, not the network: hosts are inferred from command lines, URLs and git config. That catches the destinations you'd want to know about (an unexpected registry, a proxy, a jump host, a curl to somewhere new), but it is best effort, not packet capture.
/trace opens the map pane (it runs mid-turn too)./trace hosts prints a summary: every host, call counts, failures, and which were unexpected./trace allow <host> marks a host as expected for the rest of the session./trace clear resets the session's trace.curl/wget (URL and bare-host operands, -x/--proxy)gh (honours GH_HOST; gh help/config/alias don't count)git push/pull/fetch/clone/ls-remote/remote update: remotes are resolved from the repository's config after the command runs, following cd, git -C, worktrees and submodules (.git files), pushurl for pushes, the branch's remote for a bare git push, and --all. git -c http.proxy=… is recorded too.npm/pnpm/yarn/bun/npx (--registry, NPM_CONFIG_REGISTRY, YARN_REGISTRY, tarball URLs)pip/uv/poetry/pipx (--index-url, --extra-index-url, --find-links, PIP_INDEX_URL and friends, wheel URLs)conda, brew, docker/podman pull/push/login/run, cargo (--git), go get/install (GOPROXY, including direct fetches), gem (--source), bundle, podssh/scp/sftp/rsync/mosh (including -J jump hosts, ProxyJump, and -L/-R/-W forward targets), nc/ncat/socat/telnetaws, gcloud, az, kubectl/helm (--server), terraformpython, node, psql, mongosh, redis-cli, ffmpeg, yt-dlp, …)ALL_PROXY, HTTPS_PROXY, …)$( … ), backticks, bash -c "…" and env -S are read too. Wrappers (sudo -u x, env -u X, nice -n 10, timeout, xargs) are seen through.echo, grep, cat, commit messages and heredoc bodies are ignored.mcp:<server>, drawn purple, with destination unknown: the mod can't see where a server connects.Colours:
localhost, 127.0.0.1, ::1) and unresolved remotes. These never count as unexpected.Expected hosts: GitHub, npm, Yarn, PyPI, Anaconda, crates.io, the Go proxy, RubyGems, Homebrew, Docker Hub, GHCR, CocoaPods and Anthropic, plus anything in the plugin's Expected hosts setting or added with /trace allow. Entries can be corp.com, *.corp.com or a URL; subdomains count.
Unexpected hosts are always pinned on the map, in the ticker and in a list above the map. When there are more than 16 hosts, the rest are counted as "+N more hosts".
Limits:
kubectl without --server shows as kube-apiserver.The map needs a terminal; on the desktop app the pane shows the same hosts and log as text. Wider terminals get a bigger radar.
/plugin marketplace add ccdwyer/claude-mods
/plugin install netrunner-trace@ccdwyer-mods
/reload-plugins
session.start, session.endcommand.run{command=trace}tool.call (observes; never refuses or rewrites a call)ui.render{component=Pane, requestId=netrunner-trace}ui.render{component=AbovePrompt} (composes with other mods' bands)Engine calls it makes: $.clock.every, $.clock.now, $.command.register, $.fs.read and $.fs.stat (git metadata only: .git, config, HEAD, commondir), $.session.cwd, $.state.get, $.state.set, $.ui.blit, $.ui.open, $.ui.resolve.
Runs entirely on your machine and sends nothing over the network. It never stores command arguments, URL paths, query strings or headers. Full policy: PRIVACY.md.
MIT
hooks/register.tsx 533 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { HostInfo, TraceEvent } from '../types'
5import { DEFAULT_ALLOW, classifyBash, clean, defaultRemote, hostOfRemote, hostOfUrl, isLocal, normalizeHost, parseAllow, remoteUrls, remotesOf } from './net'
6import type { GitRemote, Target } from './net'
7import { PACKET_MS, drawMap, encodeCells } from './map'
8import type { MapNode, NodeStyle, Packet } from './map'
9
10const events = atom({ plugin: 'netrunner-trace', key: 'events' } as const, [])
11const hosts = atom({ plugin: 'netrunner-trace', key: 'hosts' } as const, {})
12const allow = atom({ plugin: 'netrunner-trace', key: 'allow' } as const, [])
13const calls = atom({ plugin: 'netrunner-trace', key: 'calls' } as const, 0)
14
15const PANE = 'netrunner-trace'
16const KEEP = 200
17const FLASH_MS = 3000
18const FRAME_MS = 33
19const MAP_SLOTS = 16
20const MAX_COLS = 160
21const MAX_ROWS = 32
22const UNKNOWN = new Set(['git:unresolved', 'kube-apiserver'])
23// Hosts kept in the inventory; the least recently seen expected ones give way first.
24const MAX_HOSTS = 200
25// With nothing moving, the map repaints at most this often.
26const IDLE_MS = 400
27
28// Animation lives in module memory: packets and flashes are seconds long, so a
29// reload losing them costs nothing. What exists (events, hosts) is in $.state.
30let packets: Packet[] = []
31const flashes = new Map<string, number>()
32let mounted: { columns: number; rows: number } | null = null
33let timer: { cancel: () => void } | null = null
34let misses = 0
35let seq = 0
36let configured: string[] = []
37let ticker = true
38let home: string | null = null
39let lastBlit = 0
40// The allowlist, normalised once per change, and the map's node choice, recomputed only when hosts change.
41let allowCache: { key: string; list: string[] } | null = null
42let nodeCache: { hosts: Record<string, HostInfo>; allowKey: string; shown: [string, HostInfo][]; hidden: number } | null = null
43
44const short = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + '~' : s)
45
46// `list` is already normalised (see allowList), so this is a plain suffix check.
47function allowedBy(host: string, list: readonly string[]): boolean {
48 return isLocal(host) || list.some(entry => entry.length > 0 && (host === entry || host.endsWith('.' + entry)))
49}
50
51function expected(host: string, list: readonly string[]): boolean {
52 return host.startsWith('mcp:') || host === 'web-search' || UNKNOWN.has(host) || allowedBy(host, list)
53}
54
55// Host names are data: stored keys carry a prefix, so no host ("constructor", "__proto__") is ever a special
56// or inherited property, in memory or once the state is serialised.
57const KEY = 'host:'
58
59function own(all: Record<string, HostInfo>, host: string): HostInfo | undefined {
60 return Object.hasOwn(all, KEY + host) ? all[KEY + host] : undefined
61}
62
63function setOwn(all: Record<string, HostInfo>, host: string, info: HostInfo): void {
64 all[KEY + host] = info
65}
66
67function hostEntries(all: Record<string, HostInfo>): [string, HostInfo][] {
68 return Object.entries(all)
69 .filter(([k]) => k.startsWith(KEY))
70 .map(([k, v]) => [k.slice(KEY.length), v])
71}
72
73function styleOf(host: string, info: HostInfo, list: readonly string[]): NodeStyle {
74 if (host.startsWith('mcp:')) return 'mcp'
75 if (host === 'web-search') return 'web'
76 if (UNKNOWN.has(host)) return 'unknown'
77 if (isLocal(host)) return 'local'
78 if (!allowedBy(host, list)) return 'flagged'
79 if (info.lastStatus === 'error') return 'error'
80 return 'ok'
81}
82
83// Up to MAP_SLOTS nodes: unexpected hosts always, then the most recent. The choice is cached until the hosts
84// or the allowlist change, so a frame costs the drawn nodes, not the whole inventory.
85function pickNodes(all: Record<string, HostInfo>, list: readonly string[]): { nodes: MapNode[]; hidden: number } {
86 const allowKey = list.join(',')
87 if (nodeCache === null || nodeCache.hosts !== all || nodeCache.allowKey !== allowKey) {
88 const entries = hostEntries(all)
89 const ranked = entries.sort((a, b) => {
90 const fa = expected(a[0], list) ? 1 : 0
91 const fb = expected(b[0], list) ? 1 : 0
92 return fa - fb || b[1].last - a[1].last
93 })
94 const shown = ranked.slice(0, MAP_SLOTS)
95 nodeCache = { hosts: all, allowKey, shown, hidden: Math.max(0, entries.length - shown.length) }
96 }
97 const nodes = nodeCache.shown.map(([host, info]) => ({ host, style: styleOf(host, info, list), flashUntil: flashes.get(host) ?? 0, count: info.count, slot: info.slot }))
98 return { nodes, hidden: nodeCache.hidden }
99}
100
101function clock(at: number): string {
102 const d = new Date(at)
103 const p = (n: number) => String(n).padStart(2, '0')
104 return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
105}
106
107function mark(status: TraceEvent['status']): string {
108 if (status === 'ok') return '+'
109 if (status === 'error') return 'x'
110 if (status === 'denied') return '-'
111 return '>'
112}
113
114// Targets known from the call itself, before it runs (no file reads).
115function directTargets(call: { tool: string; [k: string]: unknown }, cwd: string | null, homeDir: string | null): { targets: Target[]; remotes: GitRemote[] } {
116 const tool = call.tool
117 if (tool === 'WebFetch') {
118 const host = hostOfUrl(String(call.url ?? ''))
119 return { targets: host ? [{ host, kind: 'web', label: 'WebFetch' }] : [], remotes: [] }
120 }
121 if (tool === 'WebSearch') return { targets: [{ host: 'web-search', kind: 'search', label: 'WebSearch' }], remotes: [] }
122 if (tool.startsWith('mcp__')) {
123 const server = clean(tool.split('__')[1] ?? 'mcp').toLowerCase()
124 return { targets: [{ host: `mcp:${server}`, kind: 'mcp', label: 'MCP call' }], remotes: [] }
125 }
126 if (tool !== 'Bash') return { targets: [], remotes: [] }
127 return classifyBash(String(call.command ?? ''), cwd, homeDir)
128}
129
130// The git directory for a working dir: walk up to a .git folder or a .git file
131// (worktrees, submodules), and its common dir for the shared config.
132async function gitDirs($: EngineInterface, start: string): Promise<{ config: string | null; head: string | null }> {
133 let dir = start
134 for (let i = 0; i < 40 && dir.length > 0; i += 1) {
135 const dotgit = `${dir}/.git`
136 let kind: string | null = null
137 try {
138 kind = (await $.fs.stat(dotgit)).kind
139 } catch {
140 kind = null
141 }
142 if (kind === 'dir') return { config: await readOr($, `${dotgit}/config`), head: await readOr($, `${dotgit}/HEAD`) }
143 if (kind === 'file') {
144 const pointer = (await readOr($, dotgit)) ?? ''
145 const m = /^gitdir:\s*(.+?)\s*$/m.exec(pointer)
146 if (!m) return { config: null, head: null }
147 const gitdir = (m[1] as string).startsWith('/') ? (m[1] as string) : `${dir}/${m[1]}`
148 const common = ((await readOr($, `${gitdir}/commondir`)) ?? '').trim()
149 const commonDir = common === '' ? gitdir : common.startsWith('/') ? common : `${gitdir}/${common}`
150 return { config: await readOr($, `${commonDir}/config`), head: await readOr($, `${gitdir}/HEAD`) }
151 }
152 const up = dir.replace(/\/[^/]*$/, '')
153 if (up === dir) break
154 dir = up
155 }
156 return { config: null, head: null }
157}
158
159async function readOr($: EngineInterface, path: string): Promise<string | null> {
160 try {
161 return await $.fs.read(path)
162 } catch {
163 return null
164 }
165}
166
167// Hosts behind remotes the command named without a URL. Read-only.
168async function resolveRemotes($: EngineInterface, remotes: readonly GitRemote[], cwd: string | null): Promise<Target[]> {
169 const out: Target[] = []
170 for (const r of remotes) {
171 const start = r.dir ?? cwd
172 const { config, head } = start ? await gitDirs($, start) : { config: null, head: null }
173 const cli = r.config ?? []
174 const names = config === null ? [] : r.all ? [...remotesOf(config).keys()] : [r.remote || defaultRemote(config, head, r.op, cli) || '']
175 let found = false
176 for (const name of names) {
177 // Every URL the operation uses (all pushurls for a push), with insteadOf rewrites applied.
178 for (const url of config === null || !name ? [] : remoteUrls(config, name, r.op, cli)) {
179 const host = hostOfRemote(url)
180 if (host && !out.some(t => t.host === host)) {
181 out.push({ host, kind: 'bash', label: r.op === 'push' ? 'git push' : 'git fetch' })
182 found = true
183 }
184 }
185 }
186 if (!found) out.push({ host: 'git:unresolved', kind: 'bash', label: 'git' })
187 }
188 return out
189}
190
191async function allowList($: EngineInterface): Promise<string[]> {
192 const extra = await read($, allow)
193 const key = extra.join(',')
194 if (allowCache === null || allowCache.key !== key) {
195 allowCache = { key, list: [...DEFAULT_ALLOW, ...configured, ...extra].map(normalizeHost).filter(Boolean) }
196 }
197 return allowCache.list
198}
199
200function launch(host: string, dir: 'out' | 'in', ok: boolean, at: number) {
201 packets.push({ host, dir, ok, startedAt: at })
202 if (packets.length > 64) packets = packets.slice(-64)
203}
204
205// Record targets as pending events and bump their hosts; returns the event ids.
206// `at` is when the call started (what the log shows); packets and first-contact flashes start now.
207async function recordStart($: EngineInterface, targets: readonly Target[], tool: string, agent: string | undefined, at: number): Promise<string[]> {
208 const now = await $.clock.now()
209 const ids = targets.map(() => `t${at}-${(seq += 1)}`)
210 const before = await read($, hosts)
211 await update($, events, cur => {
212 const added = targets.map((t, i): TraceEvent => ({
213 id: ids[i] as string,
214 at,
215 tool: clean(tool),
216 host: t.host,
217 kind: t.kind,
218 label: short(clean(t.label), 24),
219 status: 'pending',
220 ...(agent !== undefined ? { agent } : {}),
221 }))
222 const all = [...cur, ...added]
223 // Trim finished rows only, so a pending row is never dropped before it resolves.
224 let excess = all.length - KEEP
225 return excess <= 0 ? all : all.filter(ev => (excess > 0 && ev.status !== 'pending' ? ((excess -= 1), false) : true))
226 })
227 const list = await allowList($)
228 await update($, hosts, all => {
229 const out: Record<string, HostInfo> = { ...all }
230 let next = Math.max(-1, ...Object.values(out).map(h => h.slot)) + 1
231 for (const t of targets) {
232 const was = own(out, t.host)
233 setOwn(out, t.host, was
234 ? { ...was, last: at, count: was.count + 1, lastStatus: 'pending' }
235 : { first: at, last: at, count: 1, errors: 0, lastStatus: 'pending', slot: next++ })
236 }
237 // Bounded: the least recently seen expected hosts give way first, unexpected ones last.
238 const known = hostEntries(out)
239 if (known.length > MAX_HOSTS) {
240 const drop = known
241 .sort((a, b) => (expected(a[0], list) ? 0 : 1) - (expected(b[0], list) ? 0 : 1) || a[1].last - b[1].last)
242 .slice(0, known.length - MAX_HOSTS)
243 for (const [host] of drop) delete out[KEY + host]
244 }
245 return out
246 })
247 for (const t of targets) {
248 if (own(before, t.host) === undefined) flashes.set(t.host, now + FLASH_MS)
249 launch(t.host, 'out', true, now)
250 }
251 return ids
252}
253
254async function recordEnd($: EngineInterface, ids: readonly string[], targets: readonly Target[], status: TraceEvent['status'], started: number) {
255 const ended = await $.clock.now()
256 await update($, events, cur => cur.map(ev => (ids.includes(ev.id) ? { ...ev, status, ms: ended - started } : ev)))
257 await update($, hosts, all => {
258 const out: Record<string, HostInfo> = { ...all }
259 for (const t of targets) {
260 const was = own(out, t.host)
261 if (was) setOwn(out, t.host, { ...was, lastStatus: status, errors: was.errors + (status === 'error' ? 1 : 0) })
262 }
263 return out
264 })
265 if (status !== 'denied') for (const t of targets) launch(t.host, 'in', status === 'ok', ended)
266}
267
268// Draw the next frame into the mounted map; stop when the pane is gone.
269async function frame($: EngineInterface) {
270 if (mounted === null) {
271 stopAnimation()
272 return
273 }
274 const now = await $.clock.now()
275 packets = packets.filter(p => now - p.startedAt < PACKET_MS)
276 for (const [host, until] of flashes) if (until < now) flashes.delete(host)
277 // Nothing in flight and no ring pulsing: repaint rarely.
278 if (packets.length === 0 && flashes.size === 0 && now - lastBlit < IDLE_MS) return
279 lastBlit = now
280 const all = await read($, hosts)
281 const { nodes, hidden } = pickNodes(all, await allowList($))
282 const { columns, rows } = mounted
283 const cells = encodeCells(drawMap(columns, rows, nodes, packets, now, hidden))
284 const res = await $.ui.blit({ requestId: PANE, key: 'map', cells, columns, rows })
285 if (res.deny !== undefined) {
286 misses += 1
287 if (misses > 60) {
288 mounted = null
289 stopAnimation()
290 }
291 } else {
292 misses = 0
293 }
294}
295
296function stopAnimation() {
297 if (timer !== null) timer.cancel()
298 timer = null
299 misses = 0
300}
301
302function startAnimation($: EngineInterface) {
303 if (timer !== null) return
304 misses = 0
305 timer = $.clock.every(FRAME_MS, () => frame($))
306}
307
308async function reset($: EngineInterface) {
309 await update($, events, () => [])
310 await update($, hosts, () => ({}))
311 await update($, calls, () => 0)
312 packets = []
313 flashes.clear()
314}
315
316function summary(all: Record<string, HostInfo>, total: number, list: readonly string[]): string {
317 const names = hostEntries(all).sort((a, b) => a[1].first - b[1].first)
318 if (names.length === 0) return 'Netrunner Trace: no outbound traffic this session.'
319 const flagged = names.filter(([host]) => !expected(host, list)).map(([host]) => host)
320 const lines = names.map(([host, info]) => `${!expected(host, list) ? '!' : ' '} ${host} ${info.count} call${info.count === 1 ? '' : 's'}${info.errors ? `, ${info.errors} failed` : ''}${host.startsWith('mcp:') ? ' (destination unknown)' : ''}`)
321 return [
322 `Netrunner Trace: ${total} outbound call${total === 1 ? '' : 's'} to ${names.length} host${names.length === 1 ? '' : 's'}${flagged.length ? `, ${flagged.length} unexpected (${flagged.join(', ')})` : ''}.`,
323 ...lines,
324 ].join('\n')
325}
326
327export const register: Register = (on, options) => {
328 configured = parseAllow(String(options.allowedHosts ?? ''))
329 ticker = options.showTicker !== false
330
331 on('session.start', async ($, e, next) => {
332 await $.command.register({
333 name: 'trace',
334 description: 'Netrunner Trace: open the live map of hosts the agent reaches (clear, allow <host>, hosts)',
335 argumentHint: '[clear | allow <host> | hosts]',
336 immediate: true,
337 })
338 return next(e)
339 })
340
341 on('session.end', ($, e, next) => {
342 mounted = null
343 stopAnimation()
344 return next(e)
345 })
346
347 on('command.run', { command: 'trace' }, async ($, e) => {
348 const [verb, arg] = e.args.trim().split(/\s+/)
349 if (verb === 'clear') {
350 await reset($)
351 return { text: 'Netrunner Trace: cleared.' }
352 }
353 if (verb === 'allow' && arg) {
354 const host = normalizeHost(arg)
355 await update($, allow, list => (list.includes(host) ? list : [...list, host]))
356 return { text: `Netrunner Trace: ${host} (and its subdomains) is expected for the rest of this session.` }
357 }
358 const all = await read($, hosts)
359 const total = await read($, calls)
360 const list = await allowList($)
361 if (verb === 'hosts') return { text: summary(all, total, list) }
362 // 46 = header + unexpected banner + 32-row map + log rule + 10 log lines + clear button
363 const opened = await $.ui.open({ id: PANE, title: 'Netrunner Trace', rows: 46 })
364 startAnimation($)
365 return { text: opened.isPlaced === false ? `${summary(all, total, list)}\n(Widen the terminal to see the map.)` : 'Netrunner Trace: map open.' }
366 })
367
368 on('tool.call', async ($, e, next) => {
369 // Bookkeeping never stands between the agent and its tool: every step
370 // around `next` is best effort, and `next` runs exactly once.
371 let cwd: string | null = null
372 let direct: Target[] = []
373 let ids: string[] = []
374 let started = 0
375 try {
376 // Only a shell command needs the folder it runs in (cd, git -C, a repo's remotes).
377 if (e.tool === 'Bash') {
378 cwd = await $.session.cwd()
379 if (home === null) {
380 try {
381 home = (await $.env.get('HOME')) ?? ''
382 } catch {
383 home = ''
384 }
385 }
386 }
387 const found = directTargets(e as { tool: string; [k: string]: unknown }, cwd, home === '' ? null : home)
388 direct = found.targets
389 if (direct.length > 0 || found.remotes.length > 0) {
390 started = await $.clock.now()
391 await update($, calls, n => n + 1)
392 // Remotes named without a URL are resolved now, from the config as it is before the command runs,
393 // so the map shows where a push is going while it runs.
394 if (found.remotes.length > 0) {
395 const resolved = await resolveRemotes($, found.remotes, cwd)
396 direct = [...direct, ...resolved.filter(t => !direct.some(d => d.host === t.host))]
397 }
398 if (direct.length > 0) ids = await recordStart($, direct, e.tool, e.agentId, started)
399 }
400 } catch {
401 direct = []
402 ids = []
403 }
404
405 let ran
406 try {
407 ran = await next(e)
408 } catch (err) {
409 try {
410 if (ids.length > 0) await recordEnd($, ids, direct, 'error', started)
411 } catch {
412 // best effort
413 }
414 throw err
415 }
416
417 try {
418 const status: TraceEvent['status'] = ran.deny !== undefined ? 'denied' : ran.isError === true ? 'error' : 'ok'
419 if (ids.length > 0) await recordEnd($, ids, direct, status, started)
420 } catch {
421 // best effort
422 }
423 return ran
424 })
425
426 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
427 const all = await read($, hosts)
428 const list = await read($, events)
429 const total = await read($, calls)
430 const allowed = await allowList($)
431 const now = await $.clock.now()
432 const names = hostEntries(all).map(([n]) => n)
433 const flagged = names.filter(n => !expected(n, allowed))
434 const head = `NETRUNNER TRACE ${total} call${total === 1 ? '' : 's'} · ${names.length} host${names.length === 1 ? '' : 's'} · ${flagged.length} unexpected`
435 const width = Math.max(30, e.props.bodyColumns)
436 const viewRows = e.viewport?.rows ?? 30
437 const logRows = Math.max(4, Math.min(10, Math.floor(viewRows / 4)))
438 const recent = list.slice(-logRows).reverse()
439 const logLine = (ev: TraceEvent) =>
440 short(`${clock(ev.at)} ${mark(ev.status)} ${ev.tool.startsWith('mcp__') ? 'MCP' : ev.tool} ${ev.label} -> ${ev.host}${ev.ms !== undefined ? ` ${(ev.ms / 1000).toFixed(1)}s` : ''}${ev.agent ? ' (subagent)' : ''}`, width)
441 const tone = (ev: TraceEvent) => (!expected(ev.host, allowed) ? 'red' : ev.status === 'error' ? 'yellow' : 'green')
442
443 if (e.surface === 'terminal') {
444 const { Box, Text, Raster, Button } = $.ui.resolve(e)
445 const columns = Math.min(MAX_COLS, width)
446 const rows = Math.max(10, Math.min(MAX_ROWS, viewRows - logRows - 7))
447 mounted = { columns, rows }
448 startAnimation($)
449 const { nodes, hidden } = pickNodes(all, allowed)
450 const cells = encodeCells(drawMap(columns, rows, nodes, packets, now, hidden))
451 return (
452 <Box flexDirection="column">
453 <Text color="green" bold>
454 {head}
455 </Text>
456 {flagged.length > 0 && <Text color="red">{short(`! unexpected: ${flagged.join(', ')}`, width)}</Text>}
457 <Raster key="map" columns={columns} rows={rows} cells={cells} />
458 <Text color="green" dimColor>
459 {'─'.repeat(Math.min(width - 4, 60))} log
460 </Text>
461 {recent.length === 0 && <Text dimColor>waiting for outbound calls…</Text>}
462 {recent.map(ev => (
463 <Text key={ev.id} color={tone(ev)}>
464 {logLine(ev)}
465 </Text>
466 ))}
467 <Box>
468 <Button key="clear" label="clear" onPress={() => reset($)} />
469 </Box>
470 </Box>
471 )
472 }
473
474 // Surfaces without Raster get the same facts as text.
475 const { Box, Text, Button } = $.ui.resolve(e)
476 return (
477 <Box flexDirection="column">
478 <Text bold>{head}</Text>
479 {names.length === 0 && <Text>No outbound traffic yet.</Text>}
480 {names.map(n => (
481 <Text key={n}>
482 {!expected(n, allowed) ? '! ' : ' '}
483 {n} · {own(all, n)?.count} call{own(all, n)?.count === 1 ? '' : 's'}
484 {own(all, n)?.errors ? `, ${own(all, n)?.errors} failed` : ''}
485 </Text>
486 ))}
487 {recent.map(ev => (
488 <Text key={ev.id}>{logLine(ev)}</Text>
489 ))}
490 <Box>
491 <Button key="clear" label="clear" onPress={() => reset($)} />
492 </Box>
493 </Box>
494 )
495 })
496
497 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
498 if (!ticker || e.props.hasSurvey) return next(e)
499 const list = await read($, events)
500 if (list.length === 0) return next(e)
501 const all = await read($, hosts)
502 const allowed = await allowList($)
503 // The three most recently seen unexpected hosts are pinned first, then the most recent others.
504 const flagged = hostEntries(all)
505 .filter(([host]) => !expected(host, allowed))
506 .sort((a, b) => b[1].last - a[1].last)
507 .map(([host]) => host)
508 const seen: string[] = [...flagged.slice(0, 3)]
509 for (let i = list.length - 1; i >= 0 && seen.length < 4; i -= 1) {
510 const host = (list[i] as TraceEvent).host
511 if (!seen.includes(host)) seen.push(host)
512 }
513 const below = await next(e)
514 const { Box, Text } = $.ui.resolve(e)
515 return (
516 <Box flexDirection="column">
517 <Box>
518 <Text color="green">⇄ trace </Text>
519 {seen.map(host => (
520 <Text key={host} color={flagged.includes(host) ? 'red' : 'green'} dimColor={!flagged.includes(host)}>
521 {flagged.includes(host) ? '! ' : ''}
522 {short(host, 28)}
523 {' '}
524 </Text>
525 ))}
526 <Text dimColor>· /trace</Text>
527 </Box>
528 {below}
529 </Box>
530 )
531 })
532}
533hooks/net.ts 768 lines1// What leaves the machine: best-effort classification of a tool call into the
2// remote hosts it is likely to reach, read from the command text. No `$` here,
3// so it is easy to test and the directory can read it plainly.
4//
5// Labels are built only from fixed program and subcommand names, never from
6// arguments, so tokens and paths in a command line are not recorded.
7
8export type Kind = 'bash' | 'web' | 'search' | 'mcp'
9
10export type Target = { host: string; kind: Kind; label: string }
11
12// A git remote the command uses without spelling out a URL: resolved later
13// from the repository's config. `dir` is where git runs (from cd / -C), when known.
14export type GitRemote = { remote: string; op: 'push' | 'fetch'; dir: string | null; all: boolean; config?: string[] }
15
16export const DEFAULT_ALLOW = [
17 'github.com', 'githubusercontent.com', 'githubassets.com', 'ghcr.io',
18 'registry.npmjs.org', 'npmjs.org', 'registry.yarnpkg.com',
19 'pypi.org', 'pythonhosted.org', 'anaconda.com', 'anaconda.org',
20 'crates.io', 'proxy.golang.org', 'sum.golang.org', 'rubygems.org',
21 'formulae.brew.sh', 'ghcr.io', 'docker.io', 'cdn.cocoapods.org', 'anthropic.com', 'claude.ai',
22]
23
24// Hosts that never leave the machine.
25export function isLocal(host: string): boolean {
26 const h = host.replace(/^\[|\]$/g, '')
27 return h === 'localhost' || h.endsWith('.localhost') || h === '::1' || h === '0.0.0.0' || /^127\.\d+\.\d+\.\d+$/.test(h)
28}
29
30// Strip control and bidi characters before a host or label is shown or stored.
31export function clean(s: string): string {
32 return s.replace(/[\u0000-\u001f\u007f-\u009f---]/g, '')
33}
34
35// The host of a URL with any scheme: the authority ends at / ? # and userinfo
36// is only what comes before an @ inside that authority.
37export function hostOfUrl(url: string): string | null {
38 const m = /^([a-z][a-z0-9+.-]*):\/\/([^/?#\s]*)/i.exec(url.trim())
39 if (!m) return null
40 if ((m[1] as string).toLowerCase() === 'file') return null
41 const authority = m[2] as string
42 const hostport = authority.includes('@') ? authority.slice(authority.lastIndexOf('@') + 1) : authority
43 const host = hostport.startsWith('[') ? hostport.slice(0, hostport.indexOf(']') + 1) : (hostport.split(':')[0] as string)
44 const h = clean(host).toLowerCase().replace(/\.$/, '')
45 return h.length > 0 ? h : null
46}
47
48// A host named without a scheme (`user:pass@proxy:8080`, `proxy.example`): read as an authority so userinfo
49// (a password or a token) is dropped, and refused unless what is left looks like a host name or address.
50export function hostOfAuthority(value: string): string | null {
51 const v = value.trim()
52 const h = hostOfUrl(v) ?? hostOfUrl(`http://${v}`)
53 if (h === null) return null
54 return /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/.test(h) || /^\[[0-9a-f:.]+\]$/.test(h) ? h : null
55}
56
57const URL_SCAN = /\b[a-z][a-z0-9+.-]*:\/\/[^\s'"`<>]+/gi
58
59function urlHosts(text: string): string[] {
60 const out: string[] = []
61 for (const m of text.matchAll(URL_SCAN)) {
62 const h = hostOfUrl(m[0])
63 if (h) out.push(h)
64 }
65 return out
66}
67
68// A comma- or pipe-separated list of URLs (GOPROXY, extra index lists).
69function listHosts(value: string): string[] {
70 return value.split(/[,|\s]+/).map(v => hostOfUrl(v)).filter((h): h is string => h !== null)
71}
72
73// Shell words of one simple command: quotes removed, no expansion.
74export function words(segment: string): string[] {
75 const out: string[] = []
76 let cur = ''
77 let quote: '"' | "'" | null = null
78 let started = false
79 for (let i = 0; i < segment.length; i += 1) {
80 const ch = segment[i] as string
81 if (quote) {
82 if (ch === quote) quote = null
83 else if (ch === '\\' && quote === '"' && i + 1 < segment.length) cur += segment[(i += 1)]
84 else cur += ch
85 continue
86 }
87 if (ch === '\\' && i + 1 < segment.length) {
88 cur += segment[(i += 1)]
89 started = true
90 continue
91 }
92 if (ch === '"' || ch === "'") {
93 quote = ch
94 started = true
95 continue
96 }
97 if (/\s/.test(ch)) {
98 if (started || cur) out.push(cur)
99 cur = ''
100 started = false
101 continue
102 }
103 cur += ch
104 started = true
105 }
106 if (started || cur) out.push(cur)
107 return out
108}
109
110// Take heredoc bodies out of the command line. A body fed to a shell (`bash <<EOF`) is commands, and an
111// unquoted body still expands `$(…)` and backticks: both come back as `nested` command lines to classify.
112// Any other body is input to a command and never runs.
113export function stripHeredocs(command: string): { text: string; nested: string[] } {
114 const lines = command.split('\n')
115 const out: string[] = []
116 const nested: string[] = []
117 let open: { end: string; quoted: boolean; shell: boolean; body: string[] }[] = []
118 for (const line of lines) {
119 if (open.length > 0) {
120 const cur = open[0] as { end: string; quoted: boolean; shell: boolean; body: string[] }
121 if (line.replace(/^\t+/, '') === cur.end) {
122 const body = cur.body.join('\n')
123 if (cur.shell) nested.push(body)
124 else if (!cur.quoted) for (const m of body.matchAll(/\$\(([^()]*(?:\([^()]*\)[^()]*)*)\)|`([^`]*)`/g)) nested.push((m[1] ?? m[2] ?? '') as string)
125 open = open.slice(1)
126 } else cur.body.push(line)
127 continue
128 }
129 out.push(line)
130 for (const m of line.matchAll(/<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1/g)) {
131 const before = line.slice(0, m.index ?? 0)
132 const shell = /(^|[;&|(]\s*)(?:sudo\s+)?(?:\S*\/)?(?:ba|z|da|k)?sh(\s+-[a-z]+)*\s*$/.test(before)
133 open.push({ end: m[2] as string, quoted: (m[1] ?? '') !== '', shell, body: [] })
134 }
135 }
136 return { text: out.join('\n'), nested }
137}
138
139// Split a command line into simple commands at && || ; | & and newlines,
140// outside quotes, and lift $( … ) and ` … ` bodies out as their own commands.
141export function segments(command: string): string[] {
142 const out: string[] = []
143 const inner: string[] = []
144 let cur = ''
145 let quote: '"' | "'" | null = null
146 const stripped = stripHeredocs(command)
147 const text = stripped.text
148 inner.push(...stripped.nested)
149 for (let i = 0; i < text.length; i += 1) {
150 const ch = text[i] as string
151 if (quote === "'") {
152 if (ch === "'") quote = null
153 cur += ch
154 continue
155 }
156 if (ch === '\\' && i + 1 < text.length) {
157 cur += ch + (text[(i += 1)] as string)
158 continue
159 }
160 if (ch === '$' && text[i + 1] === '(' && text[i + 2] !== '(') {
161 let depth = 1
162 let j = i + 2
163 while (j < text.length && depth > 0) {
164 if (text[j] === '(') depth += 1
165 else if (text[j] === ')') depth -= 1
166 j += 1
167 }
168 inner.push(text.slice(i + 2, j - 1))
169 cur += '"$SUB"'
170 i = j - 1
171 continue
172 }
173 if (ch === '`') {
174 const j = text.indexOf('`', i + 1)
175 const end = j === -1 ? text.length : j
176 inner.push(text.slice(i + 1, end))
177 cur += '"$SUB"'
178 i = end
179 continue
180 }
181 if (quote === '"') {
182 if (ch === '"') quote = null
183 cur += ch
184 continue
185 }
186 if (ch === '"' || ch === "'") {
187 quote = ch
188 cur += ch
189 continue
190 }
191 if (ch === ';' || ch === '\n' || ch === '|' || ch === '&' || ch === '(' || ch === ')' || ch === '{' || ch === '}') {
192 if (cur.trim()) out.push(cur.trim())
193 cur = ''
194 if ((ch === '|' || ch === '&') && text[i + 1] === ch) i += 1
195 continue
196 }
197 cur += ch
198 }
199 if (cur.trim()) out.push(cur.trim())
200 for (const body of inner) out.push(...segments(body))
201 return out
202}
203
204// Wrapper programs and the flags of theirs that take a separate argument.
205const WRAPPERS: Record<string, Set<string>> = {
206 sudo: new Set(['-u', '-g', '-h', '-p', '-C', '-D', '-r', '-t', '-U', '-T', '--user', '--group', '--host', '--prompt', '--chdir']),
207 doas: new Set(['-u', '-C']),
208 env: new Set(['-u', '-C', '--unset', '--chdir']),
209 nice: new Set(['-n', '--adjustment']),
210 nohup: new Set(),
211 time: new Set(['-f', '-o', '--format', '--output']),
212 timeout: new Set(['-s', '-k', '--signal', '--kill-after']),
213 caffeinate: new Set(['-t', '-w']),
214 xargs: new Set(['-I', '-n', '-P', '-L', '-s', '-E', '-d', '-a']),
215 command: new Set(),
216 exec: new Set(['-a']),
217 stdbuf: new Set(['-i', '-o', '-e']),
218}
219
220type Program = { argv: string[]; env: Record<string, string>; nested: string[] }
221
222// Drop leading VAR=value assignments and wrappers; return the program's argv,
223// the env it set, and any command line a wrapper carries as a string (env -S, sh -c).
224function program(input: string[]): Program {
225 const env: Record<string, string> = {}
226 const nested: string[] = []
227 let argv = input
228 for (let guard = 0; guard < 8; guard += 1) {
229 let i = 0
230 while (i < argv.length) {
231 const assign = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(argv[i] as string)
232 if (!assign) break
233 env[assign[1] as string] = assign[2] as string
234 i += 1
235 }
236 argv = argv.slice(i)
237 const name = base(argv[0] ?? '')
238 const takes = WRAPPERS[name]
239 if (takes === undefined) break
240 i = 1
241 while (i < argv.length) {
242 const a = argv[i] as string
243 if (a === '--') {
244 i += 1
245 break
246 }
247 if (name === 'env' && (a === '-S' || a === '--split-string')) {
248 nested.push(argv[i + 1] ?? '')
249 i += 2
250 continue
251 }
252 if (name === 'env' && /^[A-Za-z_][A-Za-z0-9_]*=/.test(a)) {
253 const eq = a.indexOf('=')
254 env[a.slice(0, eq)] = a.slice(eq + 1)
255 i += 1
256 continue
257 }
258 if (!a.startsWith('-')) {
259 if (name === 'timeout' && /^\d/.test(a)) {
260 i += 1
261 continue
262 }
263 break
264 }
265 i += takes.has(a) ? 2 : 1
266 }
267 argv = argv.slice(i)
268 }
269 const name = base(argv[0] ?? '')
270 if (['sh', 'bash', 'zsh', 'dash', 'fish'].includes(name)) {
271 const c = argv.indexOf('-c')
272 const lc = argv.findIndex(a => /^-[a-z]*c[a-z]*$/.test(a))
273 const at = c !== -1 ? c : lc
274 if (at !== -1 && argv[at + 1] !== undefined) nested.push(argv[at + 1] as string)
275 }
276 if (name === 'eval') nested.push(argv.slice(1).join(' '))
277 return { argv, env, nested }
278}
279
280function flagValues(argv: string[], names: readonly string[]): string[] {
281 const out: string[] = []
282 for (let i = 0; i < argv.length; i += 1) {
283 const a = argv[i] as string
284 for (const n of names) {
285 if (a === n && argv[i + 1] !== undefined) out.push(argv[i + 1] as string)
286 else if (a.startsWith(n + '=')) out.push(a.slice(n.length + 1))
287 else if (n.length === 2 && !n.startsWith('--') && a.startsWith(n) && a.length > 2 && !a.startsWith('--')) out.push(a.slice(2))
288 }
289 }
290 return out
291}
292
293// Positional arguments, skipping the values of flags that take one.
294function positionals(argv: string[], takesValue: ReadonlySet<string>): string[] {
295 const out: string[] = []
296 for (let i = 0; i < argv.length; i += 1) {
297 const a = argv[i] as string
298 if (a === '--') {
299 out.push(...argv.slice(i + 1))
300 break
301 }
302 if (a.startsWith('-')) {
303 if (takesValue.has(a)) i += 1
304 continue
305 }
306 out.push(a)
307 }
308 return out
309}
310
311const base = (p: string) => p.split('/').pop() ?? p
312
313const PROXY_ENV = ['ALL_PROXY', 'all_proxy', 'HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy']
314
315// Programs whose URL arguments they actually fetch. Anything else (echo, grep,
316// cat, git commit -m …) merely mentions a URL.
317const DIALERS = new Set([
318 'python', 'python3', 'node', 'deno', 'bun', 'ruby', 'perl', 'php', 'open', 'xdg-open',
319 'yt-dlp', 'youtube-dl', 'ffmpeg', 'ffprobe', 'aria2c', 'lynx', 'w3m', 'links', 'httpie', 'http', 'https', 'xh',
320 'psql', 'pg_dump', 'pg_restore', 'mysql', 'mysqldump', 'mongosh', 'mongo', 'mongodump', 'redis-cli', 'clickhouse-client',
321 'websocat', 'wscat', 'grpcurl', 'k6', 'ab', 'hey', 'wrk', 'newman', 'lighthouse', 'playwright',
322])
323
324const CURL_ARGS = new Set([
325 '-o', '--output', '-H', '--header', '-d', '--data', '--data-raw', '--data-binary', '--data-urlencode', '-u', '--user',
326 '-X', '--request', '-A', '--user-agent', '-e', '--referer', '-b', '--cookie', '-c', '--cookie-jar', '-F', '--form',
327 '-T', '--upload-file', '-w', '--write-out', '--connect-to', '--resolve', '-m', '--max-time', '--retry', '-K', '--config',
328 '-E', '--cert', '--key', '--cacert', '-r', '--range', '-z', '--time-cond', '--output-dir', '-D', '--dump-header',
329 '--trace', '--trace-ascii', '--stderr', '--interface', '--dns-servers', '-Y', '-y', '--limit-rate', '-C', '--continue-at',
330])
331const WGET_ARGS = new Set(['-O', '--output-document', '-o', '--output-file', '-P', '--directory-prefix', '-U', '--user-agent', '--header', '-t', '--tries', '-T', '--timeout', '--user', '--password', '-a', '-i', '--input-file'])
332
333const NPM_NET = new Set(['install', 'i', 'add', 'ci', 'update', 'up', 'upgrade', 'publish', 'view', 'info', 'show', 'dlx', 'create', 'outdated', 'audit', 'login', 'exec', 'x', 'unpublish', 'deprecate', 'search'])
334const PY_NET = new Set(['install', 'add', 'sync', 'lock', 'update', 'download', 'publish', 'upgrade'])
335const GH_LOCAL = new Set(['help', 'alias', 'completion', 'config', 'version', '--version', '--help', '-h', ''])
336
337type Ctx = { dir: string | null; env: Record<string, string>; home: string | null }
338
339// Database and cache clients that name their server with -h / --host.
340const HOST_FLAG_CLIENTS = new Set(['psql', 'pg_dump', 'pg_restore', 'mysql', 'mysqldump', 'redis-cli', 'mongosh', 'mongo', 'mongodump', 'clickhouse-client'])
341// A dotted word that is a file name, not a host (`backup.tar.gz`).
342const FILE_LIKE = /\.(tar|gz|tgz|bz2|xz|zip|json|txt|sh|js|mjs|ts|py|rb|md|html?|xml|ya?ml|toml|csv|log|png|jpe?g|gif|svg|pdf|lock|conf|cfg|ini|env)$/i
343
344// The targets one simple command reaches, and remotes it names without a URL.
345function classifySegment(segment: string, ctx: Ctx): { targets: Target[]; remotes: GitRemote[]; nested: string[] } {
346 const targets: Target[] = []
347 const remotes: GitRemote[] = []
348 const { argv, env: localEnv, nested } = program(words(segment))
349 const env = { ...ctx.env, ...localEnv }
350 const cmd = base(argv[0] ?? '')
351 const sub = argv[1] ?? ''
352 const add = (host: string | null, label: string) => {
353 if (host) targets.push({ host: clean(host).toLowerCase(), kind: 'bash', label })
354 }
355 const addUrls = (values: readonly string[], label: string) => {
356 for (const v of values) add(hostOfUrl(v), label)
357 }
358 const operandUrls = (label: string) => {
359 for (const a of argv.slice(1)) if (!a.startsWith('-') || a.includes('://')) add(hostOfUrl(a.replace(/^[^=]*=(?=[a-z][a-z0-9+.-]*:\/\/)/i, '')), label)
360 }
361 const proxies = (label: string) => {
362 for (const k of PROXY_ENV) if (env[k]) add(hostOfAuthority(env[k] as string), `${label} proxy`)
363 }
364
365 switch (cmd) {
366 case 'cd': {
367 const to = argv[1]
368 if (to && !to.startsWith('-') && !to.includes('$')) {
369 const home = to === '~' || to.startsWith('~/') ? ctx.home : null
370 if (to.startsWith('~')) ctx.dir = home === null || (to !== '~' && !to.startsWith('~/')) ? null : home + to.slice(1)
371 else ctx.dir = to.startsWith('/') ? to : ctx.dir ? `${ctx.dir}/${to}` : null
372 }
373 break
374 }
375 case 'export':
376 for (const a of argv.slice(1)) {
377 const eq = a.indexOf('=')
378 if (eq > 0) ctx.env[a.slice(0, eq)] = a.slice(eq + 1)
379 }
380 break
381 case 'curl': case 'wget': {
382 const takes = cmd === 'curl' ? CURL_ARGS : WGET_ARGS
383 const pos = positionals(argv.slice(1), takes)
384 for (const p of pos) {
385 const bare = p.split(/[/?#]/)[0] as string
386 const hostish = /^[a-z0-9-]+(\.[a-z0-9-]+)+(:\d+)?$/i.test(bare) && !FILE_LIKE.test(bare.split(':')[0] as string)
387 add(hostOfUrl(p) ?? (hostish ? hostOfAuthority(bare) : null), cmd)
388 }
389 for (const v of flagValues(argv, ['-x', '--proxy', '--preproxy', '--socks5', '--socks5-hostname'])) add(hostOfAuthority(v), `${cmd} proxy`)
390 // --resolve host:port:addr and --connect-to h1:p1:h2:p2 send the request somewhere other than the URL's host.
391 for (const v of flagValues(argv, ['--resolve'])) {
392 for (const addr of v.split(':').slice(2).join(':').replace(/^\+/, '').split(',')) add(hostOfAuthority(addr.replace(/^\[|\]$/g, '')), `${cmd} resolve`)
393 }
394 for (const v of flagValues(argv, ['--connect-to'])) {
395 const to = v.split(':')[2] ?? ''
396 if (to !== '') add(hostOfAuthority(to.replace(/^\[|\]$/g, '')), `${cmd} connect-to`)
397 }
398 proxies(cmd)
399 break
400 }
401 case 'gh': {
402 const rest = positionals(argv.slice(1), new Set(['-R', '--repo', '--hostname']))
403 if (GH_LOCAL.has(rest[0] ?? '')) break
404 add(env.GH_HOST ?? flagValues(argv, ['--hostname'])[0] ?? 'github.com', rest[0] ? `gh ${rest[0]}` : 'gh')
405 proxies('gh')
406 break
407 }
408 case 'git': {
409 let i = 1
410 let dir = ctx.dir
411 const configUrls: string[] = []
412 const cliConfig: string[] = []
413 while (i < argv.length && (argv[i] as string).startsWith('-')) {
414 const a = argv[i] as string
415 if (a === '-C' && argv[i + 1] !== undefined) {
416 const to = argv[i + 1] as string
417 dir = to.startsWith('/') ? to : dir ? `${dir}/${to}` : to
418 i += 2
419 continue
420 }
421 if (a === '-c' && argv[i + 1] !== undefined) {
422 const kv = argv[i + 1] as string
423 if (/^(https?\.)?(http|https)\.(.*\.)?proxy=/i.test(kv) || /^http\..*proxy=/i.test(kv)) configUrls.push(kv.slice(kv.indexOf('=') + 1))
424 cliConfig.push(kv)
425 i += 2
426 continue
427 }
428 i += 1
429 }
430 const op = argv[i] ?? ''
431 const rest = argv.slice(i + 1)
432 const NET = ['push', 'pull', 'fetch', 'clone', 'ls-remote', 'remote', 'submodule']
433 if (!NET.includes(op)) break
434 for (const u of configUrls) add(hostOfAuthority(u), 'git proxy')
435 proxies('git')
436 if (op === 'remote' && !['update', 'show', 'prune'].includes(rest[0] ?? '')) break
437 if (op === 'submodule' && rest[0] !== 'update') break
438 const args = op === 'remote' || op === 'submodule' ? rest.slice(1) : rest
439 const pos = positionals(args, new Set(['-o', '--push-option', '--depth', '-b', '--branch', '--origin', '--upload-pack', '--receive-pack', '--recurse-submodules-default', '-j', '--jobs']))
440 // Only the repository operand can be a URL; what follows it is refspecs (`HEAD:main`), never hosts.
441 const repo = pos[0] ?? ''
442 const explicit = repo === '' ? null : (hostOfUrl(repo) ?? scpHost(repo))
443 if (explicit !== null) {
444 add(explicit, `git ${op}`)
445 break
446 }
447 if (op === 'clone' || op === 'submodule') break
448 const all = args.includes('--all') || (op === 'remote' && rest[0] === 'update' && pos.length === 0)
449 remotes.push({ remote: all ? '*' : repo, op: op === 'push' ? 'push' : 'fetch', dir, all, ...(cliConfig.length > 0 ? { config: cliConfig } : {}) })
450 break
451 }
452 case 'npm': case 'pnpm': case 'yarn': case 'bun': case 'npx': case 'bunx': {
453 const runs = cmd === 'npx' || cmd === 'bunx' || NPM_NET.has(sub) || (cmd === 'yarn' && (sub === '' || sub === 'install'))
454 if (!runs) break
455 const label = cmd === 'npx' || cmd === 'bunx' ? cmd : NPM_NET.has(sub) ? `${cmd} ${sub}` : cmd
456 const regs = [
457 ...flagValues(argv, ['--registry']),
458 ...['npm_config_registry', 'NPM_CONFIG_REGISTRY', 'YARN_REGISTRY', 'YARN_NPM_REGISTRY_SERVER', 'BUN_CONFIG_REGISTRY'].map(k => env[k] ?? '').filter(Boolean),
459 ]
460 if (regs.length > 0) addUrls(regs, label)
461 else add(cmd === 'yarn' ? 'registry.yarnpkg.com' : 'registry.npmjs.org', label)
462 operandUrls(label)
463 proxies(label)
464 break
465 }
466 case 'pip': case 'pip3': case 'pipx': case 'poetry': case 'uv': {
467 const op = cmd === 'uv' && sub === 'pip' ? (argv[2] ?? '') : sub
468 if (!PY_NET.has(op)) break
469 const label = `${cmd} ${op}`
470 const main = [
471 ...flagValues(argv, ['--index-url', '-i', '--index', '--default-index']),
472 ...['PIP_INDEX_URL', 'UV_INDEX_URL', 'UV_DEFAULT_INDEX'].map(k => env[k] ?? '').filter(Boolean),
473 ]
474 const extra = [
475 ...flagValues(argv, ['--extra-index-url', '--find-links', '-f']),
476 ...['PIP_EXTRA_INDEX_URL', 'UV_EXTRA_INDEX_URL', 'UV_INDEX', 'PIP_FIND_LINKS'].map(k => env[k] ?? '').filter(Boolean),
477 ]
478 if (main.length > 0) for (const m of main) for (const h of listHosts(m)) add(h, label)
479 else add('pypi.org', label)
480 for (const x of extra) for (const h of listHosts(x)) add(h, label)
481 operandUrls(label)
482 proxies(label)
483 break
484 }
485 case 'conda': case 'mamba': case 'micromamba': {
486 if (!['install', 'create', 'update', 'upgrade', 'search'].includes(sub)) break
487 const channels = flagValues(argv, ['-c', '--channel'])
488 let any = false
489 for (const c of channels) {
490 const h = hostOfUrl(c)
491 if (h) {
492 add(h, `${cmd} ${sub}`)
493 any = true
494 }
495 }
496 if (!any || channels.some(c => !c.includes('://'))) add('conda.anaconda.org', `${cmd} ${sub}`)
497 proxies(cmd)
498 break
499 }
500 case 'brew':
501 if (['install', 'upgrade', 'update', 'reinstall', 'fetch', 'tap'].includes(sub)) {
502 add(env.HOMEBREW_BOTTLE_DOMAIN ? hostOfUrl(env.HOMEBREW_BOTTLE_DOMAIN) : 'formulae.brew.sh', `brew ${sub}`)
503 operandUrls(`brew ${sub}`)
504 }
505 break
506 case 'docker': case 'podman': case 'nerdctl': {
507 if (!['pull', 'push', 'login', 'run', 'create'].includes(sub)) break
508 const takes = new Set(['--platform', '-u', '--username', '-p', '--password', '--context', '-e', '--env', '-v', '--volume', '--name', '-w', '--workdir', '--network', '--entrypoint', '-l', '--label', '--mount', '--user', '--add-host', '--env-file', '-h', '--hostname'])
509 const pos = positionals(argv.slice(2), takes)
510 const image = pos[0] ?? ''
511 if (sub === 'login') {
512 add(image || 'docker.io', `${cmd} login`)
513 break
514 }
515 const parts = image.split('/')
516 const first = parts[0] ?? ''
517 const isRegistry = parts.length > 1 && (first.includes('.') || first.includes(':') || first === 'localhost')
518 if (image) add(isRegistry ? (first.split(':')[0] as string) : 'docker.io', `${cmd} ${sub}`)
519 break
520 }
521 case 'cargo': {
522 if (!['add', 'install', 'update', 'fetch', 'publish', 'search', 'build', 'check'].includes(sub)) break
523 if (sub === 'build' || sub === 'check') break
524 const git = flagValues(argv, ['--git', '--registry', '--index'])
525 add('crates.io', `cargo ${sub}`)
526 for (const g of git) add(hostOfUrl(g) ?? scpHost(g), `cargo ${sub}`)
527 break
528 }
529 case 'go': {
530 if (!(sub === 'get' || sub === 'install' || (sub === 'mod' && argv[2] === 'download'))) break
531 const proxy = env.GOPROXY
532 const hosts = proxy ? listHosts(proxy) : ['proxy.golang.org']
533 for (const h of hosts) add(h, `go ${sub}`)
534 if ((proxy !== undefined && /\b(direct|off)\b/.test(proxy)) || env.GOPRIVATE || env.GONOPROXY) {
535 for (const p of positionals(argv.slice(2), new Set())) {
536 const first = p.split('/')[0] ?? ''
537 if (first.includes('.')) add(first.split('@')[0] as string, `go ${sub}`)
538 }
539 }
540 break
541 }
542 case 'gem':
543 if (['install', 'update', 'push', 'fetch'].includes(sub)) {
544 const src = flagValues(argv, ['--source', '-s', '--clear-sources'])
545 if (src.length > 0) for (const s of src) add(hostOfUrl(s), `gem ${sub}`)
546 else add('rubygems.org', `gem ${sub}`)
547 }
548 break
549 case 'bundle':
550 if (sub === '' || sub === 'install' || sub === 'update') add('rubygems.org', `bundle ${sub || 'install'}`)
551 break
552 case 'pod':
553 if (sub === 'install' || sub === 'update' || sub === 'repo') add('cdn.cocoapods.org', `pod ${sub}`)
554 break
555 case 'aws':
556 if (sub && sub !== 'help' && sub !== 'configure') add('amazonaws.com', 'aws')
557 break
558 case 'gcloud': case 'gsutil': case 'bq':
559 if (sub && sub !== 'help' && sub !== 'config') add('googleapis.com', cmd)
560 break
561 case 'az':
562 if (sub && sub !== 'help') add('management.azure.com', 'az')
563 break
564 case 'kubectl': case 'helm': {
565 const server = flagValues(argv, ['--server', '-s', '--kube-apiserver'])
566 if (server.length > 0) for (const s of server) add(hostOfAuthority(s), cmd)
567 else if (sub && !['help', 'version', 'completion', 'config', 'template', 'lint', 'create'].includes(sub)) add('kube-apiserver', cmd)
568 break
569 }
570 case 'terraform': case 'tofu':
571 if (['init', 'plan', 'apply', 'destroy', 'refresh', 'import'].includes(sub)) add('registry.terraform.io', cmd)
572 break
573 case 'ssh': case 'scp': case 'sftp': case 'rsync': case 'mosh': {
574 const takes = new Set(['-p', '-P', '-i', '-l', '-o', '-F', '-J', '-L', '-R', '-D', '-b', '-c', '-E', '-e', '-m', '-O', '-Q', '-S', '-W', '-w', '-B'])
575 for (const j of flagValues(argv, ['-J'])) for (const hop of j.split(',')) add(hop.split('@').pop()?.split(':')[0] ?? null, `${cmd} jump`)
576 for (const o of flagValues(argv, ['-o'])) {
577 const pj = /^ProxyJump[= ](.+)$/i.exec(o)
578 if (pj) for (const hop of (pj[1] as string).split(',')) add(hop.split('@').pop()?.split(':')[0] ?? null, `${cmd} jump`)
579 }
580 for (const f of [...flagValues(argv, ['-L']), ...flagValues(argv, ['-R'])]) {
581 const parts = f.split(':')
582 if (parts.length >= 3) add(parts[parts.length - 2] as string, `${cmd} forward`)
583 }
584 for (const w of flagValues(argv, ['-W'])) add(w.split(':')[0] ?? null, `${cmd} forward`)
585 const pos = positionals(argv.slice(1), takes)
586 if (cmd === 'ssh' || cmd === 'mosh' || cmd === 'sftp') {
587 const target = pos[0]
588 if (target) add(hostOfUrl(target) ?? target.split('@').pop()?.split(':')[0] ?? null, cmd)
589 } else {
590 for (const p of pos) add(hostOfUrl(p) ?? scpHost(p), cmd)
591 }
592 break
593 }
594 case 'nc': case 'ncat': case 'netcat': case 'telnet': case 'socat': {
595 const pos = positionals(argv.slice(1), new Set(['-p', '-s', '-w', '-x', '-X', '-i', '-q']))
596 const host = pos.find(a => !/^\d+$/.test(a))
597 if (host && !host.includes(':')) add(host, cmd)
598 for (const p of pos) {
599 const m = /^(?:tcp|udp|ssl|openssl)[46]?:([^:]+):\d+/i.exec(p)
600 if (m) add(m[1] as string, cmd)
601 }
602 break
603 }
604 default:
605 if (DIALERS.has(cmd)) {
606 for (const host of urlHosts(argv.slice(1).join(' '))) add(host, cmd)
607 if (HOST_FLAG_CLIENTS.has(cmd)) for (const v of flagValues(argv, ['-h', '--host'])) add(hostOfAuthority(v), cmd)
608 proxies(cmd)
609 }
610 }
611 return { targets, remotes, nested }
612}
613
614// The host of scp-like `user@host:path` (but not a Windows drive or a URL).
615function scpHost(arg: string): string | null {
616 if (arg.includes('://')) return null
617 const m = /^(?:[\w.-]+@)?([a-zA-Z0-9][\w-]*(?:\.[\w-]+)+|[a-zA-Z0-9][\w-]+):(?!\/\/)/.exec(arg)
618 if (!m) return null
619 const host = m[1] as string
620 return /^[a-zA-Z]$/.test(host) ? null : host.toLowerCase()
621}
622
623// Every remote target of a Bash command line, plus remotes named without a URL
624// (resolved later from the repository's config). `cwd` seeds `cd` tracking.
625export function classifyBash(command: string, cwd: string | null = null, home: string | null = null): { targets: Target[]; remotes: GitRemote[] } {
626 const targets: Target[] = []
627 const remotes: GitRemote[] = []
628 const ctx: Ctx = { dir: cwd, env: {}, home }
629 const queue = segments(command)
630 for (let n = 0; n < queue.length && n < 200; n += 1) {
631 const one = classifySegment(queue[n] as string, ctx)
632 targets.push(...one.targets)
633 remotes.push(...one.remotes)
634 for (const body of one.nested) queue.push(...segments(body))
635 }
636 return { targets: dedupe(targets), remotes }
637}
638
639function dedupe(list: Target[]): Target[] {
640 const seen = new Set<string>()
641 return list.filter(t => {
642 const k = `${t.kind}:${t.host}`
643 if (seen.has(k)) return false
644 seen.add(k)
645 return true
646 })
647}
648
649// One git config file as [section, subsection, key, value] rows, in order. Enough of git's format for the
650// keys this reads: `[remote "x"]`, `[branch "x"]`, `[url "x"]`, `[remote]`, `key = value`.
651function configRows(config: string): { section: string; sub: string; key: string; value: string }[] {
652 const out: { section: string; sub: string; key: string; value: string }[] = []
653 let section = ''
654 let sub = ''
655 for (const line of config.split(/\r?\n/)) {
656 const head = /^\s*\[\s*([A-Za-z0-9.-]+)(?:\s+"((?:[^"\\]|\\.)*)")?\s*\]/.exec(line)
657 if (head) {
658 section = (head[1] as string).toLowerCase()
659 sub = (head[2] ?? '').replace(/\\(.)/g, '$1')
660 continue
661 }
662 const kv = /^\s*([A-Za-z][A-Za-z0-9-]*)\s*=\s*(.*?)\s*$/.exec(line)
663 if (kv && section !== '') out.push({ section, sub, key: (kv[1] as string).toLowerCase(), value: (kv[2] as string).replace(/^"(.*)"$/, '$1') })
664 }
665 return out
666}
667
668// `-c section.sub.key=value` from the command line, as config rows (they apply after the file's).
669function cliRows(pairs: readonly string[]): { section: string; sub: string; key: string; value: string }[] {
670 return pairs.flatMap(kv => {
671 const eq = kv.indexOf('=')
672 if (eq < 0) return []
673 const name = kv.slice(0, eq)
674 const first = name.indexOf('.')
675 const last = name.lastIndexOf('.')
676 if (first < 0) return []
677 return [{ section: name.slice(0, first).toLowerCase(), sub: first === last ? '' : name.slice(first + 1, last), key: name.slice(last + 1).toLowerCase(), value: kv.slice(eq + 1) }]
678 })
679}
680
681// Remote names and every fetch / push URL each has (git pushes to all of a remote's pushurls).
682export function remotesOf(config: string): Map<string, { url: string[]; pushurl: string[] }> {
683 const out = new Map<string, { url: string[]; pushurl: string[] }>()
684 for (const r of configRows(config)) {
685 if (r.section !== 'remote' || r.sub === '') continue
686 const entry = out.get(r.sub) ?? { url: [], pushurl: [] }
687 if (r.key === 'url') entry.url.push(r.value)
688 if (r.key === 'pushurl') entry.pushurl.push(r.value)
689 out.set(r.sub, entry)
690 }
691 return out
692}
693
694// git's url.<base>.insteadOf / pushInsteadOf: the longest matching prefix wins, and a pushInsteadOf beats an
695// insteadOf for push URLs.
696export function rewriteUrl(url: string, rows: readonly { section: string; sub: string; key: string; value: string }[], op: 'push' | 'fetch'): string {
697 let best: { base: string; from: string; push: boolean } | null = null
698 for (const r of rows) {
699 if (r.section !== 'url' || (r.key !== 'insteadof' && r.key !== 'pushinsteadof')) continue
700 const push = r.key === 'pushinsteadof'
701 if (push && op !== 'push') continue
702 if (!url.startsWith(r.value)) continue
703 if (best === null || r.value.length > best.from.length || (r.value.length === best.from.length && push && !best.push)) best = { base: r.sub, from: r.value, push }
704 }
705 return best === null ? url : best.base + url.slice(best.from.length)
706}
707
708// The URLs a remote uses for an operation: every pushurl for push when set, else its urls; rewrites applied.
709export function remoteUrls(config: string, remote: string, op: 'push' | 'fetch' = 'fetch', cli: readonly string[] = []): string[] {
710 const rows = [...configRows(config), ...cliRows(cli)]
711 const r = remotesOf(config).get(remote)
712 if (!r) return []
713 const raw = op === 'push' && r.pushurl.length > 0 ? r.pushurl : r.url
714 // An explicit pushurl is used as written; a url used for push still goes through pushInsteadOf.
715 return raw.map(u => (op === 'push' && r.pushurl.length > 0 ? rewriteUrl(u, rows, 'fetch') : rewriteUrl(u, rows, op)))
716}
717
718// The URL a git remote uses for an operation (the first of remoteUrls).
719export function remoteUrl(config: string, remote: string, op: 'push' | 'fetch' = 'fetch'): string | null {
720 return remoteUrls(config, remote, op)[0] ?? null
721}
722
723// Host of a git remote URL, either a URL or scp-like `git@host:path`.
724export function hostOfRemote(url: string): string | null {
725 return hostOfUrl(url) ?? scpHost(url.trim())
726}
727
728// The remote a bare `git push` / `git pull` uses, in git's order: for a push, the branch's pushRemote, then
729// remote.pushDefault; then the branch's remote, else `origin`, else the only remote.
730export function defaultRemote(config: string, head: string | null, op: 'push' | 'fetch' = 'fetch', cli: readonly string[] = []): string | null {
731 const rows = [...configRows(config), ...cliRows(cli)]
732 const names = [...remotesOf(config).keys()]
733 const branch = head ? /^ref:\s*refs\/heads\/(.+)$/.exec(head.trim())?.[1] : undefined
734 const last = (section: string, sub: string, key: string) => rows.filter(r => r.section === section && r.sub === sub && r.key === key).pop()?.value
735 const pick = (name: string | undefined) => (name !== undefined && names.includes(name) ? name : null)
736 if (op === 'push') {
737 const pushed = pick(branch ? last('branch', branch, 'pushremote') : undefined) ?? pick(last('remote', '', 'pushdefault'))
738 if (pushed !== null) return pushed
739 }
740 const fromBranch = pick(branch ? last('branch', branch, 'remote') : undefined)
741 if (fromBranch !== null) return fromBranch
742 if (names.includes('origin')) return 'origin'
743 return names.length === 1 ? (names[0] as string) : null
744}
745
746// Is a host expected (exact match or a subdomain of an entry)?
747export function isAllowed(host: string, allow: readonly string[]): boolean {
748 if (isLocal(host)) return true
749 return allow.some(a => {
750 const entry = normalizeHost(a)
751 return entry.length > 0 && (host === entry || host.endsWith('.' + entry))
752 })
753}
754
755// An allowlist entry as typed (`*.corp.com`, `https://corp.com/x`, `corp.com:443`) to a bare host.
756export function normalizeHost(entry: string): string {
757 let e = entry.trim().toLowerCase()
758 const viaUrl = hostOfUrl(e)
759 if (viaUrl) e = viaUrl
760 e = e.replace(/^\*\./, '').replace(/\/.*$/, '')
761 if (!e.startsWith('[') && !e.startsWith('mcp:')) e = e.replace(/:\d+$/, '')
762 return e.replace(/\.$/, '')
763}
764
765export function parseAllow(text: string): string[] {
766 return text.split(/[\s,]+/).map(normalizeHost).filter(Boolean)
767}
768hooks/map.ts 250 lines1// The node map, drawn as braille: every cell is 2×4 sub-pixels, so a 120×24
2// pane is a 240×96 canvas. Pure: give it the hosts, packets and the time, get
3// back Raster cells. No `$` here.
4
5export type NodeStyle = 'ok' | 'flagged' | 'mcp' | 'web' | 'error' | 'local' | 'unknown'
6
7// `slot` is the host's first-seen index: positions never move as hosts arrive.
8export type MapNode = { host: string; style: NodeStyle; flashUntil: number; count: number; slot: number }
9
10// A packet in flight along one host's edge: out from the machine, or back in.
11export type Packet = { host: string; dir: 'out' | 'in'; ok: boolean; startedAt: number }
12
13export const PACKET_MS = 900
14const DEFAULT = 0x01000000
15const BG = 0x000000
16
17const COLORS: Record<NodeStyle, number> = {
18 ok: 0x39ff88,
19 flagged: 0xff3355,
20 mcp: 0xb57bff,
21 web: 0x33d6ff,
22 error: 0xff8844,
23 local: 0x8899aa,
24 unknown: 0xc0c0c0,
25}
26const DIM: Record<NodeStyle, number> = {
27 ok: 0x0f5a32,
28 flagged: 0x6a1020,
29 mcp: 0x45306a,
30 web: 0x0f4a5c,
31 error: 0x6a3418,
32 local: 0x2a3440,
33 unknown: 0x404040,
34}
35const GRID = 0x0c2a18
36const SWEEP = 0x1c8a4c
37const AMBER = 0xffb000
38const CENTER = 0xe6ffe6
39
40// Braille dot bit for sub-pixel (x 0..1, y 0..3) within a cell.
41const BIT = [
42 [0x01, 0x08],
43 [0x02, 0x10],
44 [0x04, 0x20],
45 [0x40, 0x80],
46]
47
48function lum(c: number): number {
49 return ((c >> 16) & 0xff) * 3 + ((c >> 8) & 0xff) * 6 + (c & 0xff)
50}
51
52class Canvas {
53 readonly w: number
54 readonly h: number
55 readonly px: Uint32Array
56 readonly text: Map<number, { cp: number; color: number }> = new Map()
57 constructor(readonly cols: number, readonly rows: number) {
58 this.w = cols * 2
59 this.h = rows * 4
60 this.px = new Uint32Array(this.w * this.h)
61 }
62 dot(x: number, y: number, color: number) {
63 const xi = Math.round(x)
64 const yi = Math.round(y)
65 if (xi < 0 || yi < 0 || xi >= this.w || yi >= this.h) return
66 const i = yi * this.w + xi
67 const cur = this.px[i] as number
68 if (cur === 0 || lum(color) >= lum(cur)) this.px[i] = color
69 }
70 line(x0: number, y0: number, x1: number, y1: number, color: number, step = 1) {
71 const n = Math.max(1, Math.ceil(Math.hypot(x1 - x0, y1 - y0)))
72 for (let k = 0; k <= n; k += step) {
73 const t = k / n
74 this.dot(x0 + (x1 - x0) * t, y0 + (y1 - y0) * t, color)
75 }
76 }
77 circle(cx: number, cy: number, r: number, color: number, step = 1) {
78 const n = Math.max(12, Math.ceil(2 * Math.PI * r))
79 for (let k = 0; k < n; k += step) {
80 const a = (k / n) * 2 * Math.PI
81 this.dot(cx + Math.cos(a) * r, cy + Math.sin(a) * r, color)
82 }
83 }
84 disc(cx: number, cy: number, r: number, color: number) {
85 for (let y = -r; y <= r; y += 1) for (let x = -r; x <= r; x += 1) if (x * x + y * y <= r * r + 0.5) this.dot(cx + x, cy + y, color)
86 }
87 // Text in cell units; it covers the braille in those cells.
88 label(col: number, row: number, s: string, color: number) {
89 row = Math.max(0, Math.min(this.rows - 1, row))
90 let c = Math.max(0, Math.min(col, this.cols - s.length))
91 for (const ch of s) {
92 if (c >= this.cols) break
93 const cp = ch.codePointAt(0) ?? 32
94 this.text.set(row * this.cols + c, { cp: cp > 0xffff || cp < 32 ? 63 : cp, color })
95 c += 1
96 }
97 }
98 cells(): Uint32Array {
99 const out = new Uint32Array(this.cols * this.rows * 3)
100 for (let row = 0; row < this.rows; row += 1) {
101 for (let col = 0; col < this.cols; col += 1) {
102 const i = row * this.cols + col
103 const t = this.text.get(i)
104 if (t) {
105 out[i * 3] = t.cp
106 out[i * 3 + 1] = t.color
107 out[i * 3 + 2] = BG
108 continue
109 }
110 let bits = 0
111 let color = 0
112 for (let dy = 0; dy < 4; dy += 1) {
113 for (let dx = 0; dx < 2; dx += 1) {
114 const c = this.px[(row * 4 + dy) * this.w + col * 2 + dx] as number
115 if (c !== 0) {
116 bits |= (BIT[dy] as number[])[dx] as number
117 if (color === 0 || lum(c) > lum(color)) color = c
118 }
119 }
120 }
121 out[i * 3] = bits === 0 ? 32 : 0x2800 + bits
122 out[i * 3 + 1] = bits === 0 ? DEFAULT : color
123 out[i * 3 + 2] = BG
124 }
125 }
126 return out
127 }
128}
129
130export type Layout = { cx: number; cy: number; rx: number; ry: number; pos: Map<string, { x: number; y: number; angle: number }> }
131
132const GOLDEN = Math.PI * (3 - Math.sqrt(5))
133
134// Hosts around the machine at golden-angle steps by first-seen slot, so a new
135// host never moves the ones already drawn, on an ellipse that fits the canvas.
136export function layout(cols: number, rows: number, nodes: readonly { host: string; slot: number }[]): Layout {
137 const w = cols * 2
138 const h = rows * 4
139 const cx = w / 2
140 const cy = h / 2
141 const rx = Math.max(6, cx - 8)
142 const ry = Math.max(4, cy - 6)
143 const pos = new Map<string, { x: number; y: number; angle: number }>()
144 for (const n of nodes) {
145 const angle = -Math.PI / 2 + n.slot * GOLDEN
146 // Alternate between two rings so neighbours in angle do not share a label line.
147 const k = n.slot % 2 === 0 ? 1 : 0.72
148 pos.set(n.host, { x: cx + Math.cos(angle) * rx * k, y: cy + Math.sin(angle) * ry * k, angle })
149 }
150 return { cx, cy, rx, ry, pos }
151}
152
153// One frame of the map, as RasterProps `cells` words.
154export function drawMap(cols: number, rows: number, nodes: readonly MapNode[], packets: readonly Packet[], now: number, hidden = 0): Uint32Array {
155 const cv = new Canvas(cols, rows)
156 const { cx, cy, rx, ry, pos } = layout(cols, rows, nodes)
157 // Radar rings and a crosshair, the grid everything sits on.
158 for (const k of [0.36, 0.72, 1]) {
159 const n = Math.max(24, Math.ceil(Math.PI * (rx + ry) * k))
160 for (let i = 0; i < n; i += 3) {
161 const a = (i / n) * 2 * Math.PI
162 cv.dot(cx + Math.cos(a) * rx * k, cy + Math.sin(a) * ry * k, GRID)
163 }
164 }
165 cv.line(cx - rx, cy, cx + rx, cy, GRID, 4)
166 cv.line(cx, cy - ry, cx, cy + ry, GRID, 4)
167 // A slow sweep, one turn every four seconds, with a short fading tail.
168 const sweep = ((now % 4000) / 4000) * 2 * Math.PI - Math.PI / 2
169 for (let k = 0; k < 6; k += 1) {
170 const a = sweep - k * 0.06
171 const fade = k === 0 ? SWEEP : GRID
172 cv.line(cx, cy, cx + Math.cos(a) * rx, cy + Math.sin(a) * ry, fade, k === 0 ? 1 : 2)
173 }
174 // Edges.
175 for (const node of nodes) {
176 const p = pos.get(node.host)
177 if (p) cv.line(cx, cy, p.x, p.y, DIM[node.style], 2)
178 }
179 // Packets with a short trail.
180 for (const pk of packets) {
181 const p = pos.get(pk.host)
182 if (!p) continue
183 const t = (now - pk.startedAt) / PACKET_MS
184 if (t < 0 || t > 1) continue
185 const color = pk.dir === 'out' ? 0xffffff : pk.ok ? COLORS.ok : COLORS.error
186 for (let k = 0; k < 4; k += 1) {
187 const tt = Math.max(0, t - k * 0.04)
188 const u = pk.dir === 'out' ? tt : 1 - tt
189 const x = cx + (p.x - cx) * u
190 const y = cy + (p.y - cy) * u
191 if (k === 0) cv.disc(x, y, 1, color)
192 else cv.dot(x, y, k === 1 ? color : DIM[pk.ok ? 'ok' : 'error'])
193 }
194 }
195 // Nodes, a pulsing amber ring on first contact, and labels.
196 for (const node of nodes) {
197 const p = pos.get(node.host)
198 if (!p) continue
199 const color = COLORS[node.style]
200 cv.disc(p.x, p.y, 2, color)
201 if (now < node.flashUntil) {
202 const phase = ((now % 600) / 600) * 2 * Math.PI
203 cv.circle(p.x, p.y, 4 + Math.sin(phase) * 1.5, AMBER, 1)
204 }
205 const col = Math.round(p.x / 2)
206 const row = Math.round(p.y / 4)
207 const name = node.host.length > 22 ? node.host.slice(0, 21) + '~' : node.host
208 const text = node.count > 1 ? `${name} x${node.count}` : name
209 const below = Math.sin(p.angle) > 0.2
210 const labelRow = below ? row + 1 : row - 1
211 const labelCol = Math.cos(p.angle) < -0.3 ? col - text.length - 1 : Math.cos(p.angle) > 0.3 ? col + 2 : col - Math.floor(text.length / 2)
212 cv.label(labelCol, labelRow, text, now < node.flashUntil ? AMBER : color)
213 }
214 // The machine at the centre.
215 cv.disc(cx, cy, 3, CENTER)
216 cv.label(Math.round(cx / 2) - 3, Math.round(cy / 4) + 1, '[ YOU ]', CENTER)
217 if (nodes.length === 0) cv.label(Math.round(cx / 2) - 11, Math.round(cy / 4) - 2, 'no outbound traffic yet', SWEEP)
218 if (hidden > 0) cv.label(cols - 14, rows - 1, `+${hidden} more hosts`, AMBER)
219 return cv.cells()
220}
221
222const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
223
224// Standard padded base64 of the words' little-endian bytes.
225export function encodeCells(words: Uint32Array): string {
226 const bytes = new Uint8Array(words.length * 4)
227 for (let i = 0; i < words.length; i += 1) {
228 const v = words[i] as number
229 bytes[i * 4] = v & 0xff
230 bytes[i * 4 + 1] = (v >>> 8) & 0xff
231 bytes[i * 4 + 2] = (v >>> 16) & 0xff
232 bytes[i * 4 + 3] = (v >>> 24) & 0xff
233 }
234 let out = ''
235 let i = 0
236 for (; i + 2 < bytes.length; i += 3) {
237 const n = ((bytes[i] as number) << 16) | ((bytes[i + 1] as number) << 8) | (bytes[i + 2] as number)
238 out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + B64[n & 63]!
239 }
240 const rest = bytes.length - i
241 if (rest === 1) {
242 const n = (bytes[i] as number) << 16
243 out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + '=='
244 } else if (rest === 2) {
245 const n = ((bytes[i] as number) << 16) | ((bytes[i + 1] as number) << 8)
246 out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + '='
247 }
248 return out
249}
250types/index.d.ts 29 lines1// One outbound target of a call: when, which tool, which host, and how it
2// ended. `label` is the program and subcommand only, never arguments.
3export type TraceEvent = {
4 id: string
5 at: number
6 tool: string
7 host: string
8 kind: 'bash' | 'web' | 'search' | 'mcp'
9 label: string
10 status: 'pending' | 'ok' | 'error' | 'denied'
11 ms?: number
12 agent?: string
13}
14
15// Per host: first and last contact, totals, the latest outcome, and its
16// first-seen slot (which fixes its place on the map).
17export type HostInfo = { first: number; last: number; count: number; errors: number; lastStatus: 'ok' | 'error' | 'pending' | 'denied'; slot: number }
18
19declare module 'claude-code' {
20 interface PluginState {
21 'netrunner-trace': {
22 events: TraceEvent[]
23 hosts: Record<string, HostInfo>
24 allow: string[]
25 calls: number
26 }
27 }
28}
29