fix(sync): trust only proven outbound project metadata

This commit is contained in:
Aditya Vikram Singh
2026-08-24 16:05:35 +05:30
parent e5f02933c4
commit 33dc05bb90
18 changed files with 817 additions and 81 deletions
+11
View File
@@ -142,6 +142,17 @@ Re-sends are byte-identical. Server-side dedup is defense-in-depth.
}
```
`ai.project` is optional. Usage spans include it only when CodeBurn can derive
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.
`ai.output_tokens` is the billable output total. For providers that meter
reasoning separately from response tokens, CodeBurn includes that reasoning in
this field; providers whose response count already includes reasoning are left
unchanged.
## Sent-Ledger
Client-side deduplication source of truth at `~/.cache/codeburn/sync-ledger.json`.
+6 -6
View File
@@ -91,9 +91,9 @@ Each AI interaction becomes one OTLP span with these attributes:
| `ai.provider` | `kiro`, `cursor`, `claude` | Which AI tool |
| `ai.model` | `claude-sonnet-4-6` | Model used |
| `ai.input_tokens` | `12500` | Prompt tokens |
| `ai.output_tokens` | `3200` | Response tokens |
| `ai.output_tokens` | `3200` | Billable output tokens (includes separately billed reasoning where applicable) |
| `ai.cost_usd` | `0.085` | Estimated cost |
| `ai.project` | `my-app` | Project name |
| `ai.project` | `my-app` | Project basename, only when backed by an exact provider-recorded working directory; otherwise omitted |
| `ai.tools` | `["Edit", "Bash"]` | Tools invoked |
A pseudonymous `device_id` distinguishes your machines without revealing hostnames.
@@ -106,7 +106,7 @@ A pseudonymous `device_id` distinguishes your machines without revealing hostnam
| Field | Example | Description |
|---|---|---|
| `ai.project` | `my-app` | Project name |
| `ai.project` | `my-app` | Repository basename derived from normalized `git.repo`; omitted for PR-only evidence |
| `git.repo` | `github.com/acme/widget` | Normalized `origin` remote (credentials and ports stripped) |
| `git.pr_links` | `["…/pull/12"]` | PR URLs captured for the session |
| `git.commit_count` | `2` | Number of attributed commits |
@@ -123,7 +123,7 @@ Attribution is **inferred** (timestamp-window correlation, the same heuristic as
With `--attribution`, normalized repo remote URLs, commit SHAs, commit timestamps (span start times), PR URLs, and the merged/reverted booleans leave your machine — plus the same pseudonymous `codeburn.device_id` resource attribute the usage spans carry. PR links are rebuilt client-side from scheme + host + path only (userinfo, query strings, and fragments are dropped; https, `/org/repo/pull/N` path, bounded length, max 20 per session), and the repo identity itself passes a strict hostname/path allow-list before sending — malformed or transport-helper remotes (`ext::…`, `codecommit::…`) are rejected outright rather than parsed. Precisely what is and is not sent:
- **Commits**: only from repos with a network `origin` remote, and only for sessions whose own project path resolved to that repo. Local-only repos, `file://` remotes, and Windows filesystem paths are never emitted as repo identities. A session whose project path no longer resolves never inherits the repo of the directory you happen to push from.
- **Commits**: only from repos with a network `origin` remote, and only for sessions whose trusted provider-recorded working directory resolved to that repo. Local grouping labels, provider storage paths, prompt text, local-only repos, `file://` remotes, and Windows filesystem paths are never emitted as repo identities. A session without trusted cwd provenance never inherits the repo of the directory you happen to push from.
- **PR links**: sent whenever a session captured them, even when the session's repo could not be identified — the PR URL itself names the repo, so this adds no information beyond the link the session already recorded.
- Without the flag, none of this is sent.
@@ -132,7 +132,7 @@ With `--attribution`, normalized repo remote URLs, commit SHAs, commit timestamp
- **Prompts** — your actual messages to AI are never included
- **Code** — file contents, diffs, and paths stay local
- **Bash commands** — may contain secrets, never sent
- **Your name/email** — identity is derived server-side from your login token
- **Your name/email** — identity is derived server-side from your login token; home-directory, email-shaped, and credential-shaped project labels are omitted
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.
@@ -162,7 +162,7 @@ A: On purpose. A Copilot session's input/cache can leave your machine in one of
A: Next push catches up. The default window is 7 days; use `--since 30d` or `--since all` (up to 6 months) for longer gaps. A push runs to completion regardless of size — server rate limits (429) are waited out automatically.
**Q: Can my admin see my prompts?**
A: No. Prompts are never included in the payload. The server only sees token counts, costs, model names, and project names.
A: No. Prompts are never included in the payload. The server sees token counts, costs, conservatively sanitized provider/model/tool identifiers, and an optional project basename only when CodeBurn has trusted cwd provenance.
**Q: How do I stop syncing?**
A: `codeburn sync logout` removes everything. Or just stop running `push`.
+32 -11
View File
@@ -60,6 +60,7 @@ import type {
} from './types.js'
import { classifyTurn, BASH_TOOLS, EDIT_TOOLS } from './classifier.js'
import { extractBashCommands } from './bash-utils.js'
import { isTrustedAbsoluteWorkingDirectory } from './path-privacy.js'
function unsanitizePath(dirName: string): string {
return dirName.replace(/-/g, '/')
@@ -2081,12 +2082,13 @@ async function scanProjectDirs(
const installClaudeFile = async (filePath: string, info: FileInfo, parsed: ClaudeFileParse): Promise<void> => {
const cwd = parsed.workingDirectory
const canonical = (cwd && !isCoworkSession(cwd, filePath)) ? await resolveCanonicalProjectPath(cwd) : undefined
const trustedCwd = cwd && !isCoworkSession(cwd, filePath) ? cwd : undefined
const canonical = trustedCwd ? await resolveCanonicalProjectPath(trustedCwd) : undefined
section.files[filePath] = {
fingerprint: info.fp,
lastCompleteLineOffset: parsed.lastCompleteLineOffset,
canonicalCwd: canonical?.path,
...(cwd ? { workingDirectory: cwd } : {}),
...(trustedCwd ? { workingDirectory: trustedCwd } : {}),
canonicalProjectName: canonical?.isWorktree ? projectNameFromPath(canonical.path, info.dirName) : undefined,
mcpInventory: parsed.mcpInventory,
turns: parsed.turns,
@@ -2205,10 +2207,12 @@ async function scanProjectDirs(
let canonicalCwd = cached.canonicalCwd
let canonicalProjectName = cached.canonicalProjectName
let workingDirectory = cached.workingDirectory
if (workingDirectory && isCoworkSession(workingDirectory, filePath)) workingDirectory = undefined
if (canonicalCwd === undefined && newEntries) {
const cwd = extractCanonicalCwd(newEntries)
workingDirectory = workingDirectory ?? cwd
const canonical = (cwd && !isCoworkSession(cwd, filePath)) ? await resolveCanonicalProjectPath(cwd) : undefined
const trustedCwd = cwd && !isCoworkSession(cwd, filePath) ? cwd : undefined
workingDirectory = workingDirectory ?? trustedCwd
const canonical = trustedCwd ? await resolveCanonicalProjectPath(trustedCwd) : undefined
canonicalCwd = canonical?.path
canonicalProjectName = canonical?.isWorktree ? projectNameFromPath(canonical.path, info.dirName) : undefined
}
@@ -2372,7 +2376,9 @@ async function scanProjectDirs(
const projectName = cachedFile.canonicalProjectName ?? dirName
const mcpInv = cachedFile.mcpInventory.length > 0 ? cachedFile.mcpInventory : undefined
const session = buildSessionSummary(sessionId, projectName, classifiedTurns, mcpInv, source)
if (cachedFile.workingDirectory) session.workingDirectory = cachedFile.workingDirectory
if (cachedFile.workingDirectory && !isCoworkSession(cachedFile.workingDirectory, filePath)) {
session.workingDirectory = cachedFile.workingDirectory
}
session.agentType = cachedFile.agentType
if (everHadBranch) session.everHadBranch = true
const observedPrLinks = new Set(classifiedTurns.flatMap(turn => turn.prRefs ?? []))
@@ -2537,7 +2543,9 @@ function providerCallToCachedCall(call: ParsedProviderCall): CachedCall {
deduplicationKey: call.deduplicationKey,
project: call.project,
projectPath: call.projectPath,
workingDirectory: call.workingDirectory,
...(isTrustedAbsoluteWorkingDirectory(call.workingDirectory)
? { workingDirectory: call.workingDirectory, workingDirectoryProvenance: 'provider-field' as const }
: {}),
toolSequence: call.toolSequence,
...(call.locAdded ? { locAdded: call.locAdded } : {}),
...(call.locRemoved ? { locRemoved: call.locRemoved } : {}),
@@ -2557,11 +2565,13 @@ async function canonicalizeProviderCallProject(call: ParsedProviderCall): Promis
if (!call.projectPath) return call
const canonical = await resolveCanonicalProjectPath(call.projectPath)
if (!canonical.isWorktree) return { ...call, workingDirectory: call.workingDirectory ?? call.projectPath }
// projectPath is also used for local grouping and can be a provider storage
// directory or derived label. Only a dedicated provider cwd is trusted for
// outbound project and attribution data.
if (!canonical.isWorktree) return call
return {
...call,
workingDirectory: call.workingDirectory ?? call.projectPath,
project: projectNameFromPath(canonical.path, call.project ?? canonical.path),
projectPath: canonical.path,
}
@@ -3910,13 +3920,20 @@ export async function parseProviderSources(
const project = copilotServeProject(turn.sessionId) ?? slicedTurn.calls[0]?.project ?? source.project
const key = `${providerName}:${turn.sessionId}:${project}`
// Old caches can contain workingDirectory synthesized from projectPath.
// Marker absence fails closed while preserving historical usage totals.
const trustedWorkingDirectory = slicedTurn.calls[0]?.workingDirectoryProvenance === 'provider-field'
&& isTrustedAbsoluteWorkingDirectory(slicedTurn.calls[0].workingDirectory)
? slicedTurn.calls[0].workingDirectory
: undefined
const existing = sessionMap.get(key)
if (existing) {
existing.turns.push(classified)
if (!existing.projectPath && slicedTurn.calls[0]?.projectPath) {
existing.projectPath = slicedTurn.calls[0]!.projectPath
}
if (!existing.workingDirectory && slicedTurn.calls[0]?.workingDirectory) existing.workingDirectory = slicedTurn.calls[0].workingDirectory
if (!existing.workingDirectory && trustedWorkingDirectory) existing.workingDirectory = trustedWorkingDirectory
if (cachedFile.prLinks?.length) {
const links = (existing.prLinks ??= new Set())
for (const link of cachedFile.prLinks) links.add(link)
@@ -3926,7 +3943,7 @@ export async function parseProviderSources(
sessionMap.set(key, {
project,
projectPath: slicedTurn.calls[0]?.projectPath,
workingDirectory: slicedTurn.calls[0]?.workingDirectory,
workingDirectory: trustedWorkingDirectory,
turns: [classified],
...(cachedFile.prLinks?.length ? { prLinks: new Set(cachedFile.prLinks) } : {}),
...(cachedFile.title ? { title: cachedFile.title } : {}),
@@ -3970,6 +3987,10 @@ export async function parseProviderSources(
const project = copilotServeProject(turn.sessionId) ?? slicedTurn.calls[0]?.project ?? providerName
const key = `${providerName}:${turn.sessionId}:${project}`
const trustedWorkingDirectory = slicedTurn.calls[0]?.workingDirectoryProvenance === 'provider-field'
&& isTrustedAbsoluteWorkingDirectory(slicedTurn.calls[0].workingDirectory)
? slicedTurn.calls[0].workingDirectory
: undefined
const existingEntry = sessionMap.get(key)
if (existingEntry) {
existingEntry.turns.push(classified)
@@ -3977,7 +3998,7 @@ export async function parseProviderSources(
existingEntry.projectPath = slicedTurn.calls[0]!.projectPath
}
} else {
sessionMap.set(key, { project, projectPath: slicedTurn.calls[0]?.projectPath, workingDirectory: slicedTurn.calls[0]?.workingDirectory, turns: [classified] })
sessionMap.set(key, { project, projectPath: slicedTurn.calls[0]?.projectPath, workingDirectory: trustedWorkingDirectory, turns: [classified] })
}
}
}
+21
View File
@@ -0,0 +1,21 @@
import { homedir } from 'node:os'
import { posix, win32 } from 'node:path'
function normalizedPath(value: string): string {
return value.trim().replace(/\\/g, '/').replace(/\/+$/, '')
}
/** True when an absolute path identifies a user home root, not a project. */
export function isUserHomeRoot(value: string | undefined): boolean {
if (!value) return false
const normalized = normalizedPath(value)
if (normalized.toLowerCase() === normalizedPath(homedir()).toLowerCase()) return true
return /(?:^|\/)(?:users|home|profiles)\/[^/]+$/i.test(normalized)
|| normalized === '/root'
}
/** Absolute, non-home provider cwd eligible for outbound provenance. */
export function isTrustedAbsoluteWorkingDirectory(value: string | undefined): value is string {
if (!value || isUserHomeRoot(value)) return false
return posix.isAbsolute(value) || win32.isAbsolute(value)
}
+6
View File
@@ -219,6 +219,12 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
deduplicationKey: dedupKey,
userMessage,
sessionId,
...(session.working_dir
? {
projectPath: session.working_dir,
workingDirectory: session.working_dir,
}
: {}),
}
} finally {
db.close()
+11 -7
View File
@@ -4,6 +4,7 @@ import { basename, dirname, join } from 'path'
import { homedir } from 'os'
import { calculateCost, getShortModelName } from '../models.js'
import { isUserHomeRoot } from '../path-privacy.js'
import { isSqliteAvailable, getSqliteLoadError, openDatabase, isSqliteBusyError, type SqliteDatabase } from '../sqlite.js'
import type { ProbeRoot, Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
import type { ToolCall } from '../types.js'
@@ -419,7 +420,7 @@ function isRealWorkspace(cwd: string | null | undefined): cwd is string {
: trimmed.startsWith('/') && !trimmed.startsWith('//')
if (!isAbsolute) return false
const normalized = trimmed.replace(/\\/g, '/').replace(/\/+$/, '')
if (normalized === '/' || normalized === homedir() || normalized === homedir().replace(/\\/g, '/')) return false
if (normalized === '/' || normalized === homedir() || normalized === homedir().replace(/\\/g, '/') || isUserHomeRoot(normalized)) return false
if (/\.app\/Contents\//.test(normalized)) return false
return true
}
@@ -427,19 +428,20 @@ function isRealWorkspace(cwd: string | null | undefined): cwd is string {
function resolveHermesWorkspace(
row: HermesSessionRow,
messages: HermesMessageRow[],
): { project: string; projectPath?: string; provider: 'hermes' } {
): { project: string; projectPath?: string; workingDirectory?: string; provider: 'hermes' } {
const provider = 'hermes' as const
const repo = row.git_repo_root?.trim()
if (isRealWorkspace(repo)) {
return { project: sanitizeProject(basename(repo)), projectPath: repo, provider }
return { project: sanitizeProject(basename(repo)), projectPath: repo, workingDirectory: repo, provider }
}
const cwd = row.cwd?.trim()
if (isRealWorkspace(cwd)) {
return { project: sanitizeProject(cwd), projectPath: cwd, provider }
return { project: sanitizeProject(cwd), projectPath: cwd, workingDirectory: cwd, provider }
}
const inferred = inferProject(messages, '')
if (isRealWorkspace(inferred.projectPath)) {
return { ...inferred, provider }
// Prompt-derived paths remain local display/grouping labels only.
return { project: inferred.project, provider }
}
return { project: provider, provider }
}
@@ -483,6 +485,7 @@ function observationToCall(
userMessage: string
project: string
projectPath?: string
workingDirectory?: string
prLinks?: string[]
costIsEstimated: boolean
},
@@ -515,6 +518,7 @@ function observationToCall(
sessionId: args.sessionId,
project: args.project,
projectPath: args.projectPath,
workingDirectory: args.workingDirectory,
...(later || !args.prLinks?.length ? {} : { prLinks: args.prLinks }),
...(later ? { supplementaryAccounting: true } : {}),
}
@@ -524,8 +528,7 @@ function inferProject(messages: HermesMessageRow[], fallback: string): { project
const cwdPattern = /^Current working directory:\s*([a-zA-Z]:\\[^\r\n`"]+|\/[^\r\n`"\\]+)/m
for (const msg of messages) {
if (msg.role !== 'user' && msg.role !== 'system') continue
const text = msg.content ?? ''
const match = cwdPattern.exec(text)
const match = cwdPattern.exec(msg.content ?? '')
if (match?.[1]) {
const projectPath = match[1].trim()
return { project: sanitizeProject(projectPath), projectPath }
@@ -745,6 +748,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>, hermesHome:
userMessage: firstUserMessage(messages),
project: workspace.project,
projectPath: workspace.projectPath,
workingDirectory: workspace.workingDirectory,
prLinks,
costIsEstimated: cost.costIsEstimated,
})),
+3
View File
@@ -36,6 +36,8 @@ export type CachedCall = {
deduplicationKey: string
project?: string
projectPath?: string
/** Present only when workingDirectory came from a dedicated provider field. */
workingDirectoryProvenance?: 'provider-field'
workingDirectory?: string
toolSequence?: ToolCall[][]
// Rich-session-capture (capture-only; no report consumes these yet). All
@@ -621,6 +623,7 @@ function validateCall(c: unknown): c is CachedCall {
&& (o['subagentTypes'] === undefined || isStringArray(o['subagentTypes']))
&& isOptionalString(o['project'])
&& isOptionalString(o['projectPath'])
&& (o['workingDirectoryProvenance'] === undefined || o['workingDirectoryProvenance'] === 'provider-field')
&& isOptionalString(o['workingDirectory'])
&& (o['toolSequence'] === undefined || (Array.isArray(o['toolSequence']) && (o['toolSequence'] as unknown[]).every(s => isToolCallArray(s))))
&& isOptionalNum(o['locAdded'])
+113 -14
View File
@@ -7,6 +7,8 @@
import { createHash } from 'crypto'
import { hostname, userInfo } from 'os'
import { posix, win32 } from 'path'
import { isTrustedAbsoluteWorkingDirectory } from '../path-privacy.js'
import type { ParsedApiCall } from '../types.js'
import type { SessionAttributionRecord } from '../yield.js'
@@ -78,31 +80,120 @@ function toUnixNano(isoTimestamp: string): string {
export interface CallWithSession {
call: ParsedApiCall
sessionId: string
project: string
/** Local reconciliation label retained for compatibility; never serialized. */
project?: string
/** Exact provider-recorded cwd. Synthetic labels and storage paths are excluded upstream. */
workingDirectory?: string
}
function isEmailOrCredentialShaped(value: string): boolean {
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
if (/(?:^|[^A-Za-z0-9])(?:AKIA|ASIA)[A-Z0-9]{16}(?:$|[^A-Z0-9])/i.test(value)) return true
return false
}
/**
* Convert a trusted absolute provider cwd into the only project label allowed
* on usage spans. Imported Windows paths are handled on every host. Anything
* ambiguous fails closed.
*/
export function projectBasenameFromWorkingDirectory(workingDirectory: string | undefined): string | undefined {
if (!isTrustedAbsoluteWorkingDirectory(workingDirectory)) return undefined
const flavour = posix.isAbsolute(workingDirectory)
? posix
: win32.isAbsolute(workingDirectory)
? win32
: null
if (!flavour) return undefined
const value = flavour.basename(workingDirectory)
if (!value || value === '.' || value === '..' || value.length > 128) return undefined
if (/[\\/\u0000-\u001f\u007f]/.test(value)) return undefined
if (/%(?:2f|5c)/i.test(value)) return undefined
if (isEmailOrCredentialShaped(value)) return undefined
return value
}
const IDENTIFIER_SHAPE = /^[A-Za-z0-9][A-Za-z0-9._:+/@-]*$/
function isPathUrlOrCredentialShaped(value: string): boolean {
if (posix.isAbsolute(value) || win32.isAbsolute(value)) return true
if (/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(value)) return true
if (/[=?&#\\]/.test(value) || /%(?:2f|5c)/i.test(value)) return true
if (/(?:^|\/)(?:\.{1,2})(?:\/|$)/.test(value)) return true
if (/^(?:users|home|private|tmp|var|etc|root|workspaces?|projects?)\//i.test(value)) return true
if (/^(?:[A-Za-z]--)?(?:users|home|private|tmp|var|etc|root|workspaces?|projects?)[-_]/i.test(value)) return true
return isEmailOrCredentialShaped(value)
}
function sanitizeIdentifier(value: string, maxBytes: number, fallback?: string): string | undefined {
if (/\p{Cc}/u.test(value)) return fallback
if (Buffer.byteLength(value, 'utf8') > maxBytes
|| !IDENTIFIER_SHAPE.test(value)
|| isPathUrlOrCredentialShaped(value)) {
return fallback
}
return value
}
export function sanitizeProviderIdentifier(value: string): string {
return sanitizeIdentifier(value, 64, 'unknown') ?? 'unknown'
}
export function sanitizeModelIdentifier(value: string): string {
return sanitizeIdentifier(value, 160, 'unknown') ?? 'unknown'
}
export function sanitizeToolIdentifiers(values: readonly string[]): string[] {
const result: string[] = []
let totalBytes = 0
for (const value of values) {
if (result.length >= 64) break
const safe = sanitizeIdentifier(value, 128)
if (!safe) continue
const bytes = Buffer.byteLength(safe, 'utf8')
if (totalBytes + bytes > 4096) break
result.push(safe)
totalBytes += bytes
}
return result
}
function safeProjectAttribute(project: string | undefined): OtlpAttribute | null {
if (!project || project.length > 128 || /[\\/\u0000-\u001f\u007f]/.test(project)
|| /%(?:2f|5c)/i.test(project) || isEmailOrCredentialShaped(project)) return null
return { key: 'ai.project', value: { stringValue: project } }
}
export function buildOtlpPayload(calls: CallWithSession[]): OtlpPayload {
const deviceId = getDeviceId()
const spans: OtlpSpan[] = calls.map(({ call, sessionId, project }) => {
const spans: OtlpSpan[] = calls.map(({ call, sessionId, workingDirectory }) => {
const startNano = toUnixNano(call.timestamp)
// End time = start + 1ms (we don't have real duration, but OTLP requires both)
const endNano = (BigInt(startNano) + 1_000_000n).toString()
const provider = sanitizeProviderIdentifier(call.provider)
const model = sanitizeModelIdentifier(call.model)
const project = projectBasenameFromWorkingDirectory(workingDirectory)
const tools = sanitizeToolIdentifiers(call.tools)
const attributes: OtlpAttribute[] = [
{ key: 'ai.provider', value: { stringValue: call.provider } },
{ key: 'ai.model', value: { stringValue: call.model } },
{ key: 'ai.provider', value: { stringValue: provider } },
{ key: 'ai.model', value: { stringValue: model } },
{ key: 'ai.input_tokens', value: { intValue: String(call.usage.inputTokens) } },
{ key: 'ai.output_tokens', value: { intValue: String(call.usage.outputTokens) } },
{ key: 'ai.cost_usd', value: { doubleValue: call.costUSD } },
{ key: 'ai.project', value: { stringValue: project } },
{ key: 'ai.speed', value: { stringValue: call.speed } },
]
const projectAttribute = safeProjectAttribute(project)
if (projectAttribute) attributes.push(projectAttribute)
if (call.tools.length > 0) {
if (tools.length > 0) {
attributes.push({
key: 'ai.tools',
value: { arrayValue: { values: call.tools.map(t => ({ stringValue: t })) } },
value: { arrayValue: { values: tools.map(t => ({ stringValue: t })) } },
})
}
@@ -113,7 +204,7 @@ export function buildOtlpPayload(calls: CallWithSession[]): OtlpPayload {
return {
traceId: deriveTraceId(sessionId),
spanId: deriveSpanId(call.deduplicationKey),
name: `${call.provider}/${call.model}`,
name: `${provider}/${model}`,
startTimeUnixNano: startNano,
endTimeUnixNano: endNano,
attributes,
@@ -164,7 +255,7 @@ export type AttributionItem = {
/** Span end for session items (session lastTimestamp). Absent for commits. */
endTimestamp?: string
sessionId: string
project: string
project?: string
repo: string | null
// session kind
prLinks?: string[]
@@ -175,6 +266,13 @@ export type AttributionItem = {
wasReverted?: boolean
}
function attributionProjectFromRepo(repo: string | null): string | undefined {
if (!repo) return undefined
const parts = repo.split('/').filter(Boolean)
if (parts.length < 3) return undefined
return projectBasenameFromWorkingDirectory(`/${parts.at(-1)}`)
}
function stateHash(parts: string[]): string {
return createHash('sha256').update(parts.join('\u001e')).digest('hex').slice(0, 16)
}
@@ -211,13 +309,14 @@ export function sessionAttributionKeyPrefix(sessionId: string): string {
export function flattenAttributionRecords(records: SessionAttributionRecord[]): AttributionItem[] {
const items: AttributionItem[] = []
for (const record of records) {
const project = attributionProjectFromRepo(record.repo)
items.push({
kind: 'session',
dedupKey: sessionAttributionKey(record),
timestamp: record.firstTimestamp,
endTimestamp: record.lastTimestamp,
sessionId: record.sessionId,
project: record.project,
...(project ? { project } : {}),
repo: record.repo,
prLinks: record.prLinks,
commitCount: record.commits.length,
@@ -228,7 +327,7 @@ export function flattenAttributionRecords(records: SessionAttributionRecord[]):
dedupKey: commitAttributionKey(record.sessionId, commit.sha, commit.inMain, commit.wasReverted),
timestamp: commit.timestamp,
sessionId: record.sessionId,
project: record.project,
...(project ? { project } : {}),
repo: record.repo,
sha: commit.sha,
inMain: commit.inMain,
@@ -255,9 +354,9 @@ export function buildAttributionOtlpPayload(items: AttributionItem[]): OtlpPaylo
const rawEndNano = item.endTimestamp ? BigInt(toUnixNano(item.endTimestamp)) : 0n
const endNano = (rawEndNano > minEndNano ? rawEndNano : minEndNano).toString()
const attributes: OtlpAttribute[] = [
{ key: 'ai.project', value: { stringValue: item.project } },
]
const attributes: OtlpAttribute[] = []
const projectAttribute = safeProjectAttribute(item.project)
if (projectAttribute) attributes.push(projectAttribute)
if (item.repo) {
attributes.push({ key: 'git.repo', value: { stringValue: item.repo } })
}
+1
View File
@@ -187,6 +187,7 @@ export function collectUnsentCalls(projects: ProjectSummary[], now: number = Dat
call,
sessionId: session.sessionId,
project: project.project,
workingDirectory: session.workingDirectory,
})
}
}
+32 -25
View File
@@ -2,6 +2,7 @@ import { execFileSync } from 'child_process'
import { realpathSync } from 'fs'
import { resolve } from 'path'
import { parseAllSessions } from './parser.js'
import { isTrustedAbsoluteWorkingDirectory } from './path-privacy.js'
import type { DateRange, ProjectSummary, SessionSummary } from './types.js'
export type YieldCategory = 'productive' | 'reverted' | 'abandoned' | 'ambiguous'
@@ -399,6 +400,8 @@ type RepoGroup = {
gitDir: string | null
}
type RepoIdentityMode = 'project-path' | 'trusted-session-cwd'
/**
* Group sessions by canonical repository identity and load each group's
* commits for the range. Shared by `computeYield` (categorization) and
@@ -412,6 +415,7 @@ function buildRepoGroups(
projects: ProjectSummary[],
range: DateRange,
cwd: string,
identityMode: RepoIdentityMode = 'project-path',
): Map<string, RepoGroup> {
const repoIdentityCache = new Map<string, RepoIdentity | null>()
@@ -422,31 +426,34 @@ function buildRepoGroups(
const repoGroups = new Map<string, RepoGroup>()
for (const project of projects) {
const projectIdentity = project.projectPath
? resolveRepoIdentity(project.projectPath, repoIdentityCache)
: null
const identity = projectIdentity ?? cwdIdentity
const groupKey = identity ? identity.key : cwd
let group = repoGroups.get(groupKey)
if (!group) {
group = {
commits: !identity
? []
: cwdIdentity && identity.key === cwdIdentity.key
? cwdCommits
: getCommitsInRange(identity.gitDir, range.start, range.end, getMainBranch(identity.gitDir)),
sessions: [],
projectNames: [],
ownIdentity: [],
gitDir: identity?.gitDir ?? null,
}
repoGroups.set(groupKey, group)
}
for (const session of project.sessions) {
const sourcePath = identityMode === 'trusted-session-cwd'
? session.workingDirectory
: project.projectPath
const ownIdentity = isTrustedAbsoluteWorkingDirectory(sourcePath)
? resolveRepoIdentity(sourcePath, repoIdentityCache)
: null
const identity = ownIdentity ?? cwdIdentity
const groupKey = identity ? identity.key : cwd
let group = repoGroups.get(groupKey)
if (!group) {
group = {
commits: !identity
? []
: cwdIdentity && identity.key === cwdIdentity.key
? cwdCommits
: getCommitsInRange(identity.gitDir, range.start, range.end, getMainBranch(identity.gitDir)),
sessions: [],
projectNames: [],
ownIdentity: [],
gitDir: identity?.gitDir ?? null,
}
repoGroups.set(groupKey, group)
}
group.sessions.push(session)
group.projectNames.push(project.project)
group.ownIdentity.push(projectIdentity !== null)
group.ownIdentity.push(ownIdentity !== null)
}
}
@@ -652,14 +659,14 @@ export function computeAttributionRecords(
range: DateRange,
cwd: string,
): SessionAttributionRecord[] {
const repoGroups = buildRepoGroups(projects, range, cwd)
const repoGroups = buildRepoGroups(projects, range, cwd, 'trusted-session-cwd')
const records: SessionAttributionRecord[] = []
for (const group of repoGroups.values()) {
const remote = group.gitDir ? getRepoRemote(group.gitDir) : null
// Privacy gate: only sessions whose identity came from their OWN project
// path participate in commit attribution. A session whose project path no
// Privacy gate: only sessions whose identity came from their own explicit
// provider-recorded cwd participate in commit attribution. A session whose cwd no
// longer resolves (deleted/renamed dir, non-repo session) inherits the
// cwd-fallback identity in buildRepoGroups — attributing it here would
// egress whatever repo the user happens to be pushing from, with commits
+41
View File
@@ -4,6 +4,8 @@ import { join, relative } from 'path'
import { tmpdir, homedir } from 'os'
import { parseAllSessions } from '../src/parser.js'
import { collectUnsentCalls } from '../src/sync/push.js'
import { buildOtlpPayload } from '../src/sync/otlp.js'
import type { DateRange } from '../src/types.js'
let tmpDir: string
@@ -186,6 +188,45 @@ describe('Claude cwd project paths', () => {
expect(projects[0]!.projectPath).not.toBe('Projects/Content/OS')
})
it('does not promote a Claude Cowork container cwd into trusted session provenance', async () => {
const desktopBase = process.env['CODEBURN_DESKTOP_SESSIONS_DIR']!
const projectDir = join(
desktopBase,
'app-1',
'workspace-1',
'local_session-1',
'.claude',
'projects',
'-sessions-synthetic-cowork-secret',
)
await mkdir(projectDir, { recursive: true })
const filePath = join(projectDir, 'cowork-session.jsonl')
const timestamp = '2099-05-11T10:00:00.000Z'
await writeFile(filePath, `${JSON.stringify({
type: 'assistant',
sessionId: 'cowork-session',
timestamp,
cwd: '/sessions/synthetic-cowork-secret',
message: {
id: 'msg-cowork-session',
type: 'message',
role: 'assistant',
model: 'claude-sonnet-4-5',
content: [],
usage: { input_tokens: 100, output_tokens: 50 },
},
})}\n`)
await utimes(filePath, new Date(timestamp), new Date(timestamp))
const projects = await parseAllSessions(dayRange('2099-05-11'), 'claude')
expect(projects).toHaveLength(1)
expect(projects[0]!.sessions[0]!.workingDirectory).toBeUndefined()
const wire = JSON.stringify(buildOtlpPayload(collectUnsentCalls(projects).allCalls))
expect(wire).not.toContain('synthetic-cowork-secret')
expect(wire).not.toContain('ai.project')
})
it('does not group sibling projects under a parent directory that merely contains .git', async () => {
const projectsRoot = join(tmpDir, 'Projects')
const swiftbar = join(projectsRoot, 'Swiftbar')
+131
View File
@@ -0,0 +1,131 @@
import { createRequire } from 'node:module'
import { mkdir, mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createGooseProvider } from '../../src/providers/goose.js'
import { isSqliteAvailable } from '../../src/sqlite.js'
import { buildOtlpPayload, deriveSpanId } from '../../src/sync/otlp.js'
import type { ParsedApiCall } from '../../src/types.js'
const requireForTest = createRequire(import.meta.url)
type TestDb = {
exec(sql: string): void
prepare(sql: string): { run(...params: unknown[]): void }
close(): void
}
let root: string
const originalRoot = process.env.GOOSE_PATH_ROOT
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'codeburn-goose-sync-'))
process.env.GOOSE_PATH_ROOT = root
})
afterEach(async () => {
if (originalRoot === undefined) delete process.env.GOOSE_PATH_ROOT
else process.env.GOOSE_PATH_ROOT = originalRoot
await rm(root, { recursive: true, force: true })
})
const sqliteDescribe = isSqliteAvailable() ? describe : describe.skip
sqliteDescribe('Goose sync project provenance', () => {
it('carries the exact working_dir and emits only its basename', async () => {
const dbPath = join(root, 'data', 'sessions', 'sessions.db')
await mkdir(dirname(dbPath), { recursive: true })
const { DatabaseSync: Database } = requireForTest('node:sqlite') as {
DatabaseSync: new (path: string) => TestDb
}
const db = new Database(dbPath)
db.exec(`
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
name TEXT,
working_dir TEXT,
created_at TEXT,
updated_at TEXT,
accumulated_input_tokens INTEGER,
accumulated_output_tokens INTEGER,
provider_name TEXT,
model_config_json TEXT
);
CREATE TABLE messages (
session_id TEXT,
message_id TEXT,
role TEXT,
content_json TEXT,
created_timestamp INTEGER
);
`)
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(
'goose-session-1',
'LLM-authored session title',
'/Users/alice/company/private-widget',
'2026-08-23T10:00:00.000Z',
'2026-08-23T10:01:00.000Z',
100,
20,
'openai',
JSON.stringify({ model_name: 'gpt-5.4' }),
)
db.close()
const provider = createGooseProvider()
const sources = await provider.discoverSessions()
expect(sources).toHaveLength(1)
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')
const raw = calls[0]!
const parsed: ParsedApiCall = {
provider: raw.provider,
model: raw.model,
usage: {
inputTokens: raw.inputTokens,
outputTokens: raw.outputTokens,
cacheCreationInputTokens: raw.cacheCreationInputTokens,
cacheReadInputTokens: raw.cacheReadInputTokens,
cachedInputTokens: raw.cachedInputTokens,
reasoningTokens: raw.reasoningTokens,
webSearchRequests: raw.webSearchRequests,
},
costUSD: raw.costUSD,
tools: raw.tools,
mcpTools: [],
skills: [],
subagentTypes: [],
hasAgentSpawn: false,
hasPlanMode: false,
speed: raw.speed,
timestamp: raw.timestamp,
bashCommands: raw.bashCommands,
deduplicationKey: raw.deduplicationKey,
}
const payload = buildOtlpPayload([{
call: parsed,
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]))
expect(attributes['ai.project']).toEqual({ stringValue: 'private-widget' })
expect(JSON.stringify(payload)).not.toContain('/Users/alice')
expect(JSON.stringify(payload)).not.toContain('LLM-authored session title')
expect(span.spanId).toBe(deriveSpanId(raw.deduplicationKey))
})
})
+9 -11
View File
@@ -432,6 +432,8 @@ skipUnlessSqlite('hermes provider', () => {
expect(sessions.reduce((sum, session) => sum + session.totalCacheReadTokens, 0)).toBe(41)
expect(sessions.reduce((sum, session) => sum + session.totalCacheWriteTokens, 0)).toBe(53)
expect(sessions.reduce((sum, session) => sum + session.totalCostUSD, 0)).toBeCloseTo(0.72)
// Prompt-derived names remain useful for local grouping, but never become
// trusted projectPath/workingDirectory provenance.
expect(projects.map(project => project.project).sort()).toEqual(['tmp-profile-project', 'tmp-root-project'])
const modelTokens = sessions.flatMap(session => Object.values(session.modelBreakdown).map(model => model.tokens))
@@ -618,6 +620,7 @@ skipUnlessSqlite('hermes provider', () => {
const calls = await collectCalls(tmpDir, `${dbPath}#hermes-session=home-cwd`)
expect(calls[0]?.provider).toBe('hermes')
expect(calls[0]?.project).toBe('hermes')
expect(calls[0]?.workingDirectory).toBeUndefined()
})
it('does not treat a relative cwd as a workspace', async () => {
@@ -858,7 +861,7 @@ skipUnlessSqlite('hermes provider', () => {
expect(calls[0]?.projectPath).toBeUndefined()
})
it('infers projects from Windows current working directory messages', async () => {
it('never promotes prompt text into project or cwd provenance', async () => {
const dbPath = createHermesDb(tmpDir)
withTestDb(dbPath, (db) => {
insertSession(db, {
@@ -871,19 +874,13 @@ skipUnlessSqlite('hermes provider', () => {
startedAt: 1779549200,
})
db.prepare('INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)')
.run('windows-cwd-session', 'user', 'Current working directory: C:\\AI_LAB\\OPENCLAW\nAdd Windows path support', 1779549201)
.run('windows-cwd-session', 'user', 'Current working directory: /tmp/secret-client\nAdd path support', 1779549201)
})
const calls = await collectCalls(tmpDir, `${dbPath}#hermes-session=windows-cwd-session`)
if (process.platform === 'win32') {
expect(calls[0]).toMatchObject({
project: 'C--AI_LAB-OPENCLAW',
projectPath: 'C:\\AI_LAB\\OPENCLAW',
})
} else {
expect(calls[0]?.project).toBe('hermes')
expect(calls[0]?.projectPath).toBeUndefined()
}
expect(calls[0]?.project).toBe('tmp-secret-client')
expect(calls[0]?.projectPath).toBeUndefined()
expect(calls[0]?.workingDirectory).toBeUndefined()
})
it('groups by the sessions.cwd column when present, ahead of message scraping', async () => {
@@ -907,6 +904,7 @@ skipUnlessSqlite('hermes provider', () => {
expect(calls[0]).toMatchObject({
project: 'Users-me-projects-codeburn',
projectPath: '/Users/me/projects/codeburn',
workingDirectory: '/Users/me/projects/codeburn',
})
})
+1
View File
@@ -105,6 +105,7 @@ beforeEach(async () => {
const first = new Date(now - 90 * 60 * 1000).toISOString()
const last = new Date(now - 30 * 60 * 1000).toISOString()
const session = makeSession('cli-sess-1', first, last, [makeCall(`call-${now}`, first)])
session.workingDirectory = repoDir
parseAllSessionsMock.mockResolvedValue([
{ project: 'app', projectPath: repoDir, sessions: [session] } as ProjectSummary,
])
+54 -7
View File
@@ -183,6 +183,7 @@ describe('computeAttributionRecords', () => {
const session = makeSession({
sessionId: 'sess-a',
workingDirectory: repoDir,
prLinks: ['https://github.com/acme/widget/pull/12'],
})
const projects = [
@@ -218,7 +219,7 @@ describe('computeAttributionRecords', () => {
// permanently zero a still-correct server-side count.
commitAt(repoDir, 'feat: unrelated', '2026-01-01T20:00:00Z')
const session = makeSession({ sessionId: 'sess-idle' })
const session = makeSession({ sessionId: 'sess-idle', workingDirectory: repoDir })
const projects = [
{ project: 'app', projectPath: repoDir, sessions: [session] } as ProjectSummary,
]
@@ -246,10 +247,11 @@ describe('computeAttributionRecords', () => {
const tight = makeSession({
sessionId: 'sess-tight',
workingDirectory: repoDir,
firstTimestamp: '2026-01-01T10:15:00.000Z',
lastTimestamp: '2026-01-01T10:45:00.000Z',
})
const broadLoser = makeSession({ sessionId: 'sess-broad-loser' })
const broadLoser = makeSession({ sessionId: 'sess-broad-loser', workingDirectory: repoDir })
const projects = [
{ project: 'app', projectPath: repoDir, sessions: [tight, broadLoser] } as ProjectSummary,
]
@@ -276,11 +278,12 @@ describe('computeAttributionRecords', () => {
const withPr = makeSession({
sessionId: 'sess-pr',
workingDirectory: repoDir,
prLinks: ['https://github.com/acme/widget/pull/7'],
firstTimestamp: '2026-01-01T10:15:00.000Z',
lastTimestamp: '2026-01-01T10:45:00.000Z',
})
const withoutPr = makeSession({ sessionId: 'sess-nopr' })
const withoutPr = makeSession({ sessionId: 'sess-nopr', workingDirectory: repoDir })
const projects = [
{ project: 'app', projectPath: repoDir, sessions: [withPr, withoutPr] } as ProjectSummary,
]
@@ -321,7 +324,7 @@ describe('computeAttributionRecords', () => {
lastTimestamp: '2026-01-01T12:30:00.000Z',
})
// Session C: genuinely belongs to the cwd repo (own path resolves)
const genuine = makeSession({ sessionId: 'genuine-cwd', firstTimestamp: '2026-01-01T10:00:00.000Z', lastTimestamp: '2026-01-01T11:00:00.000Z' })
const genuine = makeSession({ sessionId: 'genuine-cwd', workingDirectory: cwdRepo, firstTimestamp: '2026-01-01T10:00:00.000Z', lastTimestamp: '2026-01-01T11:00:00.000Z' })
const projects = [
{ project: 'ghost', projectPath: join(cwdRepo, 'no-such-dir-anymore-xyz'), sessions: [orphanNoPr] },
@@ -365,7 +368,7 @@ describe('computeAttributionRecords', () => {
firstTimestamp: '2026-01-01T10:25:00.000Z',
lastTimestamp: '2026-01-01T10:35:00.000Z',
})
const genuineBroad = makeSession({ sessionId: 'genuine-broad' })
const genuineBroad = makeSession({ sessionId: 'genuine-broad', workingDirectory: cwdRepo })
const projects = [
{ project: 'ghost', projectPath: '', sessions: [fallbackTight] },
@@ -391,10 +394,11 @@ describe('computeAttributionRecords', () => {
const tight = makeSession({
sessionId: 'sess-tight',
workingDirectory: repoDir,
firstTimestamp: '2026-01-01T10:15:00.000Z',
lastTimestamp: '2026-01-01T10:45:00.000Z',
})
const broad = makeSession({ sessionId: 'sess-broad' })
const broad = makeSession({ sessionId: 'sess-broad', workingDirectory: repoDir })
const projects = [
{ project: 'app', projectPath: repoDir, sessions: [tight, broad] } as ProjectSummary,
]
@@ -412,6 +416,49 @@ describe('computeAttributionRecords', () => {
await rm(repoDir, { recursive: true, force: true })
}
})
it('never treats a valid but unproven projectPath as attribution provenance', async () => {
const repoDir = await mkdtemp(join(tmpdir(), 'codeburn-attr-untrusted-path-'))
try {
initRepo(repoDir)
git(repoDir, ['remote', 'add', 'origin', 'git@github.com:secret-org/storage-profile.git'])
await writeFile(join(repoDir, 'file.txt'), 'x\n')
commitAt(repoDir, 'feat: unrelated storage commit', '2026-01-01T10:30:00Z')
const session = makeSession({
sessionId: 'untrusted-project-path',
prLinks: ['https://github.com/acme/widget/pull/9'],
})
const projects = [{ project: 'profile', projectPath: repoDir, sessions: [session] }] as ProjectSummary[]
const records = computeAttributionRecords(projects, range, repoDir)
expect(records).toHaveLength(1)
expect(records[0]).toMatchObject({ repo: null, commits: [] })
expect(JSON.stringify(records)).not.toContain('secret-org')
} finally {
await rm(repoDir, { recursive: true, force: true })
}
})
it('never resolves a relative workingDirectory against the sync cwd', async () => {
const repoDir = await mkdtemp(join(tmpdir(), 'codeburn-attr-relative-cwd-'))
try {
initRepo(repoDir)
git(repoDir, ['remote', 'add', 'origin', 'git@github.com:secret-org/current-repo.git'])
const session = makeSession({
sessionId: 'relative-cwd',
workingDirectory: '.',
prLinks: ['https://github.com/acme/widget/pull/10'],
})
const projects = [{ project: 'local-label', projectPath: repoDir, sessions: [session] }] as ProjectSummary[]
const records = computeAttributionRecords(projects, range, repoDir)
expect(records).toHaveLength(1)
expect(records[0]).toMatchObject({ repo: null, commits: [] })
expect(JSON.stringify(records)).not.toContain('secret-org')
} finally {
await rm(repoDir, { recursive: true, force: true })
}
})
})
// ── Dedup keys and flattening ─────────────────────────────────────────
@@ -554,7 +601,7 @@ describe('buildAttributionOtlpPayload', () => {
const sessionAttrs = attrMap(sessionSpan.attributes)
expect(sessionAttrs['ai.session_id']).toBeUndefined()
expect(sessionAttrs['ai.project']).toEqual({ stringValue: 'app' })
expect(sessionAttrs['ai.project']).toEqual({ stringValue: 'widget' })
expect(sessionAttrs['git.repo']).toEqual({ stringValue: 'github.com/acme/widget' })
expect(sessionAttrs['git.commit_count']).toEqual({ intValue: '1' })
expect(sessionAttrs['git.pr_links']).toEqual({
+1
View File
@@ -55,6 +55,7 @@ function makeCallWithSession(overrides?: Partial<ParsedApiCall> & { deduplicatio
call: makeCall({ deduplicationKey: overrides?.deduplicationKey ?? 'test:key:1', ...overrides }),
sessionId: 'session-abc',
project: 'my-project',
workingDirectory: '/workspace/my-project',
}
}
+191
View File
@@ -0,0 +1,191 @@
import { mkdtemp, mkdir, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { parseProviderSources } from '../src/parser.js'
import { CACHE_VERSION, computeEnvFingerprint, type CachedFile, type SessionCache } from '../src/session-cache.js'
import { collectUnsentCalls } from '../src/sync/push.js'
import { buildOtlpPayload } from '../src/sync/otlp.js'
import type { SessionSource } from '../src/providers/types.js'
let root: string
const originalCacheDir = process.env.CODEBURN_CACHE_DIR
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'codeburn-sync-provenance-'))
process.env.CODEBURN_CACHE_DIR = join(root, 'cache')
})
afterEach(async () => {
if (originalCacheDir === undefined) delete process.env.CODEBURN_CACHE_DIR
else process.env.CODEBURN_CACHE_DIR = originalCacheDir
await rm(root, { recursive: true, force: true })
})
async function parseOne(provider: string, source: SessionSource) {
const cache: SessionCache = { version: CACHE_VERSION, providers: {} }
return parseProviderSources(provider, [source], new Set(), cache, undefined, undefined, false)
}
function wire(projects: Awaited<ReturnType<typeof parseOne>>): string {
return JSON.stringify(buildOtlpPayload(collectUnsentCalls(projects).allCalls))
}
async function cachedFileFor(path: string, workingDirectory: string, trusted: boolean): Promise<CachedFile> {
const info = await stat(path)
return {
fingerprint: {
dev: info.dev,
ino: info.ino,
mtimeMs: info.mtimeMs,
sizeBytes: info.size,
},
mcpInventory: [],
turns: [{
timestamp: '2026-08-23T10:00:00Z',
sessionId: trusted ? 'trusted-cache' : 'legacy-cache',
userMessage: '',
calls: [{
provider: 'lingtai-tui',
model: 'gpt-5.5',
usage: {
inputTokens: 10,
outputTokens: 5,
cacheCreationInputTokens: 0,
cacheReadInputTokens: 0,
cachedInputTokens: 0,
reasoningTokens: 0,
webSearchRequests: 0,
cacheCreationOneHourTokens: 0,
},
speed: 'standard',
timestamp: '2026-08-23T10:00:00Z',
tools: [],
bashCommands: [],
skills: [],
subagentTypes: [],
deduplicationKey: trusted ? 'trusted-cache-call' : 'legacy-cache-call',
project: 'local-only-project',
projectPath: workingDirectory,
workingDirectory,
...(trusted ? { workingDirectoryProvenance: 'provider-field' as const } : {}),
}],
}],
}
}
describe('sync cwd provenance', () => {
it('does not turn a LingTai agent storage directory into ai.project', async () => {
const agentDir = join(root, 'Users', 'alice', 'secret-client-agent')
const ledger = join(agentDir, 'logs', 'token_ledger.jsonl')
await mkdir(dirname(ledger), { recursive: true })
await writeFile(ledger, `${JSON.stringify({
source: 'main', ts: '2026-08-23T10:00:00Z', input: 10, output: 5, model: 'gpt-5.5',
})}\n`)
const projects = await parseOne('lingtai-tui', { path: ledger, project: 'Private Agent', provider: 'lingtai-tui' })
expect(projects[0]!.sessions[0]!.workingDirectory).toBeUndefined()
expect(wire(projects)).not.toContain('ai.project')
expect(wire(projects)).not.toContain('secret-client-agent')
})
it('does not turn a QuickDesk profile data path into ai.project', async () => {
const profile = join(root, 'Users', 'alice', 'secret-quickdesk-profile')
const metrics = join(profile, 'metrics', 'metrics-2026-08-23.jsonl')
await mkdir(dirname(metrics), { recursive: true })
await writeFile(metrics, `${JSON.stringify({ Model: 'gpt-5.5', InputTokens: 10, OutputTokens: 5, CostUSD: 0.01 })}\n`)
const projects = await parseOne('quickdesk', {
path: metrics,
project: 'private-profile',
provider: 'quickdesk',
sourceId: 'metrics',
sourcePath: profile,
})
expect(projects[0]!.sessions[0]!.workingDirectory).toBeUndefined()
expect(wire(projects)).not.toContain('ai.project')
expect(wire(projects)).not.toContain('secret-quickdesk-profile')
})
it('does not trust a pre-fix warm-cache workingDirectory without provenance', async () => {
const sourcePath = join(root, 'legacy-source.jsonl')
await writeFile(sourcePath, '{}\n')
const secretPath = '/tmp/secret-client'
const cache: SessionCache = {
version: CACHE_VERSION,
providers: {
'lingtai-tui': {
envFingerprint: computeEnvFingerprint('lingtai-tui'),
files: { [sourcePath]: await cachedFileFor(sourcePath, secretPath, false) },
},
},
}
const projects = await parseProviderSources(
'lingtai-tui',
[{ path: sourcePath, project: 'local-only-project', provider: 'lingtai-tui' }],
new Set(),
cache,
undefined,
undefined,
false,
)
expect(projects[0]!.sessions[0]!.workingDirectory).toBeUndefined()
expect(wire(projects)).not.toContain('ai.project')
expect(wire(projects)).not.toContain('secret-client')
})
it('restores a warm-cache cwd only when marked as provider-field provenance', async () => {
const sourcePath = join(root, 'trusted-source.jsonl')
await writeFile(sourcePath, '{}\n')
const trustedPath = '/workspace/trusted-widget'
const cache: SessionCache = {
version: CACHE_VERSION,
providers: {
'lingtai-tui': {
envFingerprint: computeEnvFingerprint('lingtai-tui'),
files: { [sourcePath]: await cachedFileFor(sourcePath, trustedPath, true) },
},
},
}
const projects = await parseProviderSources(
'lingtai-tui',
[{ path: sourcePath, project: 'local-only-project', provider: 'lingtai-tui' }],
new Set(),
cache,
undefined,
undefined,
false,
)
expect(projects[0]!.sessions[0]!.workingDirectory).toBe(trustedPath)
expect(wire(projects)).toContain('trusted-widget')
})
it('rejects a provenance marker on a relative workingDirectory', async () => {
const sourcePath = join(root, 'relative-source.jsonl')
await writeFile(sourcePath, '{}\n')
const cache: SessionCache = {
version: CACHE_VERSION,
providers: {
'lingtai-tui': {
envFingerprint: computeEnvFingerprint('lingtai-tui'),
files: { [sourcePath]: await cachedFileFor(sourcePath, '.', true) },
},
},
}
const projects = await parseProviderSources(
'lingtai-tui',
[{ path: sourcePath, project: 'local-only-project', provider: 'lingtai-tui' }],
new Set(), cache, undefined, undefined, false,
)
expect(projects[0]!.sessions[0]!.workingDirectory).toBeUndefined()
expect(wire(projects)).not.toContain('ai.project')
})
})
+153
View File
@@ -0,0 +1,153 @@
import { describe, expect, it } from 'vitest'
import {
buildAttributionOtlpPayload,
buildOtlpPayload,
flattenAttributionRecords,
projectBasenameFromWorkingDirectory,
type CallWithSession,
} from '../src/sync/otlp.js'
import type { ParsedApiCall } from '../src/types.js'
import type { SessionAttributionRecord } from '../src/yield.js'
function call(overrides: Partial<ParsedApiCall> = {}): ParsedApiCall {
return {
provider: 'codex',
model: 'gpt-5.5',
usage: {
inputTokens: 10,
outputTokens: 5,
cacheCreationInputTokens: 0,
cacheReadInputTokens: 0,
cachedInputTokens: 0,
reasoningTokens: 0,
webSearchRequests: 0,
},
costUSD: 0.01,
tools: ['Edit'],
mcpTools: [],
skills: [],
subagentTypes: [],
hasAgentSpawn: false,
hasPlanMode: false,
speed: 'standard',
timestamp: '2026-08-24T10:00:00.000Z',
bashCommands: [],
deduplicationKey: 'privacy-call-1',
...overrides,
}
}
function usage(workingDirectory?: string, overrides: Partial<ParsedApiCall> = {}): CallWithSession {
return {
call: call(overrides),
sessionId: 'privacy-session-1',
workingDirectory,
}
}
function attributes(item: CallWithSession): Record<string, unknown> {
const span = buildOtlpPayload([item]).resourceSpans[0]!.scopeSpans[0]!.spans[0]!
return Object.fromEntries(span.attributes.map(attribute => [attribute.key, attribute.value]))
}
describe('sync project privacy boundary', () => {
it.each([
['/Users/alice/work/private-widget', 'private-widget'],
['C:\\Users\\alice\\work\\private-widget', 'private-widget'],
])('emits only the basename of a trusted absolute cwd %s', (cwd, expected) => {
expect(projectBasenameFromWorkingDirectory(cwd)).toBe(expected)
expect(attributes(usage(cwd))['ai.project']).toEqual({ stringValue: expected })
expect(JSON.stringify(buildOtlpPayload([usage(cwd)]))).not.toContain(cwd)
})
it.each([
undefined,
'',
'.',
'/',
'C:\\',
'-Users-alice-secret-repo',
'LLM-authored project title',
'%2FUsers%2Falice%2Fsecret',
'/Users/alice',
'/home/alice',
'/root',
'/mnt/c/Users/alice',
'/var/home/alice',
'/net/home/alice',
'\\\\server\\Users\\alice',
'D:\\Profiles\\alice',
])('omits ai.project when cwd provenance is absent or unsafe: %s', cwd => {
expect(projectBasenameFromWorkingDirectory(cwd)).toBeUndefined()
expect(attributes(usage(cwd))['ai.project']).toBeUndefined()
})
it.each([
'alice@example.com',
'api_key=synthetic-secret',
'github_pat_abcdefghijklmnopqrstuvwxyz1234567890',
'ghp_abcdefghijklmnopqrstuvwxyz1234567890',
'encoded%2Fseparator',
'encoded%5Cseparator',
])('omits credential-, email-, and encoded-path-shaped basenames: %s', basename => {
expect(attributes(usage(`/workspace/${basename}`))['ai.project']).toBeUndefined()
})
})
describe('sync identifier privacy boundary', () => {
it('redacts unsafe provider/model strings, drops unsafe tools, and keeps useful identifiers', () => {
const payload = buildOtlpPayload([usage('/workspace/widget', {
provider: '/Users/alice/.config/private-provider',
model: 'api_key=synthetic-secret',
tools: ['Edit', '/Users/alice/.ssh/id_rsa', 'mcp__github__search', 'alice@example.com'],
})])
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: 'unknown' })
expect(attrs['ai.model']).toEqual({ stringValue: 'unknown' })
expect(attrs['ai.tools']).toEqual({
arrayValue: { values: [{ stringValue: 'Edit' }, { stringValue: 'mcp__github__search' }] },
})
expect(span.name).toBe('unknown/unknown')
const wire = JSON.stringify(payload)
expect(wire).not.toContain('/Users/alice')
expect(wire).not.toContain('synthetic-secret')
expect(wire).not.toContain('alice@example.com')
})
})
describe('sync attribution project privacy boundary', () => {
function record(overrides: Partial<SessionAttributionRecord> = {}): SessionAttributionRecord {
return {
sessionId: 'privacy-session-1',
project: 'LLM-authored /Users/alice/secret',
repo: 'github.com/acme/widget',
prLinks: [],
commits: [],
firstTimestamp: '2026-08-24T10:00:00.000Z',
lastTimestamp: '2026-08-24T10:01:00.000Z',
...overrides,
}
}
it('derives ai.project from normalized git.repo, never the parser project label', () => {
const payload = buildAttributionOtlpPayload(flattenAttributionRecords([record()]))
const attrs = Object.fromEntries(payload.resourceSpans[0]!.scopeSpans[0]!.spans[0]!.attributes
.map(attribute => [attribute.key, attribute.value]))
expect(attrs['ai.project']).toEqual({ stringValue: 'widget' })
expect(JSON.stringify(payload)).not.toContain('/Users/alice/secret')
})
it('omits ai.project for PR-only attribution without a normalized repo', () => {
const payload = buildAttributionOtlpPayload(flattenAttributionRecords([record({
repo: null,
prLinks: ['https://github.com/acme/widget/pull/1'],
})]))
const attrs = Object.fromEntries(payload.resourceSpans[0]!.scopeSpans[0]!.spans[0]!.attributes
.map(attribute => [attribute.key, attribute.value]))
expect(attrs['ai.project']).toBeUndefined()
expect(JSON.stringify(payload)).not.toContain('/Users/alice/secret')
})
})