8000 security: authenticate the V1 pipe surface and hook.event/agent.activity (fixes #72) by Rajveerx11 · Pull Request #73 · amirlehmam/wmux · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/cli/wmux.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,11 @@ function readPipeToken(): string {
const PIPE_TOKEN = readPipeToken();

function sendV1(command: string): Promise<string> {
// V1 state updates authenticate with an "auth <token> " prefix (issue #72).
const line = PIPE_TOKEN ? `auth ${PIPE_TOKEN} ${command}` : command;
return new Promise((resolve, reject) => {
const client = net.connect({ path: PIPE_PATH }, () => {
client.write(command + '\n');
client.write(line + '\n');
});
let data = '';
client.on('data', (chunk) => { data += chunk.toString(); });
Expand Down
51 changes: 36 additions & 15 deletions src/main/pipe-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,18 @@ export interface V2Request {
token?: string;
}

// V2 methods that are safe to call without authentication. These are read-only
// or telemetry-style and don't grant code execution / file access. Everything
// else (agent.spawn, browser.eval, markdown.load_file, pane/workspace mutation,
// …) requires a valid per-instance token. Keeping an allowlist (rather than a
// blocklist) means any new privileged method is locked down by default.
// V2 methods that are safe to call without authentication. These are strictly
// read-only and don't mutate any UI/agent state. Everything else — including
// telemetry-style writes like hook.event and agent.activity, which can spoof
// agent status / notifications / diff refreshes (issue #72) — requires a valid
// per-instance token. The legitimate telemetry clients (Claude Code hooks,
// agents, shell integration) all run inside wmux-spawned shells and carry
// WMUX_PIPE_TOKEN, so nothing tokenless has a reason to write state. Keeping
// an allowlist (rather than a blocklist) means any new privileged method is
// locked down by default.
const PUBLIC_V2_METHODS = new Set<string>([
'system.identify',
'system.capabilities',
'hook.event',
'agent.activity',
]);

export interface V2Response {
Expand Down Expand Up @@ -102,6 +104,21 @@ export class PipeServer extends EventEmitter {
}

private handleV1(line: string, socket: net.Socket): void {
// V1 lines from wmux-spawned clients carry an "auth <token> " prefix
// (WMUX_PIPE_TOKEN is injected into every wmux shell). All V1 commands
// mutate UI state (notify, report_pwd, report_pr, shell state, …), so —
// like privileged V2 methods — they require the per-instance token; an
// unauthenticated local process must not be able to spoof them (issue
// #72). Only `ping` (read-only liveness probe) stays public.
let authed = false;
if (line.startsWith('auth ')) {
const rest = line.substring(5);
const tokenEnd = rest.indexOf(' ');
const token = tokenEnd === -1 ? rest : rest.substring(0, tokenEnd);
authed = !!this.authToken && tokensMatch(token, this.authToken);
line = tokenEnd === -1 ? '' : rest.substring(tokenEnd + 1).trim();
}

// Parse command and surfaceId from fixed positions, then handle args
// per-command to avoid breaking paths that contain spaces (e.g. OneDrive).
const firstSpace = line.indexOf(' ');
Expand Down Expand Up @@ -132,15 +149,19 @@ export class PipeServer extends EventEmitter {
break;
}

const v1Command: V1Command = { command, surfaceId, args };
this.emit('v1', v1Command);

// Respond with OK for simple commands
if (command === 'ping') {
socket.write('pong\n');
} else {
socket.write('ok\n');
return;
}

if (!authed) {
socket.write('unauthorized\n');
return;
}

const v1Command: V1Command = { command, surfaceId, args };
this.emit('v1', v1Command);
socket.write('ok\n');
}

private handleV2(request: V2Request, socket: net.Socket): void {
Expand All @@ -154,8 +175,8 @@ export class PipeServer extends EventEmitter {
socket.write(JSON.stringify(response) + '\n');
};

// Authenticate privileged methods. Public methods (identify/capabilities/
// hooks) are exempt so detection and shell/agent telemetry keep working
// Authenticate privileged methods. Only read-only discovery methods
// (identify/capabilities) are exempt so instance detection keeps working
// without a token. -32001 signals "unauthorized" to clients.
if (!PUBLIC_V2_METHODS.has(request.method)) {
if (!this.authToken) {
Expand Down
13 changes: 9 additions & 4 deletions src/shell-integration/wmux-powershell-integration.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,13 @@ try {
# wmux CLI shortcut — Claude Code and users can just type: wmux browser open <url>
function wmux { node "$env:WMUX_CLI" @args }

# Named pipe client helper
# Named pipe client helper. State updates carry an "auth <token> " prefix —
# wmux injects WMUX_PIPE_TOKEN into every shell it spawns, and the pipe server
# rejects unauthenticated V1 commands (issue #72).
function Send-WmuxMessage {
param([string]$Message)
try {
if ($env:WMUX_PIPE_TOKEN) { $Message = "auth $($env:WMUX_PIPE_TOKEN) $Message" }
$pipe = New-Object System.IO.Pipes.NamedPipeClientStream(".", "wmux", [System.IO.Pipes.PipeDirection]::InOut)
$pipe.Connect(1000)
$writer = New-Object System.IO.StreamWriter($pipe)
Expand Down Expand Up @@ -109,23 +112,25 @@ $null = Register-EngineEvent -SourceIdentifier ([System.Management.Automation.PS
if ($global:_wmux_pr_started) { return }
$global:_wmux_pr_started = $true
$global:_wmux_pr_job = Start-Job -ScriptBlock {
param($surfaceId, $pipeName)
param($surfaceId, $pipeName, $pipeToken)
while ($true) {
Start-Sleep -Seconds 45
try {
$prJson = gh pr view --json number,state,title 2>$null
if ($LASTEXITCODE -eq 0 -and $prJson) {
$pr = $prJson | ConvertFrom-Json
$msg = "report_pr $surfaceId $($pr.number) $($pr.state) $($pr.title)"
if ($pipeToken) { $msg = "auth $pipeToken $msg" }
$pipe = New-Object System.IO.Pipes.NamedPipeClientStream(".", $pipeName, [System.IO.Pipes.PipeDirection]::InOut)
$pipe.Connect(1000)
$writer = New-Object System.IO.StreamWriter($pipe)
$writer.AutoFlush = $true
$writer.WriteLine("report_pr $surfaceId $($pr.number) $($pr.state) $($pr.title)")
$writer.WriteLine($msg)
$pipe.Close()
}
} catch { }
}
} -ArgumentList $env:WMUX_SURFACE_ID, "wmux"
} -ArgumentList $env:WMUX_SURFACE_ID, "wmux", $env:WMUX_PIPE_TOKEN
}

# Quick-launch profile startup commands (issue #32).
Expand Down
74 changes: 70 additions & 4 deletions tests/unit/pipe-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,21 +43,48 @@ describe('PipeServer', () => {
expect(response).toBe('pong');
});

it('parses V1 commands', async () => {
it('parses authenticated V1 commands', async () => {
const pipe = uniquePipe();
server = new PipeServer(pipe);
server = new PipeServer(pipe, 'test-token');
const commands: any[] = [];
server.on('v1', (cmd) => commands.push(cmd));
server.start();
await new Promise(r => setTimeout(r, 200));

await connectAndSend(pipe, 'report_pwd surf-123 C:\\Users\\test');
const response = await connectAndSend(pipe, 'auth test-token report_pwd surf-123 C:\\Users\\test');
expect(response).toBe('ok');
expect(commands.length).toBe(1);
expect(commands[0].command).toBe('report_pwd');
expect(commands[0].surfaceId).toBe('surf-123');
expect(commands[0].args).toEqual(['C:\\Users\\test']);
});

it('rejects V1 state updates without a token', async () => {
const pipe = uniquePipe();
server = new PipeServer(pipe, 'secret');
const commands: any[] = [];
server.on('v1', (cmd) => commands.push(cmd));
server.start();
await new Promise(r => setTimeout(r, 200));

const response = await connectAndSend(pipe, 'notify surf-123 agent needs your password');
expect(response).toBe('unauthorized');
expect(commands.length).toBe(0);
});

it('rejects V1 state updates with a wrong token', async () => {
const pipe = uniquePipe();
server = new PipeServer(pipe, 'secret');
const commands: any[] = [];
server.on('v1', (cmd) => commands.push(cmd));
server.start();
await new Promise(r => setTimeout(r, 200));

const response = await connectAndSend(pipe, 'auth wrong report_pwd surf-123 C:\\evil');
expect(response).toBe('unauthorized');
expect(commands.length).toBe(0);
});

it('handles V2 JSON-RPC', async () => {
const pipe = uniquePipe();
server = new PipeServer(pipe, 'test-token');
Expand Down Expand Up @@ -168,7 +195,7 @@ describe('PipeServer', () => {
expect(parsed.result.name).toBe('wmux');
});

it('still accepts unauthenticated V1 telemetry', async () => {
it('still accepts unauthenticated V1 ping', async () => {
const pipe = uniquePipe();
server = new PipeServer(pipe, 'secret');
server.start();
Expand All @@ -177,4 +204,43 @@ describe('PipeServer', () => {
const response = await connectAndSend(pipe, 'ping');
expect(response).toBe('pong');
});

it('rejects hook.event and agent.activity without a token', async () => {
const pipe = uniquePipe();
server = new PipeServer(pipe, 'secret');
let handlerCalled = false;
server.on('v2', (req, respond) => { handlerCalled = true; respond({ ok: true }); });
server.start();
await new Promise(r => setTimeout(r, 200));

for (const method of ['hook.event', 'agent.activity']) {
const response = await connectAndSend(pipe, JSON.stringify({
method,
params: { surfaceId: 'surf-victim', done: true, tool: 'Edit' },
id: 7,
}));
const parsed = JSON.parse(response);
expect(parsed.error?.code).toBe(-32001);
}
expect(handlerCalled).toBe(false);
});

it('allows hook.event and agent.activity with the correct token', async () => {
const pipe = uniquePipe();
server = new PipeServer(pipe, 'secret');
server.on('v2', (req, respond) => respond({ ok: true }));
server.start();
await new Promise(r => setTimeout(r, 200));

for (const method of ['hook.event', 'agent.activity']) {
const response = await connectAndSend(pipe, JSON.stringify({
method,
params: { surfaceId: 'surf-1', tool: 'Edit' },
id: 8,
token: 'secret',
}));
const parsed = JSON.parse(response);
expect(parsed.result).toEqual({ ok: true });
}
});
});
0