Skip to content

Commit 1f83839

Browse files
debugmcpdevcynarlabclaude
authored
fix(logging): log debuggee output events by shape, not text, at the default level (#852) (#870)
Every DAP output event was logged with its full body at info: by the worker's MinimalDapClient (a redirectable logger, so into the per-session proxy log too) and twice by the server (ProxyManager.handleDapEvent and the functional core's log command). A program that printed a credential left it in plain text in two files under the temp directory that outlive the session, while get_output masked the same value (#237), and the logs grew with everything a chatty program printed. describeDapEventBodyForLog (shared, next to sanitizePayloadForLogging) reduces an output body to { category, outputLength } and passes every other event body through the request-payload sanitizer; the three info sites use it. The worker's existing debug-level copy of the output body is now sanitized too, so even --log-level debug logs mask token-shaped values the way get_output does. The events themselves are untouched. Measured (Windows 11, JavaScript session printing a fake ghp_ token and 500 stderr lines): before, the proxy log held the token once and 78 lines of program text, the per-pid server log held it twice in 156 lines (143 KB); after, neither file contains the token or any program line, and the server log is 60 KB. Co-authored-by: JF <john.franklin@gmail.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 5dfec6e commit 1f83839

12 files changed

Lines changed: 167 additions & 7 deletions

File tree

‎changelog.d/852.fixed.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
**Debuggee output is no longer copied verbatim into the server and proxy logs at the default level** — every DAP `output` event was written with its full body, at `info`, to the per-process server log (twice) and to the per-session proxy log, two files under the temp directory that outlive the session; a program that printed a credential left it there in plain text while `get_output` masked the same value, and the logs grew with everything a chatty program printed (one 480-line debuggee: 1.3 MB of log). Those records now carry the output's category and length only; the one verbatim copy is at `debug` level and masked like `get_output` is. Other event bodies (`process`, `stopped`, …) are logged after the same sanitization as request payloads — environment blocks counted, token-shaped values masked (#852)

‎docs/tool-reference.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -750,6 +750,8 @@ Results that had values masked carry a `redaction` field (`{ masked, notice }` o
750750

751751
**Opting out**: start the server with `DEBUG_MCP_NO_REDACT=1` to disable redaction entirely (e.g. when debugging credential-handling code itself). Adapter stderr sanitization (unconditional, whole-line) is unaffected by this flag.
752752

753+
**Logs**: the server's per-process log and the per-session proxy log (both under the temp directory, outliving the session) record a debuggee output event by its category and length only; the text itself is written only at `--log-level debug`, masked the same way `get_output` is. Other DAP event bodies are logged after the same sanitization as request payloads (environment blocks counted, token-shaped values masked).
754+
753755
**Limitations**: redaction is display-level protection against credentials leaking into transcripts, not a security boundary against a hostile agent — an agent can still compute over secrets via `evaluate_expression` side effects. Secrets split across separate output chunks, and generic high-entropy strings with no recognizable shape or name, are not detected. The [`expose_session`](#expose_session) IDE mirror shows **raw, unredacted** values — it serves a human's IDE, not the agent.
754756

755757
### Least-privilege mode

‎packages/shared/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,7 @@ export type { DebugProtocol } from '@vscode/debugprotocol';
257257
export {
258258
sanitizeEnvForLogging,
259259
sanitizePayloadForLogging,
260+
describeDapEventBodyForLog,
260261
sanitizeStderr,
261262
sanitizeStderrTail
262263
} from './utils/env-sanitizer.js';

‎packages/shared/src/utils/env-sanitizer.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,27 @@ export function sanitizePayloadForLogging(payload: unknown): unknown {
7979
return redactEnvDeep(payload, new WeakSet());
8080
}
8181

82+
/**
83+
* The log-safe view of a DAP event body (issue #852). An `output` event's
84+
* body is the debuggee's own text: logged as it came, at the default level,
85+
* a program that prints a credential left it in plain text in two files
86+
* that outlive the session (the per-pid server log and the per-session
87+
* proxy log) while `get_output` masked the same value, and the logs grew
88+
* with everything a chatty program printed. The default-level record keeps
89+
* only the shape of the output — its category and length — and every other
90+
* event body goes through the same sanitizer as a request payload.
91+
*/
92+
export function describeDapEventBodyForLog(event: string, body: unknown): unknown {
93+
if (event === 'output') {
94+
const fields = body && typeof body === 'object' ? (body as Record<string, unknown>) : {};
95+
return {
96+
category: typeof fields.category === 'string' ? fields.category : 'console',
97+
outputLength: typeof fields.output === 'string' ? fields.output.length : 0
98+
};
99+
}
100+
return sanitizePayloadForLogging(body);
101+
}
102+
82103
function redactEnvDeep(value: unknown, ancestors: WeakSet<object>): unknown {
83104
// Value-shape masking on string leaves (issue #237): DAP response payloads
84105
// (variables/evaluate bodies) flow through here on their way to proxy debug

‎packages/shared/tests/unit/env-sanitizer.test.ts‎

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import {
66
sanitizeEnvForLogging,
77
sanitizePayloadForLogging,
88
sanitizeStderr,
9-
sanitizeStderrTail
9+
sanitizeStderrTail,
10+
describeDapEventBodyForLog
1011
} from '../../src/utils/env-sanitizer.js';
1112

1213
describe('sanitizeEnvForLogging', () => {
@@ -341,3 +342,35 @@ describe('sanitizeStderrTail', () => {
341342
expect(sanitizeStderrTail(' \n \r\n')).toBe('');
342343
});
343344
});
345+
346+
describe('describeDapEventBodyForLog (issue #852)', () => {
347+
const token = 'ghp_A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8S9t0';
348+
349+
it('keeps only the shape of an output event: category and length, never the text', () => {
350+
const output = `token=${token}\n`;
351+
const described = describeDapEventBodyForLog('output', { category: 'stdout', output });
352+
expect(described).toEqual({ category: 'stdout', outputLength: output.length });
353+
expect(JSON.stringify(described)).not.toContain('ghp_');
354+
});
355+
356+
it('defaults a missing category to console and a missing text to length 0', () => {
357+
expect(describeDapEventBodyForLog('output', {})).toEqual({ category: 'console', outputLength: 0 });
358+
expect(describeDapEventBodyForLog('output', undefined)).toEqual({ category: 'console', outputLength: 0 });
359+
});
360+
361+
it('sanitizes every other event body like a request payload', () => {
362+
const described = describeDapEventBodyForLog('process', {
363+
name: 'app',
364+
env: { SECRET: 'x' },
365+
note: `bearer ${token}`
366+
}) as Record<string, unknown>;
367+
expect(described.env).toBe('<1 env vars redacted>');
368+
expect(String(described.note)).not.toContain(token);
369+
expect(described.name).toBe('app');
370+
});
371+
372+
it('passes a stopped body through unchanged in shape', () => {
373+
expect(describeDapEventBodyForLog('stopped', { reason: 'breakpoint', threadId: 1 }))
374+
.toEqual({ reason: 'breakpoint', threadId: 1 });
375+
});
376+
});

‎src/dap-core/handlers.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
getPendingRequest,
1919
removePendingRequest
2020
} from './state.js';
21+
import { describeDapEventBodyForLog } from '@debugmcp/shared';
2122

2223
/**
2324
* Main handler for proxy messages
@@ -132,7 +133,8 @@ function handleDapEvent(
132133
type: 'log',
133134
level: 'info',
134135
message: `[ProxyManager] DAP event: ${message.event}`,
135-
data: message.body
136+
// Debuggee output by shape only, other bodies sanitized (issue #852)
137+
data: describeDapEventBodyForLog(message.event, message.body)
136138
}
137139
];
138140

‎src/proxy/dap-proxy-worker.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@ import {
4646
DotnetAdapterPolicy,
4747
MockAdapterPolicy,
4848
getPolicyForLanguage,
49-
resolveExceptionFilters
49+
resolveExceptionFilters,
50+
sanitizePayloadForLogging
5051
} from '@debugmcp/shared';
5152

5253
export type DapProxyWorkerHooks = {
@@ -1011,7 +1012,9 @@ export class DapProxyWorker {
10111012
}
10121013
},
10131014
onOutput: (body) => {
1014-
this.logger!.debug('[Worker] DAP event: output', body);
1015+
// The one verbatim copy of debuggee output in the logs, at debug
1016+
// level only and masked like get_output is (issue #852).
1017+
this.logger!.debug('[Worker] DAP event: output', sanitizePayloadForLogging(body));
10151018
this.noteDebuggeeExitCodeFromOutput(body);
10161019
this.sendDapEvent('output', body);
10171020
},

‎src/proxy/minimal-dap.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ import {
1818
type ParentStart,
1919
type ChildSessionConfig,
2020
buildNoDebugTargetError,
21-
sanitizePayloadForLogging
21+
sanitizePayloadForLogging,
22+
describeDapEventBodyForLog
2223
} from '@debugmcp/shared';
2324
import { ChildSessionManager, type ChildSessionOptions } from './child-session-manager.js';
2425
import { sendSetBreakpointsStamping } from './breakpoint-condition-errors.js';
@@ -257,9 +258,12 @@ export class MinimalDapClient extends EventEmitter implements IDapClient {
257258
});
258259
} else if (message.type === 'event') {
259260
const evt = message as DebugProtocol.Event;
261+
// An output event's body is the debuggee's own text, and this logger
262+
// also writes the per-session proxy log: only its shape is recorded,
263+
// and other bodies are sanitized like request payloads (issue #852).
260264
logger.info(`[MinimalDapClient] Event: ${evt.event}`, {
261265
event: evt.event,
262-
body: evt.body
266+
body: describeDapEventBodyForLog(evt.event, evt.body)
263267
});
264268
}
265269

‎src/proxy/proxy-manager.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
IDebugAdapter,
3838
AdapterLaunchBarrier,
3939
sanitizePayloadForLogging,
40+
describeDapEventBodyForLog,
4041
sanitizeStderr,
4142
LineBuffer
4243
} from '@debugmcp/shared';
@@ -1346,7 +1347,8 @@ export class ProxyManager extends EventEmitter implements IProxyManager {
13461347
message.body as DebugProtocol.Event['body'] | undefined
13471348
);
13481349

1349-
this.logger.info(`[ProxyManager] DAP event: ${message.event}`, message.body);
1350+
// Debuggee output is logged by shape only (issue #852); see describeDapEventBodyForLog.
1351+
this.logger.info(`[ProxyManager] DAP event: ${message.event}`, describeDapEventBodyForLog(message.event, message.body));
13501352

13511353
switch (message.event) {
13521354
case 'stopped':

‎tests/unit/dap-core/handlers.test.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,30 @@ describe('DAP Core Handlers', () => {
242242
});
243243
});
244244

245+
it('logs an output event as category and length, never its text (issue #852)', () => {
246+
const message: ProxyDapEventMessage = {
247+
type: 'dapEvent',
248+
sessionId: 'test-session-123',
249+
event: 'output',
250+
body: { category: 'stdout', output: 'token=ghp_A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8S9t0\n' }
251+
};
252+
253+
const result = handleProxyMessage(state, message);
254+
255+
expect(result.commands[0]).toEqual({
256+
type: 'log',
257+
level: 'info',
258+
message: '[ProxyManager] DAP event: output',
259+
data: { category: 'stdout', outputLength: (message.body as { output: string }).output.length }
260+
});
261+
// The event itself still carries the real text to the session buffer
262+
expect(result.commands[1]).toEqual({
263+
type: 'emitEvent',
264+
event: 'output',
265+
args: [message.body]
266+
});
267+
});
268+
245269
it('should forward unknown DAP events', () => {
246270
const message: ProxyDapEventMessage = {
247271
type: 'dapEvent',

0 commit comments

Comments
 (0)