Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions docs/ARCHITECTURE-PROXY.md
Original file line number Diff line number Diff line change
Expand Up @@ -917,6 +917,24 @@ sequenceDiagram
PX-->>VS: Byte-preserved SSE stream
```

### 6.14 Claude Code OTLP Per-Project Allowlist

The OTLP plugin (`otlp.plugin.ts`) receives Claude Code OTel data and hook events into a disk spool (`otlp-spool/`). Which projects are tracked is decided by the allowlist `CODEMIE_ANALYTICS_PROJECT_FILTER` (JSON array of absolute paths, stored as a string in `env` of `~/.claude/settings.json`). An absent or empty list tracks every project; a `cwd` inside any entry (nested directories included) is tracked; an invalid value tracks nothing. `codemie proxy connect --claude-code-otlp` writes it (default `--scope user` resets it to `[]`; `--scope project` adds the current project root). See [COMMANDS.md](COMMANDS.md#claude-code-otlp-analytics).

**Filtering is hook-side only.** The daemon has no allowlist logic because OTLP carries no `cwd`. The Claude Code OTLP plugin (`src/agents/plugins/claude-code-otlp/claude-code-otlp.plugin.ts`) reads the allowlist on every hook event. For an untracked project it returns early: no daemon start (`ensureOtlpProxy` is not called), no SSO check, no forward to the spool.

**OTEL-only invariant.** The OTel environment is global, so untracked sessions still export OTEL data to the daemon, but they never receive hooks data. The completeness gate (`otlp-spool/completeness-gate.ts`) only sends sessions that have hooks data; an OTEL-only session gets `wait` and, once `waitTicks >= OTLP_SEND_MAX_ATTEMPTS` (limit resolved by `hooksOnlyWaitTicks()` in `otlp-spool/spool-config.ts`), `skip`. On `skip` the tick processor advances the OTEL cursors to EOF without sending. No code path may send OTEL-only data without first adding daemon-side project filtering.

**Deletion timeline for untracked data**

| Stage | When | Result |
| ------- | ----------------------------------------------------------------------------------------------- | ---------------------------------------------- |
| Spooled | OTEL data arrives | Held on disk, never sent |
| Skipped | After `OTLP_SEND_MAX_ATTEMPTS` ticks (`wait` counted per send tick) | Cursors advanced to EOF, session counts as drained |
| Swept | Drained and idle longer than `OTLP_ABANDONED_SESSION_GRACE_MINUTES` (measured from last write) | Spool deleted by the sweeper |

The decision is per hook event: a session that starts outside and later enters a tracked project becomes sendable from then on, but a tracked session whose first hook arrives after the wait limit loses the OTEL bytes received before that.

---

## 7. Quality Attributes
Expand Down
19 changes: 19 additions & 0 deletions docs/COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1578,3 +1578,22 @@ codemie version
- CLI version
- Node.js version
- Package name and description

### Claude Code OTLP analytics

`codemie proxy connect --claude-code-otlp` writes the hooks and OTel environment variables into the user-level `~/.claude/settings.json`, plus `CODEMIE_ANALYTICS_PROJECT_FILTER`: a JSON array (stored as a string) of absolute project paths that are tracked.

| Command | Allowlist result |
| ----------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| `connect --claude-code-otlp` (default `--scope user`) | `[]` - every project is tracked (resets any existing list) |
| `connect --claude-code-otlp --scope project` | adds the current project root (deduplicated); run it in each project to track |
| `disconnect --claude-code-otlp --scope project` | removes the current project root; when the list becomes empty everything is disconnected |
| `disconnect --claude-code-otlp` | removes hooks, OTel variables and the allowlist |

A `cwd` inside any listed path is tracked (nested directories included). An invalid value stops `connect` before anything is written; fix it manually or rerun with `--force` to discard it. `--force` never edits a valid list.

**How the filter works.** Only the hook process reads the allowlist. For an untracked project the hook does nothing: no proxy start, no SSO check, nothing forwarded. The daemon has no allowlist logic; its completeness gate (`otlp-spool/completeness-gate.ts`) only sends sessions that have hooks data, and never sends OTEL-only sessions. The decision is per hook event, so a tracked session that moves fully outside the project loses hook events from that stretch, and a session that starts outside and later enters a tracked project becomes sendable from then on.

**Privacy note.** The OTel environment is global, so every Claude Code session, including untracked projects, exports telemetry (with tool details) to the local daemon whenever it runs. That data stays on disk: it is skipped after the send wait limit (`OTLP_SEND_MAX_ATTEMPTS` ticks) and deleted by the sweeper after the abandoned grace period (`OTLP_ABANDONED_SESSION_GRACE_MINUTES`). Nothing from untracked projects is sent to the backend.

Allowlist changes apply immediately; the first-time setup needs a Claude Code restart.
13 changes: 13 additions & 0 deletions docs/HOOKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ The CodeMie Code hooks system allows you to execute custom shell commands or LLM
- [Hook Events](#hook-events)
- [Security Considerations](#security-considerations)
- [Examples](#examples)
- [Claude Code OTLP Hook Filter](#claude-code-otlp-hook-filter)
- [Troubleshooting](#troubleshooting)

## Overview
Expand Down Expand Up @@ -568,6 +569,18 @@ fi
}
```

## Claude Code OTLP Hook Filter

The `claude-code-otlp` integration (`codemie proxy connect --claude-code-otlp`) registers Claude Code hooks in `~/.claude/settings.json` that forward hook events to the CodeMie proxy daemon for analytics. Which projects are tracked is controlled by the allowlist `CODEMIE_ANALYTICS_PROJECT_FILTER`, a JSON array (stored as a string) of absolute project paths in the `env` block of `~/.claude/settings.json`.

- Absent or `[]`: every project is tracked.
- Non-empty: an event is tracked when its `cwd` equals or is nested inside a listed path. A missing `cwd` or an invalid value is not tracked.
- Managed through `connect`/`disconnect --claude-code-otlp --scope user|project` (see [COMMANDS.md](COMMANDS.md#claude-code-otlp-analytics)).

The filter runs in the hook process. The plugin (`src/agents/plugins/claude-code-otlp/claude-code-otlp.plugin.ts`), not `hook.ts`, decides whether the daemon is ensured: `hook.ts` passes an `ensureOtlpProxy` callback and the plugin calls it only for tracked projects. For an untracked project the hook does nothing: no daemon start, no SSO check, nothing forwarded to the spool.

Claude Code OTel telemetry is still exported globally, so untracked sessions reach the daemon as OTEL-only data. The daemon never sends OTEL-only sessions (completeness gate decision `skip`); they are skipped after `OTLP_SEND_MAX_ATTEMPTS` ticks and deleted by the sweeper after `OTLP_ABANDONED_SESSION_GRACE_MINUTES`. Details: [ARCHITECTURE-PROXY.md](ARCHITECTURE-PROXY.md#614-claude-code-otlp-per-project-allowlist).

## Environment Variables

Hooks have access to the following environment variables:
Expand Down
14 changes: 13 additions & 1 deletion src/agents/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -735,7 +735,19 @@ export interface OtlpAgentAdapter {
readonly name: string;
readonly type: AgentAdapterType.OTLP;

processOtlpEvent(rawHookInput: string): Promise<void>;
/**
* Handles one hook event. The adapter owns the decision of whether the OTLP
* daemon is needed: it MUST call `deps.ensureProxy()` before forwarding
* anything to the daemon and MAY skip it for events it will not forward.
*
* INVARIANT: events from untracked projects must never reach the daemon
* spool.
*/
processOtlpEvent(rawHookInput: string, deps: OtlpAdapterDeps): Promise<void>;
}

export interface OtlpAdapterDeps {
ensureOtlpProxy: (agentName: string) => Promise<void>;
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, it, expect } from 'vitest';
import {
addProjectPath,
isPathInside,
isProjectTracked,
parseAllowlist,
removeProjectPath,
} from '../claude-code-otlp.allowlist.js';

describe('parseAllowlist', () => {
it('maps values to states', () => {
expect(parseAllowlist(undefined)).toEqual({ kind: 'absent' });
expect(parseAllowlist('[]')).toEqual({ kind: 'valid', paths: [] });
expect(parseAllowlist('["/a/b"]')).toEqual({ kind: 'valid', paths: ['/a/b'] });
for (const bad of ['nope', '{}', '["rel/path"]', '[""]', '[1]', 5]) {
expect(parseAllowlist(bad)).toEqual({ kind: 'invalid' });
}
});
});

describe('isPathInside', () => {
it('uses path semantics, not string prefixes', () => {
expect(isPathInside('/a/b', '/a/b')).toBe(true);
expect(isPathInside('/a/b/c', '/a/b')).toBe(true);
expect(isPathInside('/a/bc', '/a/b')).toBe(false);
expect(isPathInside('/a', '/a/b')).toBe(false);
});

it('treats a child directory literally named "..foo" as inside', () => {
expect(isPathInside('/a/b/..foo', '/a/b')).toBe(true);
expect(isPathInside('/a/b/..foo/c', '/a/b')).toBe(true);
});

it('treats a parent-relative sibling as outside', () => {
expect(isPathInside('/a/x', '/a/b')).toBe(false);
expect(isPathInside('/a/b/../x', '/a/b')).toBe(false);
});
});

describe('isProjectTracked', () => {
it('handles each state', async () => {
expect(await isProjectTracked('/x', { kind: 'absent' })).toBe(true);
expect(await isProjectTracked('/x', { kind: 'valid', paths: [] })).toBe(true);
expect(await isProjectTracked('/x', { kind: 'invalid' })).toBe(false);
expect(await isProjectTracked(undefined, { kind: 'valid', paths: ['/a/b'] })).toBe(false);
expect(await isProjectTracked('/a/b/c', { kind: 'valid', paths: ['/a/b/'] })).toBe(true);
expect(await isProjectTracked('/a/bc', { kind: 'valid', paths: ['/a/b'] })).toBe(false);
});
});

describe('add/removeProjectPath', () => {
it('dedupes on add and removes by raw string', async () => {
const once = await addProjectPath([], '/nonexistent/proj');
expect(await addProjectPath(once, '/nonexistent/proj/')).toEqual(once);
expect(await removeProjectPath(once, '/nonexistent/proj')).toEqual([]);
expect(await removeProjectPath(['/gone/raw'], '/gone/raw')).toEqual([]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';

vi.mock('../claude-code-otlp.allowlist.js', () => ({
readAllowlistState: vi.fn(async () => ({ kind: 'valid', paths: ['/proj'] })),
isProjectTracked: vi.fn(),
}));
vi.mock('../../utils.js', () => ({ forwardOtlpEventToSpool: vi.fn() }));
vi.mock('@/providers/plugins/sso/sso.auth-gate.js', () => ({ ensureCodeMieSsoAuth: vi.fn() }));
vi.mock('@/utils/config.js', () => ({ ConfigLoader: { load: vi.fn(async () => ({})) } }));
vi.mock('@/utils/logger.js', () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));

import { ClaudeCodeOtlpPlugin } from '../claude-code-otlp.plugin.js';
import { isProjectTracked } from '../claude-code-otlp.allowlist.js';
import { forwardOtlpEventToSpool } from '../../utils.js';
import { ensureCodeMieSsoAuth } from '@/providers/plugins/sso/sso.auth-gate.js';

const event = (name: string) => JSON.stringify({ session_id: 's', transcript_path: '', cwd: '/x', hook_event_name: name });

describe('ClaudeCodeOtlpPlugin.processOtlpEvent', () => {
const plugin = new ClaudeCodeOtlpPlugin();
const ensureOtlpProxy = vi.fn(async () => {});

beforeEach(() => vi.clearAllMocks());

it('does nothing for untracked projects, for every event', async () => {
vi.mocked(isProjectTracked).mockResolvedValue(false);
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
for (const name of ['SessionStart', 'UserPromptSubmit', 'Stop']) {
await plugin.processOtlpEvent(event(name), { ensureOtlpProxy });
}
expect(ensureOtlpProxy).not.toHaveBeenCalled();
expect(ensureCodeMieSsoAuth).not.toHaveBeenCalled();
expect(forwardOtlpEventToSpool).not.toHaveBeenCalled();
expect(log).not.toHaveBeenCalled();
log.mockRestore();
});

it('ensures the proxy before forwarding for tracked projects', async () => {
vi.mocked(isProjectTracked).mockResolvedValue(true);
await plugin.processOtlpEvent(event('SessionStart'), { ensureOtlpProxy });
expect(ensureOtlpProxy).toHaveBeenCalledTimes(1);
expect(forwardOtlpEventToSpool).toHaveBeenCalledTimes(1);
expect(vi.mocked(ensureOtlpProxy).mock.invocationCallOrder[0]).toBeLessThan(
vi.mocked(forwardOtlpEventToSpool).mock.invocationCallOrder[0]
);
});
});
184 changes: 184 additions & 0 deletions src/agents/plugins/claude-code-otlp/claude-code-otlp.allowlist.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
/**
* Per-project allowlist for Claude Code OTLP analytics.
*
* The allowlist is a JSON array of absolute paths stored as a string in
* `~/.claude/settings.json` -> `env.CODEMIE_ANALYTICS_PROJECT_FILTER`. The
* connector writes it; the hook process (the OTLP plugin) is its only runtime
* consumer. The daemon has no allowlist logic.
*/

import { realpath, readFile } from 'node:fs/promises';
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
import { logger } from '@/utils/logger.js';
import { resolveHomeDir } from '@/utils/paths.js';

export const CODEMIE_ANALYTICS_PROJECT_FILTER_ENV = 'CODEMIE_ANALYTICS_PROJECT_FILTER';

export type AllowlistState =
| { kind: 'absent' }
| { kind: 'valid'; paths: string[] }
| { kind: 'invalid' };

/**
* Parses the raw env value. Invalid = unparsable JSON, not an array, or any
* item that is not a non-empty absolute path string.
*/
export function parseAllowlist(value: unknown): AllowlistState {
if (value === undefined) {
return { kind: 'absent' };
}

if (typeof value !== 'string') {
return { kind: 'invalid' };
}

let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch {
return { kind: 'invalid' };
}

if (!Array.isArray(parsed)) {
return { kind: 'invalid' };
}

const paths: string[] = [];
for (const item of parsed) {
if (typeof item !== 'string' || item.length === 0 || !isAbsolute(item)) {
return { kind: 'invalid' };
}
paths.push(item);
}
return { kind: 'valid', paths };
}

function stripTrailingSeparators(p: string): string {
let end = p.length;
while (end > 1 && (p[end - 1] === '/' || p[end - 1] === '\\')) end--;
// Keep a Windows drive root such as `C:\` intact
if (/^[A-Za-z]:$/.test(p.slice(0, end))) {
return p.slice(0, end) + sep;
}
return p.slice(0, end);
}

/**
* Resolves symlinks (falls back to `path.resolve` for missing dirs) and strips
* trailing separators. Casing is preserved; use {@link comparable} to compare.
*/
export async function canonicalizePath(p: string): Promise<string> {
let resolved: string;
try {
resolved = await realpath(p);
} catch {
resolved = resolve(p);
}
return stripTrailingSeparators(resolved);
}

/** Case-insensitive on win32 and darwin, for comparison only. */
function comparable(p: string): string {
return process.platform === 'win32' || process.platform === 'darwin' ? p.toLowerCase() : p;
}

/** True if `child` equals or is nested under `parent`. Never a string-prefix check. */
export function isPathInside(child: string, parent: string): boolean {
const rel = relative(comparable(parent), comparable(child));
return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
}

/** Path of the user-level Claude Code settings file that holds the allowlist. */
export function getClaudeSettingsPath(): string {
return join(resolveHomeDir(), '.claude', 'settings.json');
}

/** Reads the allowlist from `~/.claude/settings.json`. Never throws. */
export async function readAllowlistState(): Promise<AllowlistState> {
let raw: string;
try {
raw = await readFile(getClaudeSettingsPath(), 'utf-8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return { kind: 'absent' };
}
logger.debug('[Claude Code OTLP allowlist] Failed to read settings, treating allowlist as invalid');
return { kind: 'invalid' };
}

if (raw.trim().length === 0) {
return { kind: 'absent' };
}

try {
const settings: unknown = JSON.parse(raw);
if (typeof settings !== 'object' || settings === null || Array.isArray(settings)) {
return { kind: 'invalid' };
}
const env = (settings as { env?: unknown }).env;
if (env === undefined) {
return { kind: 'absent' };
}
if (typeof env !== 'object' || env === null || Array.isArray(env)) {
return { kind: 'invalid' };
}
return parseAllowlist((env as Record<string, unknown>)[CODEMIE_ANALYTICS_PROJECT_FILTER_ENV]);
} catch {
return { kind: 'invalid' };
}
}

/**
* absent or empty list => tracked (all projects); invalid => not tracked;
* otherwise `cwd` must sit inside at least one entry.
*/
export async function isProjectTracked(cwd: string | undefined, state: AllowlistState): Promise<boolean> {
if (state.kind === 'absent') {
return true;
}
if (state.kind === 'invalid') {
return false;
}
if (state.paths.length === 0) {
return true;
}
if (!cwd) {
return false;
}

const canonicalCwd = await canonicalizePath(cwd);
for (const entry of state.paths) {
if (isPathInside(canonicalCwd, await canonicalizePath(entry))) {
return true;
}
}
return false;
}

/** Returns a new list with `projectPath` added (canonical dedupe). */
export async function addProjectPath(paths: string[], projectPath: string): Promise<string[]> {
const canonical = await canonicalizePath(projectPath);
for (const existing of paths) {
if (comparable(await canonicalizePath(existing)) === comparable(canonical)) {
return [...paths];
}
}
return [...paths, canonical];
}

/**
* Returns a new list without `projectPath`. Matches by canonical path and by
* raw string so an entry for a deleted project can still be removed.
*/
export async function removeProjectPath(paths: string[], projectPath: string): Promise<string[]> {
const canonical = comparable(await canonicalizePath(projectPath));
const kept: string[] = [];
for (const existing of paths) {
const matches =
existing === projectPath || comparable(await canonicalizePath(existing)) === canonical;
if (!matches) {
kept.push(existing);
}
}
return kept;
}
Loading
Loading