tunnel-mcp 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -11,6 +11,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
11
11
 
12
12
  - Nothing yet.
13
13
 
14
+ ## [0.1.1] - 2026-07-01
15
+
16
+ ### Security
17
+
18
+ - **Join links are now single-use and expiring.** A join link is consumed by
19
+ the first guest who successfully authenticates and can no longer be redeemed
20
+ afterward — even once that guest disconnects — and links expire on their own
21
+ after 10 minutes (`DEFAULT_JOIN_LINK_TTL_MS`). This bounds the damage from a
22
+ leaked link, which previously stayed valid for the whole session.
23
+ `tunnel_open` now returns `joinLinkExpiresInSec` so the host can tell the
24
+ human how long the link is good for.
25
+
14
26
  ## [0.1.0] - 2026-06-30
15
27
 
16
28
  ### Added
package/README.md CHANGED
@@ -4,7 +4,6 @@
4
4
 
5
5
  [![CI](https://github.com/zachlikefolio/tunnel-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/zachlikefolio/tunnel-mcp/actions/workflows/ci.yml)
6
6
  [![npm version](https://img.shields.io/npm/v/tunnel-mcp)](https://www.npmjs.com/package/tunnel-mcp)
7
- [![license](https://img.shields.io/npm/l/tunnel-mcp)](./LICENSE)
8
7
  ![node](https://img.shields.io/badge/node-%3E%3D20-brightgreen)
9
8
 
10
9
  When two developers each run a Claude agent and need those agents to collaborate,
@@ -70,7 +69,9 @@ it isn't already on your `PATH` — there's nothing extra to install.
70
69
 
71
70
  Claude calls `tunnel_open({ goal })` and returns a join link. Share that link
72
71
  with the other developer over a trusted channel (Slack DM, etc.) — **it's a
73
- secret**, since it contains the encryption key for the session.
72
+ secret**, since it contains the encryption key for the session. The link is
73
+ **single-use and expires after ~10 minutes** (`tunnel_open` reports
74
+ `joinLinkExpiresInSec`), so share it promptly.
74
75
 
75
76
  **Guest** — paste the link and ask Claude to join:
76
77
 
@@ -113,9 +114,12 @@ between two AI agents. Here's exactly what it does and does not protect:
113
114
  - **Authentication is proof-of-key-possession, not key transmission.** Joining
114
115
  uses an HMAC challenge to prove the guest holds the same key as the host; the
115
116
  raw key itself is never sent over the wire.
116
- - **The join link is a credential.** It embeds the session key, so treat it
117
- like a password — share it only over a channel you already trust (Slack DM,
118
- etc.), never in a public issue, PR, or chat.
117
+ - **The join link is a single-use, expiring credential.** It embeds the session
118
+ key, so treat it like a password — share it only over a channel you already
119
+ trust (Slack DM, etc.), never in a public issue, PR, or chat. It is consumed
120
+ by the first guest who joins (and can't be reused, even after they leave) and
121
+ expires on its own after ~10 minutes, so a leaked link has a short, bounded
122
+ window of exposure.
119
123
  - **Exactly two participants, enforced by a lock.** The first guest to
120
124
  authenticate locks the session; nobody else can join after that.
121
125
  - **The peer is untrusted input, not an instruction source.** Messages from the
package/SECURITY.md CHANGED
@@ -63,9 +63,15 @@ share a join link with anyone.
63
63
  like a password** — share it only over a trusted, already-authenticated
64
64
  channel (e.g. a Slack DM to a known teammate), not in a public channel or
65
65
  ticket.
66
+ - **Join links are single-use and expiring.** A join link is consumed by the
67
+ first guest who successfully authenticates and can never be redeemed again —
68
+ even after that guest disconnects. Links also expire on their own (10 minutes
69
+ by default), so a link that is never used stops working. This bounds the
70
+ damage from a leaked link to a short window before it is used or expires.
66
71
  - **Single-guest lock.** The first participant who successfully
67
72
  authenticates as guest locks the session. Sessions are strictly two-party;
68
- a second join attempt is rejected.
73
+ a second concurrent join attempt is rejected ("tunnel full"), and any join
74
+ after the link has been consumed is rejected ("join link already used").
69
75
  - **Peer input is untrusted.** Everything a peer sends over the tunnel is
70
76
  data, never an instruction. The bundled `tunnel-etiquette` skill
71
77
  instructs each agent to treat incoming peer messages as untrusted input
@@ -89,13 +95,15 @@ protect against:
89
95
  and system/connection events are visible in plaintext to anything that
90
96
  can observe that path (including Cloudflare's infrastructure). Do not
91
97
  put secrets in the goal or names.
92
- - **No link rotation or expiry beyond session teardown.** A join link is
93
- valid for the lifetime of the session. If a join link leaks (pasted into
94
- the wrong channel, logged, etc.) before the host closes the session,
95
- anyone with that link can join as the guest up until the host runs
96
- `tunnel_close`, the session idles out, or the host process exits. There
97
- is currently no way to rotate the key or issue single-use/expiring
98
- tokens.
98
+ - **A leaked link can still be redeemed within its window, before your guest
99
+ joins.** Join links are single-use and expire (10 minutes by default), so a
100
+ leaked link that is never used, has already been used, or has aged out can no
101
+ longer admit anyone. The residual risk is a race: if a link leaks and an
102
+ attacker redeems it faster than your intended guest within the expiry
103
+ window and before that guest connects the attacker consumes the single-use
104
+ link, joins as the guest, and locks out the real one. Share links only over
105
+ trusted channels, and open a fresh tunnel if you suspect a link was exposed
106
+ before it was used. There is no in-session key rotation.
99
107
  - **The goal is never encrypted.** By design, the goal string is plaintext
100
108
  metadata used for connection setup and display; it receives no
101
109
  confidentiality protection at any layer.
@@ -113,9 +121,9 @@ protect against:
113
121
  instructions by an unguarded agent.
114
122
  - **Out of scope for this release**: host-offline/async messaging,
115
123
  more than two participants, alternative transports (ngrok, WebRTC),
116
- link rotation or one-time join tokens, and encryption of the goal or
117
- other connection metadata. These may be considered for future versions
118
- but should not be assumed to exist today.
124
+ in-session key/link rotation, and encryption of the goal or other
125
+ connection metadata. These may be considered for future versions but
126
+ should not be assumed to exist today.
119
127
 
120
128
  If you find a way to break any of the guarantees above (e.g. read a chat
121
129
  message body without the key, join a locked session, or get an agent to
package/dist/config.d.ts CHANGED
@@ -3,6 +3,7 @@ export declare const BIN_DIR: string;
3
3
  export declare const SESSIONS_DIR: string;
4
4
  export declare const DEFAULT_LISTEN_TIMEOUT_MS = 60000;
5
5
  export declare const DEFAULT_IDLE_TEARDOWN_MS: number;
6
+ export declare const DEFAULT_JOIN_LINK_TTL_MS: number;
6
7
  export declare const CLOUDFLARED_URL_TIMEOUT_MS = 30000;
7
8
  export declare const CLOUDFLARED_HEALTH_ATTEMPTS = 10;
8
9
  export declare const CLOUDFLARED_HEALTH_INTERVAL_MS = 1000;
package/dist/config.js CHANGED
@@ -5,6 +5,9 @@ export const BIN_DIR = path.join(TUNNEL_HOME, 'bin');
5
5
  export const SESSIONS_DIR = path.join(TUNNEL_HOME, 'sessions');
6
6
  export const DEFAULT_LISTEN_TIMEOUT_MS = 60_000;
7
7
  export const DEFAULT_IDLE_TEARDOWN_MS = 30 * 60_000;
8
+ // Join links are single-use and expire after this window; a leaked link that
9
+ // is never used (or is reused after the guest joined) can't admit anyone.
10
+ export const DEFAULT_JOIN_LINK_TTL_MS = 10 * 60_000;
8
11
  // cloudflared startup robustness
9
12
  export const CLOUDFLARED_URL_TIMEOUT_MS = 30_000; // wait for the URL line
10
13
  export const CLOUDFLARED_HEALTH_ATTEMPTS = 10; // edge-reachability probes
@@ -8,6 +8,7 @@ export interface HostRelayOptions {
8
8
  goal: string;
9
9
  hostName: string;
10
10
  idleMs?: number;
11
+ joinTtlMs?: number;
11
12
  }
12
13
  export declare class HostRelay extends EventEmitter {
13
14
  private opts;
@@ -19,9 +20,12 @@ export declare class HostRelay extends EventEmitter {
19
20
  private challenges;
20
21
  private idleTimer?;
21
22
  private tearingDown;
23
+ private consumed;
24
+ private joinDeadline;
22
25
  constructor(opts: HostRelayOptions, log: SessionLog);
23
26
  start(): Promise<number>;
24
27
  get peerConnected(): boolean;
28
+ armJoinDeadline(ttlMs?: number): void;
25
29
  submitLocal(msg: WireMessage): WireMessage;
26
30
  private resetIdle;
27
31
  private submit;
@@ -3,7 +3,7 @@ import { EventEmitter } from 'node:events';
3
3
  import { WebSocketServer, WebSocket } from 'ws';
4
4
  import { makeChallenge, verifyChallenge } from '../protocol/crypto.js';
5
5
  import { encodeFrame, decodeFrame, buildSystem, } from '../protocol/messages.js';
6
- import { DEFAULT_IDLE_TEARDOWN_MS } from '../config.js';
6
+ import { DEFAULT_IDLE_TEARDOWN_MS, DEFAULT_JOIN_LINK_TTL_MS } from '../config.js';
7
7
  export class HostRelay extends EventEmitter {
8
8
  opts;
9
9
  log;
@@ -14,10 +14,16 @@ export class HostRelay extends EventEmitter {
14
14
  challenges = new WeakMap();
15
15
  idleTimer;
16
16
  tearingDown = false;
17
+ // Single-use, expiring join link: the link is valid until `joinDeadline`, and
18
+ // is consumed by the first successful authentication so it can never be reused
19
+ // (even after that guest disconnects).
20
+ consumed = false;
21
+ joinDeadline;
17
22
  constructor(opts, log) {
18
23
  super();
19
24
  this.opts = opts;
20
25
  this.log = log;
26
+ this.joinDeadline = Date.now() + (opts.joinTtlMs ?? DEFAULT_JOIN_LINK_TTL_MS);
21
27
  this.server = http.createServer();
22
28
  this.wss = new WebSocketServer({ server: this.server, path: `/t/${opts.tunnelId}` });
23
29
  this.wss.on('connection', (ws) => this.onConnection(ws));
@@ -39,6 +45,13 @@ export class HostRelay extends EventEmitter {
39
45
  get peerConnected() {
40
46
  return !!this.guest && this.guest.readyState === WebSocket.OPEN;
41
47
  }
48
+ // (Re)start the single-use join link's expiry window. The session calls this
49
+ // once the link is actually minted (after cloudflared provisioning), so the
50
+ // window is measured from when the host receives the link to share — not from
51
+ // relay construction, which happens before provisioning could burn time.
52
+ armJoinDeadline(ttlMs) {
53
+ this.joinDeadline = Date.now() + (ttlMs ?? this.opts.joinTtlMs ?? DEFAULT_JOIN_LINK_TTL_MS);
54
+ }
42
55
  submitLocal(msg) {
43
56
  return this.submit(msg);
44
57
  }
@@ -95,11 +108,24 @@ export class HostRelay extends EventEmitter {
95
108
  ws.close();
96
109
  return;
97
110
  }
98
- if (this.guest && this.guest !== ws && this.guest.readyState === WebSocket.OPEN) {
99
- ws.send(encodeFrame({ t: 'auth_fail', reason: 'tunnel full' }));
111
+ if (Date.now() > this.joinDeadline) {
112
+ ws.send(encodeFrame({ t: 'auth_fail', reason: 'join link expired' }));
113
+ ws.close();
114
+ return;
115
+ }
116
+ if (this.consumed) {
117
+ // The single-use link was already redeemed. Distinguish the case
118
+ // where the original guest is still connected ('tunnel full') from a
119
+ // reuse attempt after they left ('join link already used').
120
+ const connected = !!this.guest && this.guest.readyState === WebSocket.OPEN;
121
+ ws.send(encodeFrame({
122
+ t: 'auth_fail',
123
+ reason: connected ? 'tunnel full' : 'join link already used',
124
+ }));
100
125
  ws.close();
101
126
  return;
102
127
  }
128
+ this.consumed = true;
103
129
  this.guest = ws;
104
130
  this.guestName = frame.name;
105
131
  const sinceSeq = Number.isFinite(frame.sinceSeq) ? frame.sinceSeq : 0;
package/dist/session.d.ts CHANGED
@@ -4,6 +4,7 @@ export interface SessionDeps {
4
4
  ensureCloudflared: () => Promise<string>;
5
5
  startCloudflared: (bin: string, port: number) => Promise<TunnelHandle>;
6
6
  idleMs?: number;
7
+ joinTtlMs?: number;
7
8
  }
8
9
  export interface SessionStatus {
9
10
  role: Role;
@@ -30,6 +31,7 @@ export declare class TunnelSession {
30
31
  tunnelId: string;
31
32
  joinLink: string;
32
33
  status: string;
34
+ joinLinkExpiresInSec: number;
33
35
  }>;
34
36
  join(joinLink: string, guestName: string): Promise<{
35
37
  tunnelId: string;
package/dist/session.js CHANGED
@@ -6,7 +6,7 @@ import { HostRelay } from './relay/hostRelay.js';
6
6
  import { GuestClient } from './relay/guestClient.js';
7
7
  import { ensureCloudflared as realEnsure } from './cloudflared/provision.js';
8
8
  import { startCloudflared as realStart } from './cloudflared/tunnelProcess.js';
9
- import { DEFAULT_LISTEN_TIMEOUT_MS, DEFAULT_IDLE_TEARDOWN_MS, OPEN_RETRY_ATTEMPTS, } from './config.js';
9
+ import { DEFAULT_LISTEN_TIMEOUT_MS, DEFAULT_IDLE_TEARDOWN_MS, DEFAULT_JOIN_LINK_TTL_MS, OPEN_RETRY_ATTEMPTS, } from './config.js';
10
10
  const DEFAULT_DEPS = {
11
11
  ensureCloudflared: realEnsure,
12
12
  startCloudflared: (bin, port) => realStart(bin, port),
@@ -35,8 +35,9 @@ export class TunnelSession {
35
35
  const key = generateKey();
36
36
  const tunnelId = generateTunnelId();
37
37
  const idleMs = this.deps.idleMs ?? DEFAULT_IDLE_TEARDOWN_MS;
38
+ const joinTtlMs = this.deps.joinTtlMs ?? DEFAULT_JOIN_LINK_TTL_MS;
38
39
  const log = new SessionLog(tunnelId);
39
- const relay = new HostRelay({ tunnelId, key, goal, hostName, idleMs }, log);
40
+ const relay = new HostRelay({ tunnelId, key, goal, hostName, idleMs, joinTtlMs }, log);
40
41
  const port = await relay.start();
41
42
  // Bounded retry: cloudflared may crash or never yield a URL; re-spawn before giving up.
42
43
  let tunnel;
@@ -56,6 +57,10 @@ export class TunnelSession {
56
57
  log.delete();
57
58
  throw new Error(`could not establish a cloudflared tunnel after ${OPEN_RETRY_ATTEMPTS} attempts: ${String(lastErr)}`);
58
59
  }
60
+ // Start the single-use link's expiry window now that the link exists —
61
+ // measured from mint time, not from relay construction (which happened
62
+ // before cloudflared provisioning could burn part of the window).
63
+ relay.armJoinDeadline();
59
64
  const joinLink = mintLink(tunnel.publicUrl, tunnelId, key);
60
65
  this.role = 'host';
61
66
  this.key = key;
@@ -71,7 +76,12 @@ export class TunnelSession {
71
76
  void this.close();
72
77
  });
73
78
  relay.submitLocal(buildSystem('host', `tunnel opened — goal: ${goal}`));
74
- return { tunnelId, joinLink, status: 'waiting_for_guest' };
79
+ return {
80
+ tunnelId,
81
+ joinLink,
82
+ status: 'waiting_for_guest',
83
+ joinLinkExpiresInSec: Math.round(joinTtlMs / 1000),
84
+ };
75
85
  }
76
86
  async join(joinLink, guestName) {
77
87
  if (this.isOpen)
package/dist/tools.js CHANGED
@@ -28,7 +28,7 @@ function register(server, name, schema, cb) {
28
28
  }
29
29
  export function registerTools(server, session, opts) {
30
30
  register(server, 'tunnel_open', {
31
- description: 'Open a tunnel as host and get a join link to share.',
31
+ description: 'Open a tunnel as host and get a join link to share. The link is a secret — share it over a trusted channel. It is single-use (works for exactly one guest) and expires (see joinLinkExpiresInSec in the result), so tell the human to share it promptly.',
32
32
  inputSchema: { goal: z.string() },
33
33
  }, async ({ goal }) => ok(await session.open(goal, opts.displayName)));
34
34
  register(server, 'tunnel_join', {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tunnel-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Let two developers' Claude agents talk directly through an ephemeral, end-to-end-encrypted tunnel.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,6 +28,7 @@
28
28
  "format": "prettier --write .",
29
29
  "format:check": "prettier --check .",
30
30
  "dev": "tsx src/index.ts",
31
+ "e2e": "tsx scripts/e2e-two-agents.ts",
31
32
  "prepublishOnly": "npm run build"
32
33
  },
33
34
  "keywords": [
@@ -62,6 +63,7 @@
62
63
  "zod": "^3.23.0"
63
64
  },
64
65
  "devDependencies": {
66
+ "@anthropic-ai/sdk": "^0.109.0",
65
67
  "@types/node": "^20.14.0",
66
68
  "@types/ws": "^8.5.10",
67
69
  "@vitest/coverage-v8": "^2.0.0",