Skip to content
Merged
Show file tree
Hide file tree
Changes from 92 commits
Commits
Show all changes
93 commits
Select commit Hold shift + click to select a range
de9f9b9
feat(utils): add centralized redaction utility
Gravirei Jun 18, 2026
d272057
refactor(Feedback): import redactSensitiveInfo from utils
Gravirei Jun 18, 2026
3226635
refactor(submitTranscriptShare): import redactSensitiveInfo from utils
Gravirei Jun 18, 2026
7320cc2
feat(log,debug): redact secrets in default error and debug output
Gravirei Jun 18, 2026
2090098
feat(api/logging): redact error message in logAPIError
Gravirei Jun 18, 2026
94bc46b
fix: resolve merge conflict from upstream sync
Gravirei Jun 18, 2026
5f9aef7
fix(channelNotification): allow null in getEffectiveChannelAllowlist …
Gravirei Jun 18, 2026
f7ea365
feat(redaction): exclude specific token fields from redaction process
Gravirei Jun 18, 2026
1a2217b
fix(redaction): lower AIza minimum length to {10,}
Gravirei Jun 18, 2026
bf6a9e8
fix(redaction,log): address review feedback
Gravirei Jun 18, 2026
fe7323f
refactor(redaction): consolidate into single module + add channel gat…
Gravirei Jun 22, 2026
da2138c
test(channelNotification): cover findChannelEntry multi-candidate branch
Gravirei Jun 19, 2026
e61492c
fix(redaction): align URL fallback regex + add path-prefix boundary c…
Gravirei Jun 22, 2026
905cd37
fix(channel,redaction): restore dev-channel warning + align URL fallback
Gravirei Jun 22, 2026
fcb94c4
fix(redaction): widen key boundary class + tighten dev-channel comment
Gravirei Jun 19, 2026
46bde41
fix(redaction,channel): address P1/P2 review findings
Gravirei Jun 20, 2026
86428b3
fix(channel): apply marketplace matching to permission relays, remove…
Gravirei Jun 20, 2026
36ad53f
test(dev-channel): clarify count assertion comment + add afterEach wi…
Gravirei Jun 21, 2026
e3229b8
fix(channel): mirror marketplace gate in permission relay + restore mock
Gravirei Jun 22, 2026
e239f6b
refactor(redaction): consolidate into single module + add channel gat…
Gravirei Jun 22, 2026
468708e
fix(test): align malformed URL fragment expectation with preservation…
Gravirei Jun 22, 2026
d8a1daa
fix: address review findings P1 and P2
Gravirei Jun 22, 2026
be5f6db
test(relay): add plugin-kind marketplace regression tests
Gravirei Jun 22, 2026
e3b0651
fix: address review findings P1 and P2
Gravirei Jun 22, 2026
3b43715
fix(debug): redact before JSON-stringify multiline messages
Gravirei Jun 23, 2026
7da2fe4
test(debug): add end-to-end regression for multiline PEM redaction in…
Gravirei Jun 23, 2026
b204bcb
fix(test): preserve original process.env.DEBUG and process.argv in lo…
Gravirei Jun 23, 2026
ed3ffac
fix: address PR review findings P1-P3/P5-P7
Gravirei Jun 24, 2026
7b73de1
fix: address CodeRabbit review findings
Gravirei Jun 24, 2026
6740fc8
fix: address second review round
Gravirei Jun 24, 2026
b418dbb
fix: address CodeRabbit second round
Gravirei Jun 24, 2026
77c54b4
fix: improve permission relay client filtering and enhance redaction …
Gravirei Jun 24, 2026
8c7a0e7
fix: address third review round (P1, P2, P3)
Gravirei Jun 25, 2026
4e78f8f
fix: address latest reviewer P2/P3 findings (errorLogSink redaction, …
Gravirei Jun 25, 2026
019a101
fix: address reviewer P1/P2 — bracketed values and multi-word header …
Gravirei Jun 25, 2026
82eca19
fix: address reviewer findings P1-P4
Gravirei Jun 26, 2026
be9aca7
fix: extract sanitizeError() to fix CI test fragility
Gravirei Jun 26, 2026
faf795f
fix: use Object.getPrototypeOf(err) instead of err as prototype in sa…
Gravirei Jun 26, 2026
beada5b
fix: apply key-aware redaction and fail closed on non-serializable er…
Gravirei Jun 26, 2026
8ac90ee
Update src/utils/log.ts
Gravirei Jun 26, 2026
c6d381e
fix: redact bare auth header keys in JSON/header objects
openhands-agent Jun 26, 2026
dfe8a11
fix: narrow auth matching, redact nested transcript JSONL, fix channe…
Gravirei Jun 27, 2026
ef925aa
fix: address CodeRabbit nits — comment, hint, JSONL fallback redaction
Gravirei Jun 27, 2026
0d59998
fix: key-aware malformed JSONL fallback and auth/x-auth in free-form …
Gravirei Jun 27, 2026
89852e9
fix: strengthen redactJsonLines trailing rest redaction and auth test…
Gravirei Jun 27, 2026
54c7245
fix: preserve non-JSON prefix in redactJsonLines fallback and redact it
Gravirei Jun 27, 2026
f58b2b6
fix: tighten redactJsonLines prefix test to exact output assertion
Gravirei Jun 27, 2026
cd4140c
fix: redact MCP log sink payloads and errorStr before writing to disk
Gravirei Jun 27, 2026
5fef836
fix: address P1 findings — URL #-in-password, ;-delimited query param…
Gravirei Jun 27, 2026
8ba877a
fix: update callers to match upstream/main function signatures
Gravirei Jun 28, 2026
439c206
fix: address reviewer findings — OAuth mock, notice states, marketpla…
Gravirei Jun 28, 2026
e66466a
fix: address reviewer findings — relay gate parity and allowlist regr…
Gravirei Jun 28, 2026
9ee608e
fix: redact mixed semicolon secrets in valid-URL path and route OpenA…
Gravirei Jun 28, 2026
1f54149
Update src/utils/redaction.ts
Gravirei Jun 28, 2026
eaa9ce2
fix: add fragment-query credential regression test and correct dev-ch…
Gravirei Jun 28, 2026
42166c0
fix: add port+fragment+@ fallback test and restructure dev-channels d…
Gravirei Jun 28, 2026
cb5f2f1
fix: registerDevChannels seam, bare-host #-in-password heuristic, and…
Gravirei Jun 28, 2026
d4b5000
fix: add OAuth and org-policy gate test coverage
Gravirei Jun 28, 2026
6311960
fix: prefer exact server channel entries before plugin disambiguation
Gravirei Jun 28, 2026
e3f75d7
fix: only trust exact [REDACTED] placeholder in generic header field …
Gravirei Jun 28, 2026
decd516
fix: handle bare hosts in malformed URL userinfo fallback
Gravirei Jun 28, 2026
b207c35
fix: add relay dispatch path test for non-allowlisted plugin
Gravirei Jun 28, 2026
598cb8a
fix: enhance URL redaction logic to handle valid hosts before fragment
Gravirei Jun 28, 2026
4be666c
fix: refine URL redaction logic to ensure valid host checks before fr…
Gravirei Jun 28, 2026
b2e4742
fix: enhance redaction logic to handle embedded URLs in free-form text
Gravirei Jun 28, 2026
1be0ada
fix: update redaction logic to remove user info from OpenAI base URL …
Gravirei Jun 28, 2026
8226f6e
fix: ensure findChannelEntry returns undefined when no exact matches …
Gravirei Jun 28, 2026
98a828e
fix: improve URL redaction logic to remove user info and ensure prope…
Gravirei Jun 28, 2026
1f03e16
fix: enhance redactDiagnosticUrl to preserve query-param values and t…
Gravirei Jun 28, 2026
5480f09
fix: refine redaction logic to preserve meaningful path segments and …
Gravirei Jun 28, 2026
4fd38db
fix: enhance redactDiagnosticUrl to preserve literal path segments an…
Gravirei Jun 28, 2026
f3b85c4
fix: preserve semicolon-delimited query params during redaction
Gravirei Jun 29, 2026
1804607
fix: update redaction logic to support semicolon-delimited query para…
Gravirei Jun 29, 2026
dbd4d6a
fix: enhance redactUrlForDisplay to handle bare hosts and improve fra…
Gravirei Jun 29, 2026
d6413d3
fix: enhance redactUrlForDisplay to correctly handle username-only us…
Gravirei Jun 29, 2026
76534b1
fix: address privacy findings — URL redaction in jsonRedactor, base U…
Gravirei Jun 29, 2026
5d9afce
fix: preserve falsey env-presence values in diagnostic redaction
Gravirei Jun 29, 2026
aa140ab
fix: address CodeRabbit findings — sync describe, heartbeat emitter, …
Gravirei Jun 29, 2026
9ce9f28
chore: remove stray Windows path artifact
Gravirei Jun 29, 2026
ce7925b
fix: update redaction import path in taskReport module
Gravirei Jun 29, 2026
c15a60d
fix: address CodeRabbit P1-P3 findings and rebase regressions
Gravirei Jun 29, 2026
05a2be1
fix: address reviewer findings — generic regex &-handling and diagnos…
Gravirei Jun 29, 2026
2c2a55f
fix: address reviewer findings — URL delimiter safety, jsonRedactor #…
Gravirei Jun 30, 2026
adff034
fix: add Cookie/Set-Cookie semicolon-safe redaction pass, tighten &-t…
Gravirei Jun 30, 2026
e7a7e64
fix: COOKIE_PATTERN consume comma-joined multi-cookie values
Gravirei Jun 30, 2026
1892de4
fix: address P2 findings — URL redact skip, pre-drain write promise, …
Gravirei Jun 30, 2026
dbeb0a3
fix: update log.test.ts expectation, add protocol-relative URL support
Gravirei Jun 30, 2026
ed84f9c
fix: enhance redaction for provider env-vars in URLs, preserve safe q…
Gravirei Jun 30, 2026
c2a81de
fix: enhance redaction for uppercase provider keys and cookie query p…
Gravirei Jun 30, 2026
7fea750
fix: enhance redaction for bare Bearer and JWT tokens in sensitive info
Gravirei Jul 1, 2026
1af7e58
fix: update report task test expectations for new redaction format
Gravirei Jul 1, 2026
1dd6abb
fix: limit token exemption to numeric values, protect semicolon cooki…
Gravirei Jul 1, 2026
ac39d56
test: add tests for truncateForPreview to ensure sensitive data redac…
Gravirei Jul 2, 2026
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
2 changes: 1 addition & 1 deletion scripts/system-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import {
redactSecretSubstringsForDisplay,
type SecretValueSource,
} from '../src/utils/providerSecrets.js'
import { redactUrlForDisplay } from '../src/utils/urlRedaction.js'
import { redactUrlForDisplay } from '../src/utils/redaction.js'
import {
MIN_NODE_ENGINE_RANGE,
checkSupportedNodeVersion,
Expand Down
148 changes: 147 additions & 1 deletion src/__tests__/bugfixes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
* 4. Web search result count improvements
*/

import { describe, test, expect } from 'bun:test'
import { afterEach, describe, test, expect, mock } from 'bun:test'
import { resolve } from 'path'
import {
clearRegisteredHooks,
Expand All @@ -18,6 +18,13 @@ import { getMatchingHooks } from '../utils/hooks.js'
import type { PluginHookMatcher } from '../utils/settings/types.js'

const SRC = resolve(import.meta.dir, '..')

// Real channelAllowlist module — captured before mocking so describe-block
// afterEach can re-register it. Must be at module scope so describe() is
// synchronous (Bun registers tests synchronously from describe callbacks).
const _realChannelAllowlist = await import(
`../services/mcp/channelAllowlist.js?real=${Date.now()}-${Math.random()}`
)
const file = (relative: string) => Bun.file(resolve(SRC, relative))

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -551,3 +558,142 @@ describe('Project-scope MCP approval — third-party providers (issue #696)', ()
expect(content).toContain('#696')
})
})

// ---------------------------------------------------------------------------
// Fix N: --dangerously-load-development-channels dialog coverage (PR review)
// ---------------------------------------------------------------------------
describe('Dev-channels dialog coverage', () => {
// Source structure check: verify the branching logic exists in the code
test('showSetupScreens guards dev-channels dialog behind isChannelsEnabled', async () => {
const content = await file('interactiveHelpers.tsx').text()

// The dev-channels section at interactiveHelpers.tsx:~263 must branch on
// isChannelsEnabled(): true → show dialog, false → register directly.
expect(content).toContain('if (!isChannelsEnabled())')
expect(content).toContain('DevChannelsDialog')

// Verify that registerDevChannels is called in exactly two sites.
// This count is a SEMANTIC requirement, not a style preference.
// interactiveHelpers.tsx has exactly two sites that register
// dev entries:
// 1. The `!isChannelsEnabled()` branch (~line 286): entries
// are registered directly without user interaction.
// 2. The DevChannelsDialog `onAccept` handler (~line 303):
// entries are registered after the user confirms.
// Both sites delegate to registerDevChannels() which sets
// `dev: true` per-entry so the allowlist bypass (granted by the
// dev flag in `gateChannelServer`) cannot leak to production
// `--channels` entries. If a refactor adds or removes a site,
// update this count AND verify the security invariant still
// holds: a dev entry is never confused with a production entry
// in the allowlist check.
const regCalls = content.match(/registerDevChannels\(devChannels\)/g)
expect(regCalls).not.toBeNull()
expect(regCalls!.length).toBe(2)
})

// The function that materialises dev: true per-entry lives in the
// importable seam, not in showSetupScreens inline.
test('registerDevChannels definition sets dev: true', async () => {
const content = await file('utils/devChannelRegistration.ts').text()
expect(content).toContain('dev: true')
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Runtime tests: exercise the same behavior paths that showSetupScreens
// uses when --dangerously-load-development-channels is passed.
//
// NOTE: We cannot import showSetupScreens() directly in tests. The
// module chain (interactiveHelpers.tsx → main.js → main.tsx) triggers
// Bun's compile-time `feature()` macro checker at main.tsx lines ~1494
// and ~1516, which require `feature()` to appear directly in an
// `if`/ternary — the object-literal usage there fails at parse time
// before mock.module can intercept resolution. The tests below
// exercise the identical state-mutation patterns through the directly
// importable registerDevChannels seam and DevChannelsDialog component.
describe('isChannelsEnabled branching', () => {
// afterEach re-registers the real module so neighboring test files
// (e.g. channelNotification.test.ts) don't fail with "Export named
// 'getChannelAllowlist' not found". mock.restore() does NOT clear
// module-level mock.module() overrides in bun (registry is
// process-global), so we must re-register from the cache-busted
// reference captured at module scope.
afterEach(() => {
mock.restore()
mock.module(
'../services/mcp/channelAllowlist.js',
() => _realChannelAllowlist,
)
// Reset shared bootstrap state so failures don't leak into later tests.
const {
setAllowedChannels: resetAllowed,
setHasDevChannels: resetHasDev,
} = require('../bootstrap/state.js')
resetAllowed([])
resetHasDev(false)
})

const devChannels = [
{ kind: 'server' as const, name: 'dev-server' },
]

test(
'isChannelsEnabled=true: DevChannelsDialog onAccept calls registerDevChannels',
async () => {
mock.module('../services/mcp/channelAllowlist.js', () => ({
isChannelsEnabled: () => true,
}))

const { registerDevChannels } = await import(
'../utils/devChannelRegistration.js'
)
const { DevChannelsDialog } = await import(
'../components/DevChannelsDialog.js'
)
const React = await import('react')
const { getAllowedChannels, getHasDevChannels } = await import(
'../bootstrap/state.js'
)

let onAcceptCalled = false
const element = React.createElement(DevChannelsDialog, {
channels: devChannels,
onAccept: () => {
registerDevChannels(devChannels)
onAcceptCalled = true
},
})

element.props.onAccept()
expect(onAcceptCalled).toBe(true)

const all = getAllowedChannels()
expect(all.length).toBe(1)
expect(all[0]).toMatchObject({ name: 'dev-server', dev: true })
expect(getHasDevChannels()).toBe(true)
},
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

test(
'isChannelsEnabled=false: registerDevChannels called directly without dialog',
async () => {
mock.module('../services/mcp/channelAllowlist.js', () => ({
isChannelsEnabled: () => false,
}))

const { registerDevChannels } = await import(
'../utils/devChannelRegistration.js'
)
const { getAllowedChannels, getHasDevChannels } = await import(
'../bootstrap/state.js'
)

registerDevChannels(devChannels)

const all = getAllowedChannels()
expect(all.length).toBe(1)
expect(all[0]).toMatchObject({ name: 'dev-server', dev: true })
expect(getHasDevChannels()).toBe(true)
},
)
})
})
6 changes: 4 additions & 2 deletions src/cli/print.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1092,7 +1092,9 @@ export function createHeadlessHeartbeatStructuredEmitter(
): (message: HeadlessHeartbeatEvent) => void | Promise<void> {
return message => {
if (!hasDrainStarted()) {
return
// Before drain starts, write directly so startup signals in
// stream-json mode are not silently dropped.
return structuredIO.write(message)
}
structuredIO.outbound.enqueue(message)
}
Expand Down Expand Up @@ -4980,7 +4982,7 @@ function reregisterChannelHandlerAfterReconnect(
)
if (gate.action !== 'register') return

const entry = findChannelEntry(connection.name, getAllowedChannels())
const entry = findChannelEntry(connection.name, getAllowedChannels(), connection.config.pluginSource)
const pluginId =
entry?.kind === 'plugin'
? (`${entry.name}@${entry.marketplace}` as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS)
Expand Down
27 changes: 23 additions & 4 deletions src/cli/printHeartbeat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ const heartbeatEvent: HeadlessHeartbeatEvent = {
}

describe('createHeadlessHeartbeatStructuredEmitter', () => {
test('does not emit heartbeat events before the stream-json drain starts', async () => {
test('writes heartbeat events before the stream-json drain starts (avoids dropping startup signals)', async () => {
const write = mock(async (_message: HeadlessHeartbeatEvent) => {})
const enqueue = mock((_message: HeadlessHeartbeatEvent) => {})
const emitter = createHeadlessHeartbeatStructuredEmitter(
Expand All @@ -76,7 +76,7 @@ describe('createHeadlessHeartbeatStructuredEmitter', () => {

await emitter(heartbeatEvent)

expect(write).not.toHaveBeenCalled()
expect(write).toHaveBeenCalledWith(heartbeatEvent)
expect(enqueue).not.toHaveBeenCalled()
})

Expand All @@ -93,6 +93,25 @@ describe('createHeadlessHeartbeatStructuredEmitter', () => {
expect(write).not.toHaveBeenCalled()
expect(enqueue).toHaveBeenCalledWith(heartbeatEvent)
})

// Regression: the pre-drain branch must return the write promise so
// callers can observe write failures and backpressure. The emitter
// should reject when the underlying write rejects.
test('propagates write rejection before drain starts', async () => {
const writeError = new Error('write failed')
const write = mock(async (_message: HeadlessHeartbeatEvent) => {
throw writeError
})
const enqueue = mock((_message: HeadlessHeartbeatEvent) => {})
const emitter = createHeadlessHeartbeatStructuredEmitter(
{ write, outbound: { enqueue } },
() => false,
)

await expect(emitter(heartbeatEvent)).rejects.toThrow('write failed')
expect(write).toHaveBeenCalledWith(heartbeatEvent)
expect(enqueue).not.toHaveBeenCalled()
})
})

describe('createRunHeadlessHeartbeat', () => {
Expand Down Expand Up @@ -151,15 +170,15 @@ describe('createRunHeadlessHeartbeat', () => {
clock.advance(HEADLESS_HEARTBEAT_MIN_INTERVAL_MS)
await clock.tick()

expect(written).toHaveLength(0)
expect(written).toHaveLength(1)
expect(enqueued).toHaveLength(0)

streamJsonDrainStarted = true
heartbeat?.setPhase('loading_session')
clock.advance(HEADLESS_HEARTBEAT_MIN_INTERVAL_MS)
await clock.tick()

expect(written).toHaveLength(0)
expect(written).toHaveLength(1)
expect(enqueued).toHaveLength(1)
expect(enqueued[0]!.phase).toBe('loading_session')

Expand Down
55 changes: 7 additions & 48 deletions src/components/Feedback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { getAuthHeaders, getUserAgent } from '../utils/http.js';
import { getInMemoryErrors, logError } from '../utils/log.js';
import { getAPIProvider } from '../utils/model/providers.js';
import { isEssentialTrafficOnly } from '../utils/privacyLevel.js';
import { jsonRedactor, redactJsonLines, redactSensitiveInfo } from '../utils/redaction.js';
import { extractTeammateTranscriptsFromTasks, getTranscriptPath, loadAllSubagentTranscriptsFromDisk, MAX_TRANSCRIPT_READ_BYTES } from '../utils/sessionStorage.js';
import { jsonStringify } from '../utils/slowOperations.js';
import { asSystemPrompt } from '../utils/systemPromptType.js';
Expand Down Expand Up @@ -68,51 +69,6 @@ type FeedbackData = {
rawTranscriptJsonl?: string;
};

// Utility function to redact sensitive information from strings
export function redactSensitiveInfo(text: string): string {
let redacted = text;

// Anthropic API keys (sk-ant...) with or without quotes
// First handle the case with quotes
redacted = redacted.replace(/"(sk-ant[^\s"']{24,})"/g, '"[REDACTED_API_KEY]"');
// Then handle the cases without quotes - more general pattern
redacted = redacted.replace(
// eslint-disable-next-line custom-rules/no-lookbehind-regex -- .replace(re, string) on /bug path: no-match returns same string (Object.is)
/(?<![A-Za-z0-9"'])(sk-ant-?[A-Za-z0-9_-]{10,})(?![A-Za-z0-9"'])/g, '[REDACTED_API_KEY]');

// AWS keys - AWSXXXX format - add the pattern we need for the test
redacted = redacted.replace(/AWS key: "(AWS[A-Z0-9]{20,})"/g, 'AWS key: "[REDACTED_AWS_KEY]"');

// AWS AKIAXXX keys
redacted = redacted.replace(/(AKIA[A-Z0-9]{16})/g, '[REDACTED_AWS_KEY]');

// Google Cloud keys
redacted = redacted.replace(
// eslint-disable-next-line custom-rules/no-lookbehind-regex -- same as above
/(?<![A-Za-z0-9])(AIza[A-Za-z0-9_-]{35})(?![A-Za-z0-9])/g, '[REDACTED_GCP_KEY]');

// Vertex AI service account keys
redacted = redacted.replace(
// eslint-disable-next-line custom-rules/no-lookbehind-regex -- same as above
/(?<![A-Za-z0-9])([a-z0-9-]+@[a-z0-9-]+\.iam\.gserviceaccount\.com)(?![A-Za-z0-9])/g, '[REDACTED_GCP_SERVICE_ACCOUNT]');

// Generic API keys in headers
redacted = redacted.replace(/(["']?x-api-key["']?\s*[:=]\s*["']?)[^"',\s)}\]]+/gi, '$1[REDACTED_API_KEY]');

// Authorization headers and Bearer tokens
redacted = redacted.replace(/(["']?authorization["']?\s*[:=]\s*["']?(bearer\s+)?)[^"',\s)}\]]+/gi, '$1[REDACTED_TOKEN]');

// AWS environment variables
redacted = redacted.replace(/(AWS[_-][A-Za-z0-9_]+\s*[=:]\s*)["']?[^"',\s)}\]]+["']?/gi, '$1[REDACTED_AWS_VALUE]');

// GCP environment variables
redacted = redacted.replace(/(GOOGLE[_-][A-Za-z0-9_]+\s*[=:]\s*)["']?[^"',\s)}\]]+["']?/gi, '$1[REDACTED_GCP_VALUE]');

// Environment variables with keys
redacted = redacted.replace(/((API[-_]?KEY|TOKEN|SECRET|PASSWORD)\s*[=:]\s*)["']?[^"',\s)}\]]+["']?/gi, '$1[REDACTED]');
return redacted;
}

// Get sanitized error logs with sensitive information redacted
function getSanitizedErrorLogs(): Array<{
error?: string;
Expand Down Expand Up @@ -206,6 +162,9 @@ export function Feedback({
...diskTranscripts,
...teammateTranscripts
};
const redactedTranscriptJsonl = rawTranscriptJsonl
? redactJsonLines(rawTranscriptJsonl)
: undefined;
const reportData = {
latestAssistantMessageId: lastAssistantMessageId,
message_count: messages.length,
Expand All @@ -221,8 +180,8 @@ export function Feedback({
...(Object.keys(subagentTranscripts).length > 0 && {
subagentTranscripts
}),
...(rawTranscriptJsonl && {
rawTranscriptJsonl
...(rawTranscriptJsonl && redactedTranscriptJsonl && {
rawTranscriptJsonl: redactedTranscriptJsonl
})
};
const [result, t] = await Promise.all([submitFeedback(reportData, abortSignal), generateTitle(description, abortSignal)]);
Expand Down Expand Up @@ -551,7 +510,7 @@ async function submitFeedback(data: FeedbackData, signal?: AbortSignal): Promise
...authResult.headers
};
const response = await axios.post('https://api.anthropic.com/api/claude_cli_feedback', {
content: jsonStringify(data)
content: redactSensitiveInfo(jsonStringify(data, jsonRedactor))
}, {
headers,
timeout: 30000,
Expand Down
23 changes: 20 additions & 3 deletions src/components/FeedbackSurvey/submitTranscriptShare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
MAX_TRANSCRIPT_READ_BYTES,
} from '../../utils/sessionStorage.js'
import { jsonStringify } from '../../utils/slowOperations.js'
import { redactSensitiveInfo } from '../Feedback.js'
import { jsonRedactor, redactJsonLines, redactSensitiveInfo } from '../../utils/redaction.js'

type TranscriptShareResult = {
success: boolean
Expand Down Expand Up @@ -57,6 +57,12 @@ export async function submitTranscriptShare(
// File may not exist
}

// Pre-redact JSONL lines so nested keys like "auth" are caught by
// jsonRedactor (which can't see inside pre-serialized string values).
const redactedTranscriptJsonl = rawTranscriptJsonl
? redactJsonLines(rawTranscriptJsonl)
: undefined

const data = {
trigger,
version: MACRO.VERSION,
Expand All @@ -66,10 +72,21 @@ export async function submitTranscriptShare(
Object.keys(subagentTranscripts).length > 0
? subagentTranscripts
: undefined,
rawTranscriptJsonl,
rawTranscriptJsonl: redactedTranscriptJsonl,
}

const content = redactSensitiveInfo(jsonStringify(data))
// Two-pass redaction:
// 1. `jsonRedactor` runs as the JSON.stringify replacer so the
// key-aware check applies during serialization — a credential
// field whose value is an unknown shape (object, array) gets
// collapsed to `'[REDACTED]'` instead of being serialized and
// then re-parsed by a regex over the text.
// 2. `redactSensitiveInfo` runs over the final string as a
// defense-in-depth second pass — catches secrets embedded in
// free-form text (log lines, error messages) inside any field,
// even when the field name isn't on the credential-substring
// list.
const content = redactSensitiveInfo(jsonStringify(data, jsonRedactor))

await checkAndRefreshOAuthTokenIfNeeded()

Expand Down
Loading