@ev-ai/agent-hook 1.2.8 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/COLLECTED-DATA.md CHANGED
@@ -21,9 +21,9 @@ Every event also carries: UTC timestamp, `installation_id` (per-machine under `~
21
21
  - **Env (allowlist only)** — CI markers (`CI`, `GITHUB_*`, `GITLAB_CI`, `CIRCLECI`, `BUILDKITE`, `TF_BUILD`, …), actor identity markers (`GITHUB_ACTOR`, `GITLAB_USER_*`, `BUILD_REQUESTEDFOR*`, `BUILDKITE_BUILD_CREATOR*`, `BUILD_USER*`, `CHANGE_AUTHOR*`, `CIRCLE_USERNAME`, `CODEBUILD_INITIATOR`, `DRONE_COMMIT_AUTHOR*`, `EV_AI_HOOK_OPERATOR_*`, `GIT_AUTHOR_*`, `GIT_COMMITTER_*`, `EMAIL`, …), cloud session markers (`CLAUDE_CODE_REMOTE`, `CURSOR_AGENT`, `CURSOR_VERSION`, `CURSOR_USER_EMAIL`, `CODESPACES`), `CURSOR_PROJECT_DIR` (present/absent only — the path is not shipped), `NODE_ENV`, `TERM_PROGRAM`, vendor base-URL overrides (`ANTHROPIC_BASE_URL`, `OPENAI_BASE_URL`, …), Claude Bedrock/Vertex flags. API keys and proxy vars are never read. Gateway URLs have embedded credentials and credential-shaped query params (`key`, `api-key`, `token`, `sig`, …) redacted.
22
22
  - **MCP inventory** — declared server **names** (and discovery source), not commands/URLs/env. Sources: the manifest, `.mcp.json` / `.cursor/mcp.json` in the repo, and the runner's user file (`~/.claude.json` top-level `mcpServers` plus `projects[<cwd>].mcpServers` for the launch directory only — never another project's servers; `~/.cursor/mcp.json`). The resolved npm package + version for `npx`-launched servers rides on the user-spec MCP row and on `tool_invoked` MCP live events instead (see below).
23
23
  - **User-scope config** — `cli_version` (claude / agent|cursor-agent / codex `--version`); model (Claude; SessionStart stdin `model` preferred over settings-file, then admin-managed settings, then the untracked project override, then user settings — the committed `.claude/settings.json` is deliberately skipped because the git pipeline already sees it), Claude-only `claude_version` mirror; presence/size of global settings / memory / MCP files and name+size listings of global skills, commands, subagents, rules — union of every runner's home surfaces (Claude, Cursor, Codex), not filtered by which runner started the session. The settings probe also covers the admin-managed layer (Claude `~/.claude/remote-settings.json` plus `managed-settings.json` and `managed-settings.d/*.json`, Cursor enterprise `hooks.json` and cloud-synced team hooks under `~/.cursor/managed/<team>/`, Codex `requirements.toml` plus `managed_config.toml` in both its system and `~/.codex/` locations) (full listing when the hash changed or every 10th session start as a repair re-send; capped at 500 entries per list with `truncated: true` when capped). File **contents** are not sent on this presence/size probe — allowlisted sandbox and permissions posture from those files ships on the user-spec `SETTINGS` / `MANAGED_SETTINGS` rows below.
24
- - **User spec (home-dir components)** — per-component delta of skills / commands / rules / memory / subagents / hooks / settings / MCP servers / plugins from `~/.claude`, `~/.cursor`, `~/.codex`, `~/.agents`, and from the home root itself (`~/CLAUDE.md`, `~/AGENTS.md` — Claude's and Codex's memory walk reaches them). Hook rows are parsed out of every settings file each runner reads, not just the dedicated hooks file: Claude `settings.json` / `settings.local.json` / `hooks.json`, Cursor `hooks.json` / `sandbox.json` / `permissions.json` / `cli-config.json`, Codex `hooks.json` — plus the admin-managed layer (Claude `managed-settings.json` with its `managed-settings.d/*.json` drop-ins **and** `~/.claude/remote-settings.json`, the cached payload from the claude.ai console; Cursor enterprise `hooks.json` and cloud-synced team hooks under `~/.cursor/managed/<team>/`). Admin-managed sources are org-written rather than operator-written, so they are reported as that tier even when the file itself sits under home. Allowlisted sandbox / permissions / MCP-toggle keys from those JSON files also ship as `SETTINGS` (operator-written) or `MANAGED_SETTINGS` (admin-written) rows: one row per existing file, `name: "settings"`, nested `attributes.settings` (same shape as the parsed object, not a JSON string), plus `keys_omitted` (top-level unread key **names** only — values never leave the machine). `hooks{}` is excluded from `settings` because it already ships as HOOK rows (the name still appears in `keys_omitted`). Never shipped as values: Claude `env`, `apiKeyHelper`, `awsAuthRefresh`, `awsCredentialExport`, `otelHeadersHelper`; Cursor `authInfo`. A missing file produces no row; a file that exists but is not a valid JSON object (including JSONC) produces a row with `parse_error: true`, and one that exists but cannot be read (e.g. permission denied) produces `read_error: true` — neither carries `settings`. A leading UTF-8 byte-order mark (written by Windows editors and PowerShell 5) is ignored. Claude allowlist: `permissions` (whole object), `sandbox` (whole object), `enableAllProjectMcpServers`, `enabledMcpjsonServers`, `disabledMcpjsonServers`, `disableAllHooks`, `disableAutoMode`, and the managed-only locks `allowManagedHooksOnly`, `allowManagedPermissionRulesOnly`, `allowManagedMcpServersOnly`, `strictKnownMarketplaces`, `strictPluginOnlyCustomization`, `disableSideloadFlags`, `forceLoginMethod`, `forceLoginOrgUUID`. Cursor `sandbox.json`: `type`, `additionalReadwritePaths`, `additionalReadonlyPaths`, `disableTmpWrite`, `enableSharedBuildCache`, `networkPolicy`. Cursor `permissions.json`: `terminalAllowlist`, `mcpAllowlist`, `autoRun`. Cursor CLI (`cli-config.json` at home, `cli.json` at a project): `permissions` only. Permission rule strings (Claude `permissions.allow|deny|ask`, Cursor CLI tokens, `terminalAllowlist`, `sandbox.excludedCommands`, `mcpAllowlist`) have absolute paths rewritten to `<path>`; filesystem path lists (`sandbox.filesystem.*`, `sandbox.credentials.files[].path`, `permissions.additionalDirectories`, Cursor `additionalReadwritePaths` / `additionalReadonlyPaths`) rewrite the home prefix to `~` without collapsing the rest of the path (separators normalized to `/`; a Windows home such as `C:\Users\me` matches case-insensitively), and so do Unix socket paths in `sandbox.network.allowUnixSockets`. Every other string, rule strings included, also has the home directory replaced with `~` wherever it appears, with either separator, so no allowlisted value carries the OS username. Arrays are capped at 200 entries, strings at 200 characters, nesting at 6 levels, and serialized `settings` at 16 KiB: over the byte cap the longest lists lose trailing entries first, and a top-level key is dropped only when no list is left to shorten. When any cap hits the row carries `truncated: true` and `trimmed_paths` (the dotted paths shortened or removed). If the collector still cannot store `settings` it keeps the row and sets `settings_dropped: true`. Claude managed files add `managed_source` (`server` / `file` / `file_dropin`). Codex TOML settings (`config.toml`, `managed_config.toml`, `requirements.toml`) are presence/size only apart from their `[mcp_servers]` sections. Not collected: `$CLAUDE_CONFIG_DIR` (no walker honours it today); Claude managed policy delivered only by MDM plist / Windows registry (not a readable file path); Cursor team-admin sandbox/permissions policy (dashboard-side, not on disk). Project-directory spec is a sibling snapshot (next bullet), not omitted from this envelope. Walked on the **first** `session_start` for a runner and again only after a **14-hour cooldown** (`user_spec_collected_at_ms` in `self-config-cache.json`); mid-cooldown session starts skip the home walk and omit `user_spec*`. A session start on which the launcher also runs its background update check is collected by that check once it finishes (at most 10 s later), on whatever version it selected, so its user-spec walk runs as usual. Each upsert row carries `kind`, path-derived `name`, home-relative `path`, capped file `text` (≤100 KiB) for file-backed kinds, `content_hash`, `origin` (`user` or `plugin:<name>@<marketplace>`), `enabled`, optional `trigger` / `attributes`. Frontmatter fields (`name`, `description`, …) are not parsed on the client — the collector derives them from `text`. Only **changed or new** components are sent (`user_spec`); removals are explicit tombstones (`user_spec_removed: [{ path, name }]`). Unchanged scope after a cooldown refresh → hash/count/truncated scalars alone. First walk is a full authoritative snapshot (`user_spec_full: true`). File `text` is shipped **once per `content_hash`** (later sends omit the body when that hash was already delivered); under the 2 MiB user-spec byte budget, overflowing bodies are deferred (`user_spec_text_deferred`) before rows are dropped. Self-config caches commit only after a successful collector POST. Operator identity is attached on the POST itself (every event), not on this cooldown. Hook rows send `action_kind` + `action_hash` plus a sanitized `command` (home directory rewritten to `~`, URLs/tokens/secret-shaped args redacted, capped at 300 chars) — never the raw unsanitized string. Prompt-type hook actions send only `action_kind` + `action_hash`. MCP rows send name, `transport`, a credential-stripped `command_or_url` (from `command` / `url` / `serverUrl`), redacted `args`, env var **names** (from `env` / `envVars` / `env_vars` / `bearer_token_env_var` / `env_http_headers` values), `headers_present`, `env_http_header_names`, `auth`, `bearer_token_env_var`, `default_tools_approval_mode`, declared tool allow/deny lists (`tools`, `auto_approve`, `always_allow`, `enabled_tools`, `disabled_tools` — tool names verbatim, boolean `auto_approve` when not a list, `<attr>_truncated` when a list is capped), and — for a server launched via `npx` / `npm exec` — `package_name` / `package_spec` / `package_version` / `package_version_source` resolved from npm's `_npx` install tree exactly as on `tool_invoked` above (so a floating `@latest` row's `content_hash` moves when the installed version changes, which is the point: the row re-sends as new evidence) — never env or header values; a server switched off in config (`enabled: false` / `disabled: true`) is reported with the row's `enabled` flag false rather than as live surface. Every other declared config key is surfaced by name with its value replaced by `<omitted>`, so the config shape is visible without its contents. Plugin rows never include `installPath` / `projectPath`. Disabled with `EV_AI_HOOK_USER_SPEC=off`.
25
- - **User spec (project-dir components)** — sibling snapshot of the same component kinds from up to three extra directories on the same `session_start` POST: the launch working directory (`origin: cwd`), the git toplevel when `remote.origin` is set (`origin: repo`), and at most one covering parent above that git root (`origin: parent`). Equal resolved paths are not walked twice (a session launched at the git root skips `cwd`; a git root that is home skips the project walk). Intermediate ancestors between cwd and git root, and descendants below the launch dir, are not collected. Plugins and the admin-managed tier stay home-only. Gitignored project files (`settings.local.json`, `CLAUDE.local.md`, uncommitted / locally edited spec) **are** collected — they are the reason this walk exists. A personal clone opened in the same IDE is inventoried under the org (cwd walk always; repo walk when the clone has an origin remote); a covering parent such as `~/dev/CLAUDE.md` above both work and personal clones is collected when present. Wire `path` is `~/dev/acme/.claude/…` when the file is under home, POSIX absolute when it is not (no `cwd/` prefix; home-scope rows stay `.claude/…` with no `~/`). Same per-component delta, text-once-per-hash, and shared byte budget as home; each project directory has its own 14-hour cooldown so a first visit to a nested dir can send cwd full while home stays incremental. Envelope fields: `user_spec_scopes` (which partitions this walk covered), `user_spec_cwd_scope_ref` / `user_spec_repo_scope_ref` / `user_spec_parent_scope_ref` (path hashes of those anchors — not the git remote), `user_spec_parent_rel` (`..` / `../..` from launch cwd to the covering parent), `user_spec_cwd_in_git_repo` (telemetry), `user_spec_full_scopes` / `user_spec_truncated_scopes` (per-partition authority; mixed sends never set `user_spec_full: true`). Codex project TOML is the same presence/size gap as home (no TOML reader). Disable the three project walks with `EV_AI_HOOK_CWD_SPEC=off` (home spec and operator identity keep running). `EV_AI_HOOK_USER_SPEC=off` disables every scope.
26
- - **Operator identity** — on every event (`session_start`, `session_end`, `tool_invoked`), independently of the user-spec cooldown. Existing `self_config` fields are preserved; a stale operator on that object is replaced with the freshly resolved identity. `git_user_name` / `git_user_email` are always real git (`git config`, else `GIT_AUTHOR_*` / `GIT_COMMITTER_*` / `EMAIL`). `platform_login` is the platform logged-in email when the runner has one: Claude `~/.claude.json` `oauthAccount.emailAddress` (`identity_source: "claude_oauth"`; `accountUuid` only if email is absent), Cursor `~/.cursor/cli-config.json` `authInfo.email` (`identity_source: "cursor_oauth"`; `authId` / `userId` only if email is absent; IDE-only seats without that CLI file use `cursorAuth/*` rows in the desktop app's `state.vscdb`, opened read-only/immutable so a live Cursor cannot lock the hook — `cachedEmail`, else `stripeMembershipAuthId`; access and refresh tokens are never selected; the parsed result — the email, the auth-id fallback, or the fact that the db holds neither — is cached at `~/.ev-ai/agent-hook/cursor-ide-identity.json` and only re-read when that cache has never been written, was written against a different `state.vscdb` path, or is older than the same 14-hour cooldown the user-spec home walk uses, so identity on every event does not reopen a multi-GB sqlite file each time; the cache holds no tokens), Codex ChatGPT email from `CODEX_HOME/auth.json` (`identity_source: "codex_oauth"`). Both git and platform fields are sent together when both are known. Codex API keys, raw tokens, refresh tokens, plan data, and ChatGPT workspace/account ids are never emitted; API-key, keychain-only, absent, malformed, or incomplete Codex credentials continue through the normal fallbacks. A bot-shaped runner login is skipped so CI actor env vars can win `platform_login`. Bot-shaped git identity (`*[bot]*`, `action@github.com`, `noreply@anthropic.com`, `cursoragent@cursor.com`, …) is skipped so CI/cloud actor env vars can win `platform_login`: explicit `EV_AI_HOOK_OPERATOR_EMAIL` / `EV_AI_HOOK_OPERATOR_LOGIN` (and name) → `identity_source: "configured"`; then `GITHUB_ACTOR` / `GITHUB_TRIGGERING_ACTOR` / `GITHUB_USER` → `"github"`; `GITLAB_USER_*` → `"gitlab"`; Azure DevOps `BUILD_REQUESTEDFOR*` → `"azure_devops"`; Buildkite `BUILDKITE_BUILD_CREATOR*` → `"buildkite"`; Jenkins `BUILD_USER*` / `CHANGE_AUTHOR*` → `"jenkins"`; CircleCI `CIRCLE_USERNAME` → `"circleci"`; CodeBuild `CODEBUILD_INITIATOR` → `"codebuild"`; Drone/Woodpecker `DRONE_COMMIT_AUTHOR*` → `"drone"`. CI/cloud actor sources set `platform_login` to the platform email when they expose one, else the actor login. Claude Code remote (`CLAUDE_CODE_REMOTE`) and Cursor cloud (`CURSOR_AGENT`) without a git or CI actor are tagged `identity_source: "cloud"`; Cursor cloud sets `platform_login` from `CURSOR_USER_EMAIL` when it is not the image-baked bot address (never a session uuid). Never invents an OS username. Disabled with the same `EV_AI_HOOK_USER_SPEC=off` opt-out.
24
+ - **User spec (home-dir components)** — per-component delta of skills / commands / rules / memory / subagents / hooks / settings / MCP servers / plugins from `~/.claude`, `~/.cursor`, `~/.codex`, `~/.agents`, and from the home root itself (`~/CLAUDE.md`, `~/AGENTS.md` — Claude's and Codex's memory walk reaches them). Hook rows are parsed out of every settings file each runner reads, not just the dedicated hooks file: Claude `settings.json` / `settings.local.json` / `hooks.json`, Cursor `hooks.json` / `sandbox.json` / `permissions.json` / `cli-config.json`, Codex `hooks.json` — plus the admin-managed layer (Claude `managed-settings.json` with its `managed-settings.d/*.json` drop-ins **and** `~/.claude/remote-settings.json`, the cached payload from the claude.ai console; Cursor enterprise `hooks.json` and cloud-synced team hooks under `~/.cursor/managed/<team>/`). Admin-managed sources are org-written rather than operator-written, so they are reported as that tier even when the file itself sits under home. Allowlisted sandbox / permissions / MCP-toggle keys from those JSON files also ship as `SETTINGS` (operator-written) or `MANAGED_SETTINGS` (admin-written) rows: one row per existing file, `name: "settings"`, nested `attributes.settings` (same shape as the parsed object, not a JSON string), plus `keys_omitted` (top-level unread key **names** only — values never leave the machine). `hooks{}` is excluded from `settings` because it already ships as HOOK rows (the name still appears in `keys_omitted`). Never shipped as values: Claude `env`, `apiKeyHelper`, `awsAuthRefresh`, `awsCredentialExport`, `otelHeadersHelper`; Cursor `authInfo`. A missing file produces no row; a file that exists but is not a valid JSON object (including JSONC) produces a row with `parse_error: true`, and one that exists but cannot be read (e.g. permission denied) produces `read_error: true` — neither carries `settings`. A leading UTF-8 byte-order mark (written by Windows editors and PowerShell 5) is ignored. Claude allowlist: `permissions` (whole object), `sandbox` (whole object), `enableAllProjectMcpServers`, `enabledMcpjsonServers`, `disabledMcpjsonServers`, `disableAllHooks`, `disableAutoMode`, and the managed-only locks `allowManagedHooksOnly`, `allowManagedPermissionRulesOnly`, `allowManagedMcpServersOnly`, `strictKnownMarketplaces`, `strictPluginOnlyCustomization`, `disableSideloadFlags`, `forceLoginMethod`, `forceLoginOrgUUID`. Cursor `sandbox.json`: `type`, `additionalReadwritePaths`, `additionalReadonlyPaths`, `disableTmpWrite`, `enableSharedBuildCache`, `networkPolicy`. Cursor `permissions.json`: `terminalAllowlist`, `mcpAllowlist`, `autoRun`. Cursor CLI (`cli-config.json` at home, `cli.json` at a project): `permissions` only. Permission rule strings (Claude `permissions.allow|deny|ask`, Cursor CLI tokens, `terminalAllowlist`, `sandbox.excludedCommands`, `mcpAllowlist`) have absolute paths rewritten to `<path>`; filesystem path lists (`sandbox.filesystem.*`, `sandbox.credentials.files[].path`, `permissions.additionalDirectories`, Cursor `additionalReadwritePaths` / `additionalReadonlyPaths`) rewrite the home prefix to `~` without collapsing the rest of the path (separators normalized to `/`; a Windows home such as `C:\Users\me` matches case-insensitively), and so do Unix socket paths in `sandbox.network.allowUnixSockets`. Every other string, rule strings included, also has the home directory replaced with `~` wherever it appears, with either separator, so no allowlisted value carries the OS username. Arrays are capped at 200 entries, strings at 200 characters, nesting at 6 levels, and serialized `settings` at 16 KiB: over the byte cap the longest lists lose trailing entries first, and a top-level key is dropped only when no list is left to shorten. When any cap hits the row carries `truncated: true` and `trimmed_paths` (the dotted paths shortened or removed). If the collector still cannot store `settings` it keeps the row and sets `settings_dropped: true`. Claude managed files add `managed_source` (`server` / `file` / `file_dropin`). Codex TOML settings (`config.toml`, `managed_config.toml`, `requirements.toml`) are presence/size only apart from their `[mcp_servers]` sections. Not collected: `$CLAUDE_CONFIG_DIR` (no walker honours it today); Claude managed policy delivered only by MDM plist / Windows registry (not a readable file path); Cursor team-admin sandbox/permissions policy (dashboard-side, not on disk). Project-directory spec is a sibling snapshot (next bullet), not omitted from this envelope. Walked on every `session_start` (`user_spec_collected_at_ms` in `self-config-cache.json` records the last successful walk). A session start on which the launcher also runs its background update check is collected by that check once it finishes (at most 10 s later), on whatever version it selected, so its user-spec walk runs as usual. Each upsert row carries `kind`, path-derived `name`, home-relative `path`, capped file `text` (≤100 KiB) for file-backed kinds, `content_hash`, `origin` (`user` or `plugin:<name>@<marketplace>`), `enabled`, optional `trigger` / `attributes`. Frontmatter fields (`name`, `description`, …) are not parsed on the client — the collector derives them from `text`. Only **changed or new** components are sent (`user_spec`); removals are explicit tombstones (`user_spec_removed: [{ path, name }]`). Unchanged scope → hash/count/truncated scalars alone. First walk is a full authoritative snapshot (`user_spec_full: true`). File `text` is shipped **once per `content_hash`** (later sends omit the body when that hash was already delivered); under the 2 MiB user-spec byte budget, overflowing bodies are deferred (`user_spec_text_deferred`) before rows are dropped. Self-config caches commit only after a successful collector POST. Operator identity is attached on the POST itself (every event), independently of the user-spec walk. Hook rows send `action_kind` + `action_hash` plus a sanitized `command` (home directory rewritten to `~`, URLs/tokens/secret-shaped args redacted, capped at 300 chars) — never the raw unsanitized string. Prompt-type hook actions send only `action_kind` + `action_hash`. MCP rows send name, `transport`, a credential-stripped `command_or_url` (from `command` / `url` / `serverUrl`), redacted `args`, env var **names** (from `env` / `envVars` / `env_vars` / `bearer_token_env_var` / `env_http_headers` values), `headers_present`, `env_http_header_names`, `auth`, `bearer_token_env_var`, `default_tools_approval_mode`, declared tool allow/deny lists (`tools`, `auto_approve`, `always_allow`, `enabled_tools`, `disabled_tools` — tool names verbatim, boolean `auto_approve` when not a list, `<attr>_truncated` when a list is capped), and — for a server launched via `npx` / `npm exec` — `package_name` / `package_spec` / `package_version` / `package_version_source` resolved from npm's `_npx` install tree exactly as on `tool_invoked` above (so a floating `@latest` row's `content_hash` moves when the installed version changes, which is the point: the row re-sends as new evidence) — never env or header values; a server switched off in config (`enabled: false` / `disabled: true`) is reported with the row's `enabled` flag false rather than as live surface. Every other declared config key is surfaced by name with its value replaced by `<omitted>`, so the config shape is visible without its contents. Plugin rows never include `installPath` / `projectPath`. Disabled with `EV_AI_HOOK_USER_SPEC=off`.
25
+ - **User spec (project-dir components)** — sibling snapshot of the same component kinds from up to three extra directories on the same `session_start` POST: the launch working directory (`origin: cwd`), the git toplevel when `remote.origin` is set (`origin: repo`), and at most one covering parent above that git root (`origin: parent`). Equal resolved paths are not walked twice (a session launched at the git root skips `cwd`; a git root that is home skips the project walk). Intermediate ancestors between cwd and git root, and descendants below the launch dir, are not collected. Plugins and the admin-managed tier stay home-only. Gitignored project files (`settings.local.json`, `CLAUDE.local.md`, uncommitted / locally edited spec) **are** collected — they are the reason this walk exists. A personal clone opened in the same IDE is inventoried under the org (cwd walk always; repo walk when the clone has an origin remote); a covering parent such as `~/dev/CLAUDE.md` above both work and personal clones is collected when present. Wire `path` is `~/dev/acme/.claude/…` when the file is under home, POSIX absolute when it is not (no `cwd/` prefix; home-scope rows stay `.claude/…` with no `~/`). Same per-component delta, text-once-per-hash, and shared byte budget as home; each project directory is walked independently so a first visit to a nested dir can send cwd full while home stays incremental. Envelope fields: `user_spec_scopes` (which partitions this walk covered), `user_spec_cwd_scope_ref` / `user_spec_repo_scope_ref` / `user_spec_parent_scope_ref` (path hashes of those anchors — not the git remote), `user_spec_parent_rel` (`..` / `../..` from launch cwd to the covering parent), `user_spec_cwd_in_git_repo` (telemetry), `user_spec_full_scopes` / `user_spec_truncated_scopes` (per-partition authority; mixed sends never set `user_spec_full: true`). Codex project TOML is the same presence/size gap as home (no TOML reader). Disable the three project walks with `EV_AI_HOOK_CWD_SPEC=off` (home spec and operator identity keep running). `EV_AI_HOOK_USER_SPEC=off` disables every scope.
26
+ - **Operator identity** — on every event (`session_start`, `session_end`, `tool_invoked`), independently of the user-spec walk. Existing `self_config` fields are preserved; a stale operator on that object is replaced with the freshly resolved identity. `git_user_name` / `git_user_email` are always real git (`git config`, else `GIT_AUTHOR_*` / `GIT_COMMITTER_*` / `EMAIL`). `platform_login` is the platform logged-in email when the runner has one: Claude `~/.claude.json` `oauthAccount.emailAddress` (`identity_source: "claude_oauth"`; `accountUuid` only if email is absent), Cursor `~/.cursor/cli-config.json` `authInfo.email` (`identity_source: "cursor_oauth"`; `authId` / `userId` only if email is absent; IDE-only seats without that CLI file use `cursorAuth/*` rows in the desktop app's `state.vscdb`, opened read-only/immutable so a live Cursor cannot lock the hook — `cachedEmail`, else `stripeMembershipAuthId`; access and refresh tokens are never selected; the parsed result — the email, the auth-id fallback, or the fact that the db holds neither — is cached at `~/.ev-ai/agent-hook/cursor-ide-identity.json` and only re-read when that cache has never been written, was written against a different `state.vscdb` path, or is older than the 14-hour Cursor IDE identity cache TTL, so identity on every event does not reopen a multi-GB sqlite file each time; the cache holds no tokens), Codex ChatGPT email from `CODEX_HOME/auth.json` (`identity_source: "codex_oauth"`). Both git and platform fields are sent together when both are known. Codex API keys, raw tokens, refresh tokens, plan data, and ChatGPT workspace/account ids are never emitted; API-key, keychain-only, absent, malformed, or incomplete Codex credentials continue through the normal fallbacks. A bot-shaped runner login is skipped so CI actor env vars can win `platform_login`. Bot-shaped git identity (`*[bot]*`, `action@github.com`, `noreply@anthropic.com`, `cursoragent@cursor.com`, …) is skipped so CI/cloud actor env vars can win `platform_login`: explicit `EV_AI_HOOK_OPERATOR_EMAIL` / `EV_AI_HOOK_OPERATOR_LOGIN` (and name) → `identity_source: "configured"`; then `GITHUB_ACTOR` / `GITHUB_TRIGGERING_ACTOR` / `GITHUB_USER` → `"github"`; `GITLAB_USER_*` → `"gitlab"`; Azure DevOps `BUILD_REQUESTEDFOR*` → `"azure_devops"`; Buildkite `BUILDKITE_BUILD_CREATOR*` → `"buildkite"`; Jenkins `BUILD_USER*` / `CHANGE_AUTHOR*` → `"jenkins"`; CircleCI `CIRCLE_USERNAME` → `"circleci"`; CodeBuild `CODEBUILD_INITIATOR` → `"codebuild"`; Drone/Woodpecker `DRONE_COMMIT_AUTHOR*` → `"drone"`. CI/cloud actor sources set `platform_login` to the platform email when they expose one, else the actor login. Claude Code remote (`CLAUDE_CODE_REMOTE`) and Cursor cloud (`CURSOR_AGENT`) without a git or CI actor are tagged `identity_source: "cloud"`; Cursor cloud sets `platform_login` from `CURSOR_USER_EMAIL` when it is not the image-baked bot address (never a session uuid). Never invents an OS username. Disabled with the same `EV_AI_HOOK_USER_SPEC=off` opt-out.
27
27
  - **Cloud identity bootstrap** — Cursor cloud agents do not fire `sessionStart`. On the first `tool_invoked` / skill event when operator identity has never been sent (any runtime context — cloud VMs with a pseudo-TTY often look `interactive`), the hook POSTs a lightweight `session_start` (`run_meta.trigger: cloud_identity_bootstrap`) with host/context/operator — no home user-spec walk, and no `cli_version` / `model` / `user_scope` / `hook_input` (those stay unset so the collector never treats bootstrap nulls as a probed empty config). Debounced in-process and across concurrent hook processes via an exclusive claim lock beside `installation.json` (`cloud-bootstrap.lock`); skipped when `EV_AI_HOOK_USER_SPEC=off`.
28
28
  - **Permission keys (Claude)** — each rule as `{ key, mode }` where `mode` is the list it came from (`allow` / `deny` / `ask`), so a blocked rule is never mistaken for a permitted one. Merged across the git-invisible tiers: admin-managed settings (server-managed payload, else `managed-settings.json` + drop-ins), user settings, and the untracked project override. Managed rules are listed first because they cannot be overridden and so must not be what the cap discards; repeat `key`+`mode` pairs are sent once. Policy delivered only by MDM plist or Windows registry is not a readable file path and stays uncollected. Absolute POSIX / Windows / `~`-anchored paths inside a rule are replaced with `<path>` (`Bash(find /Users/me/repo -name x)` → `Bash(find <path> -name x)`) — the rule shape is the posture signal, the operator's home layout is not. Capped at 200 rules, 200 characters each.
29
29
  - **Allowlisted hook metadata** — see below (includes SessionStart `model`, `permission_mode`, …)
@@ -50,7 +50,7 @@ Every event also carries: UTC timestamp, `installation_id` (per-machine under `~
50
50
  - `failure_reason` — when `success === false`, a **failure class, never a runner message**: `interrupted` (stdin `is_interrupt`), `permission_denied` (Claude `PermissionDenied`), an identifier-shaped `failure_type` / `error_type` (`timeout`, `ShellError`), else the fixed value `error`. Runner `error` / `error_message` strings are dropped — they carry raw tool stderr including absolute paths and whole result bodies.
51
51
  - `duration_ms` — from Claude `duration_ms` or Cursor `duration` on post-tool / `afterShellExecution` stdin
52
52
  - `mcp_server` / `mcp_tool` — separate fields, never a `server__tool` blob. Cursor `beforeMCPExecution` / `afterMCPExecution` stdin `mcp_server_name` (the mcp.json key) is the server when present; otherwise derived from opaque `tool_name` when it matches Claude `mcp__<server>__<tool>` or Cursor `MCP:<server>:<tool>` / `MCP:<server>__<tool>` / `MCP:<tool>` (no `tool_input` parsing). `mcp_server_name` itself is not POSTed. Claude has no equivalent stdin field, so the `mcp__` form is split against the **declared** `mcpServers` keys first — a declared name whose tool-name spelling (every character outside `[A-Za-z0-9_-]` replaced, `acme.tools` → `acme_tools`) prefixes the rest wins, longest first, and `mcp_server` is then the config key rather than the runner's spelling. Splitting on the first `__` is only the fallback for a server that is not declared locally (claude.ai connectors, plugin servers), so a tool id that itself contains `__` (`mcp__aws__aws___call_aws` → tool `aws___call_aws`) and a server name that contains `__` both survive.
53
- - **`mcp_package_name` / `mcp_package_spec` / `mcp_package_version` / `mcp_package_version_source`** — npm identity of an MCP server launched through `npx` / `npm exec`, resolved on MCP live events. Cursor `beforeMCPExecution` carries the spawn command, which is tokenized in-process for package identity and never POSTed — that argv routinely embeds `--api-key <secret>`. Claude Pre/Post MCP tool hooks — and Cursor MCP tool hooks — have no spawn command on stdin, so the same identity is resolved from the declared server's `command`/`args` in MCP config (`~/.claude.json` — top-level plus the launch directory's `projects[<cwd>].mcpServers`, where Claude keeps `--scope local` servers — `.mcp.json`, `.cursor/mcp.json`); that launch line is still never POSTed. What leaves the machine is the package name (`@playwright/mcp`), the spec as declared (`@playwright/mcp@latest`), and the version npm actually installed for it. `mcp_package_version` is read from npm's own `_npx` install tree — the directory npm names from a hash of the package specs — and is therefore what really ran, not what config asked for; `mcp_package_version_source` records that provenance (`npx_cache`) so a declared pin is never mistaken for an observed install. A floating tag reports its resolved version; a cache miss reports the spec with no version, which is normal before the runner has launched the server once, after a failed or cleaned install, and for a package resolved from a local or global `node_modules` (npm creates no `_npx` tree then). HTTP / SSE servers, plain binaries, `uvx`, local paths, tarball and git specs report nothing. A bare flag of unknown arity is a hard stop rather than a guess, so `npx --api-key <secret> server` reports no package at all — mis-reading the argv would publish a credential as a package name. A `Bash` tool that happens to run `npx` is never reported as a server.
53
+ - **`mcp_package_name` / `mcp_package_spec` / `mcp_package_version` / `mcp_package_version_source`** — npm identity of an MCP server launched through `npx` / `npm exec`, resolved on MCP live events. Cursor `beforeMCPExecution` carries the spawn command, which is tokenized in-process for package identity and never POSTed — that argv routinely embeds `--api-key <secret>`. Claude Pre/Post MCP tool hooks — and Cursor MCP tool hooks — have no spawn command on stdin, so the same identity is resolved from the declared server's `command`/`args` in MCP config (`~/.claude.json` — top-level plus the launch directory's `projects[<cwd>].mcpServers`, where Claude keeps `--scope local` servers — `.mcp.json`, `.cursor/mcp.json`); that launch line is still never POSTed. What leaves the machine is the package name (`@playwright/mcp`), the spec as declared (`@playwright/mcp@latest`), and the exact version behind it. `mcp_package_version` is read from npm's own `_npx` install tree — the directory npm names from a hash of the package specs — when that tree exists, and is then what really ran (`mcp_package_version_source: npx_cache`). When there is no tree and the spec names an exact version (`gitnexus@1.6.5`), that pin is reported with `mcp_package_version_source: declared_pin`, so a declared pin is never mistaken for an observed install; an installed version always wins over the pin. A floating tag reports its resolved version; a cache miss on a floating tag or range reports the spec with no version, which is normal before the runner has launched the server once, after a failed or cleaned install, and for a package resolved from a local or global `node_modules` (npm creates no `_npx` tree then). HTTP / SSE servers, plain binaries, `uvx`, local paths, tarball and git specs report nothing. A bare flag of unknown arity is a hard stop rather than a guess, so `npx --api-key <secret> server` reports no package at all — mis-reading the argv would publish a credential as a package name. A `Bash` tool that happens to run `npx` is never reported as a server.
54
54
  - `component_reference` — an opaque exact-component identifier emitted only when the hook has complete repository-backed identity evidence. Exact skill paths map to SKILL installations; exact MCP server and MCP tool invocations map to the server INTERFACE installation, never to a tool installation; exact rule paths under a layout rules root map to RULE installations; exact command paths under a layout commands root map to COMMAND installations. Name-only, flat/unattributed MCP, malformed scope, and missing platform-agent identity stay unmatched. Three surfaces produce a file-backed reference: a slash-command event when a matching `SKILL.md` or command file is found locally, a Cursor rule-attachment event when a matching rules file is found locally, and any tool event whose stdin path resolves under a layout skills / rules / commands root — which is the only route Claude has for non-MCP components. On a tool event the MCP reference wins when both could apply, and a Write / edit of one of those files does not count as usage. The resolved path is never POSTed. Absolute local paths never leave the hook process: path evidence is realpath-canonicalized (so a `.cursor/rules/claude` symlink to `.claude/rules` hashes the git path) then stripped to the repository-relative source path before hashing, and only the opaque reference is sent.
55
55
  - `subagent_type` — on subagent start/stop hooks, which carry an agent type instead of a `tool_name`: Claude `agent_type` (`Explore`, plugin-scoped `my-plugin:reviewer`) or Cursor `subagent_type` (`generalPurpose`, `explore`). Only identifier-shaped values are sent; the neighbouring `task` / `description` / `summary` fields are free prose and are never captured
56
56
  - Allowlisted hook metadata (see below)
@@ -61,10 +61,18 @@ From runner stdin: session/conversation/turn ids (`prompt_id` / `prompt.id` / `g
61
61
 
62
62
  **Content fields (sent unless killed):** `prompt`, `tool_input`, `tool_response`, `last_assistant_message`, and Cursor top-level `command`. Two per-event exclusions apply: `command` is dropped on `beforeMCPExecution` (MCP spawn argv carries credentials) and `last_assistant_message` on `StopFailure` (runner error prose). Oversize values are clipped head+tail to a shared per-string budget; if that still cannot reach the 256 KiB cap, the largest fields are dropped (`_dropped_keys`) so the event's metadata always ships rather than the whole POST being rejected. Kill switch: `EV_AI_HOOK_CAPTURE_CONTENT=off` or `capture_hook_input: "allowlist"`. Session titles, `command_args`, transcript paths, file paths, file contents, edits, MCP URLs, and runner error prose stay dropped. Full stdin dump: `capture_hook_input: "full"` or `EV_AI_HOOK_CAPTURE_FULL_INPUT=1` (still size-capped). The first token of a shell or MCP spawn command is also sent separately as payload `executable` (see above).
63
63
 
64
- Install wires Claude `SessionStart` / `UserPromptExpansion` / `UserPromptSubmit` / `PreToolUse` (all tools) / `PostToolUse` / `PostToolUseFailure` / `PermissionDenied` / `Stop` / `StopFailure` / `SessionEnd` / `SubagentStart` / `SubagentStop`; Cursor `sessionStart` / `beforeSubmitPrompt` / `preToolUse` / `postToolUse` / `postToolUseFailure` / `beforeShellExecution` / `afterShellExecution` / `beforeReadFile` / `afterFileEdit` / `beforeMCPExecution` / `subagentStart` / `subagentStop` / `stop`; Codex `SessionStart` / `UserPromptSubmit` / `PreToolUse` (all tools) / `PostToolUse` / `Stop`. Re-run `install` to upgrade existing repos (Cursor-specific shell/read/edit/MCP events are added beside generic pre/post tool hooks; `runner_event` distinguishes the row).
64
+ Install wires Claude `SessionStart` / `UserPromptExpansion` / `UserPromptSubmit` / `PreToolUse` (all tools) / `PostToolUse` / `PostToolUseFailure` / `PermissionDenied` / `Stop` / `StopFailure` / `SessionEnd` / `SubagentStart` / `SubagentStop`; Cursor `sessionStart` / `beforeSubmitPrompt` / `preToolUse` / `postToolUse` / `postToolUseFailure` / `beforeShellExecution` / `afterShellExecution` / `beforeReadFile` / `afterFileEdit` / `beforeMCPExecution` / `subagentStart` / `subagentStop` / `stop`; Codex `SessionStart` / `UserPromptSubmit` / `PreToolUse` (all tools) / `PostToolUse` / `Stop` / `SubagentStart` / `SubagentStop`. Re-run `install` to upgrade existing repos (Cursor-specific shell/read/edit/MCP events are added beside generic pre/post tool hooks; `runner_event` distinguishes the row).
65
65
 
66
66
  When Cursor loads Claude (or Codex) file hooks as third-party configs alongside `.cursor/hooks.json`, the binary drops the foreign `--runner` twin so `platform` stays `cursor` and events are not duplicated. Claude Code and Codex hosts are unaffected. Stacked same-runner configs (user + project hooks, or plugin + file wiring) collapse to one POST via a short-lived claim lock (`EVENT_CLAIM_DEDUP_ENABLED=false` disables).
67
67
 
68
+ > **Currently disabled.** Policy detection is turned off in this build: envelopes send `policy_version: null` and `policy_capabilities: []`, the collector therefore returns no rule table, `install` sends no policy fetch, and nothing is written to or read from the local policy cache described below. The rest of this section describes the behaviour when the feature is enabled.
69
+
70
+ Every envelope also carries `policy_version` (the integer in the local cache, or `0` when none) and `policy_capabilities` (selectors this hook build can evaluate). The collector may reply with a rule table when that version is stale. `install` POSTs a lightweight `session_start` (`run_meta.trigger: policy_install_fetch`) so the first table is on disk before a session starts.
71
+
72
+ ## Local policy cache (inbound)
73
+
74
+ The hook caches your organisation's policy rules locally at `~/.ev-ai/agent-hook/policy.json`. That file is written only from the collector's response to hook events the hook already sends — not from a repo manifest or `~/.ev-ai/config.json`. Beside it, `policy-confirmed.json` records the policy version and the time the collector last confirmed it is current (from an `x-ev-policy-version` response header); past the policy's `max_age_ms` without a confirmation, findings are recorded as `policy_stale` and enforcement is off. The table may include `enforcement_mode`. In `report_only` (the organisation default), and for any rule whose action is `report`, a match is recorded as a finding and is not used to rewrite the tool call, prompt you, or block it; the hook also does not spawn a classifier for those matches. Rule evaluation happens on your machine. Prompts and tool inputs are matched against those rules locally; a match does not send anything additional. For rules that need judgement and that the organisation is enforcing, the hook invokes your own installed coding CLI (Claude / Cursor / Codex) with the text being evaluated, using your existing login. The hook does not send policy text to any additional network destination, does not call a model of ours, and does not interpret policy prose on the endpoint.
75
+
68
76
  ## PII we do not send
69
77
 
70
78
  Username and hostname *are* sent (per-member usage for the installing org). The following are not: