Skip to content
Draft
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
1 change: 1 addition & 0 deletions VERSION
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
1.0.0
39 changes: 38 additions & 1 deletion bin/run-hook.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand All @@ -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"
29 changes: 27 additions & 2 deletions hooks/1password-validate-mounted-env-files/hook.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
}

# ============================================================================
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)"
Expand Down Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions lib/json.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
153 changes: 153 additions & 0 deletions lib/telemetry.sh
Original file line number Diff line number Diff line change
@@ -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"
}
15 changes: 15 additions & 0 deletions schemas/hook-output.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
6 changes: 3 additions & 3 deletions tests/hooks/1password-validate-mounted-env-files.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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 ]]
}

Expand Down Expand Up @@ -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}' ]]
}

# ============================================================================
Expand Down
Loading