fix(browse): adversarial-review hardening — 6 findings fixed, regression-pinned

Pre-push adversarial review (4 lenses, refute-style verification: 13 raw
findings, 7 refuted, 6 confirmed) caught these; each fix carries a pin:

1. --restrict=read (equals form) sailed past validatePairAgentFlags —
   hasFlag/parseFlag are exact-token matches — so the user asked for a
   read-only sandbox and silently got FULL access: the exact failure mode
   this branch claims to close. The equals form is now a hard error before
   any server work.
2. handleTunnel trimmed the agent name but clientIds are stored verbatim,
   so a space-padded agent was unrevocable by the documented kill switch
   (trimmed DELETE 404'd while the grant stayed live). Names now pass
   through verbatim; the live-daemon test revokes ' padded'.
3. The sole pin for "CLI always sends explicit scopes" passed vacuously on
   a simulated revert: toContain('DEFAULT_PAIR_SCOPES') was satisfied by a
   comment. The tripwire now matches the code shape with a regex and bans
   the conditional spread formatting-insensitively.
4. The rewritten 403 scope hint was unpinned — new e2e asserts it names
   --restrict and --control and never --admin.
5. tunnelRevoke's verify-failure and HTTP-error branches and tunnelAgents'
   unreadable-list branch had no coverage — three stub-daemon pins added
   (an unreadable list must never render as "No paired agents").
6. CHANGELOG claimed "40+ new test cases"; the honest count is 35.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-08-20 04:29:07 +00:00
co-authored by Claude Fable 5
parent 5b22add861
commit 1ae9aef999
5 changed files with 156 additions and 5 deletions
+1 -1
View File
@@ -42,7 +42,7 @@ Revoke means revoked: one command deletes the session and every setup key, print
- `DELETE /token/:id` decodes percent-encoded client ids, so names with spaces round-trip from the CLI.
### For contributors
- 40+ new test cases: revoke-all regression shapes, a subprocess CLI harness with stub daemons pinning the version-skew net ("Revocation incomplete" on a lying daemon), e2e scope-contract pins, and source tripwires for `DEFAULT_PAIR_SCOPES` and the decode path.
- 35 new test cases: revoke-all regression shapes, a subprocess CLI harness with stub daemons pinning the version-skew net ("Revocation incomplete" on a lying daemon) and every CLI error branch, e2e scope-contract and 403-hint pins, and code-shape tripwires for `DEFAULT_PAIR_SCOPES` and the decode path.
## [1.68.1.0] - 2026-08-18
+10 -2
View File
@@ -1225,6 +1225,12 @@ async function tunnelAgents(): Promise<number> {
* opposite of the user's intent. And `control` never rides in via --restrict:
* browser-wide destructive ops stay behind the explicit --control flag. */
function validatePairAgentFlags(args: string[]): void {
// hasFlag/parseFlag are exact-token matches, so `--restrict=read` would
// sail past every check below and silently grant FULL access.
if (args.some(a => a.startsWith('--restrict='))) {
console.error('[browse] --restrict takes a space-separated value: --restrict read or --restrict "read,write". The --restrict=... form is not supported.');
process.exit(1);
}
if (!hasFlag(args, '--restrict')) return;
const restrict = parseFlag(args, '--restrict');
if (!restrict || !restrict.trim() || restrict.startsWith('--')) {
@@ -1244,8 +1250,10 @@ function validatePairAgentFlags(args: string[]): void {
async function handleTunnel(args: string[]): Promise<never> {
const sub = args[0];
if (sub === 'revoke' && args.length === 2 && args[1].trim()) {
process.exit(await tunnelRevoke(args[1].trim()));
// The name passes through VERBATIM: clientIds are stored untrimmed, so a
// space-padded name must stay revocable (encodeURIComponent handles it).
if (sub === 'revoke' && args.length === 2 && args[1]) {
process.exit(await tunnelRevoke(args[1]));
}
if (sub === 'agents' && args.length === 1) {
process.exit(await tunnelAgents());
+27
View File
@@ -264,6 +264,33 @@ describe('pair-agent flow end-to-end (HTTP only, no ngrok)', () => {
expect(body.error).toContain('control');
});
test('scope-denied 403 hint points at --restrict/--control, never --admin', async () => {
// Regression: the old hint said "re-pair with --admin", which is a legacy
// alias for --control — following it over-granted browser-wide control.
const pairResp = await fetch(`${daemon.baseUrl}/pair`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${daemon.token}` },
body: JSON.stringify({ clientId: 'hint-agent', scopes: ['read'] }),
});
const { setup_key } = await pairResp.json() as any;
const connectResp = await fetch(`${daemon.baseUrl}/connect`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ setup_key }),
});
const { token } = await connectResp.json() as any;
const resp = await fetch(`${daemon.baseUrl}/command`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ command: 'goto', args: ['https://example.com'] }),
});
expect(resp.status).toBe(403);
const body = await resp.json() as any;
expect(body.hint).toContain('--restrict');
expect(body.hint).toContain('--control');
expect(body.hint).not.toContain('--admin');
});
// ─── Revocation e2e: revoke-all + the /agents verification surface ────
test('DELETE /token revokes session AND setup keys; agent leaves /agents; token 401s; re-connect fails', async () => {
+4 -2
View File
@@ -420,8 +420,10 @@ describe('Pair scope defaults and revocation surface', () => {
const pairBlock = sliceBetween(SERVER_SRC, "url.pathname === '/pair'", "url.pathname === '/tunnel/start'");
expect(pairBlock).toContain('DEFAULT_PAIR_SCOPES');
const cliBlock = sliceBetween(CLI_SRC, 'async function handlePairAgent', 'Determine the URL to use');
expect(cliBlock).toContain('DEFAULT_PAIR_SCOPES');
expect(cliBlock).not.toContain('...(restrict ? { scopes');
// Match the CODE shape, not a comment: a bare toContain('DEFAULT_PAIR_SCOPES')
// is satisfied by the explanatory comment and passes vacuously on a revert.
expect(cliBlock).toMatch(/scopes:\s*restrict\s*\?[\s\S]{0,200}?:\s*\[\.\.\.DEFAULT_PAIR_SCOPES\]/);
expect(cliBlock).not.toMatch(/\.\.\.\(restrict\s*\?/);
});
// control is the only scope behind an explicit flag; a scopes list must
+114
View File
@@ -114,6 +114,22 @@ describe('pair-agent scope-flag validation (pre-server)', () => {
}
}, 30_000);
test('--restrict=read (equals form) → exit 1, NO daemon spawned', async () => {
// Regression: hasFlag/parseFlag are exact-token matches, so the equals
// form sailed past validatePairAgentFlags AND handlePairAgent's parse —
// the user asked for a read-only sandbox and silently got FULL access.
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-restrict-eq-'));
const stateFile = path.join(tmpDir, 'browse.json');
try {
const result = await runCli(['pair-agent', '--restrict=read'], baseEnv(stateFile));
expect(result.code).toBe(1);
expect(result.stderr).toContain('--restrict takes a space-separated value');
expect(fs.existsSync(stateFile)).toBe(false);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}, 30_000);
test('--restrict swallowing the next flag → exit 1, NO daemon spawned', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-restrict-flag-'));
const stateFile = path.join(tmpDir, 'browse.json');
@@ -248,6 +264,19 @@ describe('tunnel against a live daemon (HTTP only, no browser)', () => {
const empty = await runCli(['tunnel', 'agents'], baseEnv(stateFile));
expect(empty.code).toBe(0);
expect(empty.stdout).toContain('No paired agents.');
// Regression: handleTunnel used to trim() the name, but clientIds are
// stored verbatim — a space-padded agent became unrevocable by the
// documented kill switch (trimmed DELETE 404'd while the grant lived).
const padPair = await fetch(`${baseUrl}/pair`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${rootToken}` },
body: JSON.stringify({ clientId: ' padded' }),
});
expect(padPair.status).toBe(200);
const padRevoke = await runCli(['tunnel', 'revoke', ' padded'], baseEnv(stateFile));
expect(padRevoke.code).toBe(0);
expect(padRevoke.stdout).toContain('Verified: not in the active agent list.');
} finally {
try { daemon.kill('SIGKILL'); } catch { /* already gone */ }
fs.rmSync(tmpDir, { recursive: true, force: true });
@@ -289,6 +318,91 @@ describe('tunnel against a lying or unreachable daemon (stub harness)', () => {
}
}, 30_000);
test('DELETE succeeds but /agents errors → "could not verify", exit 1 (never a silent success)', async () => {
// Regression pin: a revoke whose verification read fails must NOT report
// clean success — the whole point of the re-read is proving the deletion.
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-tunnel-noverify-'));
const stateFile = path.join(tmpDir, 'browse.json');
const stub = Bun.serve({
hostname: '127.0.0.1',
port: 0,
fetch(req) {
const url = new URL(req.url);
if (req.method === 'DELETE' && url.pathname.startsWith('/token/')) {
return Response.json({ revoked: 'mallory', tokens_deleted: 1 });
}
if (url.pathname === '/agents') {
return new Response('boom', { status: 500 });
}
return Response.json({ status: 'healthy' });
},
});
try {
writeStateFile(stateFile, process.pid, stub.port);
const result = await runCli(['tunnel', 'revoke', 'mallory'], baseEnv(stateFile));
expect(result.code).toBe(1);
expect(result.stderr).toContain('Revoked, but could not verify against the agent list.');
} finally {
stub.stop(true);
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}, 30_000);
test('DELETE returns 500 → "Revoke failed" with the body error, exit 1', async () => {
// Regression pin: a non-404 HTTP failure must surface the daemon's error,
// not fall through to a success print.
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-tunnel-500-'));
const stateFile = path.join(tmpDir, 'browse.json');
const stub = Bun.serve({
hostname: '127.0.0.1',
port: 0,
fetch(req) {
const url = new URL(req.url);
if (req.method === 'DELETE' && url.pathname.startsWith('/token/')) {
return Response.json({ error: 'registry exploded' }, { status: 500 });
}
return Response.json({ status: 'healthy' });
},
});
try {
writeStateFile(stateFile, process.pid, stub.port);
const result = await runCli(['tunnel', 'revoke', 'mallory'], baseEnv(stateFile));
expect(result.code).toBe(1);
expect(result.stderr).toContain('Revoke failed: registry exploded');
} finally {
stub.stop(true);
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}, 30_000);
test('tunnel agents with a broken /agents → "Could not read the agent list", exit 1', async () => {
// Regression pin: a 500 from /agents must not render as "No paired
// agents." — an unreadable list is not an empty list.
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-tunnel-agents500-'));
const stateFile = path.join(tmpDir, 'browse.json');
const stub = Bun.serve({
hostname: '127.0.0.1',
port: 0,
fetch(req) {
const url = new URL(req.url);
if (url.pathname === '/agents') {
return new Response('boom', { status: 500 });
}
return Response.json({ status: 'healthy' });
},
});
try {
writeStateFile(stateFile, process.pid, stub.port);
const result = await runCli(['tunnel', 'agents'], baseEnv(stateFile));
expect(result.code).toBe(1);
expect(result.stderr).toContain('Could not read the agent list');
expect(result.stdout).not.toContain('No paired agents.');
} finally {
stub.stop(true);
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}, 30_000);
test('alive pid but unreachable port → "Could not reach daemon", exit 1 (NOT "no daemon")', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-tunnel-unreach-'));
const stateFile = path.join(tmpDir, 'browse.json');