Skip to content
Closed
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
9 changes: 9 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
}
]
}
Expand Down
9 changes: 7 additions & 2 deletions adapters/_lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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:]]*$//')
Expand All @@ -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
Expand Down
39 changes: 34 additions & 5 deletions adapters/claude-code.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:-}"
Expand All @@ -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() {
Expand Down
25 changes: 25 additions & 0 deletions hooks/1password-validate-mounted-env-files/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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:**
Expand Down
168 changes: 165 additions & 3 deletions hooks/1password-validate-mounted-env-files/hook.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
#
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -626,7 +787,8 @@ for workspace_root in "${workspace_roots_array[@]}"; do
fi
done <<< "$mount_hex_data"
fi
done
done
fi

# ============================================================================
# PERMISSION DECISION LOGIC
Expand Down
17 changes: 11 additions & 6 deletions schemas/hook-input.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
Loading