From cc721f00a707e3ba288bce69b522856a081bbc10 Mon Sep 17 00:00:00 2001 From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:09:27 +0530 Subject: [PATCH] fix(sync): address project privacy review --- docs/sync/DEVELOPER.md | 6 ++- docs/sync/README.md | 10 ++++ src/path-privacy.ts | 7 ++- src/sync/otlp.ts | 4 +- tests/providers/goose-sync-privacy.test.ts | 54 +++++++++++++++------- tests/sync-wire-privacy.test.ts | 36 +++++++++++++++ 6 files changed, 97 insertions(+), 20 deletions(-) diff --git a/docs/sync/DEVELOPER.md b/docs/sync/DEVELOPER.md index ce2cd369..e89ef4ea 100644 --- a/docs/sync/DEVELOPER.md +++ b/docs/sync/DEVELOPER.md @@ -146,7 +146,11 @@ Re-sends are byte-identical. Server-side dedup is defense-in-depth. one safe basename from a provider-recorded absolute working directory. Attribution spans derive it only from the normalized `git.repo`; PR-only evidence omits it. Receivers must group a missing project as unattributed and -must not require the field. +must not require the field. When usage and attribution spans for one trace carry +different safe basenames (for example, a fork checkout whose directory name +differs from the upstream repository), the attribution span's normalized +`git.repo` basename is authoritative for project aggregation. The usage cwd +basename is provisional; receivers must not count both as separate projects. `ai.output_tokens` is the billable output total. For providers that meter reasoning separately from response tokens, CodeBurn includes that reasoning in diff --git a/docs/sync/README.md b/docs/sync/README.md index 9727a700..3e9da775 100644 --- a/docs/sync/README.md +++ b/docs/sync/README.md @@ -111,6 +111,11 @@ A pseudonymous `device_id` distinguishes your machines without revealing hostnam | `git.pr_links` | `["…/pull/12"]` | PR URLs captured for the session | | `git.commit_count` | `2` | Number of attributed commits | +If usage and attribution spans for the same trace carry different safe project +basenames, the repository basename from attribution is authoritative for project +aggregation. The provider-recorded cwd basename on the usage span is a +provisional label and must not create a second project bucket. + **`codeburn.commit`** — one per commit attributed to a session: | Field | Example | Description | @@ -136,6 +141,11 @@ With `--attribution`, normalized repo remote URLs, commit SHAs, commit timestamp There is no flag to override this. Privacy is structural, not configurable. The only additive opt-in is `--attribution` (repo remotes, commit SHAs, and PR URLs — never code or prompts), described above. +Legacy Hermes messages that contain a textual `Current working directory:` +line, including Windows paths, may still supply a local grouping label. Prompt +text never becomes trusted `projectPath`/`workingDirectory` provenance and can +never produce outbound `ai.project`. + ## Authentication Sync uses standard OIDC (the same protocol as "Sign in with Google"). Your team's admin sets up the identity provider — you just click through the browser login once. diff --git a/src/path-privacy.ts b/src/path-privacy.ts index aea9a7f4..b4c3e7b4 100644 --- a/src/path-privacy.ts +++ b/src/path-privacy.ts @@ -5,6 +5,11 @@ function normalizedPath(value: string): string { return value.trim().replace(/\\/g, '/').replace(/\/+$/, '') } +/** Provider-owned container roots are not host workspaces, even when absolute. */ +function isProviderContainerWorkingDirectory(value: string): boolean { + return /^\/sessions\/[^/]+$/i.test(normalizedPath(value)) +} + /** True when an absolute path identifies a user home root, not a project. */ export function isUserHomeRoot(value: string | undefined): boolean { if (!value) return false @@ -16,6 +21,6 @@ export function isUserHomeRoot(value: string | undefined): boolean { /** Absolute, non-home provider cwd eligible for outbound provenance. */ export function isTrustedAbsoluteWorkingDirectory(value: string | undefined): value is string { - if (!value || isUserHomeRoot(value)) return false + if (!value || isUserHomeRoot(value) || isProviderContainerWorkingDirectory(value)) return false return posix.isAbsolute(value) || win32.isAbsolute(value) } diff --git a/src/sync/otlp.ts b/src/sync/otlp.ts index 3e1d4ee6..c41271ed 100644 --- a/src/sync/otlp.ts +++ b/src/sync/otlp.ts @@ -87,6 +87,7 @@ export interface CallWithSession { } function isEmailOrCredentialShaped(value: string): boolean { + if (/^(?:\.?(?:ssh|aws|gnupg|kube)|\.env(?:\..*)?|credentials?|id_(?:rsa|dsa|ecdsa|ed25519)|authorized_keys|known_hosts|netrc|npmrc|pypirc)$/i.test(value.trim())) return true if (/^[^@\s]{1,64}@(?![^@\s]*@)(?:[A-Za-z0-9-]+\.)+[A-Za-z]{2,}$/.test(value)) return true if (/(?:^|[^A-Za-z0-9])(?:x[-_]?access[-_]?token|api[-_]?key|access[-_]?token|refresh[-_]?token|id[-_]?token|token|authorization|bearer|password|passwd|private[-_]?key|client[-_]?secret|secret)(?=[:=])/i.test(value)) return true if (/(?:^|[^A-Za-z0-9])(?:github_pat_|gh[pousr]_|glpat-|sk-[A-Za-z0-9]|[sr]k_(?:live|test)_|whsec_|xox[baprs]-|AIza|npm_|pypi-|hf_)/i.test(value)) return true @@ -269,7 +270,8 @@ export type AttributionItem = { function attributionProjectFromRepo(repo: string | null): string | undefined { if (!repo) return undefined const parts = repo.split('/').filter(Boolean) - if (parts.length < 3) return undefined + // Normalized remotes may be either host/owner/repo or owner/repo. + if (parts.length < 2) return undefined return projectBasenameFromWorkingDirectory(`/${parts.at(-1)}`) } diff --git a/tests/providers/goose-sync-privacy.test.ts b/tests/providers/goose-sync-privacy.test.ts index 2ae478b7..af3646a4 100644 --- a/tests/providers/goose-sync-privacy.test.ts +++ b/tests/providers/goose-sync-privacy.test.ts @@ -62,13 +62,14 @@ sqliteDescribe('Goose sync project provenance', () => { created_timestamp INTEGER ); `) - db.prepare(` + const insertSession = db.prepare(` INSERT INTO sessions ( id, name, working_dir, created_at, updated_at, accumulated_input_tokens, accumulated_output_tokens, provider_name, model_config_json ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run( + `) + insertSession.run( 'goose-session-1', 'LLM-authored session title', '/Users/alice/company/private-widget', @@ -79,18 +80,33 @@ sqliteDescribe('Goose sync project provenance', () => { 'openai', JSON.stringify({ model_name: 'gpt-5.4' }), ) + insertSession.run( + 'goose-container-session', + 'Container session title must stay local', + '/sessions/synthetic-customer-secret', + '2026-08-23T11:00:00.000Z', + '2026-08-23T11:01:00.000Z', + 50, + 10, + 'openai', + JSON.stringify({ model_name: 'gpt-5.4' }), + ) db.close() const provider = createGooseProvider() const sources = await provider.discoverSessions() - expect(sources).toHaveLength(1) + expect(sources).toHaveLength(2) const calls = [] - for await (const providerCall of provider.createSessionParser(sources[0]!, new Set()).parse()) calls.push(providerCall) - expect(calls).toHaveLength(1) - expect(calls[0]!.workingDirectory).toBe('/Users/alice/company/private-widget') + for (const source of sources) { + for await (const providerCall of provider.createSessionParser(source, new Set()).parse()) calls.push(providerCall) + } + expect(calls).toHaveLength(2) + const trusted = calls.find(call => call.sessionId === 'goose-session-1')! + const container = calls.find(call => call.sessionId === 'goose-container-session')! + expect(trusted.workingDirectory).toBe('/Users/alice/company/private-widget') + expect(container.workingDirectory).toBe('/sessions/synthetic-customer-secret') - const raw = calls[0]! - const parsed: ParsedApiCall = { + const toParsed = (raw: typeof trusted): ParsedApiCall => ({ provider: raw.provider, model: raw.model, usage: { @@ -113,19 +129,23 @@ sqliteDescribe('Goose sync project provenance', () => { timestamp: raw.timestamp, bashCommands: raw.bashCommands, deduplicationKey: raw.deduplicationKey, - } - const payload = buildOtlpPayload([{ - call: parsed, + }) + const payload = buildOtlpPayload(calls.map(raw => ({ + call: toParsed(raw), sessionId: raw.sessionId, - project: sources[0]!.project, workingDirectory: raw.workingDirectory, - }]) - const span = payload.resourceSpans[0]!.scopeSpans[0]!.spans[0]! - const attributes = Object.fromEntries(span.attributes.map(attribute => [attribute.key, attribute.value])) + }))) + const spans = payload.resourceSpans[0]!.scopeSpans[0]!.spans + const trustedSpan = spans.find(span => span.spanId === deriveSpanId(trusted.deduplicationKey))! + const containerSpan = spans.find(span => span.spanId === deriveSpanId(container.deduplicationKey))! + const trustedAttributes = Object.fromEntries(trustedSpan.attributes.map(attribute => [attribute.key, attribute.value])) + const containerAttributes = Object.fromEntries(containerSpan.attributes.map(attribute => [attribute.key, attribute.value])) - expect(attributes['ai.project']).toEqual({ stringValue: 'private-widget' }) + expect(trustedAttributes['ai.project']).toEqual({ stringValue: 'private-widget' }) + expect(containerAttributes['ai.project']).toBeUndefined() expect(JSON.stringify(payload)).not.toContain('/Users/alice') + expect(JSON.stringify(payload)).not.toContain('synthetic-customer-secret') expect(JSON.stringify(payload)).not.toContain('LLM-authored session title') - expect(span.spanId).toBe(deriveSpanId(raw.deduplicationKey)) + expect(JSON.stringify(payload)).not.toContain('Container session title must stay local') }) }) diff --git a/tests/sync-wire-privacy.test.ts b/tests/sync-wire-privacy.test.ts index 8c076efb..908fe3a0 100644 --- a/tests/sync-wire-privacy.test.ts +++ b/tests/sync-wire-privacy.test.ts @@ -76,6 +76,7 @@ describe('sync project privacy boundary', () => { '/mnt/c/Users/alice', '/var/home/alice', '/net/home/alice', + '/sessions/synthetic-customer-secret', '\\\\server\\Users\\alice', 'D:\\Profiles\\alice', ])('omits ai.project when cwd provenance is absent or unsafe: %s', cwd => { @@ -116,6 +117,26 @@ describe('sync identifier privacy boundary', () => { expect(wire).not.toContain('synthetic-secret') expect(wire).not.toContain('alice@example.com') }) + + it('preserves real routed provider, model, and tool identifiers', () => { + const payload = buildOtlpPayload([usage('/workspace/widget', { + provider: 'openrouter', + model: 'anthropic/claude-sonnet-4.6', + tools: ['orcarouter/openai-compatible', 'mcp__github__search'], + })]) + const span = payload.resourceSpans[0]!.scopeSpans[0]!.spans[0]! + const attrs = Object.fromEntries(span.attributes.map(attribute => [attribute.key, attribute.value])) + + expect(attrs['ai.provider']).toEqual({ stringValue: 'openrouter' }) + expect(attrs['ai.model']).toEqual({ stringValue: 'anthropic/claude-sonnet-4.6' }) + expect(attrs['ai.tools']).toEqual({ + arrayValue: { values: [ + { stringValue: 'orcarouter/openai-compatible' }, + { stringValue: 'mcp__github__search' }, + ] }, + }) + expect(span.name).toBe('openrouter/anthropic/claude-sonnet-4.6') + }) }) describe('sync attribution project privacy boundary', () => { @@ -150,4 +171,19 @@ describe('sync attribution project privacy boundary', () => { expect(attrs['ai.project']).toBeUndefined() expect(JSON.stringify(payload)).not.toContain('/Users/alice/secret') }) + + it('accepts owner/repo identities and rejects credential-directory repo labels', () => { + const [legitimate, credentialShaped] = flattenAttributionRecords([ + record({ repo: 'acme/widget' }), + record({ sessionId: 'privacy-session-2', repo: 'acme/.ssh' }), + ]) + + expect(legitimate?.project).toBe('widget') + expect(credentialShaped?.project).toBeUndefined() + const payload = buildAttributionOtlpPayload([legitimate!, credentialShaped!]) + const spans = payload.resourceSpans[0]!.scopeSpans[0]!.spans + const credentialAttributes = Object.fromEntries(spans[1]!.attributes + .map(attribute => [attribute.key, attribute.value])) + expect(credentialAttributes['ai.project']).toBeUndefined() + }) })