From e6313233af59f3e61434302cf2f27008d7acf63c Mon Sep 17 00:00:00 2001 From: Jill Regan Date: Wed, 13 May 2026 08:21:53 -0400 Subject: [PATCH 1/6] Create cmd script for windows --- hooks/hooks.json | 2 +- ...nv-files.sh => validate-mounted-env-files} | 207 ++++++++++-------- scripts/validate-mounted-env-files.cmd | 7 + 3 files changed, 122 insertions(+), 94 deletions(-) rename scripts/{validate-mounted-env-files.sh => validate-mounted-env-files} (97%) create mode 100644 scripts/validate-mounted-env-files.cmd diff --git a/hooks/hooks.json b/hooks/hooks.json index 4ab9095..688578f 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -3,7 +3,7 @@ "hooks": { "beforeShellExecution": [ { - "command": "./scripts/validate-mounted-env-files.sh" + "command": "scripts/validate-mounted-env-files" } ] } diff --git a/scripts/validate-mounted-env-files.sh b/scripts/validate-mounted-env-files similarity index 97% rename from scripts/validate-mounted-env-files.sh rename to scripts/validate-mounted-env-files index 6dfa9a2..ee181f7 100755 --- a/scripts/validate-mounted-env-files.sh +++ b/scripts/validate-mounted-env-files @@ -2,6 +2,27 @@ set -euo pipefail +# Windows (Git Bash / MSYS / native): same fail-open JSON as validate-mounted-env-files.cmd. +WIN_SKIP_MSG='1Password Environments are not currently supported on Windows. Environment validation was skipped.' +_esc() { printf '%s' "$1" | sed 's/\\/\\\\/g;s/"/\\"/g'; } +WIN_SKIP_JSON=$(printf '%s' "{\"permission\":\"allow\",\"agent_message\":\"$(_esc "$WIN_SKIP_MSG")\",\"user_message\":\"$(_esc "$WIN_SKIP_MSG")\"}") + +case "$(uname -s 2>/dev/null)" in + MINGW* | MSYS* | CYGWIN*) + cat >/dev/null 2>&1 || true + printf '%s\n' "$WIN_SKIP_JSON" + exit 0 + ;; +esac + +if [[ -n "${WINDIR:-}" || "${OS:-}" == "Windows_NT" ]]; then + cat >/dev/null 2>&1 || true + printf '%s\n' "$WIN_SKIP_JSON" + exit 0 +fi + +unset -f _esc 2>/dev/null || true + # ============================================================================ # TABLE OF CONTENTS # ============================================================================ @@ -47,7 +68,7 @@ log() { local timestamp timestamp=$(date +"%Y-%m-%d %H:%M:%S" 2>/dev/null || echo "$(date +%s)") local log_message="[${timestamp}] [validate-mounted-env-files] $*" - + if [[ "${DEBUG:-}" == "1" ]]; then # If DEBUG=1, echo directly to terminal (stderr for logs) echo "$log_message" >&2 @@ -77,13 +98,13 @@ escape_json_string() { parse_json_workspace_roots() { local json_input json_input=$(cat) - + # Extract workspace_roots array values # Find the line(s) containing "workspace_roots" and extract the array # Handle both single-line and multi-line arrays local in_array=false local array_lines="" - + while IFS= read -r line || [[ -n "$line" ]]; do # Check if this line starts the workspace_roots array if echo "$line" | grep -qE '"workspace_roots"[[:space:]]*:[[:space:]]*\['; then @@ -105,7 +126,7 @@ parse_json_workspace_roots() { fi fi done <<< "$json_input" - + # Extract quoted strings from the array content echo "$array_lines" | grep -oE '"[^"]+"' | \ sed 's/^"//;s/"$//' | \ @@ -159,26 +180,26 @@ detect_os() { # Returns 0 if path is safe, 1 if unsafe validate_path() { local path="$1" - + # Check for empty path [[ -z "$path" ]] && return 1 - + # Check for command substitution patterns # $() command substitution if [[ "$path" =~ \$\( ]] || [[ "$path" =~ \$\{ ]]; then return 1 fi - + # Backtick command substitution if [[ "$path" =~ \` ]]; then return 1 fi - + # Check for semicolons, pipes, ampersands, and other command separators if [[ "$path" =~ [\;\|\&\<\>] ]]; then return 1 fi - + # Check for control characters that could break commands # Remove all printable characters; if anything remains, there are control chars local non_printable @@ -186,7 +207,7 @@ validate_path() { if [[ -n "$non_printable" ]]; then return 1 fi - + # Path is considered safe if it passes all checks return 0 } @@ -195,14 +216,14 @@ validate_path() { normalize_path() { local path="$1" local normalized normalized_dir file_part dir_part - + # Validate path before using it with cd to prevent command injection if ! validate_path "$path"; then log "Warning: Unsafe path detected, skipping normalization: ${path}" echo "$path" return 0 fi - + # Normalize a given path using cd # This resolves . and .. components and symlinks for existing paths if [[ -d "$path" ]]; then @@ -216,7 +237,7 @@ normalize_path() { # For files/FIFOs, resolve the directory part dir_part=$(dirname "$path") file_part=$(basename "$path") - + # Validate dir_part before using with cd if validate_path "$dir_part" && [[ -d "$dir_part" ]]; then normalized_dir=$(cd "$dir_part" && pwd 2>/dev/null) @@ -229,7 +250,7 @@ normalize_path() { # Attempt to normalize non-existent paths (e.g., with .. components) dir_part=$(dirname "$path") file_part=$(basename "$path") - + if validate_path "$dir_part" && [[ -d "$dir_part" ]]; then normalized_dir=$(cd "$dir_part" && pwd 2>/dev/null) if [[ -n "$normalized_dir" ]]; then @@ -238,7 +259,7 @@ normalize_path() { fi fi fi - + # Last resort: return path as-is echo "$path" } @@ -252,7 +273,7 @@ find_1password_db() { local os_type="$1" local home_path="${HOME}" local db_paths=() - + if [[ "$os_type" == "macos" ]]; then db_paths=( "${home_path}/Library/Group Containers/2BUA8C4S2C.com.1password/Library/Application Support/1Password/Data/1Password.sqlite" @@ -264,49 +285,49 @@ find_1password_db() { "${home_path}/.var/app/com.onepassword.OnePassword/config/1Password/1Password.sqlite" ) fi - + for db_path in "${db_paths[@]}"; do if [[ -f "$db_path" ]]; then echo "$db_path" return 0 fi done - + return 1 } # Query 1Password database for mounts query_mounts() { local db_path="$1" - + if ! command -v sqlite3 &> /dev/null; then log "Warning: sqlite3 not found, cannot query 1Password database" return 1 fi - + # Check if database is readable if [[ ! -r "$db_path" ]]; then log "Warning: 1Password database is not readable: ${db_path}" return 1 fi - + # Check if database file exists and is a valid SQLite database if ! sqlite3 "$db_path" "SELECT 1;" &>/dev/null; then log "Warning: 1Password database appears to be invalid or locked: ${db_path}" return 1 fi - + # Query for mount entries # Suppress errors but capture output local result result=$(sqlite3 "$db_path" "SELECT hex(data) FROM objects_associated WHERE key_name LIKE 'dev-environment-mount/%';" 2>/dev/null) local exit_code=$? - + if [[ $exit_code -ne 0 ]]; then log "Warning: Failed to query 1Password database (exit code: $exit_code)" return 1 fi - + # Return result even if empty (empty string is valid - means no mounts) echo "$result" return 0 @@ -320,16 +341,16 @@ query_mounts() { is_project_mount() { local mount_path="$1" local project_path="$2" - + # Normalize paths for comparison local normalized_mount normalized_project - + normalized_mount=$(normalize_path "$mount_path") normalized_project=$(normalize_path "$project_path") - + # Ensure both paths end with / for consistent comparison [[ "$normalized_project" != */ ]] && normalized_project="${normalized_project}/" - + # Check if mount path starts with project path (mount is within project) # Also check original paths in case normalization failed if [[ "$normalized_mount" == "$normalized_project"* ]] || \ @@ -338,7 +359,7 @@ is_project_mount() { [[ "$mount_path" == "$project_path" ]]; then return 0 fi - + return 1 } @@ -347,10 +368,10 @@ hex_to_json() { local hex="$1" # Remove any whitespace/newlines hex=$(echo "$hex" | tr -d '[:space:]') - + # Skip if empty [[ -z "$hex" ]] && return 1 - + # Use printf with escaped hex # Convert hex pairs to \x escaped format local escaped_hex decoded @@ -361,7 +382,7 @@ hex_to_json() { echo "$decoded" return 0 fi - + return 1 } @@ -369,46 +390,46 @@ hex_to_json() { parse_mount() { local hex_data="$1" local json_data - + json_data=$(hex_to_json "$hex_data") - + if [[ -z "$json_data" ]]; then return 1 fi - + # Extract mountPath, isEnabled, environmentName, uuid, and environmentUuid from JSON # Note: This may not handle all JSON edge cases (escaped quotes, etc.) # but should work for typical 1Password mount JSON structures local mount_path is_enabled environment_name uuid environment_uuid - + # Extract mountPath - handle both BSD and GNU sed mount_path=$(echo "$json_data" | grep -oE '"mountPath"[[:space:]]*:[[:space:]]*"[^"]*"' | sed -E 's/.*"mountPath"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/' 2>/dev/null || \ echo "$json_data" | grep -o '"mountPath"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*"mountPath"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/' 2>/dev/null || echo "") - + # Check for isEnabled: true or false if echo "$json_data" | grep -qE '"isEnabled"[[:space:]]*:[[:space:]]*true'; then is_enabled="true" else is_enabled="false" fi - + # Extract environmentName - handle both BSD and GNU sed environment_name=$(echo "$json_data" | grep -oE '"environmentName"[[:space:]]*:[[:space:]]*"[^"]*"' | sed -E 's/.*"environmentName"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/' 2>/dev/null || \ echo "$json_data" | grep -o '"environmentName"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*"environmentName"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/' 2>/dev/null || echo "") - + # Extract uuid - handle both BSD and GNU sed uuid=$(echo "$json_data" | grep -oE '"uuid"[[:space:]]*:[[:space:]]*"[^"]*"' | sed -E 's/.*"uuid"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/' 2>/dev/null || \ echo "$json_data" | grep -o '"uuid"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*"uuid"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/' 2>/dev/null || echo "") - + # Extract environmentUuid - handle both BSD and GNU sed environment_uuid=$(echo "$json_data" | grep -oE '"environmentUuid"[[:space:]]*:[[:space:]]*"[^"]*"' | sed -E 's/.*"environmentUuid"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/' 2>/dev/null || \ echo "$json_data" | grep -o '"environmentUuid"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*"environmentUuid"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/' 2>/dev/null || echo "") - + if [[ -n "$mount_path" ]]; then echo "$mount_path|$is_enabled|$environment_name|$uuid|$environment_uuid" return 0 fi - + return 1 } @@ -419,12 +440,12 @@ parse_mount() { # Remove comments and trim whitespace from a TOML line normalize_toml_line() { local line="$1" - + # Remove comments (everything after #) line="${line%%#*}" # Trim leading/trailing whitespace line=$(echo "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') - + echo "$line" } @@ -432,30 +453,30 @@ normalize_toml_line() { # Returns 0 if mount_paths field exists, 1 otherwise has_toml_mount_paths_field() { local toml_file="$1" - + # Check for TOML configuration if [[ ! -f "$toml_file" ]]; then return 1 fi - + while IFS= read -r raw_line || [[ -n "$raw_line" ]]; do local line line=$(normalize_toml_line "$raw_line") - + # Skip empty lines [[ -z "$line" ]] && continue - + # If we hit a section header, we're no longer at top level if [[ "$line" =~ ^\[\[.*\]\] ]] || [[ "$line" =~ ^\[.*\] ]]; then break fi - + # Detect 'mount_paths' field at top level if [[ "$line" =~ ^mount_paths[[:space:]]*= ]]; then return 0 fi done < "$toml_file" - + return 1 } @@ -465,11 +486,11 @@ has_toml_mount_paths_field() { # Returns exit code 1 if mount_paths field doesn't exist parse_toml_mount_paths() { local toml_file="$1" - + if [[ ! -f "$toml_file" ]]; then return 1 fi - + # Pure bash TOML parsing for environments entries # Handles formats like: # mount_paths = [".env", "billing.env"] @@ -482,19 +503,19 @@ parse_toml_mount_paths() { local mount_paths="" local array_content="" local found_mount_paths_field=false - + while IFS= read -r raw_line || [[ -n "$raw_line" ]]; do local line line=$(normalize_toml_line "$raw_line") - + # Skip empty lines [[ -z "$line" ]] && continue - + # If we hit a section header, we're no longer at top level if [[ "$line" =~ ^\[\[.*\]\] ]] || [[ "$line" =~ ^\[.*\] ]]; then break fi - + # Check for mount_paths = [...] on a single line if [[ "$line" =~ ^mount_paths[[:space:]]*=[[:space:]]*\[.*\] ]]; then found_mount_paths_field=true @@ -503,7 +524,7 @@ parse_toml_mount_paths() { array_part="${array_part%\]*}" array_content="$array_part" in_mount_paths_array=false # Array is complete on one line - + # Extract quoted strings from the array content while [[ "$array_content" =~ \"([^\"]+)\" ]]; do mount_paths="${mount_paths}${BASH_REMATCH[1]}"$'\n' @@ -550,7 +571,7 @@ parse_toml_mount_paths() { fi fi done < "$toml_file" - + # If mount_paths field was found, return success (even if empty) if [[ "$found_mount_paths_field" == "true" ]]; then # Remove trailing newline and return @@ -562,7 +583,7 @@ parse_toml_mount_paths() { fi return 0 fi - + return 1 } @@ -611,19 +632,19 @@ fi # Process each workspace root for workspace_root in "${workspace_roots_array[@]}"; do log "Processing workspace root: $workspace_root" - + # Check for TOML configuration at this workspace root toml_file="${workspace_root}/.1password/environments.toml" use_configured_mode=false - + # Check if TOML exists and has mount_paths field if [[ -f "$toml_file" ]]; then log "Found environments.toml at ${toml_file}, checking for mount_paths field..." - + if has_toml_mount_paths_field "$toml_file"; then use_configured_mode=true log "environments.toml has mount_paths field defined - validating specified mounts" - + # Parse and validate TOML mount paths toml_mounts=$(parse_toml_mount_paths "$toml_file") if [[ $? -ne 0 ]]; then @@ -639,38 +660,38 @@ for workspace_root in "${workspace_roots_array[@]}"; do else log "No environments.toml found at ${workspace_root}/.1password/environments.toml, using default mode (checking all mounts)" fi - + # Configured mode: validate only mounts specified in TOML if [[ "$use_configured_mode" == "true" ]]; then log "Validating local .env files specified in environments.toml for workspace ${workspace_root}..." - + # Build an array of unique normalized paths from TOML toml_paths_array=() while IFS= read -r mount_path || [[ -n "$mount_path" ]]; do [[ -z "$mount_path" ]] && continue - + # Validate path from TOML to prevent command injection if ! validate_path "$mount_path"; then log "Warning: Unsafe path detected in environments.toml, skipping: ${mount_path}" continue fi - + # Resolve mount path relative to workspace root if [[ "$mount_path" == /* ]]; then resolved_path="$mount_path" else resolved_path="${workspace_root}/${mount_path}" fi - + # Normalize the path resolved_path=$(normalize_path "$resolved_path") - + # Skip mounts that resolve outside the current workspace root if ! is_project_mount "$resolved_path" "$workspace_root"; then log "Skipping required mount outside workspace root: \"${resolved_path}\" (workspace: \"${workspace_root}\")" continue fi - + # Add to array only if not already present path_exists=false if [[ ${#toml_paths_array[@]} -gt 0 ]]; then @@ -685,21 +706,21 @@ for workspace_root in "${workspace_roots_array[@]}"; do toml_paths_array+=("$resolved_path") fi done <<< "$toml_mounts" - + # Check each TOML-specified mount if [[ ${#toml_paths_array[@]} -gt 0 ]]; then for resolved_path in "${toml_paths_array[@]}"; do log "Checking required local .env file from TOML: \"${resolved_path}\"" - + # First, check if it's in the database and what its status is found_in_db=false is_enabled="false" environment_name="" - + if [[ -n "$mount_hex_data" ]]; then while IFS= read -r hex_line || [[ -n "$hex_line" ]]; do [[ -z "$hex_line" ]] && continue - + mount_info=$(parse_mount "$hex_line") if [[ -n "$mount_info" ]]; then mount_path="${mount_info%%|*}" @@ -707,10 +728,10 @@ for workspace_root in "${workspace_roots_array[@]}"; do mount_is_enabled="${remaining%%|*}" remaining="${remaining#*|}" mount_env_name="${remaining%%|*}" - + # Normalize DB mount path for comparison normalized_db_path=$(normalize_path "$mount_path") - + if [[ "$normalized_db_path" == "$resolved_path" ]]; then found_in_db=true is_enabled="$mount_is_enabled" @@ -720,14 +741,14 @@ for workspace_root in "${workspace_roots_array[@]}"; do fi done <<< "$mount_hex_data" fi - + # If found in DB and disabled, report as disabled (consistent with default mode) if [[ "$found_in_db" == "true" ]] && [[ "$is_enabled" == "false" ]]; then log "Required local .env file is disabled: \"${resolved_path}\"" disabled_mounts+=("$resolved_path|$environment_name") continue fi - + # Check if path exists and is a FIFO if [[ ! -e "$resolved_path" ]] || [[ ! -p "$resolved_path" ]]; then if [[ "$found_in_db" == "true" ]]; then @@ -754,18 +775,18 @@ for workspace_root in "${workspace_roots_array[@]}"; do else # Default mode: Check all local .env files within this workspace from 1Password database log "Using default mode: checking all local .env files in workspace ${workspace_root} from 1Password database" - + if [[ -z "$mount_hex_data" ]]; then log "No mount data available from 1Password database, skipping workspace ${workspace_root}" continue fi - + log "Environment mount data found, checking relevant local .env files for workspace ${workspace_root}..." - + # Process each mount entry from database while IFS= read -r hex_line || [[ -n "$hex_line" ]]; do [[ -z "$hex_line" ]] && continue - + mount_info=$(parse_mount "$hex_line") if [[ -n "$mount_info" ]]; then # Parse mount_info: mount_path|is_enabled|environment_name|uuid|environment_uuid @@ -777,15 +798,15 @@ for workspace_root in "${workspace_roots_array[@]}"; do remaining="${remaining#*|}" uuid="${remaining%%|*}" environment_uuid="${remaining#*|}" - + log "Checking local .env file with id ${uuid} at path \"${mount_path}\" for environment ${environment_uuid} (${environment_name})" - + # Check if this local .env file is relevant to the current workspace if ! is_project_mount "$mount_path" "$workspace_root"; then log "Local .env file does not belong to workspace ${workspace_root}, skipping" continue fi - + 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)" @@ -834,18 +855,18 @@ fi # Generate unified error messages if [[ ${#all_missing_invalid[@]} -gt 0 ]] || [[ ${#disabled_mounts[@]} -gt 0 ]]; then permission="deny" - + # Build message for missing/invalid mounts if [[ ${#all_missing_invalid[@]} -gt 0 ]]; then log "Denying permission due to missing or invalid environment files" - + # Extract environment name from DB mounts if available environment_name="" if [[ ${#invalid_mounts[@]} -gt 0 ]]; then first_invalid="${invalid_mounts[0]}" environment_name="${first_invalid#*|}" fi - + if [[ ${#all_missing_invalid[@]} -eq 1 ]]; then if [[ -n "$environment_name" ]]; then agent_message="This project uses 1Password environments. An environment file is expected to be mounted at the specified path. Error: the file is missing or invalid. Environment name: \"${environment_name}\". Path: \"${all_missing_invalid[0]}\". Suggestion: ensure the local .env file is configured and enabled from the environment's destinations tab in the 1Password app." @@ -861,28 +882,28 @@ if [[ ${#all_missing_invalid[@]} -gt 0 ]] || [[ ${#disabled_mounts[@]} -gt 0 ]]; fi fi fi - + # Handle disabled mounts (different issue - needs to be enabled, not configured) if [[ ${#disabled_mounts[@]} -gt 0 ]]; then log "Denying permission due to disabled local .env files" - + # Extract environment name first_disabled="${disabled_mounts[0]}" environment_name="${first_disabled#*|}" - + # Extract mount paths mount_paths=() for mount_entry in "${disabled_mounts[@]}"; do mount_paths+=("${mount_entry%%|*}") done - + if [[ ${#disabled_mounts[@]} -eq 1 ]]; then disabled_msg="Error: the file is not mounted. Environment name: \"${environment_name}\". Path: \"${mount_paths[0]}\". Suggestion: enable the local .env file from the environment's destinations tab in the 1Password app." else file_list=$(IFS=','; echo "${mount_paths[*]}" | sed 's/,/, /g') disabled_msg="Error: these files are not mounted. Environment name: \"${environment_name}\". Paths: \"${file_list}\". Suggestion: enable the local .env files from the environment's destinations tab in the 1Password app." fi - + # Combine messages if we have both missing/invalid and disabled if [[ ${#all_missing_invalid[@]} -gt 0 ]]; then agent_message="${agent_message} ${disabled_msg}" diff --git a/scripts/validate-mounted-env-files.cmd b/scripts/validate-mounted-env-files.cmd new file mode 100644 index 0000000..442c9ca --- /dev/null +++ b/scripts/validate-mounted-env-files.cmd @@ -0,0 +1,7 @@ +@echo off +REM On Windows only: tell Cursor to allow the shell command (1Password Environments are not supported here). +REM The hook command omits a file extension, on Windows the OS finds this .cmd file for you. +REM On Mac/Linux the same hook name runs the Bash script next to this file instead. +powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -Command "& { $null = [Console]::In.ReadToEnd(); $m = '1Password Environments are not currently supported on Windows. Environment validation was skipped.'; @{ permission = 'allow'; agent_message = $m; user_message = $m } | ConvertTo-Json -Compress }" + +exit /b 0 From 55ffe242ad899b42e046038d40bd1661166f511c Mon Sep 17 00:00:00 2001 From: Jill Regan Date: Wed, 13 May 2026 15:12:26 -0400 Subject: [PATCH 2/6] Update readme --- README.md | 13 +++++++------ hooks/hooks.json | 2 +- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 622efc5..cdff58d 100644 --- a/README.md +++ b/README.md @@ -33,9 +33,9 @@ Every time Cursor attempts to execute a shell command, the hook: The hook uses a **"fail open"** approach: if 1Password is not installed, the database is unavailable, or `sqlite3` is missing, the hook allows execution to proceed. This prevents blocking development in environments where 1Password isn't set up. -> **Note:** Local `.env` files from 1Password Environments are only available on macOS and Linux. Windows is not yet supported — Cursor will automatically skip validations on Windows. +> **Note:** 1Password Environments local `.env` mounts only apply on **macOS and Linux**. **`hooks.json`** invokes **`./scripts/validate-mounted-env-files`** with no extension. On **macOS / Linux**, that runs the **Bash** script. On **Windows** the shell looks for a real file by trying suffixes from **`PATHEXT`** until one matches on disk. A typical default order is **`.COM` → `.EXE` → `.BAT` → `.CMD`** (see `echo %PATHEXT%` in **cmd**). Here that yields **`validate-mounted-env-files.cmd`**, which returns **`allow`** and skips validation so agent shells are not blocked. -For full details on how this hook was originally built and tested, see the [1Password Cursor Hooks repository](https://github.com/1Password/cursor-hooks). +For full details on how this hook was originally built and tested, see the [1Password Agent Hooks repository](https://github.com/1Password/agent-hooks). ##### Requirements @@ -93,7 +93,7 @@ For each file, the hook checks: **Cursor Execution Log** 1. Open **Cursor Settings** > **Hooks** > **Execution Log**. -2. Look for `beforeShellExecution` entries tied to `validate-mounted-env-files.sh`. +2. Look for `beforeShellExecution` entries tied to `validate-mounted-env-files`. 3. Each entry shows the hook's permission decision and any error messages. **Manual Testing with Debug Mode** @@ -101,7 +101,7 @@ For each file, the hook checks: Run the hook directly with `DEBUG=1` to see detailed output on stderr: ```bash -DEBUG=1 echo '{"command": "echo test", "workspace_roots": ["/path/to/your/project"]}' | ./scripts/validate-mounted-env-files.sh +DEBUG=1 echo '{"command": "echo test", "workspace_roots": ["/path/to/your/project"]}' | ./scripts/validate-mounted-env-files ``` **Log File** @@ -119,14 +119,15 @@ When not running in debug mode, the hook writes logs to `/tmp/1password-cursor-h ├── assets/ │ └── logo.svg # Plugin logo ├── scripts/ -│ └── validate-mounted-env-files.sh # Validation hook script +│ ├── validate-mounted-env-files # Bash hook (macOS / Linux) +│ └── validate-mounted-env-files.cmd # Windows cmd wrapper returns allow (validation skipped) ├── LICENSE └── README.md ``` ## Resources -- [1Password Cursor Hooks](https://github.com/1Password/cursor-hooks) — the original hooks repository this plugin is based on +- [1Password Agent Hooks](https://github.com/1Password/agent-hooks) — the original hooks repository this plugin is based on - [1Password Environments](https://developer.1password.com/docs/environments) — documentation for 1Password's environment and secrets management - [1Password Local `.env` Files](https://developer.1password.com/docs/environments/local-env-file) — how local `.env` file mounting works - [Cursor Hooks Documentation](https://cursor.com/docs/agent/hooks) — how Cursor hooks work diff --git a/hooks/hooks.json b/hooks/hooks.json index 688578f..d57cfee 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -3,7 +3,7 @@ "hooks": { "beforeShellExecution": [ { - "command": "scripts/validate-mounted-env-files" + "command": "./scripts/validate-mounted-env-files" } ] } From 44803d936b879533f62bdae41c0b1a79a0694bc9 Mon Sep 17 00:00:00 2001 From: Jill Regan Date: Thu, 14 May 2026 07:25:25 -0400 Subject: [PATCH 3/6] Remove bash fallback --- README.md | 2 +- scripts/validate-mounted-env-files | 21 --------------------- 2 files changed, 1 insertion(+), 22 deletions(-) diff --git a/README.md b/README.md index cdff58d..ccbce90 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Every time Cursor attempts to execute a shell command, the hook: The hook uses a **"fail open"** approach: if 1Password is not installed, the database is unavailable, or `sqlite3` is missing, the hook allows execution to proceed. This prevents blocking development in environments where 1Password isn't set up. -> **Note:** 1Password Environments local `.env` mounts only apply on **macOS and Linux**. **`hooks.json`** invokes **`./scripts/validate-mounted-env-files`** with no extension. On **macOS / Linux**, that runs the **Bash** script. On **Windows** the shell looks for a real file by trying suffixes from **`PATHEXT`** until one matches on disk. A typical default order is **`.COM` → `.EXE` → `.BAT` → `.CMD`** (see `echo %PATHEXT%` in **cmd**). Here that yields **`validate-mounted-env-files.cmd`**, which returns **`allow`** and skips validation so agent shells are not blocked. +> **Note:** 1Password Environments local `.env` mounts only apply on **macOS and Linux**. **`hooks.json`** invokes **`./scripts/validate-mounted-env-files`** with no extension. On **macOS / Linux**, that runs the **Bash** script. On **Windows** the shell looks for a real file by trying suffixes from **`PATHEXT`** until one matches on disk. That yields **`validate-mounted-env-files.cmd`**, which returns **`allow`** and skips validation so agent shells are not blocked. For full details on how this hook was originally built and tested, see the [1Password Agent Hooks repository](https://github.com/1Password/agent-hooks). diff --git a/scripts/validate-mounted-env-files b/scripts/validate-mounted-env-files index ee181f7..966b8f1 100755 --- a/scripts/validate-mounted-env-files +++ b/scripts/validate-mounted-env-files @@ -2,27 +2,6 @@ set -euo pipefail -# Windows (Git Bash / MSYS / native): same fail-open JSON as validate-mounted-env-files.cmd. -WIN_SKIP_MSG='1Password Environments are not currently supported on Windows. Environment validation was skipped.' -_esc() { printf '%s' "$1" | sed 's/\\/\\\\/g;s/"/\\"/g'; } -WIN_SKIP_JSON=$(printf '%s' "{\"permission\":\"allow\",\"agent_message\":\"$(_esc "$WIN_SKIP_MSG")\",\"user_message\":\"$(_esc "$WIN_SKIP_MSG")\"}") - -case "$(uname -s 2>/dev/null)" in - MINGW* | MSYS* | CYGWIN*) - cat >/dev/null 2>&1 || true - printf '%s\n' "$WIN_SKIP_JSON" - exit 0 - ;; -esac - -if [[ -n "${WINDIR:-}" || "${OS:-}" == "Windows_NT" ]]; then - cat >/dev/null 2>&1 || true - printf '%s\n' "$WIN_SKIP_JSON" - exit 0 -fi - -unset -f _esc 2>/dev/null || true - # ============================================================================ # TABLE OF CONTENTS # ============================================================================ From 68f8c8a451b4f7f25a97d406bb7e504ef3cc7e63 Mon Sep 17 00:00:00 2001 From: Jill Regan Date: Thu, 14 May 2026 07:52:58 -0400 Subject: [PATCH 4/6] Simplify bash fall back --- scripts/validate-mounted-env-files | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/scripts/validate-mounted-env-files b/scripts/validate-mounted-env-files index 966b8f1..9d538f7 100755 --- a/scripts/validate-mounted-env-files +++ b/scripts/validate-mounted-env-files @@ -2,6 +2,22 @@ set -euo pipefail +# On Windows, if Bash runs this file (Git Bash, MSYS, Cygwin, etc.), return allow and skip validation +# Cursor on Windows normally runs the .cmd file instead; this is for when Bash runs it. +WIN_SKIP_MSG='1Password Environments are not currently supported on Windows. Environment validation was skipped.' +_esc() { printf '%s' "$1" | sed 's/\\/\\\\/g;s/"/\\"/g'; } +WIN_SKIP_JSON=$(printf '%s' "{\"permission\":\"allow\",\"agent_message\":\"$(_esc "$WIN_SKIP_MSG")\",\"user_message\":\"$(_esc "$WIN_SKIP_MSG")\"}") + +case "$(uname -s 2>/dev/null)" in + MINGW* | MSYS* | CYGWIN*) + cat >/dev/null 2>&1 || true + printf '%s\n' "$WIN_SKIP_JSON" + exit 0 + ;; +esac + +unset -f _esc 2>/dev/null || true + # ============================================================================ # TABLE OF CONTENTS # ============================================================================ From 62edf20c98e4c5cc531df4cb9eb1e3ae2e21e8be Mon Sep 17 00:00:00 2001 From: Jill Regan Date: Fri, 15 May 2026 08:27:15 -0400 Subject: [PATCH 5/6] Refactor early exit --- scripts/validate-mounted-env-files | 53 +++++++++++++++----------- scripts/validate-mounted-env-files.cmd | 2 +- 2 files changed, 31 insertions(+), 24 deletions(-) diff --git a/scripts/validate-mounted-env-files b/scripts/validate-mounted-env-files index 9d538f7..79a437b 100755 --- a/scripts/validate-mounted-env-files +++ b/scripts/validate-mounted-env-files @@ -2,33 +2,17 @@ set -euo pipefail -# On Windows, if Bash runs this file (Git Bash, MSYS, Cygwin, etc.), return allow and skip validation -# Cursor on Windows normally runs the .cmd file instead; this is for when Bash runs it. -WIN_SKIP_MSG='1Password Environments are not currently supported on Windows. Environment validation was skipped.' -_esc() { printf '%s' "$1" | sed 's/\\/\\\\/g;s/"/\\"/g'; } -WIN_SKIP_JSON=$(printf '%s' "{\"permission\":\"allow\",\"agent_message\":\"$(_esc "$WIN_SKIP_MSG")\",\"user_message\":\"$(_esc "$WIN_SKIP_MSG")\"}") - -case "$(uname -s 2>/dev/null)" in - MINGW* | MSYS* | CYGWIN*) - cat >/dev/null 2>&1 || true - printf '%s\n' "$WIN_SKIP_JSON" - exit 0 - ;; -esac - -unset -f _esc 2>/dev/null || true - # ============================================================================ # TABLE OF CONTENTS # ============================================================================ # # - Global Variables # - Core Utility Functions (logging, JSON escaping) -# - System & Path Utility Functions (OS detection, path normalization) +# - System & Path Utility Functions (uname_platform, path normalization) # - 1Password Database Functions (finding and querying database) # - Mount Parsing & Validation Functions (parsing mount data, validation) # - TOML Parsing Functions -# - Main Execution Logic +# - Main Execution Logic (early platform exit must run before stdin is read) # - Permission Decision Logic # # ============================================================================ @@ -155,17 +139,22 @@ EOF # SYSTEM & PATH UTILITY FUNCTIONS # ============================================================================ -# Detect operating system -detect_os() { - case "$(uname -s)" in +# Classify kernel for DB paths and early platform skip. +uname_platform() { + case "$(uname -s 2>/dev/null || echo "")" in Darwin*) echo "macos" ;; Linux*) echo "unix" ;; + MINGW* | MSYS* | CYGWIN*) + echo "windows_bash" + ;; + FreeBSD*) + echo "freebsd" + ;; *) - log "Warning: Unsupported OS: $(uname -s)" echo "unknown" ;; esac @@ -586,6 +575,20 @@ parse_toml_mount_paths() { # MAIN EXECUTION LOGIC # ============================================================================ +# Unsupported platforms: return allow JSONand exit. +case "$(uname_platform)" in + windows_bash | freebsd) + UNSUPPORTED_PLATFORM_SKIP_MSG='1Password Environments local .env validation is only supported on macOS and Linux. Validation was skipped.' + _skip_msg_esc=$(escape_json_string "$UNSUPPORTED_PLATFORM_SKIP_MSG") + _skip_json=$(printf '%s' "{\"permission\":\"allow\",\"agent_message\":\"${_skip_msg_esc}\",\"user_message\":\"${_skip_msg_esc}\"}") + unset _skip_msg_esc + cat >/dev/null 2>/dev/null || true + printf '%s\n' "$_skip_json" + unset _skip_json + exit 0 + ;; +esac + # Query 1Password database and check mounts log "Checking for local .env files mounted by 1Password..." @@ -613,7 +616,11 @@ fi log "Found ${#workspace_roots_array[@]} workspace root(s) to validate" # Query 1Password database once (shared across all workspace roots) -os_type=$(detect_os) +os_type=$(uname_platform) +if [[ "$os_type" != "macos" && "$os_type" != "unix" ]]; then + log "Warning: Unsupported OS: $(uname -s 2>/dev/null || echo unknown)" + os_type="unknown" +fi db_path="" mount_hex_data="" diff --git a/scripts/validate-mounted-env-files.cmd b/scripts/validate-mounted-env-files.cmd index 442c9ca..da45327 100644 --- a/scripts/validate-mounted-env-files.cmd +++ b/scripts/validate-mounted-env-files.cmd @@ -2,6 +2,6 @@ REM On Windows only: tell Cursor to allow the shell command (1Password Environments are not supported here). REM The hook command omits a file extension, on Windows the OS finds this .cmd file for you. REM On Mac/Linux the same hook name runs the Bash script next to this file instead. -powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -Command "& { $null = [Console]::In.ReadToEnd(); $m = '1Password Environments are not currently supported on Windows. Environment validation was skipped.'; @{ permission = 'allow'; agent_message = $m; user_message = $m } | ConvertTo-Json -Compress }" +powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -Command "& { $null = [Console]::In.ReadToEnd(); $m = '1Password Environments local .env validation is only supported on macOS and Linux. Validation was skipped.'; @{ permission = 'allow'; agent_message = $m; user_message = $m } | ConvertTo-Json -Compress }" exit /b 0 From cbb37b734892ff1533b18550f2c99c4c97d457fd Mon Sep 17 00:00:00 2001 From: Jill Regan Date: Fri, 15 May 2026 08:32:26 -0400 Subject: [PATCH 6/6] Fix typo --- scripts/validate-mounted-env-files | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/validate-mounted-env-files b/scripts/validate-mounted-env-files index 79a437b..1e0840c 100755 --- a/scripts/validate-mounted-env-files +++ b/scripts/validate-mounted-env-files @@ -575,7 +575,7 @@ parse_toml_mount_paths() { # MAIN EXECUTION LOGIC # ============================================================================ -# Unsupported platforms: return allow JSONand exit. +# Unsupported platforms: return allow JSON and exit. case "$(uname_platform)" in windows_bash | freebsd) UNSUPPORTED_PLATFORM_SKIP_MSG='1Password Environments local .env validation is only supported on macOS and Linux. Validation was skipped.'