Skip to content

Commit a6e16ef

Browse files
javabrettclaude
andcommitted
feat: log proxy-blocked connections to system log via logger
When a network connection is blocked because its domain is not in allowedDomains, srt previously offered no visible signal under normal operation. Users hitting a blocked domain saw only a 403 or a failed tool call, with no indication of which domain was responsible. SRT_DEBUG=1 was required to see anything, which is not practical in normal use. This change adds logProxyDeny(), called from all three proxy handlers (HTTP CONNECT, HTTP request, SOCKS), which writes blocked-connection events to the system log via logger(1) rather than stderr: - Avoids stderr because the srt host process shares a terminal with the sandboxed child. stderr output interrupts TUI rendering in applications such as Claude Code CLI. - On macOS the message is suffixed with _SBX - the same tag srt uses in its seatbelt deny rules - making proxy-blocks visible in the same log stream as filesystem and mach-lookup denials: log stream --predicate 'eventMessage ENDSWITH "_SBX"' --style compact Example: srt proxy-blocked: HTTPS-CONNECT api.example.com:443_SBX - On Linux the message is written to syslog without the _SBX suffix (which is macOS seatbelt-specific). Monitor with journalctl -f or tail -f /var/log/syslog. Example: srt proxy-blocked: HTTPS-CONNECT api.example.com:443 The existing logForDebugging() call is retained alongside so SRT_DEBUG=1 users continue to see proxy-block events in the debug stream. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 7a725a3 commit a6e16ef

4 files changed

Lines changed: 167 additions & 2 deletions

File tree

src/sandbox/http-proxy.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { request as httpRequest } from 'node:http'
55
import { request as httpsRequest } from 'node:https'
66
import { connect } from 'node:net'
77
import { URL } from 'node:url'
8-
import { logForDebugging } from '../utils/debug.js'
8+
import { logForDebugging, logProxyDeny } from '../utils/debug.js'
99
import type { ResolvedParentProxy } from './parent-proxy.js'
1010
import {
1111
connectViaParentProxy,
@@ -69,6 +69,7 @@ export function createHttpProxyServer(options: HttpProxyServerOptions): Server {
6969

7070
const allowed = await options.filter(port, hostname, socket)
7171
if (!allowed) {
72+
logProxyDeny('HTTPS-CONNECT', hostname, port)
7273
logForDebugging(`Connection blocked to ${hostname}:${port}`, {
7374
level: 'error',
7475
})
@@ -156,6 +157,7 @@ export function createHttpProxyServer(options: HttpProxyServerOptions): Server {
156157

157158
const allowed = await options.filter(port, hostname, req.socket)
158159
if (!allowed) {
160+
logProxyDeny('HTTP', hostname, port)
159161
logForDebugging(`HTTP request blocked to ${hostname}:${port}`, {
160162
level: 'error',
161163
})

src/sandbox/socks-proxy.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { Server as NetServer, Socket } from 'net'
22
import type { Socks5Server } from '@pondwader/socks5-server'
33
import { createServer } from '@pondwader/socks5-server'
4-
import { logForDebugging } from '../utils/debug.js'
4+
import { logForDebugging, logProxyDeny } from '../utils/debug.js'
55
import type { ResolvedParentProxy } from './parent-proxy.js'
66
import {
77
connectViaParentProxy,
@@ -57,6 +57,7 @@ export function createSocksProxyServer(
5757
const allowed = await options.filter(port, hostname)
5858

5959
if (!allowed) {
60+
logProxyDeny('SOCKS', hostname, port)
6061
logForDebugging(`Connection blocked to ${hostname}:${port}`, {
6162
level: 'error',
6263
})

src/utils/debug.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,55 @@
1+
import { spawn } from 'node:child_process'
2+
3+
/**
4+
* Log a proxy deny via the system logger so it is visible in system logs
5+
* without writing to stderr.
6+
*
7+
* Writing to stderr is avoided because the srt host process shares a terminal
8+
* with the sandboxed child, and stderr output interrupts TUI rendering in
9+
* applications such as Claude Code CLI.
10+
*
11+
* On macOS the message is written via `logger`, which emits to the unified
12+
* log. The message is suffixed with `_SBX` - the same tag srt uses in its
13+
* seatbelt deny rules - so it can be captured alongside other srt violations:
14+
*
15+
* log stream --predicate 'eventMessage ENDSWITH "_SBX"' --style compact
16+
*
17+
* Example output:
18+
* srt proxy-blocked: HTTPS-CONNECT api.example.com:443_SBX
19+
*
20+
* On Linux the message is written via `logger` to syslog, without the _SBX
21+
* suffix (which is macOS seatbelt-specific). Monitor with:
22+
*
23+
* journalctl -f (systemd systems)
24+
* tail -f /var/log/syslog (syslog-ng / rsyslog systems)
25+
*
26+
* Example output:
27+
* srt proxy-blocked: HTTPS-CONNECT api.example.com:443
28+
*/
29+
export function logProxyDeny(
30+
protocol: 'HTTPS-CONNECT' | 'HTTP' | 'SOCKS',
31+
hostname: string,
32+
port: number,
33+
): void {
34+
if (process.platform === 'darwin') {
35+
// Suffix _SBX matches the seatbelt deny tag used throughout the srt
36+
// profile, making proxy-blocks visible in the same log stream as
37+
// filesystem and mach-lookup denials:
38+
// log stream --predicate 'eventMessage ENDSWITH "_SBX"'
39+
const message = `srt proxy-blocked: ${protocol} ${hostname}:${port}_SBX`
40+
spawn('logger', [message], { detached: true, stdio: 'ignore' }).unref()
41+
} else if (process.platform === 'linux') {
42+
// No _SBX suffix on Linux - that convention is macOS seatbelt-specific.
43+
const message = `srt proxy-blocked: ${protocol} ${hostname}:${port}`
44+
spawn('logger', [message], { detached: true, stdio: 'ignore' }).unref()
45+
}
46+
// Also surface in SRT_DEBUG stream for users with verbose logging enabled
47+
logForDebugging(
48+
`proxy-blocked: ${protocol} ${hostname}:${port} - not in allowedDomains`,
49+
{ level: 'error' },
50+
)
51+
}
52+
153
/**
254
* Simple debug logging for standalone sandbox
355
*/

test/utils/debug.test.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { describe, test, expect, spyOn } from 'bun:test'
2+
import { logProxyDeny, logForDebugging } from '../../src/utils/debug.js'
3+
4+
describe('logProxyDeny', () => {
5+
// The key contract: proxy-deny messages must NOT go to stderr.
6+
// Writing to stderr from the srt host process corrupts TUI rendering
7+
// in the sandboxed child (e.g. Claude Code CLI).
8+
// Instead, on macOS the message is sent to the unified log via logger.
9+
test('does not write to stderr for HTTPS-CONNECT', () => {
10+
const saved = process.env.SRT_DEBUG
11+
delete process.env.SRT_DEBUG
12+
13+
const writes: string[] = []
14+
const spy = spyOn(process.stderr, 'write').mockImplementation(
15+
(chunk: string | Uint8Array) => {
16+
writes.push(typeof chunk === 'string' ? chunk : chunk.toString())
17+
return true
18+
},
19+
)
20+
21+
logProxyDeny('HTTPS-CONNECT', 'api.example.com', 443)
22+
23+
expect(writes).toHaveLength(0)
24+
spy.mockRestore()
25+
if (saved !== undefined) process.env.SRT_DEBUG = saved
26+
})
27+
28+
test('does not write to stderr for HTTP', () => {
29+
const writes: string[] = []
30+
const spy = spyOn(process.stderr, 'write').mockImplementation(
31+
(chunk: string | Uint8Array) => {
32+
writes.push(typeof chunk === 'string' ? chunk : chunk.toString())
33+
return true
34+
},
35+
)
36+
37+
logProxyDeny('HTTP', 'example.com', 80)
38+
39+
expect(writes).toHaveLength(0)
40+
spy.mockRestore()
41+
})
42+
43+
test('does not write to stderr for SOCKS', () => {
44+
const writes: string[] = []
45+
const spy = spyOn(process.stderr, 'write').mockImplementation(
46+
(chunk: string | Uint8Array) => {
47+
writes.push(typeof chunk === 'string' ? chunk : chunk.toString())
48+
return true
49+
},
50+
)
51+
52+
logProxyDeny('SOCKS', 'internal.corp', 1080)
53+
54+
expect(writes).toHaveLength(0)
55+
spy.mockRestore()
56+
})
57+
58+
test('does not write to stderr even when SRT_DEBUG is set', () => {
59+
const saved = process.env.SRT_DEBUG
60+
process.env.SRT_DEBUG = '1'
61+
62+
const writes: string[] = []
63+
const spy = spyOn(process.stderr, 'write').mockImplementation(
64+
(chunk: string | Uint8Array) => {
65+
writes.push(typeof chunk === 'string' ? chunk : chunk.toString())
66+
return true
67+
},
68+
)
69+
70+
logProxyDeny('HTTPS-CONNECT', 'blocked.example.com', 443)
71+
72+
expect(writes).toHaveLength(0)
73+
spy.mockRestore()
74+
75+
process.env.SRT_DEBUG = saved ?? ''
76+
if (saved === undefined) delete process.env.SRT_DEBUG
77+
})
78+
})
79+
80+
describe('logForDebugging', () => {
81+
test('is silent when SRT_DEBUG is not set', () => {
82+
const saved = process.env.SRT_DEBUG
83+
delete process.env.SRT_DEBUG
84+
85+
const spy = spyOn(console, 'error').mockImplementation(() => {})
86+
logForDebugging('should not appear', { level: 'error' })
87+
expect(spy).not.toHaveBeenCalled()
88+
spy.mockRestore()
89+
90+
if (saved !== undefined) process.env.SRT_DEBUG = saved
91+
})
92+
93+
test('logs when SRT_DEBUG is set', () => {
94+
const saved = process.env.SRT_DEBUG
95+
process.env.SRT_DEBUG = '1'
96+
97+
const messages: string[] = []
98+
const spy = spyOn(console, 'error').mockImplementation(
99+
(...args: unknown[]) => {
100+
messages.push(String(args[0]))
101+
},
102+
)
103+
logForDebugging('test message', { level: 'error' })
104+
expect(messages.some(m => m.includes('test message'))).toBe(true)
105+
spy.mockRestore()
106+
107+
process.env.SRT_DEBUG = saved ?? ''
108+
if (saved === undefined) delete process.env.SRT_DEBUG
109+
})
110+
})

0 commit comments

Comments
 (0)