fix(pair-agent): keep the ngrok authtoken out of the transcript and shell argv

The not-authed flow told the user to paste their ngrok authtoken into the
chat so the agent could run `ngrok config add-authtoken` — putting a live
credential in the transcript, tool-call argv, and anything the transcript
syncs to. The user now runs the auth command in their own terminal; the
agent only verifies via `ngrok config check`, and a pasted token triggers a
rotate-and-reauth instruction. A static test pins that no agent-run bash
fence ever contains add-authtoken again.

Fixes #2335.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan 2026-08-14 15:52:35 -07:00
parent 888603f570
commit 5c9994a596
No known key found for this signature in database
GPG Key ID: C1F69E85C74EFE1D
3 changed files with 90 additions and 14 deletions

View File

@ -960,23 +960,34 @@ Then tell the user:
"Copy the block above and paste it into your other agent's chat. The setup key
expires in 5 minutes."
**If ngrok is installed but NOT authed:** Walk the user through authentication:
**If ngrok is installed but NOT authed:** Walk the user through authentication.
SECURITY: the ngrok authtoken must NEVER pass through this chat, a Bash tool
call, or shell history — a token pasted here lands in the transcript (and
anything the transcript syncs to). The user runs the auth command in their
OWN terminal; you only verify the result.
Tell the user:
"ngrok is installed but not logged in. Let's fix that:
"ngrok is installed but not logged in. Let's fix that — in your own terminal
(not here; the token should never enter this chat):
1. Go to https://dashboard.ngrok.com/get-started/your-authtoken
2. Copy your auth token
3. Come back here and I'll run the auth command for you."
3. In YOUR terminal, run: ngrok config add-authtoken <paste your token>
4. Tell me 'done' when finished."
STOP here and wait for the user to provide their auth token.
STOP here and wait for the user to say they've run it. Do NOT accept a pasted
token; if the user pastes one anyway, tell them to rotate it at
https://dashboard.ngrok.com (it's now in the transcript) and re-auth in their
terminal with the new one.
When they provide it, run:
When they say done, verify without touching the token:
```bash
ngrok config add-authtoken THEIR_TOKEN
ngrok config check 2>/dev/null && echo "NGROK_AUTHED" || echo "NGROK_NOT_AUTHED"
```
Then retry `$B pair-agent --client TARGET_HOST`.
If `NGROK_AUTHED`: retry `$B pair-agent --client TARGET_HOST`.
If still `NGROK_NOT_AUTHED`: ask them to re-run the command in their terminal.
**If ngrok is NOT installed:** Walk the user through installation:

View File

@ -155,23 +155,34 @@ Then tell the user:
"Copy the block above and paste it into your other agent's chat. The setup key
expires in 5 minutes."
**If ngrok is installed but NOT authed:** Walk the user through authentication:
**If ngrok is installed but NOT authed:** Walk the user through authentication.
SECURITY: the ngrok authtoken must NEVER pass through this chat, a Bash tool
call, or shell history — a token pasted here lands in the transcript (and
anything the transcript syncs to). The user runs the auth command in their
OWN terminal; you only verify the result.
Tell the user:
"ngrok is installed but not logged in. Let's fix that:
"ngrok is installed but not logged in. Let's fix that — in your own terminal
(not here; the token should never enter this chat):
1. Go to https://dashboard.ngrok.com/get-started/your-authtoken
2. Copy your auth token
3. Come back here and I'll run the auth command for you."
3. In YOUR terminal, run: ngrok config add-authtoken <paste your token>
4. Tell me 'done' when finished."
STOP here and wait for the user to provide their auth token.
STOP here and wait for the user to say they've run it. Do NOT accept a pasted
token; if the user pastes one anyway, tell them to rotate it at
https://dashboard.ngrok.com (it's now in the transcript) and re-auth in their
terminal with the new one.
When they provide it, run:
When they say done, verify without touching the token:
```bash
ngrok config add-authtoken THEIR_TOKEN
ngrok config check 2>/dev/null && echo "NGROK_AUTHED" || echo "NGROK_NOT_AUTHED"
```
Then retry `$B pair-agent --client TARGET_HOST`.
If `NGROK_AUTHED`: retry `$B pair-agent --client TARGET_HOST`.
If still `NGROK_NOT_AUTHED`: ask them to re-run the command in their terminal.
**If ngrok is NOT installed:** Walk the user through installation:

View File

@ -0,0 +1,54 @@
import { describe, test, expect } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
// Token hygiene for /pair-agent (#2335): the ngrok authtoken must never walk
// through the chat transcript or a Bash tool call. The skill may INSTRUCT the
// user to run `ngrok config add-authtoken` in their own terminal, but no
// agent-executed bash fence may contain the command — an agent-run
// `ngrok config add-authtoken THEIR_TOKEN` means the token arrived via the
// transcript and landed in shell argv/history.
const ROOT = path.resolve(import.meta.dir, '..');
function fencedBashBlocks(markdown: string): string[] {
// Line-based fence walk: a naive /```...```/ regex mis-pairs a CLOSING
// fence with the next block's opener and swallows the prose in between.
const blocks: string[] = [];
let inFence = false;
let lang = '';
let current: string[] = [];
for (const line of markdown.split('\n')) {
if (line.trimStart().startsWith('```')) {
if (!inFence) {
inFence = true;
lang = line.trim().slice(3).trim().toLowerCase();
current = [];
} else {
inFence = false;
if (lang === '' || lang === 'bash' || lang === 'sh') blocks.push(current.join('\n'));
}
continue;
}
if (inFence) current.push(line);
}
return blocks;
}
describe('pair-agent ngrok token hygiene (#2335)', () => {
const files = ['pair-agent/SKILL.md', 'pair-agent/SKILL.md.tmpl'];
test.each(files)('%s: no agent-run bash fence contains add-authtoken', (rel) => {
const content = fs.readFileSync(path.join(ROOT, rel), 'utf-8');
for (const block of fencedBashBlocks(content)) {
expect(block).not.toContain('add-authtoken');
}
});
test.each(files)('%s: instructs that the token never enters the chat', (rel) => {
const content = fs.readFileSync(path.join(ROOT, rel), 'utf-8');
expect(content.toLowerCase()).toContain('never enter this chat');
// The recovery path for an accidentally pasted token must exist.
expect(content.toLowerCase()).toContain('rotate');
});
});