fix(proxy): stop persisting 4xx request bodies by default (privacy) (#74)

Request bodies hold full prompts and any secrets in context. On 4xx (429s are
routine) they were gzipped to ~/.pxpipe/events.jsonl / 4xx-bodies/ with no
retention limit. Gate the capture behind captureErrorReqBody (env
PXPIPE_DEBUG_CAPTURE_4XX=1), default off, with a one-time startup warning. The
upstream errorBody (no user content) and the sha8 hash still land. Closes #69.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Shubham Srivastava
2026-07-16 23:00:09 +05:30
committed by GitHub
parent e33106b51e
commit 50ca4725df
3 changed files with 62 additions and 2 deletions
+6 -1
View File
@@ -37,6 +37,11 @@ export interface ProxyConfig {
transform?: TransformOptions | (() => TransformOptions);
/** Called after every request — useful for logging / metrics in the host. */
onRequest?: (event: ProxyEvent) => void | Promise<void>;
/** Persist the gzipped request body on 4xx (→ reqBodyGz, sidecar/inline).
* Off by default: request bodies hold full prompts and any secrets in
* context, so raw-body capture is opt-in debugging only. The upstream
* error body (errorBody) is unaffected — it carries no user content. */
captureErrorReqBody?: boolean;
}
export interface ProxyEvent {
@@ -637,7 +642,7 @@ export function createProxy(config: ProxyConfig = {}) {
// Gzip body lazily (only on 4xx). Async IIFE keeps fire() synchronous.
const finalize = async (): Promise<void> => {
let reqBodyGz: Uint8Array | undefined;
if (is4xx && reqBodyBytes && reqBodyBytes.byteLength > 0) {
if (config.captureErrorReqBody && is4xx && reqBodyBytes && reqBodyBytes.byteLength > 0) {
try {
reqBodyGz = await gzipBytes(reqBodyBytes);
} catch {
+16
View File
@@ -49,6 +49,9 @@ interface RuntimeConfig {
gatewayBaseUrl?: string;
gatewayHeaders?: Record<string, string>;
eventsFile: string;
/** Persist 4xx request bodies to disk for debugging. Off unless
* PXPIPE_DEBUG_CAPTURE_4XX=1. */
captureErrorReqBody: boolean;
}
const DEFAULT_CONFIG_FILE = path.join(os.homedir(), '.config', 'pxpipe', 'config.json');
@@ -117,6 +120,9 @@ function parseCli(argv: string[]): RuntimeConfig {
eventsFile:
process.env.PXPIPE_LOG ??
path.join(os.homedir(), '.pxpipe', 'events.jsonl'),
// Off by default: 4xx request bodies hold full prompts + any secrets in
// context. Opt in for debugging only. (issue #69)
captureErrorReqBody: process.env.PXPIPE_DEBUG_CAPTURE_4XX === '1',
};
}
@@ -168,6 +174,9 @@ Environment:
PXPIPE_LOG JSONL events path (default ~/.pxpipe/events.jsonl)
PXPIPE_DUMP_DIR debug: write every rendered PNG here (what the model
sees); off unless set. Compress arm only.
PXPIPE_DEBUG_CAPTURE_4XX debug: set to 1 to persist full 4xx request bodies
(prompts + any secrets in context) to disk. Off by
default; on-disk telemetry keeps only hashes.
Use with Claude Code:
ANTHROPIC_BASE_URL=http://127.0.0.1:47821 claude
@@ -959,6 +968,7 @@ async function main(): Promise<void> {
upstream: opts.upstream,
openAIUpstream: opts.openAIUpstream,
openAIApiKey: opts.openAIApiKey,
captureErrorReqBody: opts.captureErrorReqBody,
// Per-request transform options:
// 1. Runtime kill switch — when the dashboard "passthrough" toggle
// is off, force compress=false so /v1/messages forwards
@@ -1099,6 +1109,12 @@ async function main(): Promise<void> {
console.log(`[pxpipe] openai upstream → ${routes.openai}`);
console.log(`[pxpipe] tracking events → ${opts.eventsFile}`);
console.log(`[pxpipe] dashboard → http://127.0.0.1:${opts.port}/`);
if (opts.captureErrorReqBody) {
console.warn(
`[pxpipe] PXPIPE_DEBUG_CAPTURE_4XX=1 — persisting full 4xx request bodies ` +
`(prompts + any secrets in context) to ${bodySidecarDir}. Debugging only.`,
);
}
});
// server.close() only stops accepting new connections and waits for open
+40 -1
View File
@@ -896,11 +896,13 @@ describe('proxy usage extraction', () => {
return new Uint8Array(await new Response(stream).arrayBuffer());
}
it('captures the FULL gzipped transformed body on 4xx + sets reqBodySha8', async () => {
it('captures the FULL gzipped transformed body on 4xx (opt-in) + sets reqBodySha8', async () => {
// Pair with errorBody so a future debugger can reconstruct
// "we sent X, Anthropic said Y" from the JSONL alone. We gzip the body
// so even a 170 KiB transformed payload fits inline once base64'd
// (typical PNG-heavy bodies compress to <10% of source).
// captureErrorReqBody is off by default (privacy, issue #69); this test
// opts in explicitly.
const restore = mockUpstream(
() =>
new Response(JSON.stringify({ error: { type: 'bad' } }), {
@@ -912,6 +914,7 @@ describe('proxy usage extraction', () => {
let captured: ProxyEvent | undefined;
const proxy = createProxy({
transform: {},
captureErrorReqBody: true,
onRequest: (e) => {
captured = e;
},
@@ -948,6 +951,42 @@ describe('proxy usage extraction', () => {
expect(parsed.messages[0].role).toBe('user');
});
it('does NOT capture the 4xx request body by default (privacy, issue #69)', async () => {
// Request bodies hold full prompts + any secrets in context, so raw-body
// capture is opt-in only. errorBody (the upstream error) still lands.
const restore = mockUpstream(
() =>
new Response(JSON.stringify({ error: { type: 'bad' } }), {
status: 400,
headers: { 'content-type': 'application/json' },
}),
);
let captured: ProxyEvent | undefined;
const proxy = createProxy({
transform: {},
onRequest: (e) => {
captured = e;
},
});
const res = await proxy(
new Request('http://localhost/v1/messages', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: SAMPLE_REQ_BODY,
}),
);
await res.text();
await new Promise((r) => setTimeout(r, 20));
restore();
expect(captured!.status).toBe(400);
expect(captured!.reqBodySha8).toMatch(/^[0-9a-f]{8}$/); // hash still lands
expect(captured!.reqBodyGz).toBeUndefined(); // but not the raw body
expect(captured!.errorBody).toBeDefined(); // upstream error still captured
});
it('does NOT gzip the request body on 2xx (but still sets reqBodySha8)', async () => {
const restore = mockUpstream(
() =>