-
Notifications
You must be signed in to change notification settings - Fork 9.1k
feat(privacy): strip listing payloads on external transcript egress #2104
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
ussoewwin
wants to merge
31
commits into
Twigpine:main
from
ussoewwin:fix/external-transcript-projection
Closed
Changes from all commits
Commits
Show all changes
31 commits
Select commit
Hold shift + click to select a range
68b14d4
feat(privacy): strip listing payloads on external transcript egress
ussoewwin 525c929
refactor(privacy): rename egress listing helpers off prefix-cache labels
ussoewwin 1e0229e
fix(privacy): close CodeRabbit findings on external egress projection
ussoewwin 24f9260
fix(privacy): close CodeRabbit review 4892951252 (3 findings)
ussoewwin 545a74a
fix(privacy): close jatmn review 4893077611 on external egress
ussoewwin de683fa
fix(privacy): close jatmn review 4893077611 (all four findings)
ussoewwin 25bc648
fix(privacy): use OMISSION_REBUILD_TAIL_BYTES for egress rebuild (Cod…
ussoewwin 60993e4
fix(privacy): bound omission rebuild reads and preserve line-boundary…
ussoewwin cbd5416
fix(privacy): close maintainer review 4899061317
ussoewwin 401a74d
fix(privacy): bound evicted egress set and own test lock
ussoewwin 7071e31
fix(privacy): retain compact ancestry after double omission eviction
ussoewwin 385ca39
fix(privacy): store actual compact omission ancestors
ussoewwin 426bb47
fix(privacy): tail-window on-demand ancestry and known-omitted scan gate
ussoewwin be4c02e
fix(privacy): close maintainer review 4907143023 egress omissions
ussoewwin 9fca2a5
fix(privacy): tighten egress persist after review 4907143023 tests
ussoewwin ca2ab10
fix(privacy): close CodeRabbit review 4912132735 persist and bound eg…
ussoewwin b48bdb1
fix(privacy): tighten grandchild persist-skip assertion for 3762934396
ussoewwin c4b7239
fix(privacy): fail-close cyclic omitted parents and cache parent_safe
ussoewwin 7376d6e
test(privacy): fix seed startingParentUuid type in external egress test
ussoewwin e488ac4
fix(privacy): unified fail-closed egress classifier, explicit deliver…
ussoewwin e814390
fix(privacy): guard live egress projection on active sink, add test t…
ussoewwin 343abfc
fix(privacy): recursively resolve chained omitted/evicted ancestors a…
ussoewwin 7d1d9a9
fix(privacy): treat null target parent as safe projected root and com…
ussoewwin 88aadf1
fix(privacy): strict remote delivery witness tracking, fail-closed an…
ussoewwin d1591cc
fix(privacy): bound parent_safe lookups, track lock ownership in feed…
ussoewwin df78c5b
fix(privacy): unify external egress projection contract and register …
ussoewwin 0dbfd93
fix(privacy): bound on-demand ancestry scans and retry incomplete reb…
ussoewwin 039efa0
fix(privacy): bound incomplete rebuild retries and isolate diagnostic…
ussoewwin ea96c51
fix(privacy): recompute parent projection and clear cached misses on …
ussoewwin bc22983
fix(privacy): bound on-demand ancestry scan budget to 8 MiB in append…
ussoewwin 262eafd
fix(privacy): close jatmn 5000432029 external egress findings
ussoewwin File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,253 @@ | ||
| import { afterAll, beforeAll, expect, mock, test } from 'bun:test' | ||
| import { mkdtemp, rm, writeFile } from 'node:fs/promises' | ||
| import { tmpdir } from 'node:os' | ||
| import { join } from 'node:path' | ||
| import type { Message } from '../types/message.js' | ||
| import { | ||
| acquireSharedMutationLock, | ||
| releaseSharedMutationLock, | ||
| } from '../test/sharedMutationLock.js' | ||
|
|
||
| type AxiosModule = typeof import('axios') | ||
| type ProvidersModule = typeof import('../utils/model/providers.js') | ||
| type AuthModule = typeof import('../utils/auth.js') | ||
| type HttpModule = typeof import('../utils/http.js') | ||
| type PrivacyModule = typeof import('../utils/privacyLevel.js') | ||
|
|
||
| let originalAxiosModule: AxiosModule | undefined | ||
| let originalProvidersModule: ProvidersModule | undefined | ||
| let originalAuthModule: AuthModule | undefined | ||
| let originalHttpModule: HttpModule | undefined | ||
| let originalPrivacyModule: PrivacyModule | undefined | ||
| let originalUserType: string | undefined | ||
| let hadMacro = false | ||
| let originalMacro: unknown | ||
| let tempDir: string | undefined | ||
| let transcriptPath: string | undefined | ||
| let postedBodies: Array<{ content?: string }> = [] | ||
| let ownsSharedMutationLock = false | ||
|
|
||
| function buildAxiosModuleStub( | ||
| post: (...args: unknown[]) => Promise<unknown>, | ||
| ): AxiosModule { | ||
| const instance = { | ||
| get: async () => ({ status: 200 }), | ||
| post, | ||
| isAxiosError: () => false, | ||
| isCancel: () => false, | ||
| defaults: {} as Record<string, unknown>, | ||
| interceptors: { | ||
| request: { use: () => 0, eject: () => {} }, | ||
| response: { use: () => 0, eject: () => {} }, | ||
| }, | ||
| } | ||
| return { default: instance } as unknown as AxiosModule | ||
| } | ||
|
|
||
| beforeAll(async () => { | ||
| ownsSharedMutationLock = false | ||
| await acquireSharedMutationLock('Feedback.egress') | ||
| ownsSharedMutationLock = true | ||
| originalAxiosModule = await import('axios') | ||
| originalUserType = process.env.USER_TYPE | ||
| hadMacro = Object.prototype.hasOwnProperty.call(globalThis, 'MACRO') | ||
| originalMacro = (globalThis as { MACRO?: unknown }).MACRO | ||
| ;(globalThis as { MACRO?: { VERSION: string } }).MACRO = { | ||
| VERSION: 'test-version', | ||
| } | ||
|
|
||
| originalProvidersModule = await import('../utils/model/providers.js') | ||
| mock.module('../utils/model/providers.js', () => ({ | ||
| ...originalProvidersModule!, | ||
| getAPIProvider: () => 'firstParty', | ||
| isFirstPartyAnthropicBaseUrl: () => true, | ||
| })) | ||
|
|
||
| originalAuthModule = await import('../utils/auth.js') | ||
| mock.module('../utils/auth.js', () => ({ | ||
| ...originalAuthModule!, | ||
| checkAndRefreshOAuthTokenIfNeeded: async () => {}, | ||
| })) | ||
|
|
||
| originalHttpModule = await import('../utils/http.js') | ||
| mock.module('../utils/http.js', () => ({ | ||
| ...originalHttpModule!, | ||
| getAuthHeaders: () => ({ | ||
| headers: { Authorization: 'Bearer test' }, | ||
| error: undefined, | ||
| }), | ||
| getUserAgent: () => 'test-agent', | ||
| })) | ||
|
|
||
| originalPrivacyModule = await import('../utils/privacyLevel.js') | ||
| mock.module('../utils/privacyLevel.js', () => ({ | ||
| ...originalPrivacyModule!, | ||
| isEssentialTrafficOnly: () => false, | ||
| })) | ||
|
|
||
| tempDir = await mkdtemp(join(tmpdir(), 'openclaude-feedback-egress-')) | ||
| transcriptPath = join(tempDir, 'session.jsonl') | ||
| // f001 user → f002 listing (omitted) → f003 user (parent=f002, must reparent to f001) | ||
| await writeFile( | ||
| transcriptPath, | ||
| `${JSON.stringify({ | ||
| type: 'user', | ||
| uuid: '00000000-0000-4000-8000-00000000f001', | ||
| parentUuid: null, | ||
| timestamp: '2026-08-07T00:00:00.000Z', | ||
| message: { role: 'user', content: 'feedback main turn' }, | ||
| })}\n${JSON.stringify({ | ||
| type: 'attachment', | ||
| uuid: '00000000-0000-4000-8000-00000000f002', | ||
| parentUuid: '00000000-0000-4000-8000-00000000f001', | ||
| timestamp: '2026-08-07T00:00:00.000Z', | ||
| attachment: { | ||
| type: 'skill_listing', | ||
| content: 'Available skills:\n- /leak-me-please', | ||
| skillCount: 1, | ||
| isInitial: true, | ||
| }, | ||
| })}\n${JSON.stringify({ | ||
| type: 'user', | ||
| uuid: '00000000-0000-4000-8000-00000000f003', | ||
| parentUuid: '00000000-0000-4000-8000-00000000f002', | ||
| timestamp: '2026-08-07T00:00:01.000Z', | ||
| message: { role: 'user', content: 'after listing turn' }, | ||
| })}\n`, | ||
| ) | ||
|
|
||
| // Do NOT mock.module sessionStorage — that leaks into later suites under | ||
| // --max-concurrency=1. Pass transcript / subagent data via test seams. | ||
| mock.module('axios', () => | ||
| buildAxiosModuleStub(async (_url: unknown, body: unknown) => { | ||
| postedBodies.push(body as { content?: string }) | ||
| return { status: 200, data: { feedback_id: 'fb-egress-1' } } | ||
| }), | ||
| ) | ||
| }) | ||
|
|
||
| afterAll(async () => { | ||
| try { | ||
| if (originalUserType === undefined) { | ||
| delete process.env.USER_TYPE | ||
| } else { | ||
| process.env.USER_TYPE = originalUserType | ||
| } | ||
| if (!hadMacro) { | ||
| delete (globalThis as { MACRO?: unknown }).MACRO | ||
| } else { | ||
| ;(globalThis as { MACRO?: unknown }).MACRO = originalMacro | ||
| } | ||
| if (originalAxiosModule) { | ||
| mock.module('axios', () => originalAxiosModule!) | ||
| } | ||
| if (originalProvidersModule) { | ||
| mock.module('../utils/model/providers.js', () => originalProvidersModule!) | ||
| } | ||
| if (originalAuthModule) { | ||
| mock.module('../utils/auth.js', () => originalAuthModule!) | ||
| } | ||
| if (originalHttpModule) { | ||
| mock.module('../utils/http.js', () => originalHttpModule!) | ||
| } | ||
| if (originalPrivacyModule) { | ||
| mock.module('../utils/privacyLevel.js', () => originalPrivacyModule!) | ||
| } | ||
| if (tempDir) { | ||
| await rm(tempDir, { recursive: true, force: true }) | ||
| } | ||
| } finally { | ||
| if (ownsSharedMutationLock) { | ||
| ownsSharedMutationLock = false | ||
| releaseSharedMutationLock() | ||
| } | ||
| } | ||
| }) | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| test('Feedback upload strips listing payloads from the posted content body', async () => { | ||
| postedBodies = [] | ||
| process.env.USER_TYPE = 'external' | ||
|
|
||
| const { assembleFeedbackEgressReportData, submitFeedback } = await import( | ||
| './Feedback.js' | ||
| ) | ||
|
|
||
| const listing = { | ||
| type: 'attachment', | ||
| uuid: '00000000-0000-4000-8000-00000000m101', | ||
| attachment: { | ||
| type: 'skill_listing', | ||
| content: 'Available skills:\n- /leak-me-please', | ||
| skillCount: 1, | ||
| isInitial: true, | ||
| }, | ||
| } as unknown as Message | ||
| const user = { | ||
| type: 'user', | ||
| uuid: '00000000-0000-4000-8000-00000000m102', | ||
| message: { role: 'user', content: 'plain turn' }, | ||
| } as unknown as Message | ||
|
|
||
| const report = await assembleFeedbackEgressReportData({ | ||
| messages: [listing, user], | ||
| description: 'egress regression', | ||
| transcriptPathForTesting: transcriptPath, | ||
| subagentTranscriptsForTesting: { | ||
| 'agent-leak': [ | ||
| { | ||
| type: 'attachment', | ||
| uuid: '00000000-0000-4000-8000-00000000a201', | ||
| attachment: { | ||
| type: 'agent_listing_delta', | ||
| addedTypes: ['Explore'], | ||
| addedLines: ['- Explore: /leak-agent-listing'], | ||
| removedTypes: [], | ||
| isInitial: true, | ||
| showConcurrencyNote: false, | ||
| }, | ||
| } as unknown as Message, | ||
| { | ||
| type: 'user', | ||
| uuid: '00000000-0000-4000-8000-00000000a202', | ||
| message: { role: 'user', content: 'subagent turn' }, | ||
| } as unknown as Message, | ||
| ], | ||
| }, | ||
| }) | ||
| const result = await submitFeedback(report) | ||
|
|
||
| expect(result.success).toBe(true) | ||
| expect(postedBodies).toHaveLength(1) | ||
| const content = postedBodies[0]?.content ?? '' | ||
| expect(content).toContain('plain turn') | ||
| expect(content).toContain('feedback main turn') | ||
| expect(content).toContain('after listing turn') | ||
| expect(content).toContain('subagent turn') | ||
| expect(content).not.toContain('leak-me-please') | ||
| expect(content).not.toContain('leak-agent-listing') | ||
|
|
||
| // Posted report embeds filtered rawTranscriptJsonl — survivor f003 must | ||
| // reparent from omitted f002 onto retained ancestor f001. | ||
| const parsed = JSON.parse(content) as { rawTranscriptJsonl?: string } | ||
| expect(parsed.rawTranscriptJsonl).toBeDefined() | ||
| const lines = (parsed.rawTranscriptJsonl ?? '') | ||
| .split('\n') | ||
| .filter(l => l.length > 0) | ||
| .map( | ||
| line => | ||
| JSON.parse(line) as { | ||
| uuid?: string | ||
| parentUuid?: string | null | ||
| }, | ||
| ) | ||
| const afterListing = lines.find( | ||
| e => e.uuid === '00000000-0000-4000-8000-00000000f003', | ||
| ) | ||
| expect(afterListing).toBeDefined() | ||
| expect(afterListing?.parentUuid).toBe( | ||
| '00000000-0000-4000-8000-00000000f001', | ||
| ) | ||
| expect( | ||
| lines.some(e => e.uuid === '00000000-0000-4000-8000-00000000f002'), | ||
| ).toBe(false) | ||
| }) | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.