From f745d34113c09dec0816ce18e597e64f25fa14ad Mon Sep 17 00:00:00 2001 From: Rishi Yemme Date: Tue, 12 May 2026 15:54:07 -0700 Subject: [PATCH 1/2] Add Telemetry Reporting for Hooks --- VERSION | 1 + bin/run-hook.sh | 39 ++++- .../hook.sh | 29 +++- install.sh | 12 +- lib/json.sh | 10 ++ lib/telemetry.sh | 153 ++++++++++++++++++ schemas/hook-output.schema.json | 15 ++ .../1password-validate-mounted-env-files.bats | 6 +- 8 files changed, 258 insertions(+), 7 deletions(-) create mode 100644 VERSION create mode 100644 lib/telemetry.sh diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..3eefcb9 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.0.0 diff --git a/bin/run-hook.sh b/bin/run-hook.sh index 35a4a66..9f5ae2a 100755 --- a/bin/run-hook.sh +++ b/bin/run-hook.sh @@ -16,6 +16,13 @@ REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" source "${REPO_ROOT}/lib/logging.sh" source "${REPO_ROOT}/lib/json.sh" +source "${REPO_ROOT}/lib/telemetry.sh" + +# Read hook version from VERSION file +HOOK_VERSION="" +if [[ -f "${REPO_ROOT}/VERSION" ]]; then + HOOK_VERSION=$(head -n1 "${REPO_ROOT}/VERSION" 2>/dev/null || echo "") +fi HOOK_NAME="${1:-}" if [[ -z "$HOOK_NAME" ]]; then @@ -79,7 +86,8 @@ if [[ -z "$canonical_input" ]]; then exit 0 fi -log "Canonical event: $(extract_json_string "$canonical_input" "event")" +canonical_event=$(extract_json_string "$canonical_input" "event") +log "Canonical event: ${canonical_event}" # ── 5. Pipe to hook ───────────────────────────────────────────────────── start_ms=$(($(date +%s) * 1000)) @@ -104,5 +112,34 @@ fi log "Hook result: decision=${decision} duration_ms=${duration_ms}" +# ── 7. Write telemetry event ──────────────────────────────────────────────── +# Wrapped in a subshell with || true to guarantee fail-open behavior. +# If anything here fails, the hook decision is unaffected. +( + hook_mode=$(extract_json_string "$canonical_output" "mode") + hook_mount_count=$(extract_json_integer "$canonical_output" "mount_count") + hook_deny_reason=$(extract_json_string "$canonical_output" "deny_reason") + + # Defaults for hooks that don't emit these fields + [[ -z "$hook_mode" ]] && hook_mode="default" + [[ -z "$hook_mount_count" ]] && hook_mount_count="0" + + duration_bucket=$(bucket_duration_ms "$duration_ms") + install_method=$(detect_install_method "$SCRIPT_DIR") + + write_execution_event \ + "$HOOK_NAME" \ + "$HOOK_VERSION" \ + "$detected_client" \ + "$canonical_event" \ + "$decision" \ + "$hook_deny_reason" \ + "$duration_bucket" \ + "$hook_mode" \ + "$hook_mount_count" + + check_install_sentinel "$detected_client" "$HOOK_NAME" "$install_method" +) 2>/dev/null || true + # ── 8–9. Emit client-specific output and exit ─────────────────────────────── emit_output "$canonical_output" diff --git a/hooks/1password-validate-mounted-env-files/hook.sh b/hooks/1password-validate-mounted-env-files/hook.sh index 6b80f91..7dd24c9 100755 --- a/hooks/1password-validate-mounted-env-files/hook.sh +++ b/hooks/1password-validate-mounted-env-files/hook.sh @@ -43,6 +43,11 @@ permission="allow" # The message for the agent to interpret if the permission is denied. agent_message="" +# Telemetry metadata — passed through canonical output for run-hook.sh to read. +resolved_mode="default" +total_mount_count=0 +deny_reason="" + # ============================================================================ # 1PASSWORD DATABASE FUNCTIONS @@ -364,14 +369,21 @@ parse_toml_mount_paths() { return 1 } -# Emit one JSON line to stdout (decision, message). +# Emit one JSON line to stdout (decision, message, and telemetry metadata). output_decision() { if [[ "$permission" == "allow" ]]; then msg_escaped="" else msg_escaped=$(escape_json_string "$agent_message") fi - printf '{"decision":"%s","message":"%s"}\n' "$permission" "$msg_escaped" + local deny_reason_json + if [[ -z "$deny_reason" ]]; then + deny_reason_json="null" + else + deny_reason_json="\"${deny_reason}\"" + fi + printf '{"decision":"%s","message":"%s","mode":"%s","mount_count":%d,"deny_reason":%s}\n' \ + "$permission" "$msg_escaped" "$resolved_mode" "$total_mount_count" "$deny_reason_json" } # ============================================================================ @@ -432,6 +444,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 @@ -499,6 +512,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 @@ -596,6 +610,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)" @@ -706,6 +722,15 @@ if [[ ${#all_missing_invalid[@]} -gt 0 ]] || [[ ${#disabled_mounts[@]} -gt 0 ]]; fi fi +# Derive deny_reason for telemetry +if [[ "$permission" == "deny" ]]; then + if [[ ${#all_missing_invalid[@]} -gt 0 ]]; then + deny_reason="file_missing" + elif [[ ${#disabled_mounts[@]} -gt 0 ]]; then + deny_reason="file_disabled" + fi +fi + log "Decision: $permission" [[ "$permission" == "deny" ]] && log "Message: $agent_message" output_decision diff --git a/install.sh b/install.sh index f751641..c8c603e 100755 --- a/install.sh +++ b/install.sh @@ -230,11 +230,12 @@ fi mkdir -p "${INSTALL_DIR}/bin" "${INSTALL_DIR}/lib" "${INSTALL_DIR}/adapters" "${INSTALL_DIR}/hooks" -# Copy lib and bin +# Copy lib, bin, and VERSION cp "${REPO_ROOT}/bin/run-hook.sh" "${INSTALL_DIR}/bin/run-hook.sh" for f in "${REPO_ROOT}/lib/"*.sh; do [[ -f "$f" ]] && cp "$f" "${INSTALL_DIR}/lib/" done +[[ -f "${REPO_ROOT}/VERSION" ]] && cp "${REPO_ROOT}/VERSION" "${INSTALL_DIR}/VERSION" # Copy adapters for this agent while IFS= read -r adapter; do @@ -298,6 +299,15 @@ if [[ -n "$CONFIG_FILE" ]]; then fi fi +# Write install telemetry event +( + source "${REPO_ROOT}/lib/telemetry.sh" + while IFS=$'\t' read -r _event hook_name; do + [[ -z "$hook_name" ]] && continue + write_install_event "$AGENT" "$hook_name" "install_script" + done < <(get_hook_events "$AGENT_BLOCK") +) 2>/dev/null || true + if [[ -n "$CONFIG_FILE" ]]; then echo "Done. Hook(s) installed" else diff --git a/lib/json.sh b/lib/json.sh index 50d6370..f536852 100644 --- a/lib/json.sh +++ b/lib/json.sh @@ -89,6 +89,16 @@ parse_json_workspace_roots() { return 0 } +# Extract the first JSON integer field that matches the provided key. +# Usage: val=$(extract_json_integer "$json" "field_name") +extract_json_integer() { + local json="$1" + local key="$2" + printf '%s\n' "$json" | grep -oE "\"${key}\"[[:space:]]*:[[:space:]]*[0-9]+" \ + | head -n 1 \ + | sed -E "s/.*:[[:space:]]*([0-9]+).*/\1/" || true +} + # Check whether a key exists anywhere in a JSON object. # Usage: json_has_key "$json" "field_name" && echo "exists" json_has_key() { diff --git a/lib/telemetry.sh b/lib/telemetry.sh new file mode 100644 index 0000000..0b412de --- /dev/null +++ b/lib/telemetry.sh @@ -0,0 +1,153 @@ +# Shared telemetry utilities for agent-hooks. +# 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. + +[[ -n "${_LIB_TELEMETRY_LOADED:-}" ]] && return 0 +_LIB_TELEMETRY_LOADED=1 + +_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${_LIB_DIR}/logging.sh" +source "${_LIB_DIR}/json.sh" + +# Convert raw milliseconds to a bucketed range string +bucket_duration_ms() { + local ms="${1:-0}" + if [[ "$ms" -lt 50 ]]; then echo "0-50" + elif [[ "$ms" -lt 100 ]]; then echo "50-100" + elif [[ "$ms" -lt 200 ]]; then echo "100-200" + elif [[ "$ms" -lt 500 ]]; then echo "200-500" + elif [[ "$ms" -lt 1000 ]]; then echo "500-1000" + elif [[ "$ms" -lt 5000 ]]; then echo "1000-5000" + else echo "5000+" + fi +} + +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 (~4100 events) + 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. +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_bucket="$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 + deny_reason_json="\"${deny_reason}\"" + fi + + 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_ms\":\"${duration_bucket}\",\"mode\":\"${mode}\",\"mount_count\":${mount_count}}" + + write_telemetry_event "$json_line" +} + +# Write an agent_hook_install telemetry event. +write_install_event() { + local client="$1" + local hook_name="$2" + local install_method="$3" + + local escaped_client escaped_hook_name + escaped_client=$(escape_json_string "$client") + escaped_hook_name=$(escape_json_string "$hook_name") + + local json_line + json_line="{\"schema\":\"agent_hook_install\",\"client\":\"${escaped_client}\",\"hook_name\":\"${escaped_hook_name}\",\"install_method\":\"${install_method}\"}" + + write_telemetry_event "$json_line" +} + +# Write an install event on first execution per client+hook combination. +# Uses a sentinel file to avoid reporting duplicate events per install via plugin marketplace +check_install_sentinel() { + local client="$1" + local hook_name="$2" + local install_method="$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}-${install_method}" + if [[ ! -f "$sentinel" ]]; then + write_install_event "$client" "$hook_name" "$install_method" + touch "$sentinel" 2>/dev/null || true + fi +} + +# Detect how the hook was deployed: plugin marketplace or install script. +# Pass the caller's SCRIPT_DIR as the argument. +detect_install_method() { + local caller_dir="${1:-}" + + # Primary: IDE-provided plugin env vars (authoritative) + # Covers Cursor, Claude Code, and GitHub Copilot (which reuses CLAUDE_PLUGIN_ROOT) + if [[ -n "${CURSOR_PLUGIN_ROOT:-}" ]] || [[ -n "${CLAUDE_PLUGIN_ROOT:-}" ]]; then + echo "plugin_marketplace" + return 0 + fi + + # Secondary: Bundle directory naming convention from install.sh + if [[ -n "$caller_dir" ]] && [[ "$caller_dir" == *"-1password-hooks-bundle"* ]]; then + echo "install_script" + return 0 + fi + + echo "unknown" +} diff --git a/schemas/hook-output.schema.json b/schemas/hook-output.schema.json index e52d1f1..5133b62 100644 --- a/schemas/hook-output.schema.json +++ b/schemas/hook-output.schema.json @@ -15,6 +15,21 @@ "message": { "type": "string", "description": "Human-readable explanation. Must be non-empty when decision is 'deny'. Should be empty string when decision is 'allow'." + }, + "mode": { + "type": "string", + "enum": ["configured", "default"], + "description": "How environment files were discovered. Only present for hooks that validate mounts." + }, + "mount_count": { + "type": "integer", + "minimum": 0, + "description": "Number of environment files validated. Only present for hooks that validate mounts." + }, + "deny_reason": { + "type": ["string", "null"], + "enum": ["file_missing", "file_disabled", null], + "description": "Reason for denial. Null when decision is allow. Only present for hooks that validate mounts." } }, "allOf": [ diff --git a/tests/hooks/1password-validate-mounted-env-files.bats b/tests/hooks/1password-validate-mounted-env-files.bats index a1a5b56..30fffdc 100644 --- a/tests/hooks/1password-validate-mounted-env-files.bats +++ b/tests/hooks/1password-validate-mounted-env-files.bats @@ -33,7 +33,7 @@ canonical_one_root='{"client":"cursor","event":"before_shell_execution","type":" @test "hook output has decision and message keys" { run bash -c "echo '$canonical_empty_roots' | bash \"${HOOK_SCRIPT}\"" [[ $status -eq 0 ]] - local regex='^\{"decision":"allow","message":""\}$' + local regex='^\{"decision":"allow","message":"","mode":"default","mount_count":0,"deny_reason":null\}$' [[ $output =~ $regex ]] } @@ -71,13 +71,13 @@ canonical_one_root='{"client":"cursor","event":"before_shell_execution","type":" run bash -c "echo '$canonical_empty_roots' | bash \"${HOOK_SCRIPT}\" 2>&1" [[ $status -eq 0 ]] [[ $(echo "$output" | wc -l) -eq 1 ]] - [[ $output == '{"decision":"allow","message":""}' ]] + [[ $output == '{"decision":"allow","message":"","mode":"default","mount_count":0,"deny_reason":null}' ]] } @test "empty workspace_roots returns allow and exit 0" { run bash -c "echo '$canonical_empty_roots' | bash \"${HOOK_SCRIPT}\"" [[ $status -eq 0 ]] - [[ "$output" == '{"decision":"allow","message":""}' ]] + [[ "$output" == '{"decision":"allow","message":"","mode":"default","mount_count":0,"deny_reason":null}' ]] } # ============================================================================ From 30facbf47abeb7c1dd1545c8f000fdb6d67efe3c Mon Sep 17 00:00:00 2001 From: Rishi Yemme Date: Wed, 13 May 2026 14:48:53 -0700 Subject: [PATCH 2/2] Add unit tests --- tests/lib/json.bats | 45 +++++++ tests/lib/telemetry.bats | 261 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 306 insertions(+) create mode 100644 tests/lib/telemetry.bats diff --git a/tests/lib/json.bats b/tests/lib/json.bats index acd37d0..2e9884d 100644 --- a/tests/lib/json.bats +++ b/tests/lib/json.bats @@ -107,6 +107,51 @@ setup() { [[ "$output" == "$original" ]] } +# ========== extract_json_integer ========== + +@test "extract_json_integer extracts a simple integer" { + local json='{"mount_count": 3}' + run extract_json_integer "$json" "mount_count" + [[ "$output" == "3" ]] +} + +@test "extract_json_integer extracts zero" { + local json='{"mount_count": 0}' + run extract_json_integer "$json" "mount_count" + [[ "$output" == "0" ]] +} + +@test "extract_json_integer extracts large number" { + local json='{"mount_count": 12345}' + run extract_json_integer "$json" "mount_count" + [[ "$output" == "12345" ]] +} + +@test "extract_json_integer returns empty for missing key" { + local json='{"other": 5}' + run extract_json_integer "$json" "mount_count" + [[ "$status" -eq 0 ]] + [[ "$output" == "" ]] +} + +@test "extract_json_integer handles whitespace around colon" { + local json='{"mount_count" : 7}' + run extract_json_integer "$json" "mount_count" + [[ "$output" == "7" ]] +} + +@test "extract_json_integer does not match string values" { + local json='{"mount_count": "3"}' + run extract_json_integer "$json" "mount_count" + [[ "$output" == "" ]] +} + +@test "extract_json_integer extracts from mixed JSON" { + local json='{"decision":"allow","message":"","mode":"default","mount_count":2,"deny_reason":null}' + run extract_json_integer "$json" "mount_count" + [[ "$output" == "2" ]] +} + # ========== parse_json_workspace_roots ========== @test "parse_json_workspace_roots extracts single-line array" { diff --git a/tests/lib/telemetry.bats b/tests/lib/telemetry.bats new file mode 100644 index 0000000..1d012d3 --- /dev/null +++ b/tests/lib/telemetry.bats @@ -0,0 +1,261 @@ +#!/usr/bin/env bats + +load "../test_helper" + +setup() { + unset _LIB_TELEMETRY_LOADED _LIB_JSON_LOADED _LIB_LOGGING_LOADED _LIB_OS_LOADED + + # Use a temp HOME so we don't touch real config + export ORIGINAL_HOME="$HOME" + export HOME="${BATS_TEST_TMPDIR}/home" + mkdir -p "$HOME" + + source "${LIB_DIR}/telemetry.sh" +} + +teardown() { + export HOME="$ORIGINAL_HOME" +} + +# Helper: create the consent signal file +create_consent_signal() { + mkdir -p "${HOME}/.config/1Password" + touch "${HOME}/.config/1Password/telemetry-enabled" +} + +# ========== bucket_duration_ms ========== + +@test "bucket_duration_ms: 0 returns 0-50" { + run bucket_duration_ms 0 + [[ "$output" == "0-50" ]] +} + +@test "bucket_duration_ms: 49 returns 0-50" { + run bucket_duration_ms 49 + [[ "$output" == "0-50" ]] +} + +@test "bucket_duration_ms: 50 returns 50-100" { + run bucket_duration_ms 50 + [[ "$output" == "50-100" ]] +} + +@test "bucket_duration_ms: 99 returns 50-100" { + run bucket_duration_ms 99 + [[ "$output" == "50-100" ]] +} + +@test "bucket_duration_ms: 100 returns 100-200" { + run bucket_duration_ms 100 + [[ "$output" == "100-200" ]] +} + +@test "bucket_duration_ms: 500 returns 500-1000" { + run bucket_duration_ms 500 + [[ "$output" == "500-1000" ]] +} + +@test "bucket_duration_ms: 1000 returns 1000-5000" { + run bucket_duration_ms 1000 + [[ "$output" == "1000-5000" ]] +} + +@test "bucket_duration_ms: 5000 returns 5000+" { + run bucket_duration_ms 5000 + [[ "$output" == "5000+" ]] +} + +@test "bucket_duration_ms: 10000 returns 5000+" { + run bucket_duration_ms 10000 + [[ "$output" == "5000+" ]] +} + +@test "bucket_duration_ms: empty input returns 0-50" { + run bucket_duration_ms "" + [[ "$output" == "0-50" ]] +} + +# ========== telemetry_consent_enabled ========== + +@test "telemetry_consent_enabled: returns false when signal file absent" { + run telemetry_consent_enabled + [[ "$status" -ne 0 ]] +} + +@test "telemetry_consent_enabled: returns true when signal file present" { + create_consent_signal + run telemetry_consent_enabled + [[ "$status" -eq 0 ]] +} + +# ========== write_telemetry_event ========== + +@test "write_telemetry_event: writes a line to events.jsonl" { + create_consent_signal + write_telemetry_event '{"test":"value"}' + local event_file + event_file="$(get_telemetry_dir)/events.jsonl" + [[ -f "$event_file" ]] + [[ "$(cat "$event_file")" == '{"test":"value"}' ]] +} + +@test "write_telemetry_event: no-op when consent absent" { + write_telemetry_event '{"test":"value"}' + local event_file + event_file="$(get_telemetry_dir)/events.jsonl" + [[ ! -f "$event_file" ]] +} + +@test "write_telemetry_event: creates directory if missing" { + create_consent_signal + local event_dir + event_dir="$(get_telemetry_dir)" + [[ ! -d "$event_dir" ]] + write_telemetry_event '{"test":"value"}' + [[ -d "$event_dir" ]] +} + +@test "write_telemetry_event: respects 1MB cap" { + create_consent_signal + local event_dir + event_dir="$(get_telemetry_dir)" + mkdir -p "$event_dir" + local event_file="${event_dir}/events.jsonl" + # Create a file just over 1MB + dd if=/dev/zero of="$event_file" bs=1048577 count=1 2>/dev/null + write_telemetry_event '{"should":"not appear"}' + # File should still be ~1MB, not have our line appended + ! grep -q "should" "$event_file" +} + +@test "write_telemetry_event: appends multiple lines" { + create_consent_signal + write_telemetry_event '{"line":1}' + write_telemetry_event '{"line":2}' + write_telemetry_event '{"line":3}' + local event_file + event_file="$(get_telemetry_dir)/events.jsonl" + local count + count=$(wc -l < "$event_file" | tr -d ' ') + [[ "$count" -eq 3 ]] +} + +# ========== write_execution_event ========== + +@test "write_execution_event: correct JSON structure" { + create_consent_signal + write_execution_event \ + "validate_mounted_env_files" \ + "0.1.0" \ + "cursor" \ + "before_shell_execution" \ + "allow" \ + "" \ + "0-50" \ + "configured" \ + "3" + local event_file + event_file="$(get_telemetry_dir)/events.jsonl" + local line + line=$(cat "$event_file") + [[ "$line" == *'"schema":"agent_hook_execution"'* ]] + [[ "$line" == *'"hook_name":"validate_mounted_env_files"'* ]] + [[ "$line" == *'"hook_version":"0.1.0"'* ]] + [[ "$line" == *'"client":"cursor"'* ]] + [[ "$line" == *'"decision":"allow"'* ]] + [[ "$line" == *'"deny_reason":null'* ]] + [[ "$line" == *'"mode":"configured"'* ]] + [[ "$line" == *'"mount_count":3'* ]] +} + +@test "write_execution_event: deny_reason is set when provided" { + create_consent_signal + write_execution_event \ + "validate_mounted_env_files" \ + "0.1.0" \ + "cursor" \ + "before_shell_execution" \ + "deny" \ + "file_missing" \ + "1000-5000" \ + "default" \ + "1" + local event_file + event_file="$(get_telemetry_dir)/events.jsonl" + local line + line=$(cat "$event_file") + [[ "$line" == *'"deny_reason":"file_missing"'* ]] + [[ "$line" == *'"decision":"deny"'* ]] +} + +# ========== write_install_event ========== + +@test "write_install_event: correct JSON structure" { + create_consent_signal + write_install_event "cursor" "validate_mounted_env_files" "install_script" + local event_file + event_file="$(get_telemetry_dir)/events.jsonl" + local line + line=$(cat "$event_file") + [[ "$line" == *'"schema":"agent_hook_install"'* ]] + [[ "$line" == *'"client":"cursor"'* ]] + [[ "$line" == *'"hook_name":"validate_mounted_env_files"'* ]] + [[ "$line" == *'"install_method":"install_script"'* ]] +} + +# ========== check_install_sentinel ========== + +@test "check_install_sentinel: creates sentinel and writes event on first call" { + create_consent_signal + check_install_sentinel "cursor" "validate_mounted_env_files" "plugin_marketplace" + local event_dir + event_dir="$(get_telemetry_dir)" + [[ -f "${event_dir}/.installed-cursor-validate_mounted_env_files-plugin_marketplace" ]] + [[ -f "${event_dir}/events.jsonl" ]] +} + +@test "check_install_sentinel: no-op on second call" { + create_consent_signal + check_install_sentinel "cursor" "validate_mounted_env_files" "plugin_marketplace" + local event_file + event_file="$(get_telemetry_dir)/events.jsonl" + local count_before + count_before=$(wc -l < "$event_file" | tr -d ' ') + check_install_sentinel "cursor" "validate_mounted_env_files" "plugin_marketplace" + local count_after + count_after=$(wc -l < "$event_file" | tr -d ' ') + [[ "$count_before" -eq "$count_after" ]] +} + +@test "check_install_sentinel: no-op when consent absent" { + check_install_sentinel "cursor" "validate_mounted_env_files" "plugin_marketplace" + local event_dir + event_dir="$(get_telemetry_dir)" + [[ ! -f "${event_dir}/.installed-cursor-validate_mounted_env_files-plugin_marketplace" ]] +} + +# ========== detect_install_method ========== + +@test "detect_install_method: returns plugin_marketplace when CURSOR_PLUGIN_ROOT set" { + export CURSOR_PLUGIN_ROOT="/path/to/plugin" + run detect_install_method "/some/dir" + [[ "$output" == "plugin_marketplace" ]] + unset CURSOR_PLUGIN_ROOT +} + +@test "detect_install_method: returns plugin_marketplace when CLAUDE_PLUGIN_ROOT set" { + export CLAUDE_PLUGIN_ROOT="/path/to/plugin" + run detect_install_method "/some/dir" + [[ "$output" == "plugin_marketplace" ]] + unset CLAUDE_PLUGIN_ROOT +} + +@test "detect_install_method: returns install_script when path contains bundle marker" { + run detect_install_method "/project/.cursor/cursor-1password-hooks-bundle/bin" + [[ "$output" == "install_script" ]] +} + +@test "detect_install_method: returns unknown when no signal matches" { + run detect_install_method "/some/random/dir" + [[ "$output" == "unknown" ]] +}