fix(sync): trust only proven outbound project metadata
This commit is contained in:
@@ -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
@@ -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
@@ -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] })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
})),
|
||||
|
||||
@@ -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
@@ -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 } })
|
||||
}
|
||||
|
||||
@@ -187,6 +187,7 @@ export function collectUnsentCalls(projects: ProjectSummary[], now: number = Dat
|
||||
call,
|
||||
sessionId: session.sessionId,
|
||||
project: project.project,
|
||||
workingDirectory: session.workingDirectory,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+32
-25
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
])
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user