Skip to content
Open
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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,23 @@ When not running in debug mode, the hook writes logs to `/tmp/1password-cursor-h
└── README.md
```

## Telemetry

The plugin emits **opt-in** telemetry so 1Password can understand plugin adoption and the prevalence of common failure modes (missing files, disabled mounts). Two event types are emitted:

- `agent_hook_execution` — fired once per hook invocation; carries the hook name, plugin version, client (`cursor`), bucketed duration, decision (`allow`/`deny`), reason for deny, validation mode (`default`/`configured`), and a count of mounts checked.
- `agent_hook_install` — fired once per `(hook_name, plugin_version)` on the first hook run after installation or upgrade; `install_method` is `plugin_marketplace`.

**Opt-in only.** Events are written only when the file `~/.config/1Password/telemetry-enabled` exists. The 1Password desktop app creates and removes this file based on your in-app telemetry preference (Settings → Manage Account → Data Usage). If the app has never run, or all accounts have opted out, no events are written.

**No PII.** Events contain hook name and version, client, decision, bucketed duration, mode, mount count, and a deny reason. No paths, file contents, environment names, or workspace paths are recorded.

**Fail-open.** Telemetry runs in a detached background subshell after the hook has returned its decision to Cursor. Any failure (missing helpers, disk full, permission denied) is silently swallowed — telemetry can never affect a hook decision.

**Where events are written.** Events are appended as JSON lines to `~/.config/1Password/data/hook-events/events.jsonl`. The 1Password desktop app periodically ingests this file and forwards events to 1Password's telemetry pipeline. Telemetry only fires on macOS and Linux; the Windows stub does not emit events.

**To disable.** Open the 1Password desktop app → Settings → Manage Account → Data Usage and turn off product telemetry.

## Resources

- [Validate local `.env` files with Cursor Agent](https://developer.1password.com/docs/environments/cursor-hook-validate/) — full setup guide on the 1Password Developer site
Expand Down
191 changes: 191 additions & 0 deletions scripts/lib/telemetry.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
# Shared telemetry utilities for the 1Password Cursor plugin.
# Source this file; it defines functions only and has no side effects.
#
# Writes JSONL telemetry events to disk for the 1Password app to ingest.
# All functions fail silently — telemetry must never affect hook decisions.
#
# The host script (validate-mounted-env-files) is expected to provide its own
# `log` and `escape_json_string` functions — these helpers rely on late
# binding rather than re-defining them.

[[ -n "${_LIB_TELEMETRY_LOADED:-}" ]] && return 0
_LIB_TELEMETRY_LOADED=1

# Convert raw milliseconds to a bucketed range string.
bucket_duration_ms() {
local ms="${1:-0}"
if [[ "$ms" -lt 50 ]]; then echo "ms_0_to_50"
elif [[ "$ms" -lt 100 ]]; then echo "ms_50_to_100"
elif [[ "$ms" -lt 200 ]]; then echo "ms_100_to_200"
elif [[ "$ms" -lt 500 ]]; then echo "ms_200_to_500"
elif [[ "$ms" -lt 1000 ]]; then echo "ms_500_to_1000"
elif [[ "$ms" -lt 5000 ]]; then echo "ms_1000_to_5000"
else echo "ms_5000_plus"
fi
}

current_time_ms() {
local now seconds fraction

if [[ "${EPOCHREALTIME:-}" =~ ^[0-9]+\.[0-9]+$ ]]; then
seconds="${EPOCHREALTIME%.*}"
fraction="${EPOCHREALTIME#*.}000"
echo "${seconds}${fraction:0:3}"
return 0
fi

now=$(date +%s%3N 2>/dev/null || true)
if [[ "$now" =~ ^[0-9]+$ ]]; then
echo "$now"
return 0
fi

now=$(perl -MTime::HiRes=time -e 'printf "%.0f\n", time() * 1000' 2>/dev/null || true)
if [[ "$now" =~ ^[0-9]+$ ]]; then
echo "$now"
return 0
fi

now=$(python3 -c 'import time; print(int(time.time() * 1000))' 2>/dev/null || true)
if [[ "$now" =~ ^[0-9]+$ ]]; then
echo "$now"
return 0
fi

echo "$(($(date +%s) * 1000))"
}

get_telemetry_dir() {
echo "${HOME}/.config/1Password/data/hook-events"
}

# Check whether the 1Password app has signaled that telemetry is enabled.
# Returns 0 (true) if the signal file exists, 1 (false) otherwise.
telemetry_consent_enabled() {
[[ -f "${HOME}/.config/1Password/telemetry-enabled" ]]
}

# Append a single JSON line to the events.jsonl file.
# Checks consent and enforces a 1MB file size cap.
write_telemetry_event() {
local json_line="$1"
local event_dir
event_dir=$(get_telemetry_dir)

if ! telemetry_consent_enabled; then
return 0
fi

mkdir -p "$event_dir" 2>/dev/null || return 0

local event_file="${event_dir}/events.jsonl"

# 1MB file size cap
if [[ -f "$event_file" ]]; then
local file_size
file_size=$(stat -f%z "$event_file" 2>/dev/null || stat -c%s "$event_file" 2>/dev/null || echo "0")
if [[ "$file_size" -gt 1048576 ]]; then
log "Telemetry file exceeds 1MB, skipping write"
return 0
fi
fi

printf '%s\n' "$json_line" >> "$event_file" 2>/dev/null || true
}

# Write an agent_hook_execution telemetry event.
# `mode` and `mount_count` are hook-specific and may be empty for hooks that
# do not have a meaningful value to populate them; in that case they are
# serialized as JSON null per the schema.
write_execution_event() {
local hook_name="$1"
local hook_version="$2"
local client="$3"
local event_type="$4"
local decision="$5"
local deny_reason="$6"
local duration_ms="$7"
local mode="$8"
local mount_count="$9"

local escaped_hook_name escaped_hook_version escaped_client escaped_event_type
escaped_hook_name=$(escape_json_string "$hook_name")
escaped_hook_version=$(escape_json_string "$hook_version")
escaped_client=$(escape_json_string "$client")
escaped_event_type=$(escape_json_string "$event_type")

local deny_reason_json
if [[ -z "$deny_reason" ]]; then
deny_reason_json="null"
else
local escaped_deny_reason
escaped_deny_reason=$(escape_json_string "$deny_reason")
deny_reason_json="\"${escaped_deny_reason}\""
fi

local mode_json
if [[ -z "$mode" ]]; then
mode_json="null"
else
local escaped_mode
escaped_mode=$(escape_json_string "$mode")
mode_json="\"${escaped_mode}\""
fi

local mount_count_json
if [[ -z "$mount_count" ]]; then
mount_count_json="null"
else
mount_count_json="$mount_count"
fi

local duration_bucket
duration_bucket=$(bucket_duration_ms "$duration_ms")

local json_line
json_line="{\"schema\":\"agent_hook_execution\",\"hook_name\":\"${escaped_hook_name}\",\"hook_version\":\"${escaped_hook_version}\",\"client\":\"${escaped_client}\",\"event_type\":\"${escaped_event_type}\",\"decision\":\"${decision}\",\"deny_reason\":${deny_reason_json},\"duration_bucket\":\"${duration_bucket}\",\"mode\":${mode_json},\"mount_count\":${mount_count_json}}"

write_telemetry_event "$json_line"
}

# Write an agent_hook_install telemetry event.
write_install_event() {
local client="$1"
local hook_name="$2"
local hook_version="$3"
local install_method="$4"

local escaped_client escaped_hook_name escaped_hook_version
escaped_client=$(escape_json_string "$client")
escaped_hook_name=$(escape_json_string "$hook_name")
escaped_hook_version=$(escape_json_string "$hook_version")

local json_line
json_line="{\"schema\":\"agent_hook_install\",\"client\":\"${escaped_client}\",\"hook_name\":\"${escaped_hook_name}\",\"hook_version\":\"${escaped_hook_version}\",\"install_method\":\"${install_method}\"}"

write_telemetry_event "$json_line"
}

# Emit an `agent_hook_install` event with install_method=plugin_marketplace
# exactly once per (client, hook_name, hook_version). The plugin marketplace
# does not expose a lifecycle hook we can listen for, so we detect the install
# by sentinel on first hook execution after installation/upgrade.
emit_plugin_marketplace_install_event_once() {
local client="$1"
local hook_name="$2"
local hook_version="$3"
local event_dir
event_dir=$(get_telemetry_dir)

if ! telemetry_consent_enabled; then
return 0
fi

mkdir -p "$event_dir" 2>/dev/null || return 0

local sentinel="${event_dir}/.installed-${client}-${hook_name}-${hook_version}-plugin_marketplace"
if [[ ! -f "$sentinel" ]]; then
write_install_event "$client" "$hook_name" "$hook_version" "plugin_marketplace"
touch "$sentinel" 2>/dev/null || true
fi
}
104 changes: 102 additions & 2 deletions scripts/validate-mounted-env-files
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ set -euo pipefail
# - TOML Parsing Functions
# - Main Execution Logic (early platform exit must run before stdin is read)
# - Permission Decision Logic
# - Telemetry (opt-in; off by default; gated on a signal file from OPH)
#
# ============================================================================

Expand All @@ -38,6 +39,22 @@ permission="allow"
# The message for the agent to interpret if the permission is denied.
agent_message=""

# Telemetry: which validation mode was used (set when entering the branch).
resolved_mode=""
# Telemetry: number of workspace-relevant mounts checked during this run.
total_mount_count=0
# Telemetry: derived deny reason for the agent_hook_execution event.
deny_reason=""

# Source the shared telemetry helpers. Fail-open if missing (telemetry is
# optional; the hook must keep working even if the lib is absent).
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/telemetry.sh
source "${SCRIPT_DIR}/lib/telemetry.sh" 2>/dev/null || true

# Record start time for hook duration measurement.
start_ms=$(current_time_ms 2>/dev/null || echo "0")

# ============================================================================
# CORE UTILITY FUNCTIONS
# ============================================================================
Expand Down Expand Up @@ -135,6 +152,69 @@ EOF
fi
}

# Derive duration + deny_reason and write the telemetry events in a detached
# background subshell, then return immediately. Safe to call from any exit path
# (it fails open if the telemetry helpers are unavailable), and it must be
# called exactly once per invocation so events map 1:1 to hook executions.
emit_telemetry_async() {
local end_ms duration_ms
end_ms=$(current_time_ms 2>/dev/null || echo "$start_ms")
duration_ms=$((end_ms - start_ms))

# Three-bucket deny_reason: the "both" case maps to its own value so the
# dashboard can distinguish "configuration drift" (some mounts missing on
# disk, others toggled off in 1Password) from each pure case.
if [[ "$permission" == "deny" ]]; then
if [[ ${#all_missing_invalid[@]} -gt 0 ]] && [[ ${#disabled_mounts[@]} -gt 0 ]]; then
deny_reason="file_missing_and_disabled"
elif [[ ${#all_missing_invalid[@]} -gt 0 ]]; then
deny_reason="file_missing"
elif [[ ${#disabled_mounts[@]} -gt 0 ]]; then
deny_reason="file_disabled"
fi
fi

# Detached background subshell so the script exits as soon as the response
# has been written to Cursor. The `>/dev/null 2>&1` redirect is load-bearing:
# without it, the subshell inherits the script's stdout pipe to Cursor, which
# would defeat the backgrounding (Cursor's `read(stdout)` waits for EOF).
(
# Resolve the hook version from the plugin manifest here, off the hot
# path (it is only needed for the telemetry write).
local plugin_version="unknown"
local manifest="${SCRIPT_DIR}/../.cursor-plugin/plugin.json"
if [[ -f "$manifest" ]]; then
local manifest_version
manifest_version=$(grep -oE '"version"[[:space:]]*:[[:space:]]*"[^"]*"' "$manifest" \
| head -1 \
| sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/' 2>/dev/null \
|| echo "")
[[ -n "$manifest_version" ]] && plugin_version="$manifest_version"
fi

write_execution_event \
"1password-validate-mounted-env-files" \
"$plugin_version" \
"cursor" \
"before_shell_execution" \
"$permission" \
"$deny_reason" \
"$duration_ms" \
"$resolved_mode" \
"$total_mount_count"

# The Cursor plugin marketplace does not expose an install lifecycle
# hook, so we detect installation via a sentinel keyed by
# (client, hook_name, hook_version). The install event fires exactly
# once per (cursor, 1password-validate-mounted-env-files, version),
# so an upgrade or reinstall produces a fresh install event.
emit_plugin_marketplace_install_event_once \
"cursor" \
"1password-validate-mounted-env-files" \
"$plugin_version"
) >/dev/null 2>&1 &
}

# ============================================================================
# SYSTEM & PATH UTILITY FUNCTIONS
# ============================================================================
Expand Down Expand Up @@ -586,8 +666,12 @@ fi
# Query 1Password database and check mounts
log "Checking for local .env files mounted by 1Password..."

# Read JSON input from stdin and extract workspace_roots
workspace_roots_input=$(parse_json_workspace_roots)
# Read JSON input from stdin and extract workspace_roots.
# `|| true`: parse_json_workspace_roots ends in a grep that exits non-zero when
# the input has no workspace_roots; under `set -euo pipefail` that would abort
# the script (emitting no decision) before the fail-open branch below. Swallow
# it so empty/absent input reaches that branch and still returns an "allow".
workspace_roots_input=$(parse_json_workspace_roots) || true
workspace_roots_array=()

# Build array of workspace roots
Expand All @@ -604,6 +688,7 @@ done <<< "$workspace_roots_input"
if [[ ${#workspace_roots_array[@]} -eq 0 ]]; then
log "No workspace_roots found in JSON input, skipping validation"
output_response
emit_telemetry_async
exit 0
fi

Expand Down Expand Up @@ -632,6 +717,7 @@ for workspace_root in "${workspace_roots_array[@]}"; do

if has_toml_mount_paths_field "$toml_file"; then
use_configured_mode=true
resolved_mode="configured"
log "environments.toml has mount_paths field defined - validating specified mounts"

# Parse and validate TOML mount paths
Expand Down Expand Up @@ -699,6 +785,7 @@ for workspace_root in "${workspace_roots_array[@]}"; do
# Check each TOML-specified mount
if [[ ${#toml_paths_array[@]} -gt 0 ]]; then
for resolved_path in "${toml_paths_array[@]}"; do
((total_mount_count++)) || true
log "Checking required local .env file from TOML: \"${resolved_path}\""

# First, check if it's in the database and what its status is
Expand Down Expand Up @@ -763,6 +850,7 @@ for workspace_root in "${workspace_roots_array[@]}"; do
fi
else
# Default mode: Check all local .env files within this workspace from 1Password database
resolved_mode="default"
log "Using default mode: checking all local .env files in workspace ${workspace_root} from 1Password database"

if [[ -z "$mount_hex_data" ]]; then
Expand Down Expand Up @@ -796,6 +884,8 @@ for workspace_root in "${workspace_roots_array[@]}"; do
continue
fi

((total_mount_count++)) || true

if [[ "$is_enabled" == "true" ]]; then
if [[ ! -e "$mount_path" ]] || [[ ! -p "$mount_path" ]]; then
log "Local .env file is invalid (file is not present or not a FIFO)"
Expand Down Expand Up @@ -908,4 +998,14 @@ fi

# Output JSON response with permission decision
output_response

# ============================================================================
# TELEMETRY EMISSION (off the hot path)
# ============================================================================
# Emit duration, decision, mode, mount count and the once-per-version install
# event in a detached background subshell. Mirrors the early-exit paths above
# so every invocation is recorded exactly once, never on the latency-sensitive
# path before the decision reaches Cursor.
emit_telemetry_async

exit 0