From fad396b343df14a55d39324bc300e98b9ee26cd8 Mon Sep 17 00:00:00 2001 From: Joey Maffiola <7maffiolajoey@gmail.com> Date: Mon, 24 Aug 2026 03:04:59 +0000 Subject: [PATCH] feat: validate 1Password mounted env files on Claude Code file tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PreToolUse previously only matched Bash, and the canonical hook input schema only modeled shell-execution events. Claude Code's non-Bash file tools (Read, Edit, MultiEdit, NotebookEdit) bypassed the 1password-validate-mounted-env-files hook entirely, so reading a misconfigured .env FIFO mount via Read gave no validation, while the equivalent Bash-mediated read correctly surfaced the misconfiguration. - schemas/hook-input.schema.json: add "before_file_read" event / "file_read" type, plus an optional "file_path" property. - adapters/_lib.sh: build_canonical_input gains an optional 9th "file_path" argument (defaults to "" — existing 8-arg callers are unaffected). - adapters/claude-code.sh: for tool_name in {Read, Edit, MultiEdit, NotebookEdit}, extract tool_input.file_path (or notebook_path for NotebookEdit) and emit a before_file_read/file_read canonical event instead of a shell command. Bash and any unrecognized tool_name keep the original command-extraction behavior. - hooks/1password-validate-mounted-env-files/hook.sh: add check_single_file_mount, which validates only the single file_path from a file_read event (against environments.toml mount_paths and the 1Password DB) instead of sweeping every mount in the workspace like the command path does. Paths that aren't a known mount are allowed with no further checks. - .claude/settings.json: register a second PreToolUse matcher ("Read|Edit|MultiEdit|NotebookEdit") alongside "Bash" so the template used by install.sh wires up both paths. - README: document the new event coverage and add a Claude Code config example. - Tests: cover the new adapter branch (event/type/file_path extraction, NotebookEdit's notebook_path fallback) and the new hook.sh single-file validation path (empty file_path, unrelated file, TOML-required missing mount, DB-disabled mount, valid enabled FIFO mount). Closes #28 --- .claude/settings.json | 9 + adapters/_lib.sh | 9 +- adapters/claude-code.sh | 39 +++- .../README.md | 25 +++ .../hook.sh | 168 +++++++++++++++- schemas/hook-input.schema.json | 17 +- tests/adapters/_lib.bats | 14 ++ tests/adapters/claude-code.bats | 66 +++++++ .../1password-validate-mounted-env-files.bats | 179 ++++++++++++++++++ 9 files changed, 510 insertions(+), 16 deletions(-) diff --git a/.claude/settings.json b/.claude/settings.json index 4fd6e75..0d603d5 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -9,6 +9,15 @@ "command": "bin/run-hook.sh 1password-validate-mounted-env-files" } ] + }, + { + "matcher": "Read|Edit|MultiEdit|NotebookEdit", + "hooks": [ + { + "type": "command", + "command": "bin/run-hook.sh 1password-validate-mounted-env-files" + } + ] } ] } diff --git a/adapters/_lib.sh b/adapters/_lib.sh index 7bf3355..b8df590 100644 --- a/adapters/_lib.sh +++ b/adapters/_lib.sh @@ -65,7 +65,9 @@ detect_client() { # Build canonical JSON from extracted fields. # Embeds raw_payload as a nested JSON object. -# Usage: build_canonical_input "$ide" "$event" "$type" "$workspace_roots_json_array" "$cwd" "$command" "$tool_name" "$raw_payload" +# Usage: build_canonical_input "$ide" "$event" "$type" "$workspace_roots_json_array" "$cwd" "$command" "$tool_name" "$raw_payload" ["$file_path"] +# file_path is optional (defaults to "") — only non-shell file-tool adapters (e.g. +# Claude Code's Read/Edit/MultiEdit/NotebookEdit matchers) need to pass it. build_canonical_input() { local client="$1" local event="$2" @@ -75,16 +77,18 @@ build_canonical_input() { local command="$6" local tool_name="$7" local raw_payload="$8" + local file_path="${9:-}" local escaped_client escaped_event escaped_type escaped_client=$(escape_json_string "$client") escaped_event=$(escape_json_string "$event") escaped_type=$(escape_json_string "$type") - local escaped_cwd escaped_command escaped_tool_name + local escaped_cwd escaped_command escaped_tool_name escaped_file_path escaped_cwd=$(escape_json_string "$cwd") escaped_command=$(escape_json_string "$command") escaped_tool_name=$(escape_json_string "$tool_name") + escaped_file_path=$(escape_json_string "$file_path") local trimmed_payload trimmed_payload=$(printf '%s' "$raw_payload" | sed 's/^[[:space:]]*//' | sed 's/[[:space:]]*$//') @@ -103,6 +107,7 @@ build_canonical_input() { "cwd": "${escaped_cwd}", "command": "${escaped_command}", "tool_name": "${escaped_tool_name}", +"file_path": "${escaped_file_path}", "raw_payload": ${trimmed_payload} } CANONICAL_EOF diff --git a/adapters/claude-code.sh b/adapters/claude-code.sh index 6a41428..49a5c1f 100644 --- a/adapters/claude-code.sh +++ b/adapters/claude-code.sh @@ -5,6 +5,12 @@ # "tool_input": {"command": "...", "working_directory": "..."}, # "cwd": "...", "permission_mode": "..."} # +# Claude Code input payload (PreToolUse / non-Bash file tools — Read, Edit, +# MultiEdit; NotebookEdit uses "notebook_path" instead of "file_path"): +# {"hook_event_name": "PreToolUse", "tool_name": "Read", +# "tool_input": {"file_path": "..."}, +# "cwd": "...", "permission_mode": "..."} +# # Claude Code also sets the CLAUDE_PROJECT_DIR env var. # # Claude Code output: @@ -20,10 +26,9 @@ source "${_ADAPTER_DIR}/_lib.sh" normalize_input() { local raw_payload="$1" - local cwd command tool_name workspace_roots_json + local cwd tool_name workspace_roots_json cwd=$(extract_json_string "$raw_payload" "cwd") tool_name=$(extract_json_string "$raw_payload" "tool_name") - command=$(extract_json_string "$raw_payload" "command") # Claude Code provides CLAUDE_PROJECT_DIR as the workspace root. local project_dir="${CLAUDE_PROJECT_DIR:-}" @@ -35,15 +40,39 @@ normalize_input() { workspace_roots_json=$(paths_to_json_array "$project_dir") + local event type command file_path + case "$tool_name" in + Read|Edit|MultiEdit|NotebookEdit) + # Non-Bash file tools: the target path lives at tool_input.file_path + # for Read/Edit/MultiEdit, and tool_input.notebook_path for NotebookEdit. + file_path=$(extract_json_string "$raw_payload" "file_path") + if [[ -z "$file_path" ]]; then + file_path=$(extract_json_string "$raw_payload" "notebook_path") + fi + command="" + event="before_file_read" + type="file_read" + ;; + *) + # Bash (and any other/unrecognized tool_name): preserve the original + # behavior of extracting a shell command. + command=$(extract_json_string "$raw_payload" "command") + file_path="" + event="before_shell_execution" + type="command" + ;; + esac + build_canonical_input \ "claude-code" \ - "before_shell_execution" \ - "command" \ + "$event" \ + "$type" \ "$workspace_roots_json" \ "$cwd" \ "$command" \ "$tool_name" \ - "$raw_payload" + "$raw_payload" \ + "$file_path" } emit_output() { diff --git a/hooks/1password-validate-mounted-env-files/README.md b/hooks/1password-validate-mounted-env-files/README.md index b4baef1..14ef438 100644 --- a/hooks/1password-validate-mounted-env-files/README.md +++ b/hooks/1password-validate-mounted-env-files/README.md @@ -16,6 +16,8 @@ Use with the event that runs before shell command execution in your agent. When **Examples (event name depends on your agent):** `beforeShellExecution` (e.g. Cursor), `PreToolUse` (e.g. GitHub Copilot). +On Claude Code, `PreToolUse` also supports non-Bash file tools (`Read`, `Edit`, `MultiEdit`, `NotebookEdit`). Registering the hook for those matchers (see [Example Configuration](#example-configuration)) catches the case where the agent inspects a `.env` mount directly via a file tool instead of a shell command — otherwise that access bypasses validation entirely. For these events, the hook validates only the specific file path being accessed (not every mount in the workspace): if the path isn't a known 1Password mount, it's allowed with no further checks. + ## Functionality The hook supports two validation modes: **configured** (when a TOML configuration file is present and properly defined) and **default** (when no configuration is provided). @@ -131,6 +133,29 @@ The command must run `run-hook.sh` with the hook name. The path to `run-hook.sh` For other agents, use the event and config path for your agent. See [.github/hooks/hooks.json](../../.github/hooks/hooks.json) in this repo for another example. +Claude Code (`.claude/settings.json`) registers the hook against both `Bash` and the non-Bash file-tool matchers so file-tool access to a mounted `.env` is validated too: + +```json +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { "type": "command", "command": "claude-code-1password-hooks-bundle/bin/run-hook.sh 1password-validate-mounted-env-files" } + ] + }, + { + "matcher": "Read|Edit|MultiEdit|NotebookEdit", + "hooks": [ + { "type": "command", "command": "claude-code-1password-hooks-bundle/bin/run-hook.sh 1password-validate-mounted-env-files" } + ] + } + ] + } +} +``` + ### Dependencies **Required:** diff --git a/hooks/1password-validate-mounted-env-files/hook.sh b/hooks/1password-validate-mounted-env-files/hook.sh index 0d25a5c..4803fbe 100755 --- a/hooks/1password-validate-mounted-env-files/hook.sh +++ b/hooks/1password-validate-mounted-env-files/hook.sh @@ -17,6 +17,7 @@ source "${REPO_ROOT}/lib/logging.sh" # - 1Password Database Functions (finding and querying database) # - Mount Parsing & Validation Functions (parsing mount data, validation) # - TOML Parsing Functions +# - Single-File Validation (for non-shell file-tool events, e.g. Read/Edit) # - Main Execution Logic (canonical JSON from stdin) # - Permission Decision Logic # @@ -369,6 +370,151 @@ parse_toml_mount_paths() { return 1 } +# ============================================================================ +# SINGLE-FILE VALIDATION +# ============================================================================ +# +# Used for non-shell file-tool events (canonical type "file_read", e.g. +# Claude Code's Read/Edit/MultiEdit/NotebookEdit matchers). Unlike the +# command path above — which validates every mount discovered for a +# workspace root before any Bash command runs — a file-tool event only +# touches one specific path, so we only need to know whether *that* path +# is a known 1Password mount: +# - Not a known mount (or outside every workspace root) -> allow, no-op. +# - A known mount -> run the same enabled/exists/FIFO checks as the +# command path, appending to the same disabled_mounts/invalid_mounts +# arrays so the existing permission-decision logic below handles both +# paths uniformly. +# +# Expects the caller to have already populated $mount_hex_data (queried +# once, shared with the command path). + +# Check a single file path against a workspace's environments.toml +# mount_paths (if configured). Echoes the matching resolved mount path on +# stdout when found, otherwise nothing. Returns 0 always (absence is not +# an error). +find_toml_mount_match() { + local normalized_file_path="$1" + local workspace_root="$2" + + local toml_file="${workspace_root}/.1password/environments.toml" + [[ -f "$toml_file" ]] || return 0 + has_toml_mount_paths_field "$toml_file" || return 0 + + local toml_mounts + toml_mounts=$(parse_toml_mount_paths "$toml_file") || return 0 + [[ -z "$toml_mounts" ]] && return 0 + + local toml_mount_path resolved_path + while IFS= read -r toml_mount_path || [[ -n "$toml_mount_path" ]]; do + [[ -z "$toml_mount_path" ]] && continue + validate_path "$toml_mount_path" || continue + + if [[ "$toml_mount_path" == /* ]]; then + resolved_path="$toml_mount_path" + else + resolved_path="${workspace_root}/${toml_mount_path}" + fi + resolved_path=$(normalize_path "$resolved_path") + + if [[ "$resolved_path" == "$normalized_file_path" ]]; then + echo "$resolved_path" + return 0 + fi + done <<< "$toml_mounts" + + return 0 +} + +# Validate a single file path (from a non-shell file-tool event) against +# known 1Password mounts. Appends to disabled_mounts/invalid_mounts/ +# required_mounts and increments total_mount_count exactly like the +# per-workspace-root command validation loop, but only for this one path. +check_single_file_mount() { + local file_path="$1" + shift + local workspace_roots=("$@") + + local normalized_file_path + normalized_file_path=$(normalize_path "$file_path") + + # Find the workspace root (if any) that this file belongs to. + local matched_workspace="" + for workspace_root in "${workspace_roots[@]}"; do + if is_project_mount "$normalized_file_path" "$workspace_root"; then + matched_workspace="$workspace_root" + break + fi + done + + if [[ -z "$matched_workspace" ]]; then + log "File path is outside all workspace roots, skipping validation: \"${normalized_file_path}\"" + return 0 + fi + + local is_known_mount=false + + # Known via environments.toml mount_paths? + if [[ -n "$(find_toml_mount_match "$normalized_file_path" "$matched_workspace")" ]]; then + is_known_mount=true + fi + + # Known via the 1Password database? + local db_found=false db_is_enabled="" db_environment_name="" + if [[ -n "$mount_hex_data" ]]; then + local hex_line mount_info mount_path remaining mount_is_enabled mount_env_name normalized_db_path + while IFS= read -r hex_line || [[ -n "$hex_line" ]]; do + [[ -z "$hex_line" ]] && continue + + mount_info=$(parse_mount "$hex_line") + [[ -z "$mount_info" ]] && continue + + mount_path="${mount_info%%|*}" + remaining="${mount_info#*|}" + mount_is_enabled="${remaining%%|*}" + remaining="${remaining#*|}" + mount_env_name="${remaining%%|*}" + + normalized_db_path=$(normalize_path "$mount_path") + if [[ "$normalized_db_path" == "$normalized_file_path" ]]; then + db_found=true + db_is_enabled="$mount_is_enabled" + db_environment_name="$mount_env_name" + is_known_mount=true + break + fi + done <<< "$mount_hex_data" + fi + + if [[ "$is_known_mount" != "true" ]]; then + log "File path does not match a known 1Password mount, allowing: \"${normalized_file_path}\"" + return 0 + fi + + ((total_mount_count++)) || true + + # Disabled in the 1Password app. + if [[ "$db_found" == "true" ]] && [[ "$db_is_enabled" == "false" ]]; then + log "File being accessed is a disabled local .env mount: \"${normalized_file_path}\"" + disabled_mounts+=("${normalized_file_path}|${db_environment_name}") + return 0 + fi + + # Missing or not a valid FIFO. + if [[ ! -e "$normalized_file_path" ]] || [[ ! -p "$normalized_file_path" ]]; then + log "File being accessed is a missing or invalid local .env mount: \"${normalized_file_path}\"" + if [[ "$db_found" == "true" ]]; then + invalid_mounts+=("${normalized_file_path}|${db_environment_name}") + else + required_mounts+=("$normalized_file_path") + fi + return 0 + fi + + log "File being accessed is a valid, enabled local .env mount: \"${normalized_file_path}\"" + return 0 +} + # Emit one JSON line to stdout (decision, message, and telemetry metadata). output_decision() { if [[ "$permission" == "allow" ]]; then @@ -430,8 +576,23 @@ if [[ "$os_type" != "unknown" ]]; then fi fi -# Process each workspace root -for workspace_root in "${workspace_roots_array[@]}"; do +# Non-shell file-tool events (canonical type "file_read", e.g. Claude Code's +# Read/Edit/MultiEdit/NotebookEdit matchers) validate a single file_path +# instead of sweeping every mount in the workspace — see "SINGLE-FILE +# VALIDATION" above. +canonical_type=$(extract_json_string "$canonical_input" "type") +canonical_file_path=$(extract_json_string "$canonical_input" "file_path") + +if [[ "$canonical_type" == "file_read" ]]; then + if [[ -z "$canonical_file_path" ]]; then + log "file_read event with no file_path supplied, skipping validation" + else + log "Validating single file path for file_read event: \"${canonical_file_path}\"" + check_single_file_mount "$canonical_file_path" "${workspace_roots_array[@]}" + fi +else + # 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 @@ -626,7 +787,8 @@ for workspace_root in "${workspace_roots_array[@]}"; do fi done <<< "$mount_hex_data" fi -done + done +fi # ============================================================================ # PERMISSION DECISION LOGIC diff --git a/schemas/hook-input.schema.json b/schemas/hook-input.schema.json index c666c58..dd1c8c6 100644 --- a/schemas/hook-input.schema.json +++ b/schemas/hook-input.schema.json @@ -14,13 +14,13 @@ }, "event": { "type": "string", - "enum": ["before_shell_execution"], - "description": "Canonical event name. Currently only 'before_shell_execution' is supported." + "enum": ["before_shell_execution", "before_file_read"], + "description": "Canonical event name. 'before_shell_execution' covers shell/command execution. 'before_file_read' covers non-shell file tools (e.g. Read, Edit, MultiEdit, NotebookEdit on Claude Code)." }, "type": { "type": "string", - "enum": ["command"], - "description": "Action category as defined by the IDE's hook system. Currently only 'command' (shell execution) is supported." + "enum": ["command", "file_read"], + "description": "Action category as defined by the IDE's hook system. 'command' is shell execution. 'file_read' is a non-shell file-tool action (open/edit/inspect a file path) that pairs with the 'before_file_read' event." }, "workspace_roots": { "type": "array", @@ -36,12 +36,17 @@ }, "command": { "type": "string", - "description": "Shell command string about to be executed." + "description": "Shell command string about to be executed. Empty string for non-shell events (e.g. 'before_file_read')." }, "tool_name": { "type": "string", "default": "", - "description": "IDE tool identifier from the payload (e.g. 'Bash', 'run_in_terminal'). Empty string when not applicable." + "description": "IDE tool identifier from the payload (e.g. 'Bash', 'run_in_terminal', 'Read'). Empty string when not applicable." + }, + "file_path": { + "type": "string", + "default": "", + "description": "Absolute (or IDE-provided) path to the file being read/edited/inspected, for non-shell file-tool events (e.g. Read, Edit, MultiEdit, NotebookEdit on Claude Code). Empty string for shell-command events or when not applicable." }, "raw_payload": { "type": "object", diff --git a/tests/adapters/_lib.bats b/tests/adapters/_lib.bats index c9f8cf4..4818bff 100644 --- a/tests/adapters/_lib.bats +++ b/tests/adapters/_lib.bats @@ -46,3 +46,17 @@ setup() { result=$(build_canonical_input "cursor" "before_shell_execution" "command" '[]' "/tmp" "ls" "" "$payload") [[ "$result" == *'"raw_payload": {"command": "ls"}'* ]] } + +# ========== build_canonical_input — file_path (9th, optional) ========== + +@test "build_canonical_input defaults file_path to empty string when omitted" { + local result + result=$(build_canonical_input "cursor" "before_shell_execution" "command" '[]' "/tmp" "ls" "" "{}") + [[ "$result" == *'"file_path": ""'* ]] +} + +@test "build_canonical_input includes a supplied file_path" { + local result + result=$(build_canonical_input "claude-code" "before_file_read" "file_read" '[]' "/tmp" "" "Read" "{}" "/tmp/.env") + [[ "$result" == *'"file_path": "/tmp/.env"'* ]] +} diff --git a/tests/adapters/claude-code.bats b/tests/adapters/claude-code.bats index 7016f37..aa53d6c 100644 --- a/tests/adapters/claude-code.bats +++ b/tests/adapters/claude-code.bats @@ -73,6 +73,72 @@ setup() { [[ "$result" == *"/home/user/project"* ]] } +# ========== normalize_input — non-Bash file tools (Read/Edit/MultiEdit/NotebookEdit) ========== + +@test "normalize_input sets event to before_file_read for Read tool" { + local payload='{"hook_event_name": "PreToolUse", "tool_name": "Read", "tool_input": {"file_path": "/tmp/.env"}, "cwd": "/tmp", "permission_mode": "default"}' + result=$(normalize_input "$payload") + local event + event=$(echo "$result" | grep -oE '"event"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*: *"\(.*\)"/\1/') + [[ "$event" == "before_file_read" ]] +} + +@test "normalize_input sets type to file_read for Read tool" { + local payload='{"hook_event_name": "PreToolUse", "tool_name": "Read", "tool_input": {"file_path": "/tmp/.env"}, "cwd": "/tmp", "permission_mode": "default"}' + result=$(normalize_input "$payload") + local type + type=$(echo "$result" | grep -oE '"type"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*: *"\(.*\)"/\1/') + [[ "$type" == "file_read" ]] +} + +@test "normalize_input extracts file_path for Read tool" { + local payload='{"hook_event_name": "PreToolUse", "tool_name": "Read", "tool_input": {"file_path": "/tmp/.env"}, "cwd": "/tmp", "permission_mode": "default"}' + result=$(normalize_input "$payload") + local file_path + file_path=$(echo "$result" | grep -oE '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*: *"\(.*\)"/\1/') + [[ "$file_path" == "/tmp/.env" ]] +} + +@test "normalize_input leaves command empty for Read tool" { + local payload='{"hook_event_name": "PreToolUse", "tool_name": "Read", "tool_input": {"file_path": "/tmp/.env"}, "cwd": "/tmp", "permission_mode": "default"}' + result=$(normalize_input "$payload") + local command + command=$(echo "$result" | grep -oE '"command"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*: *"\(.*\)"/\1/') + [[ "$command" == "" ]] +} + +@test "normalize_input extracts file_path for Edit tool" { + local payload='{"hook_event_name": "PreToolUse", "tool_name": "Edit", "tool_input": {"file_path": "/tmp/.env", "old_string": "a", "new_string": "b"}, "cwd": "/tmp", "permission_mode": "default"}' + result=$(normalize_input "$payload") + local file_path + file_path=$(echo "$result" | grep -oE '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*: *"\(.*\)"/\1/') + [[ "$file_path" == "/tmp/.env" ]] +} + +@test "normalize_input extracts file_path for MultiEdit tool" { + local payload='{"hook_event_name": "PreToolUse", "tool_name": "MultiEdit", "tool_input": {"file_path": "/tmp/.env", "edits": []}, "cwd": "/tmp", "permission_mode": "default"}' + result=$(normalize_input "$payload") + local file_path + file_path=$(echo "$result" | grep -oE '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*: *"\(.*\)"/\1/') + [[ "$file_path" == "/tmp/.env" ]] +} + +@test "normalize_input falls back to notebook_path for NotebookEdit tool" { + local payload='{"hook_event_name": "PreToolUse", "tool_name": "NotebookEdit", "tool_input": {"notebook_path": "/tmp/notebook.ipynb", "new_source": "x"}, "cwd": "/tmp", "permission_mode": "default"}' + result=$(normalize_input "$payload") + local file_path + file_path=$(echo "$result" | grep -oE '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*: *"\(.*\)"/\1/') + [[ "$file_path" == "/tmp/notebook.ipynb" ]] +} + +@test "normalize_input preserves tool_name for file tools" { + local payload='{"hook_event_name": "PreToolUse", "tool_name": "Read", "tool_input": {"file_path": "/tmp/.env"}, "cwd": "/tmp", "permission_mode": "default"}' + result=$(normalize_input "$payload") + local tool_name + tool_name=$(echo "$result" | grep -oE '"tool_name"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*: *"\(.*\)"/\1/') + [[ "$tool_name" == "Read" ]] +} + # ========== emit_output ========== @test "emit_output exits 0 and produces no stdout for allow" { diff --git a/tests/hooks/1password-validate-mounted-env-files.bats b/tests/hooks/1password-validate-mounted-env-files.bats index 30fffdc..80cd464 100644 --- a/tests/hooks/1password-validate-mounted-env-files.bats +++ b/tests/hooks/1password-validate-mounted-env-files.bats @@ -5,6 +5,7 @@ load "../test_helper" HOOK_SCRIPT="${PROJECT_ROOT}/hooks/1password-validate-mounted-env-files/hook.sh" # Minimal SQLite DB at the path find_1password_db expects; query_mounts requires objects_associated. +# Echoes the resolved db_path so callers can insert additional rows (e.g. via insert_mount_row). create_minimal_1password_sqlite_fixture() { local fake_home="$1" local db_path @@ -18,6 +19,25 @@ create_minimal_1password_sqlite_fixture() { esac mkdir -p "$(dirname "$db_path")" sqlite3 "$db_path" 'CREATE TABLE objects_associated (key_name TEXT, data BLOB);' + echo "$db_path" +} + +# Insert a mount row shaped like a real 1Password dev-environment-mount entry +# (hex-encoded JSON blob, matching what parse_mount/hex_to_json expect). +insert_mount_row() { + local db_path="$1" mount_path="$2" is_enabled="$3" environment_name="$4" uuid="$5" environment_uuid="$6" + + local json_data hex_data + json_data=$(python3 -c "import json,sys; print(json.dumps({ + 'mountPath': sys.argv[1], + 'isEnabled': sys.argv[2] == 'true', + 'environmentName': sys.argv[3], + 'uuid': sys.argv[4], + 'environmentUuid': sys.argv[5], + }))" "$mount_path" "$is_enabled" "$environment_name" "$uuid" "$environment_uuid") + hex_data=$(printf '%s' "$json_data" | xxd -p | tr -d '\n') + + sqlite3 "$db_path" "INSERT INTO objects_associated (key_name, data) VALUES ('dev-environment-mount/${uuid}', X'${hex_data}');" } canonical_empty_roots='{"client":"cursor","event":"before_shell_execution","type":"command","workspace_roots":[],"cwd":"","command":"echo hi","raw_payload":{}}' @@ -67,6 +87,165 @@ canonical_one_root='{"client":"cursor","event":"before_shell_execution","type":" printf '%s' "$output" | python3 -c 'import json,sys; d=json.load(sys.stdin); assert d.get("decision")=="deny" and d.get("message"), d' } +# ============================================================================ +# file_read event tests (non-Bash file tools, e.g. Claude Code Read/Edit) +# ============================================================================ + +# NOTE: every case below exports a fake HOME with a minimal (possibly empty) +# 1Password sqlite fixture, even the ones that only need TOML data. hook.sh +# unconditionally queries the 1Password DB up front (same as the Bash/command +# path), and find_1password_db fails (non-fatal in production only because +# run-hook.sh wraps hook.sh and fails open on any error) when no real +# 1Password install is present — a fake HOME keeps these tests independent of +# whatever happens to be installed on the machine running them. + +@test "file_read event with empty file_path allows without validation" { + if ! command -v sqlite3 &>/dev/null; then + skip "sqlite3 not available" + fi + + export HOME="${BATS_TEST_TMPDIR}/home_empty_file_path" + mkdir -p "$HOME" + create_minimal_1password_sqlite_fixture "$HOME" >/dev/null + + local payload='{"client":"claude-code","event":"before_file_read","type":"file_read","workspace_roots":["/tmp"],"cwd":"/tmp","command":"","tool_name":"Read","file_path":"","raw_payload":{}}' + run bash -c "echo '$payload' | bash \"${HOOK_SCRIPT}\"" + [[ $status -eq 0 ]] + [[ "$output" == '{"decision":"allow","message":"","mode":"default","mount_count":0,"deny_reason":null}' ]] +} + +@test "file_read event with file_path that is not a known mount allows, even when the workspace has an unrelated required mount" { + if ! command -v sqlite3 &>/dev/null; then + skip "sqlite3 not available" + fi + + export HOME="${BATS_TEST_TMPDIR}/home_unrelated" + mkdir -p "$HOME" + create_minimal_1password_sqlite_fixture "$HOME" >/dev/null + + local ws="${BATS_TEST_TMPDIR}/workspace_unrelated" + mkdir -p "$ws/.1password" + printf '%s\n' 'mount_paths = [".env.missing"]' > "$ws/.1password/environments.toml" + + local other_file="${ws}/README.md" + printf 'hello\n' > "$other_file" + + local payload + payload=$(python3 -c "import json,sys; print(json.dumps({ + 'client': 'claude-code', + 'event': 'before_file_read', + 'type': 'file_read', + 'workspace_roots': [sys.argv[1]], + 'cwd': sys.argv[1], + 'command': '', + 'tool_name': 'Read', + 'file_path': sys.argv[2], + 'raw_payload': {}, + }))" "$ws" "$other_file") + + run bash -c "echo '$payload' | bash \"${HOOK_SCRIPT}\"" + [[ $status -eq 0 ]] + printf '%s' "$output" | python3 -c 'import json,sys; d=json.load(sys.stdin); assert d.get("decision")=="allow" and d.get("mount_count")==0, d' +} + +@test "file_read event with file_path matching a TOML-required missing mount denies" { + if ! command -v sqlite3 &>/dev/null; then + skip "sqlite3 not available" + fi + + export HOME="${BATS_TEST_TMPDIR}/home_toml_deny" + mkdir -p "$HOME" + create_minimal_1password_sqlite_fixture "$HOME" >/dev/null + + local ws="${BATS_TEST_TMPDIR}/workspace_toml_deny" + mkdir -p "$ws/.1password" + printf '%s\n' 'mount_paths = [".env.missing"]' > "$ws/.1password/environments.toml" + + local payload + payload=$(python3 -c "import json,sys; print(json.dumps({ + 'client': 'claude-code', + 'event': 'before_file_read', + 'type': 'file_read', + 'workspace_roots': [sys.argv[1]], + 'cwd': sys.argv[1], + 'command': '', + 'tool_name': 'Read', + 'file_path': sys.argv[1] + '/.env.missing', + 'raw_payload': {}, + }))" "$ws") + + run bash -c "echo '$payload' | bash \"${HOOK_SCRIPT}\"" + [[ $status -eq 1 ]] + printf '%s' "$output" | python3 -c 'import json,sys; d=json.load(sys.stdin); assert d.get("decision")=="deny" and d.get("message") and d.get("mount_count")==1, d' +} + +@test "file_read event with file_path matching a disabled DB mount denies" { + if ! command -v sqlite3 &>/dev/null || ! command -v xxd &>/dev/null; then + skip "sqlite3 or xxd not available" + fi + + export HOME="${BATS_TEST_TMPDIR}/home_disabled" + mkdir -p "$HOME" + local db_path + db_path=$(create_minimal_1password_sqlite_fixture "$HOME") + + local ws="${BATS_TEST_TMPDIR}/workspace_disabled" + mkdir -p "$ws" + local env_path="${ws}/.env" + insert_mount_row "$db_path" "$env_path" "false" "Production" "uuid-1" "env-uuid-1" + + local payload + payload=$(python3 -c "import json,sys; print(json.dumps({ + 'client': 'claude-code', + 'event': 'before_file_read', + 'type': 'file_read', + 'workspace_roots': [sys.argv[1]], + 'cwd': sys.argv[1], + 'command': '', + 'tool_name': 'Read', + 'file_path': sys.argv[2], + 'raw_payload': {}, + }))" "$ws" "$env_path") + + run env HOME="$HOME" bash "$HOOK_SCRIPT" <<<"$payload" + [[ $status -eq 1 ]] + printf '%s' "$output" | python3 -c 'import json,sys; d=json.load(sys.stdin); assert d.get("decision")=="deny" and "Production" in d.get("message",""), d' +} + +@test "file_read event with file_path matching a valid enabled FIFO mount allows" { + if ! command -v sqlite3 &>/dev/null || ! command -v xxd &>/dev/null || ! command -v mkfifo &>/dev/null; then + skip "sqlite3, xxd, or mkfifo not available" + fi + + export HOME="${BATS_TEST_TMPDIR}/home_valid" + mkdir -p "$HOME" + local db_path + db_path=$(create_minimal_1password_sqlite_fixture "$HOME") + + local ws="${BATS_TEST_TMPDIR}/workspace_valid" + mkdir -p "$ws" + local env_path="${ws}/.env" + mkfifo "$env_path" + insert_mount_row "$db_path" "$env_path" "true" "Production" "uuid-2" "env-uuid-2" + + local payload + payload=$(python3 -c "import json,sys; print(json.dumps({ + 'client': 'claude-code', + 'event': 'before_file_read', + 'type': 'file_read', + 'workspace_roots': [sys.argv[1]], + 'cwd': sys.argv[1], + 'command': '', + 'tool_name': 'Read', + 'file_path': sys.argv[2], + 'raw_payload': {}, + }))" "$ws" "$env_path") + + run env HOME="$HOME" bash "$HOOK_SCRIPT" <<<"$payload" + [[ $status -eq 0 ]] + [[ "$output" == '{"decision":"allow","message":"","mode":"default","mount_count":1,"deny_reason":null}' ]] +} + @test "hook produces no extra lines or stderr" { run bash -c "echo '$canonical_empty_roots' | bash \"${HOOK_SCRIPT}\" 2>&1" [[ $status -eq 0 ]]